Files
Shaojin WenandQwen-Coder 0646b57f06 fix(managed-agent): prove A3 rewritten command recovery without Hook rerun (#16116)
* fix(managed-agent): prove A3 rewritten command recovery without Hook rerun

Add the packaged command-rewrite arm and a focused three-generation witness
for two approval-bound extension command steps. Recovery keeps each rewritten
input on its own command Action and original deadline, replays settled
Hook/tool-stage results without physical rerun, and still accepts a relief
Turn and reliable close.

The real MySQL/Linux counterexample exposed small lifecycle gaps that are
fixed here: command_approval_* Action ID grammar, extension recovery resource
closure, commandStep load routing and Hook owner construction, a fast Shell
close race, and command receipt acknowledgement before Runtime retirement.

* fix(managed-agent): refuse Hook broker swap while the mount is held

delegateBrokerToMcp(broker) replaced the broker without reconciling the acquired/mount-claim state, so a config-revision enable that swapped owners after a Hook turn had acquired the mount would orphan the old claim and leave acquired pointing at the previous broker. Refuse the swap while the mount is held (the no-argument handover keeps the same broker and stays valid); a fresh load binds the new owner at construction. Adds a red/green regression plus free-swap ownership and shared-disown pins.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed-agent): capture extension Shell output before an already-closed handoff

The already-closed Shell adoption resolved closure before any data listener was installed, so a command that wrote stdout/stderr and exited during owners.start()'s membership wait was persisted as a successful empty result (silent data loss) instead of the base's explicit unknown-outcome failure. start() now optionally buffers stdout/stderr synchronously from spawn (prereadOutput), and the Shell detaches, replays those bytes through its capture, and installs live listeners in one synchronous step, so no output window is lost. hook/stdio callers do not opt in and keep their fail-safe behavior. Adds a deterministic pre-handoff replay regression (red before, green after) and a real-owner spawn-buffering regression.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed-agent): release preread capture on failed extension Shell startup

A failed start() never handed detachPreread to a caller and the catch left the early stdout/stderr listeners attached; since stopOwner keeps the child in the owner map, child -> listener -> buffer retained every preread byte for the instance lifetime (reproduced at 8 MiB per failed start). The catch now detaches the listeners and clears the buffers. Adds a red/green regression that injects a post-spawn journal failure and asserts the retained child has no data listeners. Also strengthens the mount-held swap guard test to construct mountWorkUnproven()=false with the mount held so the new clause itself is pinned (whole-guard removal now reds it), and adds a Linux-runnable real-spawn preread test (cgroup unit mocked) so the spawn-time attach is covered on the OS CI uses.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed-agent): use ChildProcess type for the preread test cleanup handle

The Linux preread regression typed its cleanup handle as { kill: (signal?: string) => void }, whose kill parameter is wider than ChildProcess.kill(number | NodeJS.Signals). Under strictFunctionTypes the assignment of started.child is TS2322, and the test file is part of the CLI tsc --build regardless of it.skipIf, so clean builds failed on every platform. Use a type-only ChildProcess import. Verified the exact rule with an isolated strictFunctionTypes probe (old annotation TS2322, ChildProcess clean) and a full CLI tsc --noEmit (rc 0); the host OOMs on the full reference-graph tsc --build, so the bundle build is left to CI.

Co-authored-by: Qwen-Coder <[email protected]>

* test(managed-agent): pin failed-start preread cleanup on Linux

verify-pr round 2 F7: the failed-start preread detach/clear was pinned only by a darwin-only test, and the macOS CI lane never runs on pull_request, so removing the cleanup (M9) stayed green on Linux. Add a linux-cgroup failure regression (cgroup unit mocked, real child) that rejects the post-spawn saveOwner after a short delay so sentinels are captured, then asserts the retained child has zero data listeners; removing the detach reds it on the platform CI runs. Mirrors the reviewer's Linux probe.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(managed-agent): address A3 review R1 findings

