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
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 keptconst artifactPaths = new Set<string>(), returned in the neutralAdReplayRunOutcome.runReplayScriptFilekept an outerSet<string>()that only its exception handler read.dispatchStepwrote 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.
dispatchStepstays its sole writer and returns its contents (cumulative for the run, not just the step's own entries); the engine drops itsSetfor a plainreadonly string[]re-bound to whatever the capability last returned.Per the maintainer's framing,
AdReplayRunOutcome.artifactPathsstays 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 (
buildTargetBindingFailureand friends, viarecordFailure), which the daemon's set never saw —dispatchStepwas its only writer.That difference is observable in precisely one place: what the catch block reports if
handleActionFailureitself throws. So those artifacts deliberately stay out of the ledger and reachhandleActionFailurethrough 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 makesresolveReplayActionthrow from inside the loop; the failure must report exactly those two artifacts.Verified in both directions, then restored:
dispatchStepledger writeThe two tests discriminate the two halves independently — the write and the threading.
The pinned pre-step-snapshot invariant is untouched:
buildPostDispatchTargetBindingFailurestill 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
declaredScriptPlatformbetween the engine's inspect/digest path and the daemon'sreadScriptReplaySelection. That landed inside #1555 itself, in its structural-quality round:resolveDeclaredScriptPlatformis owned bypackages/ad-script(internal/open-script.ts), exported from its façade, and imported by both call sites —packages/ad-replay/src/internal/inspect.ts:89andsrc/daemon/replay-device-selection.ts:89. NodeclaredScriptPlatformsymbol remains, and ad-script's existing boundary row already covers façade additions, so no layering-gate row needed changing.Re-verified on merged
mainbefore starting. Nothing left to dedupe, hence one commit.Gates
All green at head
db53efba1:pnpm typecheckpnpm lintpnpm format:checkpnpm check:layeringpnpm check:replay-compatnpx vitest run packages src/daemonGenerated by Claude Code