Skip to content

feat(daemon): async providers, scoped caches, refresh dedup, cancellation (#18) - #49

Merged
axisrow merged 4 commits into
mainfrom
ao/ccstatusline-19/daemon-async-providers
Sep 29, 2026
Merged

axisrow merged 4 commits into
mainfrom
ao/ccstatusline-19/daemon-async-providers

Conversation

@axisrow

@axisrow axisrow commented Sep 29, 2026

Copy link
Copy Markdown
Owner

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.

  • RefreshGroup (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. capMap gives shared maps a FIFO bound.
  • Daemon render path: process-global process.env/cwd swapping 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 (deduped counter); in-flight renders are bounded (503 on saturation); an idle sweeper reclaims provider caches after 5 quiet minutes.
  • 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, handed to widgets through RenderPrefetch so the sync section never spawns.
  • Async provider twins: 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).
  • Scoped caches: usage memory cache is gated by the credential fingerprint before the fast return — account switch refetches, and a no-credentials request never selects a logged-in entry; transcript whole-analysis reuse is validated by file identity (dev/ino/size/mtime) captured after the scan, with invalidation on truncation, append, replacement and subagent updates.
  • Bounded memory: capped caches/maps, idle sweep, no env or token contents in logs.

Tests

  • New 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.
  • New server-dedup.test.ts: identical in-flight renders join one job; different payloads never join.
  • New transcript-reuse.test.ts: reuse by reference, concurrent-scan dedup, invalidation on append/truncation/subagent update/new subagent.
  • New usage-identity.test.ts: child-process probes (same style as usage-fetch.test.ts) prove request sequences [1,1,2,3,3,3] across account switches and that a foreign file cache is never read.
  • Full suite: bun test 2694 pass / 0 fail; bun run lint green; one-shot pipe smoke verified.

Known risks / notes

  • Client-disconnect cancellation is Node-only: bun 1.3.13 never surfaces premature client disconnects to node:http servers (verified with a socket probe — no close/end events until the first write), so the request.on('close') release wiring is inert under bun and effective under Node deployments. Cancellation semantics themselves are covered deterministically at the RefreshGroup level.
  • Incremental transcript tail parsing and fs-scan cancellation stay out of scope (per Daemon: async providers, scoped caches, refresh dedup, cancellation #18's conditional follow-ups).

Closes #18

🤖 Generated with Claude Code

axisrow and others added 4 commits September 29, 2026 18:29
…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>
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 axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Cross-profile usage leak via the global usage-credentials key (high) — fixed (a670f85). The dedup key now carries CLAUDE_CONFIG_DIR and CLAUDE_SECURESTORAGE_CONFIG_DIR from the request snapshot (JSON-encoded, absent-vs-empty preserved), so two profiles can no longer join one credential resolution.
  2. fetchFromUsageApi read process.env (medium) — fixed. It now takes the request env snapshot (options.env threaded through), so allowlisted HTTPS_PROXY/NO_PROXY reach the usage API on the daemon path.
  3. ClaudeAccountEmail read process.env (medium) — fixed. Uses getClaudeJsonPath(context.env).
  4. Async git-review ignored request env (medium) — fixed. The whole chain (runGitForCacheAsync, execFileTimeout, gh/glab/ssh helpers, provider candidates, origin ref) threads env from GitReviewFetchOptions.env, and prefetchGitReview passes scope.env.
  5. RefreshGroup unregister race (low) — fixed (0ec7af3), plus the missing regression test. does not join a cancelled job couldn'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. mergeRequestEnvironment is pure (fresh object, allowlist set / Reflect.deleteProperty), renders read invocation.env/cwd, request-scoped terminal width reads the snapshot before the memo. After the reviewer's fixes above, the remaining process.env reads 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; identity is the preferredHash (undefined = never-populated scope, null = no-credentials scope); null-identity entries can only serve null-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): versionOverride seam landed cleanly on the rewritten server.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 axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обзор PR #49 (head a670f85) — повторный раунд после changes_requested

Вердикт: approved.

Полный дифф против main уже был отрецензирован в предыдущем раунде (против 7e2bf1c); с тех пор добавлены только 0ec7af3 и a670f85 — оба проверены. Все пять находок предыдущего раунда закрыты:

  1. Кросс-профильная утечка usage-данных — исправлена: ключ usage-credentials теперь несёт CLAUDE_CONFIG_DIR и CLAUDE_SECURESTORAGE_CONFIG_DIR (JSON.stringify сохраняет absent-vs-empty), два профиля больше не джойнятся в одно разрешение credentials.
  2. HTTPS_PROXY для usage API — исправлена: fetchFromUsageApi принимает env с дефолтом process.env, fetchUsageData пробрасывает options.env; one-shot-путь не изменён.
  3. ClaudeAccountEmail — исправлена: getClaudeJsonPath(context.env), дефолтный параметр сохраняет прежнее поведение в one-shot.
  4. env для async-цепочки git-review — исправлена: runGitForCacheAsync/execFileTimeout и вся цепочка до gh/glab/ssh принимают env, prefetchGitReview передаёт scope.env; при unset дети наследуют daemon-env, как и задумано.
  5. RefreshGroup unregister-race — исправлена в 0ec7af3 guardом владения ключом; в a670f85 добавлен корректный регрессионный тест (поздний settle отменённой job не разрегистрирует замену, третий refresh джойнится — work вызван дважды суммарно).

Дополнительных проблем в новых коммитах не найдено: ключ-строка детерминирован, дефолты env не меняют поведение без снапшота, threading полный без пропущенных вызовов. Ранее отмеченные принятые компромиссы (синхронный spawn для custom-команд с ttl 0, дублирование transcript-опций между prefetch и render с комментарием о синхронизации) остались задокументированными и не блокируют.

Мержу.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Daemon: async providers, scoped caches, refresh dedup, cancellation

1 participant