R1-1: replace the broker-swap guard with close-and-rebuild so a config revision bump that keeps the Hook catalog no longer parks a mount-held apply at recovery_required; revert the delegate overload. R1-2: cap the spawn-time preread at the shell 1 MiB budget with an overflow flag folded into outputCaptureLimitExceeded. R1-3: add a tool_stage domain-refusal load test. R1-4: batch the package-config retention walk (BFS + IN(...) + delta insert) with a delta witness. R1-5: precompile the Action-id Pattern at four sites. R1-6/7/8: anchor the crash/final evidence matrices and the package hook-command contract. R1-9: drop the inert admission-failure acknowledge. R1-10: read back both command-action decisions in the A3 arm. Verified by red/green regressions, CLI typecheck, the affected suites, and packaged arm owner-r46 on real MySQL 8.4/cgroup v2.

Co-authored-by: Qwen-Coder <[email protected]>

* test(managed-agent): pin A3 owner rebuild, preread overflow fold, broker disown

verify-pr round 3 found three test-evidence gaps where the R1 mechanism
was fixed but the regression pinned only half of it. Strengthen the tests
so each named mutation reds:
- N1: fire a second Hook after the revision bump and assert the executing
  broker owner moves ownerFor(1)->ownerFor(2); kills M-close (no-arg
  handover) and M-close2 (close without rebuild).
- N2: assert preread.overflow folds into outputCaptureLimitExceeded and
  [Output truncated]; kills deleting the fold line.
- N3: assert close() does not release the shared broker after disown;
  kills removing ownsBroker=false (double release).

Test-only; production byte-identical to 6d15829e52.

* fix(managed-agent): narrow A3 load-gate takeover and degrade pre-edge snapshots

Round-4 review (bot R2/R3 + maintainer) raised two behavioural Criticals:
- R2-1: the resume-approval gate's extensions arm lost its takeover
  conjunct, so a bare/lifecycle load of an extensions Session could set
  resume, skip the only 409 and drive a parked Turn. Require
  takeoverFlags && !lifecycle (dropping only !session.hooks so a Hook
  catalog still reaches the arm). Harness suite 693 green.
- R3-1: the bundle->contributions closure edge was enforced
  unconditionally on read, so a snapshot committed before the edge
  shipped read as corrupt. Mirror the page-envelope precedent and drop
  an absent contributions child in both the inspection walk and the
  native read; add a read-side fixture proven red/green.

Also address two Suggestions with red/green witnesses:
- R2-3: lift the A3 decision read-back into assertCommandRewriteDecision
  so the contract suite drives it; add disagreeing-revision/option cases.
- R2-6: pin that a lost durable receipt write leaves the native entry
  unacknowledged; moving acknowledge above the write reds the case.

* ci(managed-agent): cap the rewritten-command cgroup delegation step

The failover job's rewritten-command cgroup delegation was the only arm
step without timeout-minutes, so a wedged smoke could spend the whole job
ceiling uncapped. Cap it at 10 like its sibling arms and re-derive the
hosted-failover-e2e ceiling (280->290 summed, 295->305 job, allowance 15)
in both the workflow and the hosted-process-ci meta-test that pins it.

* test(managed-agent): pin the projection dedupe so lost retention reds

R2-8/R2-5: the delta-growth test could not red if the retained seed was
lost, because re-walking an already-projected level left row counts and
gmt_create unchanged. Assert the projection holds every recovery resource
exactly once across the preparing->prepared revisions; emptying the
retained seed now reds it with duplicate resource ids, proving projection
growth is bounded by the delta rather than the revision count.

* fix(managed-agent): repair merge column residue, size ratchet, native tolerance scope

Round-4 review (bot 5757389507) caught three issues introduced by the
round-4 merge / cgroup / native-tolerance changes:
- R4-1: the merge rename missed SUM(terminal) in the plain-arm durable-event
  audit; ma_public_event only declares is_terminal, so MySQL 1054 aborted the
  arm before its verdict. Restore SUM(is_terminal) and add a contract test
  reconciling every single-table ma_public_event column the runner names
  against the V1 schema (reds on terminal, passes on is_terminal).
