Skip to content

refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5) - #1555

Merged
thymikee merged 31 commits into
mainfrom
p5/extract-ad-replay
Aug 3, 2026
Merged

refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5)#1555
thymikee merged 31 commits into
mainfrom
p5/extract-ad-replay

Conversation

@thymikee

@thymikee thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member

refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5)

Implements P5 per the approved design of record (proposal, binding amendment), built as staged, individually-gated commits on one branch, cutting over atomically in this PR. Step 2 of the approved sequence (behavior pinning) merged separately as #1552 and is this PR's regression net — none of those tests' assertions changed anywhere in this branch. All four findings from the first review are addressed in commits 281f33599..947b90ca3; the response comment maps them one-by-one.

The façade — final shape (structural-quality round)

packages/ad-replay exports exactly inspectAdReplay and runAdReplay as values, plus the enumerated neutral runtime vocabulary (20 types: runtime bag, outcomes/failures, manifest, evidence, and the ReplaySelectorPort family) — the maestro-shaped typed façade, pinned by the exact-symbol gate in scripts/layering/package-boundaries.test.ts (oxc static-export table; bare export * and default exports throw; plant-verified). Package exports map is exactly ".".

The façade design went through a deliberate reversal during review: an earlier round required hiding all types behind entrypoint signatures, which produced a root-side derivation shim, four daemon twin types, and a readonly→mutable copy translator; the structural-quality review reversed that rule, and this branch now exports the typed vocabulary directly — shim, twins, and translator deleted, daemon consumers use the engine's own types.

