Skip to content

fix(hooks): release a hold when the turn ends, not when the answer arrives - #396

Merged
wenzowski merged 1 commit into
mainfrom
claude/batten-signals-processes-f3jpoa
Aug 13, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/batten-signals-processes-f3jpoa

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-511.

CLOUD-485 moved the hold's release onto the tool path, so a human answering
AskUserQuestion or approving ExitPlanMode releases it. That was correct and it
closed CLOUD-451's third acceptance bullet — and it made the second handoff in a
turn unguarded
, because the plan-mode workflow prescribes exactly two: clarify
with AskUserQuestion, then propose with ExitPlanMode.

Measured in one turn, from the harness's own task ids:

step result
plan-hold armed (bm78255x5) 1 hold(s) live
AskUserQuestion answered released, exit 0 — correct in isolation
ExitPlanMode, same turn refused: "No background hold is live…"
re-armed (bk0tm0tc3), retried approved

With the guard wired that costs one extra tool call and is visible. With
BATTEN_PLAN_HOLD_BYPASS=1, or on any handoff the guard does not reach, the same
sequence ends the turn idle with no hold — the reclaim CLOUD-451 exists to
prevent, arriving through its own fix, with nothing announcing it.

Release now needs both conditions

They stopped being the same moment: the human answered and the turn is over.

  • plan-hold-release-tool (PostToolUse, already wired) records the answer as a
    mark instead of removing the sentinel.
  • New plan-hold-release-turn on Stop removes the sentinel only when the mark
    is present
    , then spends the mark, so one answer licenses exactly one release.
  • plan-hold-release (UserPromptSubmit) is unchanged: a typed prompt is both an
    answer and a new turn, so both conditions already hold there.
sequence outcome
ExitPlanMode → turn ends, nobody has answered hold stays live — the human is still reading
answered → next turn ends released, once, at the boundary
AskUserQuestion answered → ExitPlanMode same turn hold stays live, second handoff guarded

No new PreToolUse entry, which the issue's own Ready block first proposed.
That event is paid on every Bash call: CLOUD-435 deleted six of them over ~150ms of
task-runner startup each, and CLOUD-479 is currently removing that same class of
cost. Stop fires once per turn, so this costs a test -e per turn, invoked by
path.

The mark lives beside the hold directory, never inside it — the reason
CLOUD-491 already documented for its heartbeat: both loops iterate the hold
directory and delete every file whose first line is not a pid, so a mark filed
inside would be reaped by the check that reads it. CLOUD-491's poll/exit records
keep their present meaning, or its sensor would start lying.

The manual hang, absorbed rather than filed apart

Both release paths read a bare cat. Right for a hook — the harness pipes JSON and
closes it. Invoked by hand with no redirect it blocks forever: measured this
session at exit 143 after the harness's ~2-minute kill, in a session whose
container is the thing the hold protects. It is reachable by following the guard's
own deny message, which points at the manual path. CLOUD-485 recorded the adjacent
case (empty stdin → silent no-op); this is the stronger neighbour it missed.

Bounded now, and it reports that it released nothing instead of exiting 0 silently.
Same two files as the change above, which is why it rides here rather than in a
ticket of its own.

Verification

tests/plan-hold.bats 25/25. One existing case was rewritten, not deleted —
"answering a handoff tool releases the hold" becomes "…marks the answer and leaves
the hold standing", because the property still holds one step later.

Mutation-checked in both directions, each reddening a different specific case:

  • revert the tool path to releasing → 16 and 17 red (17 is the acceptance case);
  • release at turn end unconditionally → 19 and 20 red (19 is CLOUD-485's
    anti-vacuity row, the one whose failure would silently restore the reclaim).

A gate seen failing one way is half-tested, which is why both are recorded.

@linear-code

linear-code Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
CLOUD-511 Two handoffs in one turn: the hold's tool-path release disarms the second, so `ExitPlanMode` after `AskUserQuestion` is unguarded

Why

CLOUD-485 landed plan-hold-release-tool on PostToolUse over the two tools the guard gates, so answering AskUserQuestion or approving ExitPlanMode now releases the hold. That is correct and it closes CLOUD-451's third acceptance bullet.

It also makes the second handoff in a turn unguarded, and the plan-mode workflow actively prescribes two: use AskUserQuestion to clarify … then ExitPlanMode to request approval. The release is per-hold; the need is per-handoff.

Measured 2026-08-13, one turn, task ids from the harness:

step result
mise run plan-hold armed (bm78255x5) plan-hold-check: 1 hold(s) live
AskUserQuestion answered plan-hold-release-tool fired, hold released, task exited 0 — correct
ExitPlanMode, same turn refused: "No background hold is live…"
re-armed (bk0tm0tc3), ExitPlanMode again approved