- R4-2: the rewritten-command arm plus its capped cgroup delegation grew
  sdk-java.yml past its size ratchet; bump .size-baseline 150332->155957 the
  growth is real and the Lint & Static size gate reds without it.
- R4-3/R4-6: scope the native-snapshot contributions tolerance to transitive
  refs collected from resource bodies; a directly declared contributions ref
  stays on the strict isDeepStrictEqual path, matching the inspection walk's
  direct-reference guard instead of silently accepting a missing dependency.

* fix(managed-agent): correct the column guard tokenizer and legacy MCP retention

Round-5 review on the round-4 fixes:
- R5-1: the ma_public_event column guard was wrong in both directions — a
  quoted value (LIKE 'agent.%') posed as a column and red valid SQL, while
  ${sessionFilter} was blanked so a column typo inside it passed unseen.
  Extract a maPublicEventColumns helper that strips quoted literals and
  expands interpolated SQL fragments, with a two-direction witness.
- R2-5: the retention read-then-insert form was applied unconditionally, so
  the legacy MCP-settings path stopped releasing the projection row it
  supersedes and accumulated one row per revision for the Session's life.
  Keep the accumulate form only on the package-config branch; the legacy
  branch replaces its row. Witness: two legacy revisions leave exactly one
  SESSION_RECORD row (reds if the else branch keeps accumulating).

* fix(managed-agent): tolerate a legacy bundle's absent contributions child on commit

R6-1 (Critical, fails-closed regression): isReferenceContainer gained the
extension bundle/contributions kinds, so the commit-side closure walk descends
into a persisted legacy bundle's contributions child. That child was never
staged, so the read 404s and aborts the commit permanently — a legacy Session
loads via the read-side tolerance but can never be reconfigured. Add the
commit-side counterpart of that tolerance: drop only an absent contributions
child from the closure; a present or conflicting one stays fatal. Witness: a
commit whose closure reaches a persisted bundle whose contributions child was
never staged resolves and keeps the bundle in the census; removing the skip
reds it with 'Resource not found.'.

* fix(managed-agent): structural legacy-closure tolerance + command-step takeover arm

Round-7 review, two Criticals on the earlier tolerance/takeover fixes:
- R7-1: the legacy-snapshot tolerance was enumerated site-by-site and
  kind-by-kind, missing a dangling contributions part and a staged bundle
  whose contributions child was never uploaded. Hoist the tolerated kinds
  into one module constant (contributions + contributions-part) and apply it
  uniformly in all four walks (inspection, native page, commit persisted,
  commit staged); the staged arm gains the same async has-tolerance. Direct
  refs and present-but-conflicting children stay fatal. Two witnesses: an
  absent manifest part inspects cleanly, and a staged bundle with a 404
  contributions child commits with the bundle in the census; emptying the
  constant reds both.
- R7-2: the extensions takeover arm admitted every approval continuation, so
  an extensions+Hook Session's native approval reached resumeSavedNativeBatch's
  no-hooks refusal and 503'd the load. Scope the arm to a command-step plan;
  native approvals stay on the no-hooks arm.

* fix(managed-agent): share the extensions command-step takeover with the settle exemption

R8-1 (Critical, fails-closed): an extensions Session that also carries a Hook
catalog never satisfies `takeover` (hooks set), so the file-history settle probe
took canSettleHostedFileHistory and refused on the await_action phase alone. A
batch that owes file history while a command step parks at await_action was 409'd
on every load before the extensions resume arm was ever consulted, leaving the
parked round recoverable only by an out-of-band cancel. Compute
extensionsCommandStepTakeover once and use it in both the settle exemption and
the resume arm, so the parked command-step batch is resumed instead of demanding
a settle a parked approval can never produce. The R7-2 command-step scoping is
unchanged.