Engine internals follow the maestro precedent: runtime-port-types.ts (374 LOC, boundary vocabulary), verify-dispatch.ts (245, verify-then-dispatch policy), step-loop.ts (237, the loop + terminal-close). The daemon engine-adapter is 314 LOC (target was <300; the overage is the lastObservation per-step fix's load-bearing comment, kept deliberately). The engine's parse gate honors the port's own contract (readSelectorExpression('ordinary', [token]), with a contract-suite cell pinning adapter parity on malformed bare tokens).

  • Parsing/planning/digest/resume behind the entrypoints: inspectAdReplay's manifest carries planDigest/resolveEntryIndex; resume validation stays eager by design (a rejected --from must not mutate coordinator state — pinned by test).
  • Target verification runs inside the engine step loop via narrow daemon capabilities; mismatch evidence crosses as typed values (AdReplayGuardMismatchEvidence/AdReplayLandmarkMismatchEvidence), never a wire details bag.
  • Neutral outcomes: no generic, no DaemonResponse in either direction; the daemon side-map preserves the exact wire object.
  • Variables: the engine builds the scope and resolves each action exactly once (${VAR} interpolation is a linear scanner in ad-script after a CodeQL polynomial-redos find — 200k-trial differential fuzz, zero mismatches); scrub values are computed once per step under one name.

Shared vocabulary went to its owner

Per the review's "proper shared owner" alternative, measured per symbol: the identity vocabulary (annotationLocalIdentity, matchesLocalIdentity, matchesAncestryPrefix, LocalIdentity, identityFieldMismatches, firstAncestryMismatch) and classifyTargetBindingMatch (+ vars.ts) moved to packages/ad-script — they interpret .ad/TargetAnnotationV1 semantics and are consumed by recording-side root code (and, for vars, the Maestro path). session-replay-report-action.ts / session-replay-suggestion-ranking.ts measured as root-only consumers and moved back to the daemon (undoing an over-move). ad-script's façade additions are covered by its existing boundary row.

The selector port

Three operations (readSelectorExpression, resolveRecordedTarget — same-alternative winner+domain invariant implemented in the production adapter, lifted verbatim; buildSelectorCandidates), trafficking only in strings, kernel snapshot values, and tagged unions. Production adapter: src/daemon/replay-selector-port.ts. In-memory adapter: src/__tests__/test-utils/in-memory-replay-selector-port.ts — now honors ReplaySelectorCandidateOptions.nodes with production's exact shared-ID drop semantics. Contract suite: 9 cells × 2 adapters (18 tests), including the shared-ID demotion cell (counterfactual: ignoring nodes fails the in-memory leg while production stays green). Two AST-needing helpers (resolveReplaySuggestionCandidate, readReplaySelectorDisplayValue) are daemon-side plain exports beside the adapter — provably inexpressible through the port without leaking the AST.

Invalid-backend rejection restored

prepareReplayPlan rejects any non-maestro replayBackend with the byte-identical INVALID_ARGS message from main, before any inspection or session work. Handler-level regression test proves zero step dispatch (counterfactual: without the guard, the script executed); a companion test pins that maestro still routes.

What moved / stayed (final)

  • Package: step loop + verify-and-dispatch, inspect/manifest, digest/resume internals, verification policy, selector-port type family.
  • ad-script: annotation identity vocabulary, binding classification, ${VAR} vars module (shared with the Maestro path).
  • Daemon (root): request admission, invalid-backend gate, P4b coordinator (sole transaction owner, ownership test untouched), capture/dispatch/publication/artifacts, wire builders consuming neutral evidence values, Maestro dispatch and format.ts routing (above both engines), report-action + suggestion-ranking, sanitizeIdentity/describeCandidate (pinned by snapshot-lines), target-identity-node.ts/target-evidence-tree.ts (shared with dispatch/recording).

Rebase: #1554's keep-session absorbed into the engine (head e6cbe6b76)

After #1554 merged, this branch rebased onto main and folded its terminal-lifecycle policy into the P5 architecture rather than keeping a parallel decider: resolveSuppressedTerminalCloseIndex/countExecutedReplayActions unified with the engine's repair terminal-close predicate inside the step loop (one OR'd suppression condition, keepSession || runtime.isRepairArmed(), checked dynamically after armStep so a first-time --save-script arm is visible); AdReplayRunRequest gained one field (keepSession); the daemon's session-replay-terminal-lifecycle.ts module is deleted (no duplicate isExecutableReplayAction anywhere); the SessionStore postcondition stays daemon-side. #1554's six unit tests pass unchanged end-to-end, plus five new package-internal runAdReplay tests cover the unified policy directly. The replayed count is now a per-dispatched-step counter (fixing the old approximation that over-counted nested-replay markers).

Decomposition: the daemon adapter is now four cohesive modules

session-replay-runtime.ts went from ~1100 lines (post-rebase) to 242 — thin orchestration only. Extracted along its natural seams: session-replay-runtime-engine-adapter.ts (473: the runtime-bag capabilities, build*Failure implementations, side-map mechanics), session-replay-runtime-plan.ts (261: backend validation, manifest inspection, resume-index resolution, Maestro routing), session-replay-runtime-session.ts (219: session preparation, repair preflight, save-script arming). Coordinator construction stays solely in the orchestrator — the ownership test passes untouched, no allowlist changes.

Two load-bearing ordering invariants discovered during the extraction are now pinned by counterfactual-verified tests: post-dispatch mismatch divergences report the pre-step artifact snapshot (engine test; counterfactual red showed the failed dispatch's own artifacts leaking in), and a rejected --from/--plan-digest never reaches prepareReplaySession's coordinator-mutating writes (plan test; counterfactual red showed pendingRecordAndHeal being cleared before the failure).

Gates (re-run at every stage; latest full chain at head e6cbe6b76)

typecheck / lint / format:check / check:layering (53 tests incl. the new exact-symbol gate) / check:replay-compat (10 mined scripts, 6 tags, 12 digest-pinned entries) / fallow — green. Full vitest: 5351/5352 at the last full run with the only failures being the documented contention-timeout class (isolate-rerun green; the two pid-liveness assertion races are fixed separately in #1556). Fallow baseline: one surgical 8-line addition for the relocated in-memory adapter; full regen deliberately rejected (would silently drop 12 unrelated stale entries).

Live evidence (exact head 947b90ca3, round 2)

Standard suites:

Leg Scenario Result Time
Android (Pixel_7_CI, Release APK, android-helper 0.20.3 probe-verified) checkout-form-android.ad PASS 23.7s
Android gesture-lab-android.ad PASS 12.3s
iOS (iPhone 17 Pro sim) gesture-lab.ad PASS 31.0s
iOS checkout-form.ad BLOCKED — #1542 (known; one clean attempt; Android twin passes, engine exonerated) 15.1s

Extended evidence (Android, constructed via the CLI's own record/replay loop; verbatim log in artifacts):

  • target-v1 + save-script: recorded flow with open --save-script; saved script carries # agent-device:target-v1 {"id":"refresh-metrics","role":"button",...,"verification":"verified"}. (Finding: annotations require arming at openclose --save-script alone yields selector chains without target-v1 evidence, per session-open-surface.ts arming semantics.)
  • Verification green path: replay of the annotated script — 6 steps, clean pass.
  • Divergence red path: replay against the wrong screen → REPLAY_DIVERGENCE, classification selector-miss (matchCount 0) — recorded target evidence did not verify, with a record-and-heal repair suggestion including --from 3 --plan-digest 8ff7b932….
  • Resume: replay --from 3 --plan-digest <hash> after correcting the screen — Replayed 2 steps in 0.8s, completed cleanly.
  • Save-script repair: full record-and-heal loop — armed diverging replay, live-corrected via a blessed @ref, resumed, explicit close --save-script committed the repair; repaired script ends with # agent-device:heal-complete, carries fresh "verification":"verified" annotations for every step, and replays green (6 steps, 4.0s). Behavioral observation (informational, consistent with the repair-transaction commit boundary): an armed session's --from resume does not auto-run a trailing scripted bare close; the explicit close commits.

Artifacts: /private/tmp/ad-p5-live-artifacts-r2/ (28 files incl. the verbatim command log, original/divergence/repaired scripts, per-attempt replay trees). Round-1 artifacts at the pre-review head remain in /private/tmp/ad-p5-live-artifacts/.

Residual risks

Later rounds (each gated green)

Evidence status (final): rounds 1–4 of live evidence across the branch's history proved both platform suites, the extended target-v1/divergence/resume/keep-session/save-script-rejection/${VAR} legs, and the #1558/#1563 compose checks (verbatim details in the PR comments). At the final head, a controlled 2×2 attribution matrix (main vs. this branch, same simulator) confirmed the one remaining iOS checkout flake is a main-side contention-triggered gap in the #1563/#1566 interaction — filed as #1569 with full mechanism evidence (stale_accept saturates at the cap on every run, main included; the wall-clock retry budget starves under load) — explicitly not a finding against this branch (P5 touches no iOS capture code; main passed and failed identically). Merged on maintainer decision with #1569 as the documented residual, parallel fix in progress; a final quiet-host evidence round was in flight at merge time and its results follow up on this PR.

Generated by Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-03 15:25 UTC

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.94 MB 1.94 MB +4.9 kB
JS gzip 620.4 kB 621.3 kB +958 B
npm tarball 740.4 kB 741.4 kB +1.0 kB
npm unpacked 2.59 MB 2.60 MB +4.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.1 ms 29.3 ms +1.2 ms
CLI --help 66.4 ms 67.2 ms +0.8 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session.js +6.1 kB +1.7 kB
dist/src/agent-device-client.js -224 B -90 B
dist/src/cli.js -162 B -60 B
dist/src/cli-help.js -94 B -47 B
dist/src/registry.js -27 B -37 B

@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Review verdict: request changes / not ready at 62cde8db9.

  • [P1] Preserve invalid-backend rejection. Before this extraction, parseReplayInput rejected any .ad request whose replayBackend was set to an unknown value with INVALID_ARGS. The new native path falls through resolveReplayFormat and calls inspectAdReplay, which receives no flags; the remaining advisory parse in device selection catches and discards the error. A raw .ad replay with replayBackend: unknown`` can now execute. Restore validation in the authoritative replay path and add a real handler/router regression test proving no step dispatch occurs.

  • [P1] Complete the binding façade instead of documenting deviations. The approved P5 amendment requires the package root to expose only inspectAdReplay and runAdReplay, with parsing, variables, planning, digest/resume, verification, divergence, and neutral outcomes private. packages/ad-replay/src/index.ts instead exports broad vars/digest/identity/verification/ranking/selector policy, and root handlers import those directly. That preserves the ownership smear P5 exists to remove. Move those consumers behind the two entrypoints (or keep genuinely shared recording vocabulary in its proper shared owner), then add an exact exported-symbol shape gate—not only an exports-subpath gate.

  • [P1] Do not smuggle daemon wire failures through a generic. AdReplayStepRuntime<TResponse> is instantiated as AdReplayStepRuntime<DaemonResponse>, and runAdReplay returns {ok:false,response:TResponse}. Hiding the type parameter does not make the outcome neutral: opaque daemon wire/error data crosses and returns through the engine. Replace it with explicit neutral tagged execution/failure outcomes and map them to DaemonResponse only in the daemon adapter; parsing/planning/digest/resume must also occur behind runAdReplay per the accepted façade.

  • [P2] Make the second selector adapter conform. The in-memory adapter ignores ReplaySelectorCandidateOptions, including nodes, while the production adapter uses it for shared-ID demotion. The dual-adapter contract only tests a unique ID, so it cannot prove the binding amendment's shared-ID-demotion cell. Make both adapters honor the same contract and add a shared-ID case that drops the ID candidate on both.

The selector port direction, package dependency direction, coordinator ownership, and current CI are otherwise clean. The PR should remain draft: #1478 still requires both exact-head live replay suites plus target-v1 verification, divergence, resume, and save-script repair evidence; the iOS checkout leg is currently blocked by #1542. No ready-for-human label until the code findings and live-readiness blockers are cleared.

thymikee added a commit that referenced this pull request Aug 2, 2026
thymikee added a commit that referenced this pull request Aug 2, 2026
thymikee added a commit that referenced this pull request Aug 2, 2026
…inspectAdReplay (#1555 review)

P1 "do not smuggle daemon wire failures through a generic": drop the
TResponse generic from AdReplayStepRuntime/runAdReplay. executeStep and
handleActionFailure now return neutral tagged AdReplayStepOutcome/
AdReplayStepFailure values (kind/message/artifactPaths only); runAdReplay
returns a neutral completed/failed AdReplayRunOutcome. The engine never
holds or returns a DaemonResponse. The daemon adapter
(createAdReplayStepRuntime, session-replay-runtime.ts) keeps its real wire
response in a local side-map as it builds each neutral outcome, and
runReplayScriptFile reads it back once runAdReplay reports which step
failed, so the final response is byte-identical to before this split.

P1 "parsing/planning/digest/resume must also occur behind runAdReplay":
relocate computeReplayPlanDigest's call site and the --from/--plan-digest
resume-point math (resolveReplayEntryIndex) behind inspectAdReplay's
manifest as planDigest and a resolveEntryIndex closure. Neither is a new
top-level export -- inspectAdReplay/runAdReplay stay the only two. Timing
is preserved exactly (still called eagerly in prepareReplayPlan, before
prepareReplaySession's coordinator-mutating side effects) since moving
resume validation to run inside runAdReplay itself would let a rejected
--from request mutate coordinator/session state first -- a real ordering
hazard, not just a cosmetic one.

computeReplayPlanDigest/ReplayPlanDigestMetadata/resolveReplayEntryIndex
leave the ad-replay façade; request-router-repair-expired.test.ts and
prepareReplayPlan read the digest/resume result off the manifest instead.
thymikee added a commit that referenced this pull request Aug 2, 2026
…replay façade (#1555 review)

P1 "complete the binding façade instead of documenting deviations":
classifyTargetBindingMatch never had a real consumer reachable through
inspectAdReplay/runAdReplay -- both its callers (the daemon's record-time
self-check in session-target-evidence.ts and its replay-time
classification wrapper in session-replay-target-classification.ts) are
daemon files that imported it directly. It interprets TargetAnnotationV1
evidence semantics shared beyond the engine, so it moves to
packages/ad-script alongside target-annotation-identity.ts (new
target-annotation-classification.ts + its test), and both daemon call
sites now import it from there instead of @agent-device/ad-replay.

One deviation remains and is reported rather than papered over per the
review's own instruction: the four target-verification policy functions
(planPreDispatchTargetVerification, planPostResolutionTargetVerification,
deriveReplayTargetGuardMismatchEvidence,
deriveWaitLandmarkMismatchEvidence) and the ReplaySelectorPort type
family stay exported. Their sole caller,
session-replay-target-verification.ts, interleaves these pure decisions
with daemon-only async work (capture, SessionStore, coordinator/resume
stamping, wire shaping) that must stay outside the engine by design;
moving their call sites to live only behind runAdReplay would require
restructuring that whole orchestration into new fine-grained
AdReplayStepRuntime capabilities, which is out of scope for this pass.
See packages/ad-replay/src/index.ts's header comment for the full
reasoning.

P1 "add the reviewer-required exact exported-symbol gate": adds
readNamedExports (scripts/layering/package-boundaries.ts), a small
parser over a façade's `export { .. } from`, `export type { .. } from`,
and direct-declaration forms, and pins @agent-device/ad-replay's exact
21-symbol export list in package-boundaries.test.ts. Plant-verified: a
stray `export const` addition failed the assertion; removed it and the
gate went green again.
thymikee added a commit that referenced this pull request Aug 2, 2026
…#1555 review)

Moves the verify-then-dispatch decision flow into packages/ad-replay's
step loop so the four target-verification policy functions
(plan{PostResolution,PreDispatch}TargetVerification,
derive{ReplayTargetGuardMismatch,WaitLandmark}MismatchEvidence) become
engine-private and leave the ad-replay façade. The daemon
(session-replay-target-verification.ts) shrinks to the narrow
AdReplayStepRuntime capabilities the engine drives: routing
(beginTargetVerification), capture (captureObservation), classification
(classifyTarget), dispatch (dispatchStep), and wire-building
(buildRecordedUnverifiableFailure, buildTargetBindingFailure,
buildPostDispatchTargetBindingFailure). Wire output and replay-compat
stay byte-identical; the exact-symbol façade gate is updated to the
shrunken export list.
@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Re-review verdict: still request changes at 947b90ca3.

Resolved since 62cde8db9: unknown replay backends are rejected before dispatch; both selector adapters now honor shared-ID demotion; runAdReplay drives the production verification loop and coordinator ownership remains singular.

Remaining blockers:

  • [P1] Enforce the approved two-entrypoint boundary. packages/ad-replay/src/index.ts still exports a broad symbol set (including formatReplaySuccessMessage and selector/runtime types), and the new exact-symbol gate pins those extras instead of the approved inspectAdReplay / runAdReplay façade. Planning/variable assembly also remains daemon-side in session-replay-runtime.ts, though P5 assigns it to the engine.
  • [P1] Keep daemon wire details out of the engine. The adapter passes response.error.details verbatim as Record<string, unknown> and engine policy reads it to derive mismatch evidence. Replace this with an explicit neutral tagged mismatch payload built by the daemon adapter.
  • [P1] Preserve the terminal-executable seam from feat: keep replay session active on request #1554 before integration. This head still tests terminal close against the raw final action index and returns actions.length - entryIndex; trailing non-executable replay markers can therefore dispatch a repair close, and skipped actions are counted. Rebase/sequence with feat: keep replay session active on request #1554 and retain its last-executable-action semantics and postcondition.
  • [P1] Restore the exact-head quality gate. Fallow fails on new dispatchStep / prepareReplayPlan complexity and the 231-line runtime adapter; this is owner-action, not infrastructure. Split by domain question rather than baselining the new debt.

The prior live evidence is for 62cde8db9, not this head. After code blockers clear, run the required exact-head target-v1/divergence/resume/save-script suites; iOS remains explicitly blocked by #1542. Keep the PR draft and do not apply ready-for-human.

@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

All four findings addressed at head 947b90ca3 (commits 281f33599..947b90ca3), with round-2 live evidence at the exact head — point by point:

P1 invalid-backend rejection — restored at the authoritative point (prepareReplayPlan entry, before inspection or any session work), byte-identical INVALID_ARGS message vs main. Handler-level regression test proves zero step dispatch (counterfactual: with the guard removed, the script executed — expected true to be false); a companion test pins that maestro still routes.

P1 complete the façade — done, no deviations remain. Shared vocabulary went to its proper owner (packages/ad-script): the annotation identity functions, classifyTargetBindingMatch, and vars (shared with the Maestro path); report-action/ranking measured as root-only and moved back to the daemon. Everything else is package-private; parsing/planning/digest/resume run behind the entrypoints (inspectAdReplay's manifest carries planDigest/resolveEntryIndex; resume validation stays eager by design — moving it inside runAdReplay would let a rejected --from mutate coordinator state before failing). The requested exact exported-symbol gate now pins the named export list in package-boundaries.test.ts (plant-verified: a stray export fails with a clear diff).

P1 no wire smuggling — the TResponse generic is gone. The engine returns neutral tagged outcomes; mismatch evidence crosses as values into daemon-side build*Failure capabilities; the daemon adapter keeps a side-map so the client-visible DaemonResponse is the literal same object as before (replay-compat green; the only DaemonResponse mentions inside the package are comments stating its absence). Target verification itself now runs inside the engine step loop via narrow capture/classify/dispatch capabilities, with the per-step order — including the pre-step artifact-snapshot subtlety on post-dispatch mismatches — mapped before/after and preserved.

P2 second adapter conformance — the in-memory adapter honors ReplaySelectorCandidateOptions.nodes with production's exact shared-ID drop semantics (read from build.ts, not guessed); a shared-ID cell now runs on both adapters (9 cells × 2 = 18 green). Counterfactual: reverting the in-memory demotion fails its leg while production stays green.

Live-readiness — round-2 evidence at 947b90ca3 (PR body has the tables; artifacts in /private/tmp/ad-p5-live-artifacts-r2/ incl. a verbatim command log): both standard suites re-ran (Android 2/2; iOS gesture-lab PASS; checkout leg blocked by #1542, one clean attempt, Android twin passing), plus the four named behaviors constructed live via the CLI's own loop — target-v1 annotations recorded and quoted, green-path verification, a real selector-miss divergence with its record-and-heal suggestion, --from/--plan-digest resume, and the full repair loop ending in a # agent-device:heal-complete script that replays green. One informational observation from the repair leg: an armed session's --from resume does not auto-run a trailing scripted bare close (the explicit close --save-script commits) — consistent with the repair-transaction commit boundary, reported rather than worked around.

Remaining ready-for-human blocker: #1542 (iOS checkout leg). The PR stays draft until that's fixed or the corpus is repaired without weakening coverage.

Generated by Claude Code

@thymikee
thymikee marked this pull request as ready for review August 2, 2026 16:34
@thymikee
thymikee marked this pull request as draft August 2, 2026 16:39
@thymikee
thymikee force-pushed the p5/extract-ad-replay branch from 5a4f4c2 to e6cbe6b Compare August 2, 2026 17:26
thymikee added a commit that referenced this pull request Aug 2, 2026
thymikee added a commit that referenced this pull request Aug 2, 2026
thymikee added a commit that referenced this pull request Aug 2, 2026
…inspectAdReplay (#1555 review)

P1 "do not smuggle daemon wire failures through a generic": drop the
TResponse generic from AdReplayStepRuntime/runAdReplay. executeStep and
handleActionFailure now return neutral tagged AdReplayStepOutcome/
AdReplayStepFailure values (kind/message/artifactPaths only); runAdReplay
returns a neutral completed/failed AdReplayRunOutcome. The engine never
holds or returns a DaemonResponse. The daemon adapter
(createAdReplayStepRuntime, session-replay-runtime.ts) keeps its real wire
response in a local side-map as it builds each neutral outcome, and
runReplayScriptFile reads it back once runAdReplay reports which step
failed, so the final response is byte-identical to before this split.

P1 "parsing/planning/digest/resume must also occur behind runAdReplay":
relocate computeReplayPlanDigest's call site and the --from/--plan-digest
resume-point math (resolveReplayEntryIndex) behind inspectAdReplay's
manifest as planDigest and a resolveEntryIndex closure. Neither is a new
top-level export -- inspectAdReplay/runAdReplay stay the only two. Timing
is preserved exactly (still called eagerly in prepareReplayPlan, before
prepareReplaySession's coordinator-mutating side effects) since moving
resume validation to run inside runAdReplay itself would let a rejected
--from request mutate coordinator/session state first -- a real ordering
hazard, not just a cosmetic one.

computeReplayPlanDigest/ReplayPlanDigestMetadata/resolveReplayEntryIndex
leave the ad-replay façade; request-router-repair-expired.test.ts and
prepareReplayPlan read the digest/resume result off the manifest instead.
thymikee added a commit that referenced this pull request Aug 2, 2026
…replay façade (#1555 review)

P1 "complete the binding façade instead of documenting deviations":
classifyTargetBindingMatch never had a real consumer reachable through
inspectAdReplay/runAdReplay -- both its callers (the daemon's record-time
self-check in session-target-evidence.ts and its replay-time
classification wrapper in session-replay-target-classification.ts) are
daemon files that imported it directly. It interprets TargetAnnotationV1
evidence semantics shared beyond the engine, so it moves to
packages/ad-script alongside target-annotation-identity.ts (new
target-annotation-classification.ts + its test), and both daemon call
sites now import it from there instead of @agent-device/ad-replay.

One deviation remains and is reported rather than papered over per the
review's own instruction: the four target-verification policy functions
(planPreDispatchTargetVerification, planPostResolutionTargetVerification,
deriveReplayTargetGuardMismatchEvidence,
deriveWaitLandmarkMismatchEvidence) and the ReplaySelectorPort type
family stay exported. Their sole caller,
session-replay-target-verification.ts, interleaves these pure decisions
with daemon-only async work (capture, SessionStore, coordinator/resume
stamping, wire shaping) that must stay outside the engine by design;
moving their call sites to live only behind runAdReplay would require
restructuring that whole orchestration into new fine-grained
AdReplayStepRuntime capabilities, which is out of scope for this pass.
See packages/ad-replay/src/index.ts's header comment for the full
reasoning.

P1 "add the reviewer-required exact exported-symbol gate": adds
readNamedExports (scripts/layering/package-boundaries.ts), a small
parser over a façade's `export { .. } from`, `export type { .. } from`,
and direct-declaration forms, and pins @agent-device/ad-replay's exact
21-symbol export list in package-boundaries.test.ts. Plant-verified: a
stray `export const` addition failed the assertion; removed it and the
gate went green again.
thymikee added a commit that referenced this pull request Aug 2, 2026
…#1555 review)

Moves the verify-then-dispatch decision flow into packages/ad-replay's
step loop so the four target-verification policy functions
(plan{PostResolution,PreDispatch}TargetVerification,
derive{ReplayTargetGuardMismatch,WaitLandmark}MismatchEvidence) become
engine-private and leave the ad-replay façade. The daemon
(session-replay-target-verification.ts) shrinks to the narrow
AdReplayStepRuntime capabilities the engine drives: routing
(beginTargetVerification), capture (captureObservation), classification
(classifyTarget), dispatch (dispatchStep), and wire-building
(buildRecordedUnverifiableFailure, buildTargetBindingFailure,
buildPostDispatchTargetBindingFailure). Wire output and replay-compat
stay byte-identical; the exact-symbol façade gate is updated to the
shrunken export list.
thymikee added a commit that referenced this pull request Aug 2, 2026
…les (#1555)

Splits the ~1096-line replay runtime into cohesive pieces, keeping
session-replay-runtime.ts as thin orchestration (~240 LOC):

- session-replay-runtime-engine-adapter.ts: the AdReplayStepRuntime
  adapter (createAdReplayStepRuntime, the build*Failure capability
  implementations, and the lastResponse/lastObservation side-map
  mechanics), extracted verbatim.
- session-replay-runtime-plan.ts: extended with the plan-side helpers
  (validateReplayBackendFlag, inspectReplayPlanManifest,
  resolveReplayPlanEntryIndex, prepareReplayPlan, routeMaestroReplay)
  alongside the buildReplayMetadataFlags helper already there —
  buildReplayMetadataFlags is now module-private since its one caller
  moved into the same file. Also introduces ReplayScriptFileParams,
  named here (instead of derived via Parameters<typeof
  runReplayScriptFile>) so routeMaestroReplay can reference the shape
  without importing back from session-replay-runtime.ts.
- session-replay-runtime-session.ts (new): session preparation
  (prepareReplaySession and its coordinator arming/repair-preflight
  helpers), extracted verbatim.

Coordinator ownership is unchanged: createReplayCoordinator is still
constructed only in session-replay-runtime.ts, matching
replay-coordinator-ownership.test.ts's allowlist as-is — every
extracted module receives the already-constructed ReplayCoordinator as
a parameter. Pure move; no behavior change.
thymikee added a commit that referenced this pull request Aug 2, 2026
…tion (#1555)

Two invariants found during the P5 decomposition pass now have direct
counterfactual-verified coverage:

- packages/ad-replay/src/internal/__tests__/step-loop.test.ts: a
  post-dispatch target-binding mismatch (dispatchWithGuard) must report
  the accumulated PRE-step artifact snapshot it was called with, never
  the artifacts the failed dispatch itself produced. Verified red by
  swapping the buildPostDispatchTargetBindingFailure call to
  outcome.artifactPaths.

- src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts: a
  rejected --from/--plan-digest resume must never reach
  prepareReplaySession's coordinator-mutating writes (the R2 ordering
  invariant) — a pre-armed repair transaction and corrective-resume
  watermark are asserted byte-for-byte unchanged after rejection.
  Verified red by calling prepareReplaySession before honoring the
  plan-validation rejection.
@thymikee

thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Resync complete at head e6cbe6b76, all CI green — requesting re-review.

Since the redraft: rebased onto main absorbing #1554 (its terminal-lifecycle policy folded INTO the engine step loop, unified with the repair terminal-close predicate as one suppression condition; the daemon module deleted; all six #1554 unit tests pass unchanged end-to-end, plus five new package-internal runAdReplay tests on the unified policy). Then the maintainer-approved decomposition: session-replay-runtime.ts is now 242 lines of orchestration over three extracted modules (engine adapter 473, plan 261, session prep 219); coordinator construction unmoved, ownership test untouched. Both load-bearing ordering invariants found during the extraction are pinned with counterfactual-verified tests: pre-step artifact snapshot on post-dispatch mismatches (red showed the failed dispatch's artifacts leaking), and rejected --from never reaching coordinator-mutating writes (red showed pendingRecordAndHeal cleared before failure).

Round-3 live evidence at exact head e6cbe6b76 (artifacts /private/tmp/ad-p5-live-artifacts-r3/): Android suite 2/2 (checkout 20 steps 12.7s, gesture-lab 32 steps 20.3s, helper backend/version probe-verified); iOS gesture-lab 31 steps PASS; iOS checkout blocked by still-open #1542 (one clean attempt; partial fix for it is up separately as #1559 with the remaining stabilization defect awaiting a design decision). NEW: keep-session live check--keep-session replay ran 19/20 steps (authored terminal close suppressed), session verifiably alive afterward with populated app state, explicit close clean — proving #1554's feature rides the extracted engine. Divergence/resume spot-check: mutated script → REPLAY_DIVERGENCE selector-miss with repair hint, live correction, --from 12 --plan-digest resume completed 9 steps green.

Prior review findings all addressed in 281f33599..947b90ca3 (see the point-by-point comment above); the façade carries zero deviations with the exact-symbol gate enforcing it. Remaining ready-for-human blocker: #1542's second defect (post-gesture stabilization semantics — options documented in #1559 for a maintainer decision).

Generated by Claude Code

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Review findings at e6cbe6b:\n\n- [P1] Enforce the accepted two-entrypoint facade. The approved P5 amendment says packages/ad-replay exports only inspectAdReplay and runAdReplay; the current facade additionally exports formatReplaySuccessMessage, runtime/outcome types, and the full selector-port family, and the new exact-symbol gate blesses that widening. The gate also ignores export-star declarations, so it can miss future widening. Keep success formatting daemon-side, hide internal capability/selector types behind entrypoint signatures, and make the gate reject every export form.\n- [P1] Translate wire failures before the engine boundary. AdReplayDispatchOutcome carries details as a generic unknown-valued record, the daemon adapter assigns response.error.details verbatim, and engine policy parses that bag. This is still daemon wire projection crossing into the engine despite the PR's neutral-outcomes/no-generic claim. Narrow each mismatch in the adapter into explicit tagged evidence values.\n- [P1] Move variable semantics/planning behind the replay entrypoint. The daemon still assembles ReplayVarScope, interpolates actions in invokeReplayAction, and independently interpolates target verification. P5 assigns variables and planning to ad-replay; leaving these paths daemon-owned preserves duplicated orchestration/semantics.\n- [P1] Keep this draft pending exact-head live evidence. The PR body's live corpus is for 947b90c, not current head e6cbe6b, and the prescribed iOS checkout leg remains blocked by #1542. Re-run the full required corpus on the corrected exact head before readiness.\n\nThe #1554 terminal-close fold-in and current CI checks look sound, but the accepted P5 boundary/readiness gates are not yet met.

thymikee added a commit that referenced this pull request Aug 3, 2026
… P1)

packages/ad-replay/src/index.ts now exports exactly two value symbols,
inspectAdReplay and runAdReplay, and zero types — formatReplaySuccessMessage
(presentation) moves beside its one caller in session-replay-runtime.ts, and
every type a root daemon file needs is derived structurally off the two
entrypoints in the one new src/daemon/ad-replay-facade-types.ts module
instead of being named off the façade.

scripts/layering/package-boundaries.ts's readNamedExports is rewritten on
oxc-parser's own static-export table instead of a regex, so it can no longer
silently miss a widening export form: a bare `export *` re-export or an
`export default` now throws (an un-enumerable, and therefore un-pinnable,
export), while `export * as ns` and every other enumerable form is still
counted. The pinned exact-symbol assertion in package-boundaries.test.ts is
narrowed to ['inspectAdReplay', 'runAdReplay'].
thymikee added a commit that referenced this pull request Aug 3, 2026
…1555 review P1)

AdReplayDispatchOutcome's guard-mismatch/landmark-mismatch variants carried
a generic `details: Record<string, unknown> | undefined` bag straight off
the wire response — a daemon wire projection crossing into the engine even
though the outcome itself was already a neutral type. The daemon adapter
(session-replay-runtime-engine-adapter.ts) now narrows that bag into the
typed AdReplayGuardMismatchEvidence/AdReplayLandmarkMismatchEvidence shapes
(observed identity, expected/observed structural denotation, ancestry
entries, match count) before returning the outcome; the unknown-parsing
readers move there with the wire-reading responsibility they always were.
target-verification.ts's deriveReplayTargetGuardMismatchEvidence/
deriveWaitLandmarkMismatchEvidence now consume only the typed values — no
`unknown`-valued record type remains on any engine-crossing signature.
thymikee added 20 commits August 3, 2026 15:46
…replay façade (#1555 review)

P1 "complete the binding façade instead of documenting deviations":
classifyTargetBindingMatch never had a real consumer reachable through
inspectAdReplay/runAdReplay -- both its callers (the daemon's record-time
self-check in session-target-evidence.ts and its replay-time
classification wrapper in session-replay-target-classification.ts) are
daemon files that imported it directly. It interprets TargetAnnotationV1
evidence semantics shared beyond the engine, so it moves to
packages/ad-script alongside target-annotation-identity.ts (new
target-annotation-classification.ts + its test), and both daemon call
sites now import it from there instead of @agent-device/ad-replay.

One deviation remains and is reported rather than papered over per the
review's own instruction: the four target-verification policy functions
(planPreDispatchTargetVerification, planPostResolutionTargetVerification,
deriveReplayTargetGuardMismatchEvidence,
deriveWaitLandmarkMismatchEvidence) and the ReplaySelectorPort type
family stay exported. Their sole caller,
session-replay-target-verification.ts, interleaves these pure decisions
with daemon-only async work (capture, SessionStore, coordinator/resume
stamping, wire shaping) that must stay outside the engine by design;
moving their call sites to live only behind runAdReplay would require
restructuring that whole orchestration into new fine-grained
AdReplayStepRuntime capabilities, which is out of scope for this pass.
See packages/ad-replay/src/index.ts's header comment for the full
reasoning.

P1 "add the reviewer-required exact exported-symbol gate": adds
readNamedExports (scripts/layering/package-boundaries.ts), a small
parser over a façade's `export { .. } from`, `export type { .. } from`,
and direct-declaration forms, and pins @agent-device/ad-replay's exact
21-symbol export list in package-boundaries.test.ts. Plant-verified: a
stray `export const` addition failed the assertion; removed it and the
gate went green again.
…#1555 review)

Moves the verify-then-dispatch decision flow into packages/ad-replay's
step loop so the four target-verification policy functions
(plan{PostResolution,PreDispatch}TargetVerification,
derive{ReplayTargetGuardMismatch,WaitLandmark}MismatchEvidence) become
engine-private and leave the ad-replay façade. The daemon
(session-replay-target-verification.ts) shrinks to the narrow
AdReplayStepRuntime capabilities the engine drives: routing
(beginTargetVerification), capture (captureObservation), classification
(classifyTarget), dispatch (dispatchStep), and wire-building
(buildRecordedUnverifiableFailure, buildTargetBindingFailure,
buildPostDispatchTargetBindingFailure). Wire output and replay-compat
stay byte-identical; the exact-symbol façade gate is updated to the
shrunken export list.
… into the ad-replay engine

Rebasing p5/extract-ad-replay onto main pulled in #1554's --keep-session
feature, which had grown its own daemon-side terminal-close-suppression
predicate (session-replay-terminal-lifecycle.ts's
resolveSuppressedTerminalCloseIndex/countExecutedReplayActions) independently
of this branch's own engine-side one (step-loop.ts's
isRepairArmedTerminalCloseAction). Both are the same decision family — replay
--keep-session and an active --save-script repair now share ONE structural
resolution (resolveSuppressedTerminalCloseIndex, generalized to "terminal
among EXECUTABLE actions" rather than the old physical-last-index check) and
one suppression check inside runAdReplay, gated on keepSession OR
runtime.isRepairArmed(). AdReplayRunRequest grew a keepSession field; the
neutral 'replayed' count in AdReplayRunOutcome is now computed inline in the
loop instead of the daemon's old actions.length - entryIndex approximation.

requireLiveSessionForKeepSession (the --keep-session live-session
postcondition) stays daemon-side, inlined into session-replay-runtime.ts,
since it inspects SessionStore state the engine never sees. The daemon-only
session-replay-terminal-lifecycle.ts this arrived with is deleted entirely —
its isExecutableReplayAction was a duplicate of the engine's own.

runReplayScriptFile's Maestro-format routing (including the new --keep-session
Maestro rejection) was extracted into routeMaestroReplay to keep the function
under fallow's complexity threshold after re-threading keepSession through it.

Added packages/ad-replay/src/internal/__tests__/step-loop.test.ts covering the
unified suppression decision (both keepSession and repair-armed) directly
against runAdReplay, including the terminal-among-executable-actions case with
a trailing nested replay marker. The daemon-level integration tests (6 tests
in session-replay-terminal-lifecycle.test.ts, exercising the same behavior
through runReplayScriptFile) and the SDK provider-scenario test
(active-session-script-publication.test.ts) needed no changes and pass
unmodified.
…les (#1555)

Splits the ~1096-line replay runtime into cohesive pieces, keeping
session-replay-runtime.ts as thin orchestration (~240 LOC):

- session-replay-runtime-engine-adapter.ts: the AdReplayStepRuntime
  adapter (createAdReplayStepRuntime, the build*Failure capability
  implementations, and the lastResponse/lastObservation side-map
  mechanics), extracted verbatim.
- session-replay-runtime-plan.ts: extended with the plan-side helpers
  (validateReplayBackendFlag, inspectReplayPlanManifest,
  resolveReplayPlanEntryIndex, prepareReplayPlan, routeMaestroReplay)
  alongside the buildReplayMetadataFlags helper already there —
  buildReplayMetadataFlags is now module-private since its one caller
  moved into the same file. Also introduces ReplayScriptFileParams,
  named here (instead of derived via Parameters<typeof
  runReplayScriptFile>) so routeMaestroReplay can reference the shape
  without importing back from session-replay-runtime.ts.
- session-replay-runtime-session.ts (new): session preparation
  (prepareReplaySession and its coordinator arming/repair-preflight
  helpers), extracted verbatim.

Coordinator ownership is unchanged: createReplayCoordinator is still
constructed only in session-replay-runtime.ts, matching
replay-coordinator-ownership.test.ts's allowlist as-is — every
extracted module receives the already-constructed ReplayCoordinator as
a parameter. Pure move; no behavior change.
…tion (#1555)

Two invariants found during the P5 decomposition pass now have direct
counterfactual-verified coverage:

- packages/ad-replay/src/internal/__tests__/step-loop.test.ts: a
  post-dispatch target-binding mismatch (dispatchWithGuard) must report
  the accumulated PRE-step artifact snapshot it was called with, never
  the artifacts the failed dispatch itself produced. Verified red by
  swapping the buildPostDispatchTargetBindingFailure call to
  outcome.artifactPaths.

- src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts: a
  rejected --from/--plan-digest resume must never reach
  prepareReplaySession's coordinator-mutating writes (the R2 ordering
  invariant) — a pre-armed repair transaction and corrective-resume
  watermark are asserted byte-for-byte unchanged after rejection.
  Verified red by calling prepareReplaySession before honoring the
  plan-validation rejection.
… P1)

packages/ad-replay/src/index.ts now exports exactly two value symbols,
inspectAdReplay and runAdReplay, and zero types — formatReplaySuccessMessage
(presentation) moves beside its one caller in session-replay-runtime.ts, and
every type a root daemon file needs is derived structurally off the two
entrypoints in the one new src/daemon/ad-replay-facade-types.ts module
instead of being named off the façade.

scripts/layering/package-boundaries.ts's readNamedExports is rewritten on
oxc-parser's own static-export table instead of a regex, so it can no longer
silently miss a widening export form: a bare `export *` re-export or an
`export default` now throws (an un-enumerable, and therefore un-pinnable,
export), while `export * as ns` and every other enumerable form is still
counted. The pinned exact-symbol assertion in package-boundaries.test.ts is
narrowed to ['inspectAdReplay', 'runAdReplay'].
…1555 review P1)

AdReplayDispatchOutcome's guard-mismatch/landmark-mismatch variants carried
a generic `details: Record<string, unknown> | undefined` bag straight off
the wire response — a daemon wire projection crossing into the engine even
though the outcome itself was already a neutral type. The daemon adapter
(session-replay-runtime-engine-adapter.ts) now narrows that bag into the
typed AdReplayGuardMismatchEvidence/AdReplayLandmarkMismatchEvidence shapes
(observed identity, expected/observed structural denotation, ancestry
entries, match count) before returning the outcome; the unknown-parsing
readers move there with the wire-reading responsibility they always were.
target-verification.ts's deriveReplayTargetGuardMismatchEvidence/
deriveWaitLandmarkMismatchEvidence now consume only the typed values — no
`unknown`-valued record type remains on any engine-crossing signature.
…1555 review P1)

The daemon assembled the `${VAR}` scope (buildPreparedReplayScope) and
interpolated actions at two independent call sites: dispatch's own
(invokeReplayAction) and target verification's separate one
(resolveTargetVerificationEntry) — duplicated orchestration the P5 design
assigns to the engine.

runAdReplay's request now carries the raw scope INPUTS (varSources: plain
builtins/file/shell/cli-env data, plus actionLines/actionSourcePaths/
resolvedPath for interpolation-error location) instead of a built scope; the
engine builds the scope and resolves each action exactly once per step,
handing the RESOLVED action to dispatchStep/beginTargetVerification while
every other capability still receives the ORIGINAL recorded action (a
target-binding divergence reports the recorded selector, never an expanded
${VAR}). This is the one resolution site now — session-replay-action-runtime.ts's
invokeReplayAction and session-replay-target-verification.ts's
resolveTargetVerificationEntry no longer hold a scope or call
resolveReplayAction themselves.

Scrub-value collection (collectReplayScrubbableVarValues, for divergence-report
redaction) is kept single-sourced in the engine too: it's computed from the
engine's own live scope and threaded to each build-failure/handleActionFailure
capability as an explicit scrubVars argument, rather than the daemon
recomputing it from a second scope object (which would have gone stale,
since expandedBuiltinNames tracking now only happens engine-side).

The Maestro replay path's own daemon-side vars usage is unrelated (a
different engine) and is out of scope here.
CodeQL flagged the interpolation regex's fallback group as js/polynomial-redos
once vars.ts moved into packages/ (library-input classification): every
${NAME:- prefix of an unclosed input rescanned to end-of-string, quadratic
overall — 1,857 ms measured on 20k repetitions of '${A:-['. Replaced with a
single-pass scanner; failed fallback scans emit their span verbatim and resume
after it (escape-pair alignment is identical from every candidate start inside
the span, so no later candidate can terminate where the failed scan could not).
Equivalence: 200k-trial differential fuzz against the retired regex over the
adversarial alphabet, zero mismatches; both adversarial shapes now resolve in
1-2 ms.
…structural-quality review)

Reverses the exact-two-value zero-type export shape #1555's second review
pass established: it forced every root type derivation through one shim
(src/daemon/ad-replay-facade-types.ts) and left four daemon-side twin types
(TargetVerificationEntry, TargetClassificationOutcome,
TargetBindingFailureEvidence, ReplayVerifiedTargetGuard) plus a
toDaemonEvidence copy translator shadowing the engine's own shapes.

packages/ad-replay/src/index.ts now exports inspectAdReplay/runAdReplay
(unchanged, still the only two values) plus the neutral vocabulary their
signatures are built from, by name — following packages/maestro's façade
precedent. The exact-symbol gate in scripts/layering/package-boundaries.test.ts
is widened to pin the full sorted list (values + types).

The four daemon twins are deleted; session-replay-target-verification.ts and
session-replay-runtime-engine-adapter.ts now use the engine's own
AdReplayVerificationEntry/AdReplayTargetClassification/
AdReplayTargetBindingEvidence/AdReplayVerifiedTargetGuard directly.
TargetBindingDivergenceBuilt's array fields are now readonly-compatible, so
toDaemonEvidence's copy is gone — evidence flows through unchanged.
target-verification.ts's planPreDispatchTargetVerification used
resolveRecordedTarget (operation 2, resolve) over an empty node tree purely
to read its parse-invalid reason — a resolve call standing in for a parse
call, even though readSelectorExpression (operation 1, parse) exists to
answer exactly that question and was already unused inside the engine.

Replaced with port.readSelectorExpression('ordinary', [token]). The mapping
is not 'invalid' -> skip: production's 'ordinary'/'wait' grammars only ever
record a boundary once it has already parsed, so a single malformed token
can only come back 'not-applicable' there ('invalid' is unreachable from
this call site on the production adapter). Both non-'expression' outcomes
map to skip, matching the historical behavior (a single parse-invalid reason
covered both cases). platform dropped from the function's params — it was
only ever threaded to the resolve call this replaces.

Added a contract-suite cell pinning the exact (diverging) discriminant each
adapter reports for a selector-shaped-but-malformed bare token, and why the
divergence is harmless for the one real consumer.
…#1555 structural-quality review)

step-loop.ts (810 LOC) splits three ways, following packages/maestro's own
precedent:
- internal/runtime-port-types.ts: the AdReplayStepRuntime boundary
  vocabulary (all the neutral types the engine/daemon exchange).
- internal/verify-dispatch.ts: verifyAndDispatchStep + its dispatchNoGuard/
  dispatchWithGuard helpers.
- internal/step-loop.ts: runAdReplay itself plus the terminal-close/
  executable-action structural logic (isExecutableReplayAction,
  resolveSuppressedTerminalCloseIndex).

packages/ad-replay/src/index.ts's type exports now source from
runtime-port-types.ts. step-loop.test.ts's AdReplayStepRuntime import moves
to the new path (no assertion changes).

src/daemon/handlers/session-replay-runtime-engine-adapter.ts (553 LOC after
item 1's twin removal) shrinks to 294 via two further extractions:
- session-replay-dispatch-narrowing.ts: the wire `details` bag -> typed
  evidence narrowing and dispatch-failure classification.
- session-replay-runtime-step-support.ts: ReplayStepContext (moved here to
  avoid a cycle with the adapter, which re-exports it by name) plus the
  failure-wrapping/diagnostics-support helpers.

Final LOC: adapter 294, dispatch-narrowing 148, step-support 153,
step-loop 225, verify-dispatch 246, runtime-port-types 374.
…n.ts + terminal-lifecycle test rename

resume.test.ts covers resolveReplayEntryIndex directly (previously only
exercised transitively through the daemon's session-replay-runtime-plan
tests): no --from/--plan-digest, the paired-flags requirement, in-range
--from, out-of-range rejection, stale-digest rejection, the authorized
empty-tail boundary (actionCount + 1) gated on a matching watermark, and the
unperformed-record-and-heal growth check. Counterfactual run and restored:
widening describeOutOfRangeResumeFrom's bound turns the out-of-range/
empty-tail-without-watermark assertions red (2 failures observed).

target-verification.test.ts covers all four engine policy functions
directly: the two plan* pre-capture gates and the two derive* post-dispatch
evidence builders, including item 2's own new decision surface (a fake
ReplaySelectorPort proving both non-'expression' readSelectorExpression
outcomes map to skip). Counterfactual run and restored: narrowing the check
to the literal `'invalid' -> skip` reading turns the 'not-applicable' case
red (reports recorded-unverifiable instead of skip).

session-replay-terminal-lifecycle.test.ts renamed to
session-replay-runtime-keep-session.test.ts: its production module
(session-replay-terminal-lifecycle.ts) was already deleted by the #1554
fold-in, and its six cases drive the full runReplayScriptFile round trip
against a real SessionStore (including daemon-only postconditions the
engine's step loop never reaches) rather than testing engine policy through
the façade in isolation — the engine's own terminal-close-suppression
decision already has direct, cheaper coverage in step-loop.test.ts. No
assertion changes; both files' header comments cross-reference the split.
… to end

collectReplayScrubbableVarValues(scope) was called fresh at 5 separate
return points inside one verifyAndDispatchStep invocation plus once more in
handleActionFailure — always the same result, since nothing between them
mutates scope. step-loop.ts's runAdReplay now computes scrubVars ONCE per
step, right after resolveReplayAction (the one call that can grow the
scope's expanded-builtins set), and threads it as a plain
readonly AdReplayScrubValue[] value; verify-dispatch.ts no longer imports
ReplayVarScope or collectReplayScrubbableVarValues at all.

"One name" end to end: the daemon's TargetBindingDivergenceContext.scrubVars
and withReplayFailureDiagnostics's scrubVars param used a separately-derived
ReturnType<typeof collectReplayScrubbableVarValues> (mutable array) instead
of the engine's own AdReplayScrubValue, requiring a [...scrubVars] copy at
every daemon call site to satisfy the mutable-array type. Both now use
readonly AdReplayScrubValue[]/readonly ReplayVarScrubEntry[] (structurally
identical, already readonly-safe downstream — scrubReplayVarValues and
createReplayDivergenceSanitizer already accepted readonly arrays), so the
four [...scrubVars] copies in session-replay-runtime-engine-adapter.ts are
gone.
createAdReplayStepRuntime's lastObservation closure lives for the whole
replay run (one factory call covers every step), but was never reset
between steps. Every current buildTargetBindingFailure call site happens to
be preceded by this same step's own captureObservation, so the
`lastObservation ?? { reason: 'observation-missing' }` fallback could never
actually fire — but if it ever did (a future call path reaching
buildTargetBindingFailure without capturing first), it would silently
attach the PREVIOUS step's screen instead of reporting the missing-capture
condition the fallback message claims.

armStep runs exactly once per step, before any of that step's other
capabilities (verified against step-loop.ts's runAdReplay loop order) — the
natural per-step boundary. It now clears lastObservation first. No behavior
change on any reachable path today (full daemon + ad-replay suite: 1766/1766
green); an unrelated device-claim-prune contention flake was observed once
and did not reproduce on isolated or full-suite reruns.
… symbols

Four comments named symbols/paths that no longer exist, left behind by
earlier review passes describing PR history rather than the current
constraint:
- session-replay-runtime-step-support.ts / session-replay-runtime.ts (2
  sites): referenced a function called executeStep, which was never
  reintroduced under that name after the P5 split — the actual mechanism is
  the runtime's dispatch/build-failure capabilities recording into the
  lastResponse side-map.
- session-replay-runtime.ts: referenced an engine collectArtifactPaths
  capability that does not exist — artifactPaths is a daemon-side Set the
  adapter mutates via collectReplayActionArtifactPaths.
- packages/ad-replay/src/internal/selector-port.ts: pointed at
  ./testing/in-memory-selector-port.ts, the in-memory adapter's pre-stage-D
  location — it has lived at
  src/__tests__/test-utils/in-memory-replay-selector-port.ts since.
- session-replay-repair-hint.ts / session-replay-runtime-step-support.ts (2
  sites): named target-identity.ts, which does not exist (the real file is
  target-identity-node.ts); the second site additionally mislabeled
  classifyReplayTarget as engine-side when it is daemon-side
  (session-replay-target-classification.ts).

Comment-only; no behavior change.
…d owner

packages/ad-replay/src/internal/inspect.ts's declaredScriptPlatform and
src/daemon/replay-device-selection.ts's readScriptReplaySelection each kept
their own copy of the same "platform declared before the first open" scan
over runtime/open actions — .ad script semantics, not engine or daemon
policy, needed independently by ad-replay's plan-digest precedence and the
daemon's device-selection platform resolution.

Verified this was a genuine duplicate (not the single-sourced state I
initially reported): readScriptReplaySelection's platform-tracking loop
computes the identical result via a differently-shaped traversal fused with
its own app-target scan.

resolveDeclaredScriptPlatform now lives in packages/ad-script (its natural
owner: the one package both ad-replay and the daemon already depend on,
avoiding the R11 issue that justified the original duplication). The
daemon's app-target scan stays its own separate pass; fusing it back into
the shared function would smuggle a daemon-only concern into ad-script for
no measurable cost (the actions array is small, and the shared function
already stops at the same point the app-target scan needs to look).
…çade

Described "target-identity, variable substitution, plan-digest, and report
primitives" — the wide pre-#1555-review façade shape. Vars/identity/report
vocabulary moved to ad-script/daemon across the P5 and #1555 review passes;
the package now exports exactly inspectAdReplay/runAdReplay plus the
neutral AdReplayStepRuntime vocabulary. Description updated to match.
… adapter

A simplicity audit judged session-replay-runtime-step-support.ts a
size-target fragment, not a concern boundary: four unrelated concerns,
one consumer, and a header comment admitting it existed to satisfy the
<300 LOC metric. Folded back; the previously-exported helpers are
module-private again; the adapter's honest size is renegotiated from the
plan metric (dispatch-narrowing stays extracted — it has one nameable
job).
@thymikee
thymikee force-pushed the p5/extract-ad-replay branch from 49d4d70 to 6a69554 Compare August 3, 2026 13:55
@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 6a69554. No new code findings: the rebase onto #1563/#1566 is sound, and the P5 extraction's facade/adapter/engine behavior, target verification, resume ordering, artifact ordering, keep-session handling, and selector-port contracts remain intact.

Readiness is still pending exact-head practical evidence: now that #1542 is fixed, run and attach the prescribed iOS checkout corpus plus the extended target-v1/divergence/resume/save-script evidence at this head. Then refresh the PR body to remove the stale #1542 blocker and future-tense evidence language. The PR remains draft and should not receive ready-for-human yet.

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Post-merge evidence round (branch head 6a695544c = merged content; verbatim command logs at /private/tmp/ad-p5-evidence-FINAL/retry2/command-log.txt, 1573 lines):

Fully green: Android suite (checkout 20/20, gesture-lab 32/32, helper backend/version probe-verified), iOS gesture-lab 3/3, and all seven extended legs with device-state proofs — target-v1 annotation quoted; recorded replay green; wrong-screen REPLAY_DIVERGENCE with repair hint; --from/--plan-digest resume completing through the terminal close; --keep-session suppression with a live device status claim check; unarmed close --save-script rejection with the recovery hint; ${VAR} interpolation proven on-device (get text returned the interpolated value from the live field).

iOS checkout-form: 0/3 this round, all failures attributed to known/new main-side issues, none to this PR's content (the pre-merge 2×2 matrix proved main fails/passes identically): twice the #1569 signature (stale_accept at attempts:6-7 / cap, then the step-16 off-screen refusal — fresh ndjson quotes posted on #1569; fix in progress), once a newly-identified cross-platform app-mount race on fast/cold opens, filed as #1571 (also reproduced independently on Android in the same round, retry-green). Host contention from a concurrent session was present throughout the iOS legs and is the known #1569 trigger.

Net: the extraction's own behavior is fully evidenced; the checkout corpus's reliability now tracks #1569/#1571, both filed with mechanisms and owned separately.

Generated by Claude Code

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.

1 participant