fix(record): make an aborted authoring recording terminal by construction - #1712
Conversation
…tion
A second successful `open` on an `open --save-script` session aborts the
recording: the aggregate goes to `authoring{aborted}`, `recordSession` is
cleared, and the caller is warned. `close --save-script` then refuses it with
"Retry with plain close; it will tear down the session without writing."
That promise was not kept. When the second `open` itself carried
`--save-script`, the recorder's shared flag ingress re-armed `recordSession`
while leaving the status terminal, and a bare `close` published the full
session log — the writer gated only on `recordSession` and the repair variant,
so nothing on the ordinary authoring path refused an aborted lifecycle.
The abort is now terminal by construction rather than inert by ordering:
- `isAuthoringAborted` gives the pure aggregate one home for the question.
- `applyRecordedSaveScriptFlags` takes no branch for an aborted lifecycle:
it neither re-arms recording nor retargets the output path.
- `SessionScriptWriter` asks one `isPublicationWriteBlocked` question covering
all three reasons to publish nothing, so every path reaching the writer
(bare `close`, teardown, idle-reap, active publication) refuses it.
Armed recordings, published recordings, and every repair transaction are
unaffected; the control tests for those stay green against the pre-fix code
while the five new regressions go red.
Closes #1533
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Exact-head review at |
…reference The ADR 0016 close-time amendment still described #1533 as unresolved, and the session-close.ts note named `isAuthoringAbortedWriteBlocked` — a private helper that was folded into `isPublicationWriteBlocked` when the writer's three sequential guards collapsed into one predicate, so the symbol names nothing in the tree.
|
Both addressed in ADR 0016. The 2026-08-02 amendment's closing sentence now points forward instead of declaring the case unresolved, and a new Stale symbol. Correct, and it was mine — Re-ran Generated by Claude Code |
|
iOS Smoke Tests failed on
The failure looks like simulator keyboard-state contention rather than a product defect. The test's own precondition is the assertion that fell over: XCTAssertFalse(
isKeyboardVisible(app: app),
"the test must exercise a focused responder with the software keyboard hidden"
)Software-keyboard visibility is simulator-global (hardware-keyboard attach state), so it is order- and host-sensitive. Corroborating it in the same run: the test immediately preceding it, I have re-run the failed job. If it comes back red on the same test I will treat it as real and investigate properly rather than re-running again; if it goes green, this is worth noting as a flake candidate in the iOS lane, since the precondition assertion depends on global simulator state the test does not itself establish. Generated by Claude Code |
|
Exact-head re-review at |
…und it `recordSession` is an evidence-capture flag, but three surfaces set it directly without consulting the publication aggregate, so it could contradict a terminal ABORTED authoring status. The #1533 fix closed the recorded-action ingress and made the writer refuse an ABORTED lifecycle, then documented the remaining contradiction as acceptable — the writer's own comment noted that "something can re-arm that boolean behind the terminal status". That something was live: `buildNextOpenSession` re-armed recording for any `open --save-script`, and `applyOrdinaryScriptRecordingOpenOutcome` only aborts a lifecycle that is still ARMED. A third `open --save-script` on an already-ABORTED session therefore left `recordSession` true behind the terminal status. The writer gate hid the publication symptom, but the session kept paying recording-time costs for a recording that can never publish: `recordSession` disables the direct iOS selector fast paths for click and get, forcing every interaction onto the snapshot route. Route the flag through one rule owned by the publication projection (`recordSessionAfterSaveScriptFlag`), which answers "not recording" for an ABORTED lifecycle on every surface that handles it — the re-open builder, the close finalizer, and the recorded-action ingress. The writer's gate is unchanged and still correct; it now stands on the aggregate alone rather than as a net under a known drift, so the comments defending the drift are replaced by statements of the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW
|
Exact-head re-review at |
|
…anch Two conflicts, both resolved as posted on the PR before the merge landed. `scripts/layering/daemon-modularity.ts` — the R7 ratchet. #1724 and this branch each remove a different `SessionState` field, so neither side's baseline survives. Note git only flagged `ownerFileClaims` (26 vs 28); `writerOwnedFields: 22` merged silently because both sides wrote 22, yet the merged manifest holds 21. Counted the auto-merged `SESSION_STATE_FIELD_OWNERS` rather than doing the subtraction: 21 entries, 25 owner claims. The layering guard confirms — R10 now pins R7 at 21/25. `src/daemon/handlers/record-trace-recording.ts` — modify/delete. #1724 deletes the module; this branch's only change in it was a doc comment that stopped naming the removed `recordSession` field, so the deletion wins. Nothing references the path any more. `session-state.ts` auto-merged correctly: #1724 predates #1712/#1719 and still carried the `recordSession` manifest entry, and the merge drops it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW
Summary
A second successful
openon anopen --save-scriptsession aborts the recording: the aggregate goes toauthoring{aborted},recordSessionis cleared, and the caller is warned.close --save-scriptthen refuses it with "Retry with plain close; it will tear down the session without writing."That promise was not kept.
recordSessionis an evidence-capture flag that several surfaces set directly, without consulting the publication aggregate — so it could contradict the terminal status. When the secondopencarried--save-script, the recorder's shared flag ingress (applyRecordedSaveScriptFlags) re-armed the boolean while the status stayed ABORTED —session-open.tsaborts at line 454 and records theopenaction, flags included, at line 461 — and a bareclosepublished the full session log.SessionScriptWriter.writegated only onrecordSessionand the repair variant, so nothing on the ordinary authoring path refused an aborted lifecycle.The abort is now terminal by construction rather than inert by ordering:
isAuthoringAbortedgives the pure aggregate one home for the question, next to the repair-side terminality it mirrors.recordSessionAfterSaveScriptFlagmakes--save-scriptarm recording through the publication projection instead of around it. It answers "not recording" for an ABORTED lifecycle on every surface that handles the flag — the re-open builder (buildNextOpenSession), the close finalizer (finalizeOrdinaryCloseScript), and the recorded-action ingress — so the flag can no longer contradict the status.SessionScriptWriterasks oneisPublicationWriteBlockedquestion covering all three reasons to publish nothing, so every path reaching the writer — bareclose, teardown, idle-reap, active publication — refuses it.Armed recordings, published recordings, and every repair transaction are unaffected.
session-close.ts's note that this case was unresolved is updated rather than left stale.Closes #1533
Why the ingress fix alone was not enough
The first pass fixed the recorded-action ingress and made the writer refuse an ABORTED lifecycle, but left the other two
recordSessionwriters alone and documented the resulting contradiction as acceptable — the writer's own comment observed that "something can re-arm that boolean behind the terminal status".That something was live.
buildNextOpenSessionre-armed recording for anyopen --save-script(existingSession.recordSession || saveScript), andapplyOrdinaryScriptRecordingOpenOutcomeonly aborts a lifecycle that is still ARMED — an already-ABORTED one returns early without clearing anything. A thirdopen --save-scripttherefore leftrecordSessiontrue behind the terminal status.The writer gate hid the publication symptom, so this was not a second publication bug. It was still a real one:
recordSessiondisables the direct iOS selector fast paths forclickandget(interaction-touch.ts,selector-runtime.ts), forcing every interaction onto the snapshot route. An aborted session went on paying recording-time costs for a recording that can never publish.With the rule owned in one place, the writer's gate stands on the aggregate alone rather than as a net under a known drift, and the paragraphs defending that drift are replaced by statements of the rule.
Scope: 12 files (6 implementation, 4 regression tests, ADR 0016, CHANGELOG). The writer change is net-simplifying — the three sequential guards in
write()collapse to one predicate, which is also what keeps the function under the fallow complexity threshold it tripped when the gate was added as a fourth branch.Validation
Red-before-fix evidence, ingress and writer (source fix stashed, tests unchanged):
the --save-script ingress does not re-arm an aborted authoring lifecycle— failed;recordSessionwastrue.an aborted lifecycle cannot be retargeted by a later --save-script= either— failed; the target moved to the hijacking path.write() publishes nothing for an aborted authoring lifecycle that is still recording— failed; a script was written.the abort is terminal per-target too — an explicit --save-script path is not written either— failed; the explicit target was written.bare close on an aborted authoring session writes no script— failed; the session log landed on disk.Red-before-fix evidence, re-open surface (only
buildNextOpenSession's one line reverted, new tests in place — stashing everything would have removed the tests too and proved nothing):re-opening an aborted authoring session with --save-script does not re-arm recording— failed;true !== false.an aborted lifecycle that is already recording is corrected, not carried forward— failed;true !== false.All seven are assertion failures, not timeouts. The paired control tests stayed green against the pre-fix code, which is what pins the fix as scoped rather than a blanket refusal:
an ARMED authoring lifecycle still takes the ingress,an ARMED authoring recording is untouched by the abort gate,an ordinary armed authoring session still publishes on bare close, plus the two new re-open controls (a re-open with --save-script still arms recording for a session with no publication yet,a re-open without --save-script leaves an armed authoring session recording).After the fix:
pnpm exec vitest run --project unit-core: 674 files / 5,800 tests passed, 3 skipped.pnpm exec vitest run src/daemon: 222 files / 1,950 tests passed.pnpm format:check,pnpm lint,pnpm typecheck,pnpm check:layering— all passed. Layering includes the R7 ownership check, which is the relevant one here: this moves aSessionState.recordSessionwrite into the publication projection.No device lane is involved: the bug and all seven regressions are unit-level, reached through the daemon-private publication projections, the open surface, and the writer. Native/device lanes remain GitHub-CI-authoritative.
Generated by Claude Code