Skip to content

refactor(replay): make the daemon's artifact set the run's one ledger (#1478 P5 follow-up) - #1575

Open
thymikee wants to merge 1 commit into
mainfrom
claude/jolly-bohr-yumg41
Open

refactor(replay): make the daemon's artifact set the run's one ledger (#1478 P5 follow-up)#1575
thymikee wants to merge 1 commit into
mainfrom
claude/jolly-bohr-yumg41

Conversation

@thymikee

@thymikee thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member

Unwinds one of the two consciously-accepted seams from the P5 extraction (#1555), per maintainer disposition. One commit, not two — see "The second item was already fixed upstream" below.

The seam

Artifact-path accumulation became double-written during P5:

  • packages/ad-replay's step loop kept const artifactPaths = new Set<string>(), returned in the neutral AdReplayRunOutcome.
  • runReplayScriptFile kept an outer Set<string>() that only its exception handler read.
  • dispatchStep wrote both — adding entries to the daemon set and returning them for the engine to add to its own.

Two mutable collections, no single owner, kept in sync by hand.

The fix

The daemon's set is now the run's one ledger. dispatchStep stays its sole writer and returns its contents (cumulative for the run, not just the step's own entries); the engine drops its Set for a plain readonly string[] re-bound to whatever the capability last returned.

Per the maintainer's framing, AdReplayRunOutcome.artifactPaths stays a façade field — it is wire-relevant, since the daemon's success response reports it — but it is now a projection of what the capability handed back rather than an independent accumulation. Exactly one mutable collection remains, and it is the daemon's.

Tracing the exception path

The two old sets differed in exactly one way, and it took tracing to find: the engine's set also absorbed a divergence build's own fresh capture (buildTargetBindingFailure and friends, via recordFailure), which the daemon's set never saw — dispatchStep was its only writer.

That difference is observable in precisely one place: what the catch block reports if handleActionFailure itself throws. So those artifacts deliberately stay out of the ledger and reach handleActionFailure through a derived union (mergeArtifactPaths) instead — a value, not a write. A failing step always ends the run, so nothing downstream ever observes that union, and the exception path reports the same set byte-identically.

Naively folding them into the ledger would have been the obvious move and would have silently changed that report.

Counterfactual test

The ledger's one independent observation point is the catch block — on a mid-loop throw there is no run outcome to read artifacts from, so what surfaces comes from the ledger and nothing else. New test: two artifact-producing steps, then an unresolved ${VAR} on step 3 makes resolveReplayAction throw from inside the loop; the failure must report exactly those two artifacts.

Verified in both directions, then restored:

Break mid-loop-throw test completed-run test
Drop the dispatchStep ledger write red red
Return per-step entries instead of ledger contents green red

The two tests discriminate the two halves independently — the write and the threading.

The pinned pre-step-snapshot invariant is untouched: buildPostDispatchTargetBindingFailure still receives the pre-step snapshot, threaded as a parameter. step-loop.test.ts's existing fixtures needed no changes, which is the regression net doing its job.

The second item was already fixed upstream

This follow-up was also scoped to dedupe declaredScriptPlatform between the engine's inspect/digest path and the daemon's readScriptReplaySelection. That landed inside #1555 itself, in its structural-quality round: resolveDeclaredScriptPlatform is owned by packages/ad-script (internal/open-script.ts), exported from its façade, and imported by both call sites — packages/ad-replay/src/internal/inspect.ts:89 and src/daemon/replay-device-selection.ts:89. No declaredScriptPlatform symbol remains, and ad-script's existing boundary row already covers façade additions, so no layering-gate row needed changing.

Re-verified on merged main before starting. Nothing left to dedupe, hence one commit.

Gates

All green at head db53efba1:

Gate Result
pnpm typecheck pass
pnpm lint pass
pnpm format:check pass
pnpm check:layering 56 tests pass
pnpm check:replay-compat 10 scripts / 6 tags / 12 digest-pinned entries — digest bytes identical
npx vitest run packages src/daemon 255 files, 2179 tests pass

Generated by Claude Code

Artifact-path accumulation was double-written after the P5 extraction: the
engine's step loop kept its own `Set` while `runReplayScriptFile` kept an
outer `Set` that only its exception handler read, and `dispatchStep` wrote
both — two mutable collections with no single owner, kept in sync by hand.

The daemon's `Set` is now the run's only ledger. `dispatchStep` remains its
sole writer and returns its CONTENTS (cumulative for the run, not just the
step's own entries); the engine drops its `Set` for a plain `readonly
string[]` re-bound to whatever the capability last returned.
`AdReplayRunOutcome.artifactPaths` stays a façade field — it is wire-relevant,
the daemon's success response reports it — but is now a projection of what the
capability handed back rather than an independent accumulation.

The exception path is preserved byte-identically. The two old sets differed in
exactly one way: the engine's also absorbed a divergence build's own fresh
capture, which the daemon's never saw. Writing those into the ledger would
change what the catch block reports when `handleActionFailure` itself throws,
so they stay out of it and reach `handleActionFailure` through a derived union
(`mergeArtifactPaths`) instead — a value, not a write. A failing step always
ends the run, so nothing downstream observes that union.

Adds a counterfactual test at the ledger's one independent observation point:
a mid-loop throw (an unresolved `${VAR}` on step 3) after two artifact-producing
steps must report exactly those two artifacts. Dropping the `dispatchStep`
write fails it; returning per-step entries instead of the ledger fails its
companion completed-run assertion — verified in both directions, then restored.

The P5 `declaredScriptPlatform` duplicate this follow-up was also meant to
unwind landed inside #1555 itself (`resolveDeclaredScriptPlatform`, owned by
packages/ad-script, consumed by both the engine's inspect/digest path and the
daemon's `readScriptReplaySelection`), so there is nothing left to dedupe.

Gates: typecheck / lint / format:check / check:layering (56) /
check:replay-compat (12 digest-pinned entries) / vitest packages src/daemon
(255 files, 2179 tests) — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrwynLjFHMfz42PFBzoEwK
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.96 MB 1.96 MB +19 B
JS gzip 626.6 kB 626.6 kB +29 B
npm tarball 746.3 kB 746.3 kB +24 B
npm unpacked 2.61 MB 2.61 MB +19 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.4 ms 26.1 ms -0.2 ms
CLI --help 60.5 ms 61.5 ms +1.0 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session.js +19 B +29 B

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 3, 2026
@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head db53efba199af42fc7e8d9ea1d4b6773fd7345ae — no actionable findings. The daemon Set is now the sole mutable artifact ledger: dispatchStep writes it and returns the cumulative ordered projection, while the engine only rebinds that readonly value. Normal failures, pre/post-dispatch divergence captures, and the outer exception path preserve their prior artifact membership and ordering; the pinned pre-step snapshot invariant is unchanged. The new mid-loop-throw and completed-run tests discriminate ledger writes from cumulative threading, and exact-head cross-platform replay CI is green. Ready for human review.

Residual risk: the focused test stubs device invocation, but exact-head iOS/Android/macOS smoke covers the production replay route. No separately authorized cross-vendor review was run.

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants