Skip to content

fix(daemon): reject unarmed close --save-script before teardown - #1558

Merged
thymikee merged 2 commits into
mainfrom
fix/reject-unarmed-close-save-script
Aug 3, 2026
Merged

fix(daemon): reject unarmed close --save-script before teardown#1558
thymikee merged 2 commits into
mainfrom
fix/reject-unarmed-close-save-script

Conversation

@thymikee

@thymikee thymikee commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

Live evidence on 2026-08-02 showed that a plain open followed by close --save-script silently succeeded and published a script — but with a critical gap: open-time arming (recordSession + scriptPublication{authoring,armed}) is the only point ADR 0016 captures recording-time target-v1 identity evidence from action zero. An unarmed close-time save folded the request into the authoring lifecycle at close time (applyRecordedSaveScriptFlags's none -> authoring branch) and published anyway — a script with selector fallback chains but no target-v1 evidence, and no signal to the caller that evidence capture never ran. Degraded replay verification with no signal is worse than a loud refusal, so the fix rejects instead of warning.

Seam and behavior

assertTerminalRecordingCloseAllowed in src/daemon/handlers/session-close.ts is the existing gate that already rejects close --save-script on an ABORTED/PUBLISHED terminal recording, before any teardown or filesystem work. This PR extends the same gate/seam to the never-armed (scriptPublication.kind === 'none') case:

  • close --save-script on a never-armed session now throws INVALID_ARGS with a hint to start over with open --save-script, before platform close, cleanup, or any .ad write.
  • The rejection does not tear the session down — least-surprising choice: the request is refused, not the close. A plain close retry immediately afterward still completes cleanly (session deleted, no script written). This mirrors how ABORTED/PUBLISHED already behave and lets the caller recover/rearm instead of losing the live session on a mistake.
  • Repair transactions (ADR 0012, a disjoint scriptPublication.kind === 'repair' lifecycle armed by replay --save-script) are explicitly unaffected — the guard returns early for them, exactly as before.
  • session save-script (mid-session active publication, src/daemon/handlers/session-script-publication.ts) is untouched; it already has its own "not armed before this journey began" rejection and remains the sanctioned way to publish without closing.

Aggregate consistency (#1478 P4a)

The P4a arc deliberately folded "never-armed close-time publication" into the authoring lifecycle's shared --save-script ingress (applyRecordedSaveScriptFlags's none -> authoring armed branch in src/daemon/session-script-publication-capability.ts). That branch is now unreachable for close — the new daemon-seam guard rejects before any action recording (and therefore before that ingress) runs for an unarmed close. I updated its docstring to say so rather than leave it describing behavior that no longer happens for close; the branch is kept as a generic safety net for any future non-close caller of the shared ingress, not as documented close-time behavior. ADR 0016 (docs/adr/0016-active-session-script-publication.md) gets two small amendments recording the same decision (recording-lifecycle prose + a validation bullet), same style as its existing #1349 amendment.

Relationship to #1533

#1533 is a different, already-armed case: a session ARMED then ABORTED by a second open, whose flag ingress re-enables recordSession if that second open (or a later action) itself carries --save-script, letting a bare close (no --save-script flag on the close itself) publish anyway. This PR's guard only fires when close itself is passed --save-script — it leaves that bare-close publication path untouched. Related: #1533, deliberately NOT fixed here; noted in the ADR amendment so the two decisions don't collide later.

CLI help

The shared --save-script flag's usage description (src/commands/cli-grammar/flag-definitions-action.ts) implied a standalone close-time save just works; updated to state the arm-at-open requirement and the close --save-script-alone rejection, plus the session save-script mid-session alternative. No test pins the old string.

Tests

  • Unit (src/daemon/handlers/__tests__/session-close-shutdown.test.ts, appended):
    • close --save-script on a never-armed session is rejected before teardown, with no script written — asserts INVALID_ARGS + hint, session survives the rejection, no file written, then a follow-up plain close tears down cleanly.
    • close --save-script on a session with an active .ad repair transaction is unaffected by the unarmed-authoring guard — proves the repair lifecycle's own close path is untouched.
  • Provider-scenario integration (test/integration/provider-scenarios/active-session-script-publication.test.ts, appended):
    • an unarmed session refuses close --save-script and closes cleanly on plain close — full daemon round trip via the real RPC surface.
    • an armed session still publishes target-v1 evidence through close --save-script — proves the legitimate armed close-time path (not just session save-script) still works and still carries target-v1 evidence end to end.
  • Existing suite fixed: test/integration/provider-scenarios/android-lifecycle.test.ts's long composed scenario used a close --save-script on a session that had never been armed (opened many times across the flow, so it couldn't retroactively arm at its original open). Updated only its setup: end that session with a plain close, then open a fresh armed session before exercising close --save-script + --shutdown; all prior assertions preserved. No other provider-scenario or unit suite assumed the old unarmed-close-publishes behavior (repair-armed close --save-script flows all arm via replay --save-script first, unaffected).

Counterfactual (per docs/agents/testing.md)

Reverted just the new unarmed branch locally and reran both new regression tests — both went red exactly as expected, proving they pin real behavior:

Unit:

× close --save-script on a never-armed session is rejected before teardown, with no script written
  → promise resolved "{ ok: true, data: { …(2) } }" instead of rejecting

Provider-scenario:

× an unarmed session refuses close --save-script and closes cleanly on plain close
  → {"jsonrpc":"2.0","id":"...","result":{"data":{"session":"default","message":"Closed: default"}}}
  actual - expected
  + undefined
  - 'INVALID_ARGS'

Restored the fix afterward; both tests pass again.

Gates run

  • pnpm typecheck
  • pnpm lint
  • pnpm format:check
  • pnpm check:layering
  • npx vitest run src/daemon test/integration — 253 files / 1916 tests ✅
  • pnpm check:replay-compat
  • npx vitest run (full) — 13 failures on first pass, all reproduced as CPU-contention timeouts (12) plus one flaky process-liveness assertion (owner-identity.test.ts, unrelated file); isolate-rerun of all 10 affected files together: 151/151 pass. Matches the documented unit-suite-flaky-under-contention pattern; none touch this change's files beyond android-lifecycle.test.ts, which passes standalone.

Test plan

  • pnpm typecheck && pnpm lint && pnpm format:check && pnpm check:layering
  • npx vitest run src/daemon test/integration
  • pnpm check:replay-compat
  • npx vitest run (full, contention flakes isolate-rerun clean)
  • Counterfactual: reverted guard, watched both new tests fail, restored, watched them pass

Generated by Claude Code

Live evidence (2026-08-02) showed a plain `open` followed by `close
--save-script` silently published a script: the close request armed
authoring at record time and published moments later in the same
request, folding the never-armed case into the ADR 0016 authoring
lifecycle. The resulting .ad carries selector fallback chains but no
recording-time target-v1 evidence, and nothing told the caller
evidence capture never ran — degraded replay verification with no
signal beats a loud refusal.

`assertTerminalRecordingCloseAllowed` (src/daemon/handlers/session-close.ts)
now rejects an unarmed `close --save-script` with INVALID_ARGS before
any teardown or filesystem work runs, the same seam that already
rejected ABORTED/PUBLISHED terminal recordings. The rejection does not
tear the session down, so a plain `close` retry still completes
cleanly; recovery names `open --save-script` since evidence can only
be captured from action zero. Repair transactions (ADR 0012) are a
disjoint lifecycle and are explicitly unaffected.

This is distinct from #1533 (an already-armed-then-aborted session
whose flag ingress re-enables recordSession and lets a *bare* close
publish); that case remains open.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.93 MB 1.93 MB +989 B
JS gzip 618.8 kB 619.2 kB +387 B
npm tarball 737.2 kB 737.6 kB +389 B
npm unpacked 2.59 MB 2.59 MB +989 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.7 ms 26.6 ms -0.1 ms
CLI --help 65.3 ms 63.2 ms -2.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/registry.js +489 B +207 B
dist/src/session.js +370 B +132 B
dist/src/cli-help.js +130 B +48 B

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Review findings:

  • [P1] Do not auto-close Aborted authoring recording still publishes on bare close after a --save-script re-arm #1533 unless this PR fixes it. GitHub currently lists Aborted authoring recording still publishes on bare close after a --save-script re-arm #1533 in closingIssuesReferences, while this PR body and ADR explicitly say that already-armed aborted-recording bug is unresolved. The unchanged applyRecordedSaveScriptFlags/writer path still permits that bare-close publication. Remove the closing linkage or include the fix and regression.

  • [P2] Keep shared --save-script help accurate for replay. The new shared description says the flag arms on open and publishes on close, but replay --save-script validly arms a repair transaction. Amend the shared text (or use command-specific help) and pin help replay.

  • [P2] Pin the promised pre-teardown boundary. The new unit test uses no cleanup-bearing resources and only checks session retention/no file. Moving the guard after stopBestEffortSessionResources would still pass while stopping logs/recording/perf capture. Add cleanup-bearing state plus spies proving no teardown hook runs.

  • [P2] Update the contradictory migration guide. website/docs/docs/migrating-gestures.md still recommends open → interact → close --save-script, which this PR now rejects; it must show arming on open --save-script.

The production guard is correctly placed before teardown and CI is green, but these gaps keep the PR from merge readiness.

- Give replay --save-script its own help text instead of the shared
  open/close "arm on open, publish on close" description: replay's flag
  arms an ADR 0012 repair transaction, a disjoint lifecycle. Adds
  CommandSchema.flagDescriptionOverrides so a command can swap a shared
  flag's usageDescription without duplicating the FlagDefinition entry
  (which would have shown --save-script twice in `help replay`). Pinned
  in src/cli/parser/__tests__/cli-help-command-usage.test.ts (open/close
  keep the shared text unchanged; replay gets the new one).

- Strengthen the never-armed close --save-script regression test in
  session-close-shutdown.test.ts: the fixture now carries real
  cleanup-bearing state (an active iOS simulator recording, reusing
  makeIosSimulatorRecordingSession/recordingKillMock) with spies proving
  no teardown hook (recorder kill, runner stop) runs on the rejected
  request, then that a follow-up plain close does tear it down. The
  prior fixture had nothing for teardown to observably touch, so moving
  the guard after stopBestEffortSessionResources would have passed it
  silently. Also fixes a latent test-isolation leak this exposed: an
  earlier test set a persistent mockStopIosRunnerSession rejection
  (vi.clearAllMocks() clears call history, not implementations), which
  would have poisoned any later Apple-platform close test; scoped it to
  mockRejectedValueOnce.

- Point the migration guide (website/docs/docs/migrating-gestures.md) at
  `open --save-script` → interact → `close` instead of the now-rejected
  `open` → interact → `close --save-script`, matching the new guard and
  the corrected help text.

_Generated by [Claude Code](https://claude.ai/code)_
@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Addressed all four consolidated-review findings in 4fa09b2.

1. [P1] #1533 auto-close linkage

GitHub's closing-keyword parser was matching "...so it does not touch or resolve #1533..." — the trailing resolve #1533 tripped the naive keyword scan even though the sentence negates it. Same pattern existed in the commit message ("does not resolve #1533").

Fixed by editing the PR body (gh pr edit --body-file) to reword the "Relationship to #1533" section: "...it leaves that bare-close publication path untouched. Related: #1533, deliberately NOT fixed here; noted in the ADR amendment..." — no closing-keyword-adjacent issue reference remains.

Verified immediately after the edit:

{"closingIssuesReferences":[]}

and again just now, after pushing the follow-up commit:

{"closingIssuesReferences":[],"mergeable":"MERGEABLE","state":"OPEN"}

The body edit alone cleared it — the commit message (which also carries the phrase) never needed a rebase, since GitHub's open-PR closingIssuesReferences is computed from the PR body, not individual commit messages.

2. [P2] Shared --save-script help text

replay --save-script arms an ADR 0012 repair transaction (a disjoint lifecycle from open/close authoring), but the shared flag description said "Arm evidence capture on open, publish the armed recording on close" for every command that allows the flag, including replay.

Added CommandSchema.flagDescriptionOverrides?: Partial<Record<FlagKey, string>> (src/cli-schema/types.ts) — lets one command swap a shared flag's usageDescription without duplicating the FlagDefinition row (duplicating would have made --save-script show up twice in help replay, since listHelpFlags filters by key across the whole shared registry). Wired into both render paths that read flag descriptions: buildCommandUsageText (src/cli/parser/cli-help.ts, used by help <command>) and describeFlags/describeCliSurface (src/commands/command-explain.ts, used by explain). replayCliSchema in src/commands/replay/index.ts now sets:

"Arm a repair transaction from this replay (ADR 0012): recording starts at step 1, and the healed script commits when the repair-armed session tears down (close, close --save-script, or idle-reap). Independent of the open/close authoring arm-on-open."

open/close keep the original shared text unchanged (no override entry for either).

Pinned in src/cli/parser/__tests__/cli-help-command-usage.test.ts — the repo's existing usageForCommand-based help-pin mechanism (same file already pins open, screenshot, diff, etc.): three new tests assert help replay shows the repair-transaction text and not the authoring text, and help open/help close show the authoring text unchanged and not the replay text.

3. [P2] Pre-teardown boundary test

The original close --save-script on a never-armed session is rejected before teardown... test used a bare Android fixture with no recording/perf/log state — nothing for stopBestEffortSessionResources to observably touch, so moving the guard after it would have passed silently.

Reworked the fixture to carry real cleanup-bearing state: switched to makeIosSimulatorRecordingSession/recordingKillMock (an active recording — the same helpers session-close-shutdown.test.ts already uses for "close finalizes an active iOS simulator recording..."). Added spies proving no teardown hook fires on the rejected request (expect(kill).not.toHaveBeenCalled(), session.recording still defined, mockStopIosRunnerSession not called), then that the follow-up plain close genuinely tears it down (kill called with SIGINT, recording cleared, runner stop called).

Counterfactual (per docs/agents/testing.md): moved assertTerminalRecordingCloseAllowed(req, session) from before prepareRepairClose in handleCloseCommand to immediately after stopBestEffortSessionResources(...) inside runSessionCloseTeardown (src/daemon/handlers/session-close.ts), ran the strengthened test, and it went red exactly where it should:

AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
Received:
  1st vi.fn() call:
    Array [
      "SIGINT",
    ]
Number of calls: 1
 ❯ src/daemon/handlers/__tests__/session-close-shutdown.test.ts:1186:20
    1184|   // was never told to stop. This is the assertion that goes red if th…
    1185|   // `stopBestEffortSessionResources` — see the counterfactual in the …
    1186|   expect(kill).not.toHaveBeenCalled();

Reverted the local edit (verified git diff on the file was empty afterward) and reran — all 26 tests in the file pass again.

Rework also surfaced and fixed a latent test-isolation bug: an earlier test in the same file (targeted close skips platform dispatch..., line ~1100) set mockStopIosRunnerSession.mockRejectedValue(...) — a persistent implementation. vi.clearAllMocks() in beforeEach only clears call history, not implementations, so this was silently poisoning every later Apple-platform-runner test in the file; mine was simply the first to collide with it. Scoped it to mockRejectedValueOnce (sufficient for that test's own two internal calls; verified its assertions still hold since the outer thrown error only depends on the first call).

4. [P2] Migration guide

website/docs/docs/migrating-gestures.md recommended open → interact → close --save-script, which the new guard now rejects. Updated to open --save-script → interact → close, with a note that recording evidence is only captured from action zero (so arming happens at open) and that a bare close --save-script on a session not armed at open is rejected — matching the new guard and the corrected help text.

Gates run

pnpm typecheck && pnpm lint && pnpm format:check, npx vitest run src/daemon test/integration src/cli/parser/__tests__ src/cli-schema/command-schema-guards.test.ts scripts/__tests__ src/commands/__tests__/command-explain.test.ts (266 files / 2175 tests, all pass), pnpm check:replay-compat, pnpm check:layering, and pnpm check:affected --base origin/main --run (full derived plan, 257 files / 2528 tests, all pass) — all green.

Generated by Claude Code

@github-actions

github-actions Bot commented Aug 3, 2026

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

@thymikee

thymikee commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Re-review at 4fa09b2a: clean and ready for human merge review. All prior findings are resolved: #1533 is no longer a closing issue; replay gets repair-specific --save-script help with pinned command-help coverage; the regression now proves recorder/runner teardown does not run before rejection and does run on the plain-close retry; and the migration guide now arms recording at open --save-script. Exact-head CI is fully green and no new actionable issue was found.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 3, 2026
@thymikee
thymikee merged commit 480e388 into main Aug 3, 2026
32 checks passed
@thymikee
thymikee deleted the fix/reject-unarmed-close-save-script branch August 3, 2026 07:10
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.

1 participant