Why it is worth more than a re-arm. Here the guard was wired, so the failure surfaced as a visible refusal costing one extra tool call. With BATTEN_PLAN_HOLD_BYPASS=1 set, or on any handoff the guard does not gate, the identical sequence ends the turn idle with no hold — which is the reclaim CLOUD-451 exists to prevent, arriving through its own fix rather than in its absence. Nothing announces that; the symptom is a human losing typed input, one turn later.

The chosen event is what produced it, and CLOUD-485's own Ready block named the alternative. That block recommended a Stop-marks / next-PreToolUse-releases shape, explicitly because there was no PostToolUse entry to rely on. The implementation used PostToolUse instead — cheaper and more direct — and the cost of that choice is exactly this: the release fires mid-turn, when "the human answered" and "the turn is over" are no longer the same moment.

Related but not this. CLOUD-491 is whether a live hold defers the reclaim at all and that nothing records it was live. CLOUD-500 is that the hold's occupancy does no I/O. Both are about a hold that exists; this is about a hold that has been correctly released while the turn still needs one.

Searched for an existing home before filing. The only open, unpulled sibling is CLOUD-500 (Todo — the hold's occupancy does no I/O), and that is a different object: it asks whether a live hold is doing enough to keep the container alive, while this asks why a hold that was correctly released leaves a handoff still to come unguarded. A fix for either changes nothing about the other. Everything else in the family — CLOUD-485, CLOUD-451, CLOUD-491 — is In Review, so it cannot host this: adding scope to a landed issue is the "reopen a closed-out ticket" failure the board avoids.

Ready

  • Source of truth (§1). The sentinel directory, spelled once in plan-hold-check, and the guard's own matcher set — the two gated tools are the definition of "a handoff". The predicate: at the moment a gated tool is invoked, a live hold exists.
  • Mechanism (§3). Release at the turn boundary rather than at the answer, which is the shape CLOUD-485's Ready block already argued for: Stop marks a live hold as answered, and the next turn's first PreToolUse releases it — so an answer mid-turn no longer disarms a handoff still to come. Invoked by path, never mise run, per CLOUD-435's measured ~150 ms of task-runner startup per call, which plan-hold itself already follows.
  • Mechanism refined 2026-08-13 — no new registration needed. Release requires both "the human answered" and "the turn is over", and both events are already wired, so the Stop-marks / new-PreToolUse-releases shape above is unnecessary — and a new PreToolUse entry would pay CLOUD-435's per-call startup on every Bash call while CLOUD-479 is trying to remove that class of cost. Instead: plan-hold-release-tool (PostToolUse, already wired) writes a mark meaning the human answered, and the existing Stop path removes the sentinel only when a mark is present. ExitPlanMode → turn ends unanswered → no mark → hold stays live, so the reclaim CLOUD-451 prevents is still covered; AskUserQuestion answered mid-turn → mark written, turn not over → hold stays live and the second handoff is guarded; answered-then-ended → released once, at the boundary.
  • Composition with the hold's own sensor (§2). CLOUD-491 gave the hold a self-grading record — each poll, and separately an intentional exit, so session-start can say whether a hold was live at the last container replacement. The mark is a distinct file from that record, and an intentional-exit entry keeps today's meaning, or CLOUD-491's sensor starts lying about what it observed.
  • The manual path hangs, measured (§2). plan-hold-release:54 and plan-hold-release-tool:53 both read raw=$(cat 2>/dev/null) || exit 0. Correct as a hook — the harness pipes JSON and closes it. Invoked manually with no stdin redirect it blocks forever: measured this session at exit 143 after the harness's ~2-minute kill, in a session whose container is the thing this mechanism exists to protect. CLOUD-485 recorded the adjacent case (empty stdin → silent no-op); this is the stronger neighbour it missed, and it is reachable by following the guard's own deny message, which points readers at the manual path. Absorbed here rather than filed apart: same two files, same release path, and splitting one file's fix across two issues is how neither gets done.
  • Anti-vacuity (§2). A fix must not release on turn end alone. A hold whose turn merely ended with no answer yet has to stay live — that is CLOUD-485's own anti-vacuity case, and a change that releases there reintroduces the reclaim both issues exist to prevent.
  • Deliberately not in scope (§2). Widening the <-prefix human/machine classifier — CLOUD-485 declined that as unmeasured and it stays declined; whether a hold defers the reclaim at all (CLOUD-491); the hold's silent occupancy (CLOUD-500).
  • Not a second authority (§2). "Has the human answered" stays plan-hold-release-check's single predicate; this changes when the answer is acted on, not who decides it.
  • Test obligation (§7). tests/plan-hold-release.bats gains: (a) hold live, a gated tool answered, a second gated tool invoked in the same turn → still live, which is this issue's acceptance case; (b) turn ended with no answer → still live; (c) answered, next turn begins → released. Mutation-checked per CLOUD-418 — with the fix reverted, case (a) must go red.
  • Commit / bump (§6). fix(hooks) → patch until 0.1.0. Nothing under crates/, so release-plz cuts nothing and In Review is the truthful column until a later release sweeps it.
  • Blockers (§8). None — CLOUD-485's fix is landed and this sits on top of it.

Acceptance

  • ☐ AskUserQuestion followed by ExitPlanMode in one turn needs one hold, not two, and the second handoff is never refused for want of one.
  • ☐ A hold whose turn merely ended, with no answer, is still live on the next turn.
  • ☐ The bypass path is checked too: with BATTEN_PLAN_HOLD_BYPASS=1, the same two-handoff turn does not end idle with no hold.
  • ☐ Shown able to fail in both directions: reverting the mark reddens the two-handoff case specifically; making Stop release unconditionally reddens the unanswered-turn case. A gate seen failing one way is half-tested.
  • ☐ mise-tasks/plan-hold-release invoked with no stdin returns in under a second and says what it did, rather than hanging until the harness kills it.

CLOUD-451 A turn that hands control to a human ends idle, so the VM is reclaimed while the human is still typing — and their input is destroyed

Waiting on a human is the one legitimate idle turn end, and it is the only one with no mechanism.

What happens

A session calls ExitPlanMode (or AskUserQuestion) and ends its turn with nothing backgrounded. The container is idle, so it is reclaimed — reported as roughly every 2 minutes. The damage lands entirely on the human: text already typed into the approval box is discarded, or the plan is closed and a fresh one reopened. Reported verbatim by the repo owner: "Every time you throw out my valuable in progress work (you delete what I typed) or you close the plan and reopen a fresh one. This makes it impossible to carefully approve your work."

So the review step this repo depends on — a human reading a plan before an autonomous agent executes it — is not merely inconvenient, it is anti-correlated with care: the longer the human reads, the likelier their input is destroyed.

Why nothing catches it

mise-tasks/run-shape-guard already names the failure — "leaving only polling or an idle turn, both forbidden, and an idle turn gets the VM reclaimed" — and AGENTS.md carries the same rule as prose. Both address the agent's own idleness. Neither has a mechanism at the boundary where ending the turn idle is correct, because control has been handed to a human on purpose. That is non-negotiable rule 2's shape exactly: a rule with no runnable gate is half a change.

A hook cannot fix it alone. The hold has to be a real backgrounded tool call the harness knows about; a nohup-detached process is the orphaned-run shape run-shape-guard refuses, and a hook has no way to create a harness-tracked background task. What a hook can do is refuse to let the handoff happen until one exists.

Mechanism

  1. mise run plan-hold, launched with run_in_background — a no-op that writes a pid sentinel and sleeps until the sentinel is removed or a hard cap elapses. Zero output until exit, so it costs no tokens while the human reads; its exit is the wake-up.
  2. plan-hold-release on UserPromptSubmit (already-wired event) removes the sentinel, so the hold exits within one poll of the human's reply rather than being killed by it.
  3. plan-hold-guard on PreToolUse, matched to ExitPlanMode|AskUserQuestion only, denies the handoff while no hold is live, naming the exact command.

Refinement — Ready

Specializations only; the shared clauses live in Definition of Ready & Done.

  • Source of truth (§1). One sentinel file under the git dir, written by plan-hold and read by plan-hold-check. The path is spelled in plan-hold-check and every other file calls that task rather than re-deriving it — the same single-spelling discipline claim-guard keeps with claim-check's receipt.
  • Mechanism as a computable predicate (§2). "A hold is live" = the sentinel exists and its recorded pid answers kill -0. A stale sentinel whose pid is gone is not-live, the same corpse-versus-free-lock distinction alive and target-ensure already draw. Pure filesystem state, no network, no board read.
  • Effect (§3). read. The hold writes only its own sentinel under $GIT_DIR, which claim-guard already treats as machinery rather than work.
  • Generated artifacts + drift gate (§4). None.
  • Output & exit contract (§5). plan-hold-check: 0 live / 1 not live / 2 cannot look. The guard uses Claude Code's permissionDecision: "deny" JSON channel, as memory-guard and claim-guard do. Pointer-only: a pid, a path, and the command to run — never a plan or a prompt body.
  • Commit / bump (§6). fix(hooks); patch until 0.1.0.
  • Test obligation (§7). tests/plan-hold.bats and tests/plan-hold-guard.bats: live pid → allow; missing sentinel → deny naming the command; stale pid → deny; unrelated tool name → allow; unparseable stdin → allow; bypass env → allow. Plus the hold's own timing properties — releases on sentinel removal, honours its cap, and a second launch while one is live is a no-op.
  • Blockers (§8). None. Related: CLOUD-435 — it deleted every PreToolUse entry on a per-tool-call cost argument, and this adds one back under that same argument's own criterion: ExitPlanMode and AskUserQuestion fire at most once per turn, the frequency class of Stop and UserPromptSubmit, which it kept. Phase 2 should port this predicate alongside the rest, and it is a capability gap for batten hook today — no rule kind expresses "a named background process is live".

Acceptance

  • A plan left open for longer than the observed reclaim window survives, with the human's typed input intact.
  • ExitPlanMode with no hold running is refused, and the refusal names the command that fixes it.
  • The hold exits by release, not by kill, when the human answers — so answering wakes the session rather than orphaning a sleeper.
  • No interactive approval is introduced anywhere before the plan: the launch runs under the existing Bash(mise:*) allowance.

Not in this issue

Whether every other idle turn end should arm the same hold. The predicate would be broader and the enforcement point different (Stop, at the cost of a model turn per end), and it is a separate decision from the human-handoff case measured here.

Review in Linear

…rives

CLOUD-485 moved the hold's release onto the tool path so a human answering
AskUserQuestion or approving ExitPlanMode releases it. Correct, and it made the
second handoff in a turn unguarded: the plan-mode workflow prescribes clarify
then propose, and the first answer removed the sentinel the second handoff is
judged by. Measured as a live guard refusal; silently, wherever the guard does
not reach, it is an idle turn end with no hold at all — the reclaim CLOUD-451
exists to prevent, arriving through its own fix.

Release now needs BOTH conditions, which stopped being the same moment: the
human answered, and the turn is over. The tool path records the answer as a
mark; a new Stop hook releases only when that mark is present, then spends it so
one answer licenses one release.

No new PreToolUse entry, which the issue's own Ready block first proposed: that
event is paid on every Bash call, CLOUD-435 deleted six of them over ~150ms of
task-runner startup each, and CLOUD-479 is removing that same class of cost.
Stop fires once per turn, so this costs a test -e per turn. Invoked by path.

The mark lives BESIDE the hold directory, never inside it, for the reason
CLOUD-491 already documented about its heartbeat: both loops iterate the hold
directory and delete every file whose first line is not a pid, so a mark filed
inside would be reaped by the check that reads it.

Also closes the manual path's hang, absorbed into this issue rather than filed
apart since it is the same two files: both release paths read a bare `cat`,
which is right for a hook whose pipe the harness closes and blocks forever when
invoked by hand with no redirect — measured at exit 143 after the harness's
~2-minute kill, in a session whose container is what the hold protects. Bounded
now, and it reports that it released nothing rather than exiting 0 in silence.

Shown able to fail in both directions, each reddening a different case: revert
the mark and the two-handoff case reds; release at turn end unconditionally and
the unanswered-turn case reds, which is CLOUD-485's anti-vacuity row.

Refs: CLOUD-511
@wenzowski
wenzowski marked this pull request as ready for review August 13, 2026 05:50
@wenzowski
wenzowski force-pushed the claude/batten-signals-processes-f3jpoa branch from 7968bb4 to 07223cd Compare August 13, 2026 05:50
@sonarqubecloud

Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor Author

Heads-up before you spend more CI here: the repo owner has asked for plan-hold to be removed entirely until its premise is proven, and that work is in flight on claude/container-hold-plan-issue-kmcpe4 under CLOUD-515.

The six mise-tasks/plan-hold* files this PR edits — plan-hold-release and plan-hold-release-tool among them — are deleted there, along with the three hook registrations and the three bats suites.

The reasoning, short version: CLOUD-491 measured a live hold failing to defer a container restart twice; CLOUD-500 conceded the occupancy premise is untested and gated its own stage 1 on a plan-hold-check spanned = 0 reading; that reading has never occurred (.git/batten-hold-heartbeat absent in a working clone today). CLOUD-511's own defect is real — I'm not disputing the finding — but it is a defect in code whose benefit has never been observed, while its cost (a deny on every path to a human) is paid unconditionally.

Nothing here is wasted if the mechanism comes back: restoring it is a git revert, and this PR's turn-boundary release is the right shape to revert to. Suggest parking #396 rather than driving it to green.


Generated by Claude Code

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 07223cd into main Aug 13, 2026
8 checks passed
@wenzowski
wenzowski deleted the claude/batten-signals-processes-f3jpoa branch August 13, 2026 05:59
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.

2 participants