feat(daemon): async providers, scoped caches, refresh dedup, cancellation (#18) - #49
Conversation
…tion Implements the daemon-epic step for issue #18 on top of the IPC transport (#44): provider work now runs in the daemon's scope through async twins, and widget output in one-shot mode is unchanged. - RefreshGroup (src/daemon/provider-scope.ts): single-flight refresh per key with prompt cancellation when the last consumer leaves; capMap FIFO bound for shared maps. - Daemon render path: no more process.env/cwd swapping — requests carry an env/cwd snapshot (RenderInvocation); identical display contexts join one in-flight render (deduped counter); bounded in-flight renders; idle cache sweep reclaims provider caches after quiet periods. - Prefetch (src/daemon/prefetch.ts): transcript analysis, usage (per credential fingerprint), service status, block metrics and git/jj/review/ custom-command warmups run async before sync formatting. - Async provider twins: git/jj execFile, custom-command capture (bounded output, process-group kill), usage fetch with signal/env scope, git review cache, block metrics — sync paths unchanged for one-shot mode. - Scoped caches: usage memory cache gated by credential fingerprint BEFORE the fast return (missing credentials never take a logged-in entry); transcript whole-analysis reuse validated by file identity with invalidation on truncation, append, replacement and subagent updates. - Bounded memory: capped caches/maps, idle sweep, no env or token logging. Tests: RefreshGroup dedup/cancellation, daemon render join semantics, transcript reuse/invalidation, usage identity gating (child-process probes, same style as usage-fetch.test.ts). Co-Authored-By: Claude Code <noreply@anthropic.com>
…emon-async-providers
Two review/CI findings for #18: - render-parity's NON_GIT_CWD was the macOS-only '/private/tmp'; the request-scoped custom-command cwd (#18) now spawns there, which is ENOENT on Linux CI. Use os.tmpdir() — exists everywhere, still outside any git repository. - RefreshGroup unregistered a settled job unconditionally, so a cancelled job settling after its abort-replacement wiped the replacement's registration and lost the single-flight window. Unregister only while still owning the key (same guard the daemon render jobs already had). Co-Authored-By: Claude Code <noreply@anthropic.com>
…review) Review findings on PR #49: - prefetch: the usage-credentials dedup key was global while credential resolution depends on CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR; two concurrent renders of different profiles could join one resolution and read the other account's usage. The key now carries both env values (absent-vs-empty preserved). - usage-fetch: fetchFromUsageApi read process.env for the API request options; it now takes the request env snapshot, so allowlisted HTTPS_PROXY/NO_PROXY reach the usage API on the daemon path. - ClaudeAccountEmail: reads the request's claude.json via getClaudeJsonPath(context.env) instead of the daemon's own profile. - git-review-cache: the whole async chain (git, gh, glab, ssh) accepts the request env and prefetchGitReview passes scope.env — request proxies no longer fall back to the daemon's environment. - RefreshGroup: unregister-race guard already landed in 0ec7af3; added the regression test (cancelled job settling after its replacement must not unregister the replacement). Co-Authored-By: Claude Code <noreply@anthropic.com>
axisrow
left a comment
There was a problem hiding this comment.
Self code-review of the full diff vs main, plus responses to the AO reviewer's changes_requested (all five findings addressed; last two commits 0ec7af3, a670f85). Verdict: approve — CI green (2721 tests / lint), one-shot behavior byte-identical.
AO reviewer findings — resolution
- Cross-profile usage leak via the global
usage-credentialskey (high) — fixed (a670f85). The dedup key now carriesCLAUDE_CONFIG_DIRandCLAUDE_SECURESTORAGE_CONFIG_DIRfrom the request snapshot (JSON-encoded, absent-vs-empty preserved), so two profiles can no longer join one credential resolution. fetchFromUsageApireadprocess.env(medium) — fixed. It now takes the request env snapshot (options.envthreaded through), so allowlistedHTTPS_PROXY/NO_PROXYreach the usage API on the daemon path.ClaudeAccountEmailreadprocess.env(medium) — fixed. UsesgetClaudeJsonPath(context.env).- Async git-review ignored request env (medium) — fixed. The whole chain (
runGitForCacheAsync,execFileTimeout, gh/glab/ssh helpers, provider candidates, origin ref) threadsenvfromGitReviewFetchOptions.env, andprefetchGitReviewpassesscope.env. - RefreshGroup unregister race (low) — fixed (
0ec7af3), plus the missing regression test.does not join a cancelled jobcouldn't catch it because its work never settled after abort; the new test settles the cancelled job after its replacement registers and asserts the replacement still owns the key (third refresh joins, work runs twice, not three times).
Also fixed along the way: render-parity's NON_GIT_CWD = '/private/tmp' is macOS-only — with #18's request-scoped custom-command cwd the sync path spawns there, ENOENT on Linux → [Cmd not found] vs golden bytes. Now os.tmpdir().
Verified clean
- env/cwd snapshot symmetry: nothing applies globals anymore, so there is no restore half to get wrong.
mergeRequestEnvironmentis pure (fresh object, allowlist set /Reflect.deleteProperty), renders readinvocation.env/cwd, request-scoped terminal width reads the snapshot before the memo. After the reviewer's fixes above, the remainingprocess.envreads on the daemon path are intentional daemon-own defaults (PATH/HOME ancestry for spawned providers), and one-shot behavior is unchanged (parity suite green). - Fingerprint gating: credentials resolve before the memory fast-return;
identityis thepreferredHash(undefined= never-populated scope,null= no-credentials scope);null-identity entries can only servenull-identity requests; the on-disk cache is hash-gated too. Child-process probes assert request sequences[1,1,2,3,3,3]across account switches and cross-account file-cache isolation. - Cancellation: RefreshGroup aborts only at zero consumers; an aborted job refuses joins; render jobs ownership-check their map slot; per-request release is wired once and guarded by the settled flag.
- Merged origin/main (#47 lifecycle, #48 install):
versionOverrideseam landed cleanly on the rewrittenserver.ts.
Documented limitation (per issue scope, not worked around in code)
Client-disconnect cancellation relies on request.on('close'), which Node fires on premature disconnects; bun 1.3.13 never surfaces them to node:http servers (socket probe verified — no close/end until first write). The wiring stays for Node deployments; under bun the release happens at settle. Cancellation semantics are covered deterministically at the RefreshGroup level.
🤖 Generated with Claude Code
axisrow
left a comment
There was a problem hiding this comment.
Обзор PR #49 (head a670f85) — повторный раунд после changes_requested
Вердикт: approved.
Полный дифф против main уже был отрецензирован в предыдущем раунде (против 7e2bf1c); с тех пор добавлены только 0ec7af3 и a670f85 — оба проверены. Все пять находок предыдущего раунда закрыты:
- Кросс-профильная утечка usage-данных — исправлена: ключ
usage-credentialsтеперь несёт CLAUDE_CONFIG_DIR и CLAUDE_SECURESTORAGE_CONFIG_DIR (JSON.stringify сохраняет absent-vs-empty), два профиля больше не джойнятся в одно разрешение credentials. - HTTPS_PROXY для usage API — исправлена: fetchFromUsageApi принимает env с дефолтом process.env, fetchUsageData пробрасывает options.env; one-shot-путь не изменён.
- ClaudeAccountEmail — исправлена: getClaudeJsonPath(context.env), дефолтный параметр сохраняет прежнее поведение в one-shot.
- env для async-цепочки git-review — исправлена: runGitForCacheAsync/execFileTimeout и вся цепочка до gh/glab/ssh принимают env, prefetchGitReview передаёт scope.env; при unset дети наследуют daemon-env, как и задумано.
- RefreshGroup unregister-race — исправлена в 0ec7af3 guardом владения ключом; в a670f85 добавлен корректный регрессионный тест (поздний settle отменённой job не разрегистрирует замену, третий refresh джойнится — work вызван дважды суммарно).
Дополнительных проблем в новых коммитах не найдено: ключ-строка детерминирован, дефолты env не меняют поведение без снапшота, threading полный без пропущенных вызовов. Ранее отмеченные принятые компромиссы (синхронный spawn для custom-команд с ttl 0, дублирование transcript-опций между prefetch и render с комментарием о синхронизации) остались задокументированными и не блокируют.
Мержу.
Summary
Implements the daemon-epic step of #18 on top of the merged IPC transport (#44): provider work moves into the daemon's scope via async twins, while widget output in one-shot (piped) mode is byte-identical — the legacy sync paths stay untouched.
src/daemon/provider-scope.ts): single-flight refresh per key; joining an aborted job starts fresh work; cancellation fires only when the last consumer releases, so one session disconnecting never kills a refresh another session needs.capMapgives shared maps a FIFO bound.process.env/cwdswapping is gone. Each request resolves through an explicit env/cwd snapshot (RenderInvocation.env/cwd); identical display contexts (config path + allowlisted env + cwd + width + payload) join one in-flight render (dedupedcounter); in-flight renders are bounded (503 on saturation); an idle sweeper reclaims provider caches after 5 quiet minutes.src/daemon/prefetch.ts): transcript analysis, usage (per credential fingerprint), service status, block metrics, and git/jj/review/custom-command warmups run async before sync formatting, handed to widgets throughRenderPrefetchso the sync section never spawns.git.ts/jj.ts(runGitArgsAsync/runJjArgsAsync),custom-command-capture.ts(captureCustomCommandAsync: bounded output, EPIPE tolerance, process-group SIGKILL),usage-fetch.ts(signal/env-scoped, async Keychain),git-review-cache.ts(fetchGitReviewDataAsync),jsonl-blocks.ts/jsonl-cache.ts(getBlockMetricsAsync).Tests
provider-scope.test.ts: RefreshGroup dedup, abort-on-last-release, no-abort-while-joined, cancelled jobs refuse joins, release-after-completion no-op; capMap eviction.server-dedup.test.ts: identical in-flight renders join one job; different payloads never join.transcript-reuse.test.ts: reuse by reference, concurrent-scan dedup, invalidation on append/truncation/subagent update/new subagent.usage-identity.test.ts: child-process probes (same style asusage-fetch.test.ts) prove request sequences[1,1,2,3,3,3]across account switches and that a foreign file cache is never read.bun test2694 pass / 0 fail;bun run lintgreen; one-shot pipe smoke verified.Known risks / notes
node:httpservers (verified with a socket probe — noclose/endevents until the first write), so therequest.on('close')release wiring is inert under bun and effective under Node deployments. Cancellation semantics themselves are covered deterministically at theRefreshGrouplevel.Closes #18
🤖 Generated with Claude Code