Skip to content

feat(hold): first-class held state, typed rulings, hold-first recovery (#627 Stage 1) - #637

Merged
HenryLach merged 8 commits into
mainfrom
feat/held-state
Sep 7, 2026
Merged

HenryLach merged 8 commits into
mainfrom
feat/held-state

Conversation

@HenryLach

Copy link
Copy Markdown
Owner

First-class held state for escalations — #627 Stage 1

Closes the four-incident class (TP-2037/2039/2042/2047): a worker escalation now produces a runtime-owned hold instead of prompt discipline + supervisor acks.

What changes for operators

  • A worker that calls escalate_to_supervisor holds: the worker exits and is not relaunched, the lane waits at zero cost (no iterations / stall clock / relaunch budget), the task shows held (⚖), worktree and branch are preserved and never merged. ⏸️ Lane held alert names the escalation id.
  • Only a typed ruling releases: send_agent_message(type="ruling", replyTo=<escalation id>) (supervisor) or /orch-rule <escalation id> <text> (operator — the only path that stamps role operator). info acknowledges without releasing; query answers from stored state (no spawn); abort cancels and fails (never approves); steer no longer releases a hold.
  • The ruling is placed at the top of the relaunched worker's first prompt and must be acknowledged before the unit can complete; unacknowledged rulings are replayed.
  • taskRunner.worker.holdTimeoutMinutes (default 240): expiry parks the batch with pause cause hold-timeout, hold stays open; rule then orch_resume(force=true).
  • Batch state schema v4 → v5 (holds[] table; v4 upconverts).

Authority is one predicate

evaluateCompletionAuthority() gates step check-off, finalize, the task monitor (hold-first, before .DONE/stall/dead-pid), resume reconciliation (hold-first, with validated replay of unrecorded escalations and hold-aware segment pinning), force-merge, retry, cleanup, takeover and runtime reconstruction (rebuilt from mailbox evidence or refused). Worker-written .DONE under a hold is quarantined. Hold persistence is strict (fail-closed): a persist failure blocks the lane, never releases.

Review

Sage design pass → 3 implementation commits → 4 blocker-only review rounds (7 + 6 + 1 blockers, most reproduced by probes: expired-hold ruling ordering, controller-exit deadlock, retry paths without the store, singleton-unit scope stamping, resume completing over an open hold, stop-wave retries, metadata loss on strict checkpoints, permissive recovery readers) → explicit sign-off at 05ab5ae1.

Validation

Suite 4017 pass / 0 fail (+~90 new behavioural tests in hold-state, held-state-runner, held-state-recovery); typecheck, format, lint (283 < 284 baseline), CLI help/doctor clean.

Follow-ups (not in this PR)

Stage 2 ratification record + finalize binding (#627 remainder, feeds #626) — to run as a Taskplane batch on this build; #631 lease/generation; #628 takeover state machine; #626 coverage gate. Closes #630 (subsumed).

…e 1a)

Pure hold-state module: HoldRecord {escalationId, unit identity, executionId,
agentId, openedAt, deadline, phase open|released|cancelled, escalation text,
ruling {id, replyTo, actor{role,id}, instructions}, deliveryState
none|pending|in-flight|acknowledged}. Transitions are pure and return new
records. evaluateCompletionAuthority() is the single predicate every
completion path will consult: open (incl. expired) or released-but-
unacknowledged holds block; cancelled does not. validateRuling() accepts
only a 'ruling' message correlated by replyTo to an OPEN hold of exactly
this unit (and execution, when known) carrying a trusted actor with role
supervisor|operator — steer never releases, acks never extend the deadline.

Schema: BATCH_STATE_SCHEMA_VERSION 4→5 adds the authoritative top-level
holds table (v4 upconverts to an empty table; a v5 file missing the table is
refused — no silent backfill of an authority table). Statuses gain 'held'
(task, segment, monitor snapshot); PauseSignal gains cause 'hold-timeout';
MailboxMessageType gains 'ruling' with an optional trusted actor stamp,
written only for ruling messages. Config: taskRunner.worker.holdTimeoutMinutes
(default 240, 5..10080) → TASKPLANE_HOLD_TIMEOUT_MIN.

Tests: hold-state.test.ts (29); migration fixtures moved to v5.
…first monitor (#627 stage 1b)

Lane-runner: the volatile Tier-1 bridge (pendingEscalation, MAX_HOLD_RELAUNCHES,
lastSupervisorReplyTs) is replaced by a durable, runner-owned hold loop.
- An escalation opens a HoldRecord that is persisted (strict) BEFORE the
  outbox message is acked; a persist failure leaves the escalation queued,
  alerts, and blocks every spawn / no-progress kill / drain until it lands.
- Any worker exit with an open hold hands over to awaitHoldResolution(): no
  worker process, no relaunch, no iteration or stall consumption. The loop
  publishes 'held' (STATUS, lane snapshot with worker:null, one alert) and
  polls the unit's inbox as the SINGLE consumer of hold-control mail:
  ruling → validateRuling (typed, replyTo-correlated, trusted actor role) →
  release + persist; info → acknowledgement only (deadline unchanged);
  query → answered from stored state, no spawn; abort → cancel, never
  approve, task fails; steer → ordinary mail, never releases.
- Pause unwinds with a 'held' outcome (hold preserved). Deadline expiry
  records expiredAt, alerts once, and parks the batch with pause cause
  'hold-timeout' — the hold stays open.
- The ruling is placed at the TOP of the relaunched worker's initial input
  with delivery marked in-flight (strict) before spawn; a worker reply with
  replyTo=<rulingId> acknowledges; unacknowledged rulings are replayed
  (at-least-once) and completion stays withheld until acknowledged.
- evaluateCompletionAuthority() gates step check-off, the all-complete
  break, and the post-loop finalize (returns 'held', never failed/succeeded,
  when the budget runs out under a hold). A worker-written .DONE while
  hold-blocked is quarantined (.DONE.unauthorized-<ts>).
- Exit-intercept skips units with an open hold and never consumes rulings.

Engine/execution/resume: createHoldStore() over the runtime batch state
persists via persistRuntimeStateStrict (throws; best-effort path untouched
for telemetry). executeWave/executeLaneV2/monitorLanes thread the store.
resolveTaskMonitorState evaluates hold authority BEFORE .DONE, dead-pid and
stall (status 'held', sessionAlive true, stall clock reset). Wave tally adds
heldTaskIds; a held lane leaves its remaining tasks pending; the pause
finalizer treats held as interrupted (preserve worktrees, no merge). Resume
restores holds verbatim. agent-host never steers a 'ruling' into a session.

Tests: held-state-runner.test.ts (9 behavioural: open-before-ack, no
relaunch, initial-input delivery + ack, rejection matrix, query, abort,
pause/resume without spawn, expiry → hold-timeout, .DONE quarantine +
monitor hold-first, fail-closed persist failure, at-least-once replay).
issue-630-hold-exit.test.ts reduced to its surviving wiring tests.
…ery guards, dashboard (#627 stage 1c)

send_agent_message: type='ruling' with replyTo (validated against the durable
hold table at the sender — correlation, unit, phase, actor role), stamped
actor role 'supervisor' by the tool itself. Held units are addressable with
NO live worker pid (the runner's hold loop, or the next resume, is the
consumer); results say delivered / queued for runner / queued while parked,
and state the effect (steer never releases). /orch-rule <escalation id>
<text> is the only path that stamps role 'operator'.

Resume: hold-first reconciliation (durable table + escalations still
unrecorded in a worker outbox → 'held', re-executed into the hold loop, a
stale .DONE never marks complete); re-execution is lane-parallel (serial
within a lane) so a restored hold does not park unrelated lanes; a 'held'
re-execution outcome is preserved as held; the hold store exists from the
re-execution phase onward with a mutable persistence context.

Guards: orch_retry_task refuses held tasks (retry is not release) and names
the ruling call; orch_force_merge refuses a wave with unresolved holds;
drainAgentOutbox preserves 'escalate' messages by default (takeover and
hard-fail paths can no longer destroy an unpersisted hold); the takeover
summary lists held units; engine pre-cleanup preserves worktrees/branches
while any hold is unresolved, even when git-clean.

Dashboard: held status (⚖) in task badges, segment pills and summary chips.
Docs: primer 'Held lane' section + recipe, supervisor template, commands
(/orch-rule), task-runner config (hold_timeout_minutes), spec status,
CHANGELOG. Tests: held-state-recovery.test.ts (12); windows widened in
source-slice tests; drain test updated for escalation preservation.
1. Hold loop reads the inbox BEFORE the deadline check, so a ruling queued
   while the batch was parked on hold-timeout is consumed on resume instead
   of the lane re-parking with the ruling unread.
2. Every 'held' return parks the batch: the post-loop budget-exhausted path
   now sets pause cause hold-timeout (+ alert). A returned held controller
   with the batch still running left the wave monitor reporting held forever.
3. Every production executeLaneV2 call carries the strict hold store:
   attemptWorkerCrashRetry / attemptModelFallbackRetry (their detached
   { paused:false } signals are now a proxy onto the batch signal — reads
   ignore only the R002-4 stop-wave cause, writes go through — and a held
   retry outcome is moved to heldTaskIds via recordHeldRetryOutcome) and the
   resume reconnect path. Resume's wave pause-finalizer honours heldTaskIds.
4. Resume hold-store persistence context is thunks: a strict checkpoint
   during re-execution serializes the persisted task table (synthesized
   pre-wave outcomes) instead of an empty outcome list, discovery is exposed
   as soon as it exists, and wave-phase checkpoints follow the live
   latestAllocatedLanes / allTaskOutcomes variables.
5. Segment scope: holdBindsUnit() — a whole-task hold binds every segment, a
   segment hold binds its segment AND the whole-task unit — used by the
   completion predicate, mail classification, ack matching and ruling
   validation; resume pins a held segment as the task's active segment
   (pinHeldSegments) so re-execution runs the held unit.
6. Escalation scope: the agent bridge stamps scope {taskId, segmentId} on
   every outbox message. The live drain opens holds only for escalations
   that match the unit (stamped scope, or unscoped-but-written-this-run);
   foreign ones are left in the outbox. Resume performs VALIDATED replay
   before frontier repair and reconciliation (replayUnrecordedEscalations:
   scoped → that task; unscoped → only a single-task lane's durable record;
   otherwise the resume is refused) and persists the opened holds strictly.
7. Runtime reconstruction rebuilds the hold table from mailbox evidence
   (reconstructHoldsFromMailbox: scoped escalations in outbox+processed,
   earliest FULLY valid ruling in inbox+ack releases, reply replyTo=ruling
   acknowledges) and REFUSES the candidate when authority cannot be
   recovered: unscoped escalation, unknown task, segment-scoped evidence
   without topology, unreadable/malformed mailbox files.

Tests: +19 (runner: expired-hold ruling consumed on resume, budget-exhausted
held parks, foreign-scope escalation left alone; recovery: replay
attribution/refusal, segment pinning, A→B mailbox reuse refusal, valid-ruling
reconstruction, malformed evidence refusal; wiring). 4009 pass.
A. Singleton units ("TP-1::default") escalated with a blank segment stamp
   (FULL_TASK mode clears the prompt-visibility segment vars) and the exact
   scope filter rejected the worker's own escalation → task succeeded, .DONE
   accepted, hold never opened. The lane now exports the REAL unit identity
   (TASKPLANE_UNIT_SEGMENT_ID) for stamping, and escalationMatchesUnit accepts
   the current run's own mail for the same task whatever its segment stamp.
B. Resume could complete, clean up and delete state with an open hold: a
   .DONE at the canonical task path hid the task from discovery so no
   controller was restored. Resume now quarantines unauthorized .DONE markers
   of hold-bound tasks (canonical + worktree paths) BEFORE discovery, and a
   terminal gate mirrors the engine: unresolved holds → paused, preserve,
   never completed, no cleanup.
C. A released ruling for a segment whose checkboxes were complete could
   never be delivered: the completed-segment spawn shortcut cancelled the
   delivery iteration. The shortcut is bypassed while a ruling is undelivered.
D. stop-wave retries no longer ran: the producer set paused=true with no
   cause, so the linked retry signal saw an active pause. The producer stamps
   cause 'stop-wave' (never overwriting an existing cause).
E. Pre-wave strict checkpoints dropped partialProgress*/exitDiagnostic and
   wrote taskFolder ''. The pre-wave outcome set carries the full persisted
   metadata and a synthetic discovery supplies folder/repo/segment fields.
F. Recovery readers were fail-open on malformed evidence: resume replay used
   the permissive readOutbox (swallows errors); reconstruction validated only
   id/type. New readOutboxStrict (throws) for replay; reconstruction uses
   isValidMailboxMessage before batch filtering.

Tests: +7 behavioural/wiring (singleton-unit stamp, delivery vs completed
segment, quarantine, terminal-gate order, linked signal both directions,
metadata carry, strict readers). 4016 pass.
…llback for every resume checkpoint (#627 stage 1)

The synthetic pre-wave discovery was replaced by fresh discovery as soon as
it existed, and fresh discovery lists only tasks still to run — so a hold
checkpoint after that point wrote taskFolder '' (and dropped partial-progress
/ diagnostic fields) for every completed task. Fresh discovery is now merged
OVER the persisted fallback for both checkpoint contexts; fresh entries win,
persisted entries fill the gaps.

Test: real hold checkpoint through persistRuntimeStateStrict with one held
task and one completed task absent from fresh discovery; reload asserts
folders, partialProgress* and exitDiagnostic survive. 4017 pass.
@HenryLach
HenryLach merged commit 8612d55 into main Sep 7, 2026
1 check passed
@HenryLach
HenryLach deleted the feat/held-state branch September 7, 2026 23:08
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.

Worker exits when an expectsReply escalation times out; lane-runner does not relaunch it and registry/lane state stay 'running'

1 participant