* fix(managed-agent): key both cold-load answer guards on one command-step predicate

R9-1 (Critical, regression): the second answerResumedTurnCalls suppression keyed
on resumeApproval, which is only assigned inside the takeover arm and is therefore
undefined on a bare load — so the guard was true exactly on the cold-load path it
was added to protect. A cold load answered the parked command-step round, writing
an irreversible tool_result for the plan's own skill call and permanently
destroying the continuation (or 503'ing via the uncaught hook-recovery throw).
Derive one commandStepPark predicate keyed on the unconditionally-computed
pendingCommandApproval (plus resumeApproval) and use it at both answer guards;
hoist the resumeApproval declaration above it. extensionsCommandStepTakeover is
unchanged (still requires takeoverFlags) — only the two answer guards change.

* refactor(managed-agent): extract MCP settings projection to honor the class-size ratchet

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): keep the deterministic Hook owner re-acquirable across detach

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): keep deterministic owners re-acquirable on every non-delete close

Move the terminal-release rule onto the owner instead of a single call site:
Hook and MCP close default to non-terminal and only DELETE opts into the
terminal release, so the shared MCP broker, the v2 lifecycle close and the
enable() owner swap no longer tombstone the id a same-revision reload
recomputes. A non-terminal close hands the mount back via releaseMount and
keeps the owner READY; the MCP drain stays ahead of the handback and skips
markReleased so the reload still derives the same owner id.

Translate the documented release-mount refusals (busy/conflict/writer-unproven)
into recovery-required on both owners instead of letting a raw rejection escape
as a close failure, and drain held Runtimes before the v1 owner close asks for
the mount back.

* fix(cli): re-initialize drained MCP configs on reload and complete the owner close fence

A non-terminal close left every mcp_configuration at 'drained', a resting state
the journal cannot reopen and nothing else consumes, so a same-revision reload
could never re-initialize MCP and stayed recovery-blocked. Advance the drained
row to released (its one legal exit) and reinstall a fresh configuration under
the same runtimeSessionId, so the server restarts, no drained row lingers, and
the deterministic owner id the reload derives does not move.

Add the documented 503 runtime_reconciliation_required release-mount refusal to
the handback fence so a Broker restart beside the Harness is named
recovery-required instead of escaping as a raw rejection that breaks the close.

Let a DELETE terminally release the deterministic owner even on a reloaded,
never-acquired Hook instance, keying the terminal arm off the terminal intent
rather than the transient mount hold, so load->detach->reload->delete no longer
orphans the owner.

* fix(cli): reinitialize released MCP configs and clear superseded drained rows on reload

The drained->released advance stranded the deterministic extension record: the
next reload resolved the same deterministic id back to the released row and
aborted. Treat a released predecessor like a drained one and reinstall a fresh
row under the same runtimeSessionId, picking a revision past every existing row.

A non-terminal close drains every row but only the latest per server is
reinstalled, so superseded same-owner drained siblings held recoveryBlocked (and
mountHandback) forever; advance them to released once the replacing active row is
committed.

The non-terminal discard sites left drained rows under a superseded owner that a
later apply's new owner made the reload reject (owners.size>1). Rather than make
the discard terminal — which would strand an apply that fails and keeps the
previous revision active — release superseded-owner drained rows at cold load,
sparing the active owner's own rows so a failed apply is never stranded.

Also sync ChildWorkspaceServiceTest's busy-storage assertion with the #16369
message reword that landed on main.

* fix(managed-agent): close the MCP/Hook owner lifecycle release gaps

Round-19 review raised three Criticals in the deterministic-owner release
path; all three are fixed:
- R15-3: the live enable callback constructed HostedMcpSession without the
  superseded-drained sweep the cold load already runs, so an off/on/off/on
  extension toggle accumulated owners.size>1 and threw recovery_required.
  Call releaseSupersededDrained before constructing on the live path too.
- R17-1: a DELETE of a never-acquired deterministic Hook owner let the
  Broker 404 (and post-restart 503 reconciliation) escape close(), wedging
  the session undeletable. Absorb both for an un-acquired owner and book it
  released, as releaseOwner does; the un-acquired terminal release itself is
  kept (R15-4). Two regression witnesses proven red/green.
- R17-2: releaseMount refused runtime_session_busy without the drain sweep
  the drain release runs, so a background Shell run outliving its turn wedged
  every detach. Sweep the unproven background rows before the busy check
  (mount handback, owner stays re-acquirable, R13-1).

hook/mcp suites 203 green; cli tsc 0; runtime-broker compiles; bundle builds.
MySQL fault-gate IT verification deferred to the CI full tier.

* fix(managed-agent): keep the mount handback observe-and-hold over live background writers

Round-20 R18-1: the R17-2 releaseMount sweep drained background rows and
then excluded exactly those rows from the busy check with no un-excluded
re-read, so a background process the sweep could not prove stopped stayed
excluded and the mount was handed back over a running writer. drain=true
also stopped legitimate background runs on the two observe-and-hold
handback routes (#13800).

Restore the contract: an observe-only sweep (drain=false) settles provable
exits and stops nothing, the excluded check clears only what settled, and
an un-excluded re-read refuses runtime_session_busy for any row still live
or unproven — the same guard the pre-R17-2 busy check already had. The
method javadoc is rewritten to match.

Witness: mountReleaseStaysBusyBehindASettledExecutionWhoseBackgroundProcessStillRuns
is green with the fix; removing the re-check reds it ("Expected
CompletionException ... nothing was thrown", releaseMount proceeds and
releaseMountCalls flips to 1). RuntimeBrokerServiceTest 199 green;
checkstyle 0 violations; spotbugs 0. MySQL fault-gate IT verification
deferred to the CI full tier.

* refactor(managed-agent): keep RuntimeBrokerService under its size ratchet

Round-21 R19-1: the R17-2/R18-1 releaseMount additions grew
RuntimeBrokerService.java to 4252 lines, past both its ratchet pin and
GLOBAL_MAX_LINES (4216), so ClassSizeRatchetTest failed and the SDK Java
lane went red. The ceilings only ratchet down, so raising them is forbidden.

Move the guarded second half of the mount handback (claim re-check, excluded
busy check, un-excluded background re-read, unproven-capture refusal, then
the transport handback) into RuntimeExecutionRecovery.releaseMountAfterSweep,
beside the observe-only sweep it follows. The facade drops to 4212 lines;
the recovery collaborator is 442 (< 800, so it needs no pin of its own). No
behaviour changes: the sweep stays drain=false, the un-excluded re-read still
refuses, and the claim re-check still runs under the lock.

Witness: ClassSizeRatchetTest reproduced the two 4252-line violations and is
green at 4212; RuntimeBrokerServiceTest 199 green; removing the re-check in
its new home reds the pinned mount test ("Expected CompletionException ...
nothing was thrown"), then restored. checkstyle 0 violations; spotbugs 0.
MySQL fault-gate IT verification deferred to the CI full tier.

* fix(cli): name a raced mount-handback admission_closed as recovery-required

Round-23 R15-2: fencedMountHandback's doc comment says its codes are the
documented release-mount refusals, and the design doc names 409
runtime_admission_closed for the close/archive/delete window
(workspace-execution-mount-recovery-block-release.md). releaseMount's
requireLifecycleRelease throws exactly that conflict when a v2 lifecycle
fence row exists and the harness is not yet draining, but the fence omitted
the code, so a non-terminal detach racing a close/archive/delete rethrew the
raw rejection out of close() and the route answered a generic
managed_session_close_failed instead of the declared recovery-required.

Add runtime_admission_closed to the 409 fence group (additive — no existing
member weakened): the lifecycle operation's own drain frees the mount, so the
detach backs off attached/retryable. Both callers (Hook close, MCP
closeResources) share the fence. Keep 404 runtime_session_not_found out (the
terminal arm books it) and do not fold in 501 workspace_mount_release_unsupported
(a permanent misconfiguration, not a retry-once-it-settles fence).

Witness: add the code to the Hook recovery-required it.each and a MCP
closeResources twin; with the fix both green (hook/mcp 208). Removing the
member reds both new cases with the raw HostedWorkspaceBrokerRejection
("Runtime Broker returned HTTP 409") instead of HostedHook/McpRecoveryRequiredError,
while the unrelated terminal-acquire admission_closed case stays green; restored.
cli tsc --noEmit 0 errors.

* fix(cli): drain held Runtimes before the v2 non-terminal Hook mount handback

Round-24 R22-1: the lifecycle-v2 settle_resources branch issued the owner
close — which for a deterministic owner on a non-terminal close hands the
Workspace mount back — before the branch's only Runtime drain. A
Hook-configured turn shares that Runtime and leaves a live background Shell
(its finish() skips the release), so the release-mount guard refused
runtime_session_busy, the fence named it recovery-required, and the route
answered 503 workspace_lifecycle_resources_unsettled on every retry while the
process lived. The drain that would have stopped the run was never reached.
The v1 close already keeps the opposite order (moved there for R14-2); the v2
branch never got the same treatment when the cleanup-debt drain landed.

Move the drain ahead of the owner close, mirroring v1. This also puts it ahead
of the publisher close, so the drain's stops land their finalize through a
still-open publisher. The owed file-history marker is already retired earlier
in the same route, so the drain's file-history guard is unaffected. DELETE is
unchanged (terminal short-circuits the handback arm), and the handback stays
non-terminal for detach with the deterministic owner id intact.

Witness: a route-level v2 settle_resources close of a session holding a
deterministic Hook owner and cleanup debt, with releaseMount refusing busy
until the descriptor drain runs — 200 with the fix, and reverting the reorder
reds it with `expected 200 "OK", got 503 "Service Unavailable"`. The first
draft of the witness was vacuous (an unrelated lease drain cleared the flag);
only the descriptor-carrying drain now counts. Full harness suite 712 green;
cli tsc --noEmit 0 errors.

* style(cli): apply Prettier to the R22-1 handback-order witness

The Lint & Static lane failed on Run Prettier: the releaseMount spy added with
the R22-1 witness exceeded the print width. Formatting only, no behaviour
change; prettier --check now passes on every file this branch touched.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): drain the held Runtime a Hook turn leaves on a v2 close

R22-1 moved the v2 drain ahead of the Hook mount handback, but the drain it
moved only acts on cleanup debt, and a Hook-configured turn writes none: it
acquires through hooks.acquire(), while oweHostedTurnCleanup and cleanupOwed
both sit in the else branch. What that turn leaves is a held Runtime recorded
client-side, and the only drain of that set was the v1 close. So the handback
still went out over a live background run, the guard refused
runtime_session_busy, and the detach parked on 503 for as long as the process
lived — the wedge R22-1 was raised for.

Drain heldRuntimeTurns in the v2 block too, mirroring v1, after the debt drain
and before hooks.close so the R22-1 order holds. Tolerate only 404
runtime_session_not_found; v1's 503 reconciliation tolerance is deliberately
not carried over because the releaseMount that follows would refuse with that
same 503 anyway, so tolerating it would only claim an unproven stop. Every
other refusal throws and the route parks honestly, as before.

The R22-1 witness was vacuous for this shape: it called f.owe() to manufacture
a descriptor the Hook path never writes. The new witness asserts there is no
cleanup debt, seeds heldRuntimeTurns instead, and keys the busy guard to the
held turn's own Runtime — the lease drain also issues {drain:true} with no
descriptor, so an opts-only check would let it clear the flag.

Red before: expected 200 "OK", got 503 "Service Unavailable". Green after.
hosted-harness-session 716/716 (full file, run alone), hook+mcp 208, build rc0,
Prettier clean.

* fix(cli): tolerate an unprovable held-Runtime drain on a v2 close

R24-1's drain tolerated only 404. The reasoning for leaving out v1's 503
tolerance was that the releaseMount right after it would refuse with the same
503 anyway, so tolerating could not unwedge the close. That premise is false:
HostedHookSession.close() guards the handback with
`ownsBroker && (mountHeld || (terminal && deterministicOwner))`, and a reload
builds a fresh instance whose mountHeld stays false until a Hook turn acquires.
After a Harness restart a non-terminal detach therefore never calls
releaseMount at all — the close would have succeeded, and the throw was the
only thing blocking it. Because the entry is deleted only on success or 404,
every retry re-seeded the same id and failed identically, leaving the mount
claimed, the lease pinned and the publishers open indefinitely.

Carry both v1 tolerances now, with the compensation that makes them honest:
503 runtime_reconciliation_required deletes the entry and marks the drain
unproven, and the two v2 monitor-loop stops settle runtime_lost rather than
stop_requested when it is. Other refusals still throw; there is no cascade to
hand those to.

Witness: a v2 settle_resources close whose held-Runtime release rejects 503
must answer 200, delete the entry, and stop the loop with runtime_lost.
Red before (expected 200, got 503), green after. Forcing stopSettle back to
stop_requested kills the test on the runtime_lost assertion, so the
compensation is pinned and not merely present.

hosted-harness-session 717/717 (full file, run alone), hook+mcp 208, build rc0
with no type errors, Prettier clean.

* fix(cli): settle a blocked v2 Session on the runtime_lost line

The v2 stopSettle added for R25-1 keyed only off heldDrainUnproven while its
comment claimed it matched the v1 close, which also folds in session.blocked.
A Session whose own settlement already failed never proved to the Runtime that
anything stopped, so certifying stop_requested there writes a terminal run
state that rebuildFromRuntimeLost refuses to recover, and reports a cancelled
task's writer as settled.

The blocked-Session half of this gap predates R25-1 — v2 hardcoded
stop_requested before it — but the parity claim in the comment did not, and
the term belongs here either way. v2 has no heldDrainFailed variable because
that arm throws instead, so the expression is session.blocked ||
heldDrainUnproven.

Witness: a v2 settle_resources close with blocked=true, no held Runtime (so
the drain is trivially proven) and an injected monitor loop must stop the loop
with runtime_lost. Red before on exactly that assertion, green after; the loop
assertion cannot pass vacuously because a failed injection fails the spy check.

* docs(cli): scope the v2 held-Runtime drain to the shapes that reach it

The R24-1 drain reads session.heldRuntimeTurns, and its comment claimed it
covered a Hook-configured turn's background run. No producer registers that
obligation for a Hook turn: finish() calls broker.release() only under
!mcp && !hooks && !cleanupOwed, runtimeHeld is assigned only in that release's
409 runtime_session_held catch, cleanup() returns early on hooks, and every
add() site keys on runtimeHeld or a cleanup hold. So the loop is empty for
that shape and the close still parks on recovery-required.

Comment-only in production. State the reachable scope (a wake turn's held
aftermath, a cleanup hold), say plainly that the Hook shape is not covered,
and record why the load restore keeps its v2 gate: a restored id can be a
deterministic Hook owner, and this drain releases terminally, which would
tombstone the id a reload recomputes.

Pin the producer fact so the drain is not mistaken for a fix of that shape:
with the same fixture and the same 409 mock, the no-hooks turn ends with
runtimeHeld true while the Hook turn never calls release and ends false.
Retitle the R24-1 case to stop claiming the Hook shape; it seeds the
obligation the way the reachable producers do.

Lifting the gap needs a keep-READY drain, deferred to a follow-up: the
terminal release used here cannot stop a deterministic owner's background
run without tombstoning it (R13-1/R14-1).

Tool-turn suite 573/573, the three v2 witnesses green, build rc0 with no type
errors, Prettier clean.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-08 04:02:29 +00:00
..
2025-08-27 15:23:21 +08:00