pi-claude-supervisor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.9.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.1...v0.9.2) (2026-09-24)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **supervisor:** finish stop and startup races; add gated live Decision spike ([#67](https://github.com/btnalit/pi-claude-supervisor/issues/67)) ([648b269](https://github.com/btnalit/pi-claude-supervisor/commit/648b269f185fbeb5ab39fe22ce855cc8d4342747))
11
+ * **supervisor:** stability fixes found by end-to-end runs with a real Decision Worker ([#65](https://github.com/btnalit/pi-claude-supervisor/issues/65)) ([cd0b8ef](https://github.com/btnalit/pi-claude-supervisor/commit/cd0b8efdd0898833036b14acab5ca7b29ca70d04))
12
+
5
13
  ## [0.9.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.9.0...v0.9.1) (2026-09-24)
6
14
 
7
15
 
@@ -517,10 +517,17 @@ parked or failed without granting remote/main authority. Model/API failures are
517
517
  detected from the Pi `stopReason` (a provider error resolves the prompt normally
518
518
  rather than throwing); if the Decision Worker
519
519
  API/model call fails, the system records `decision_worker_failed`, applies the
520
- bounded retry/park policy and preserves the candidate evidence. An abort is never
520
+ bounded retry/park policy and preserves the candidate evidence. The startup
521
+ instructions prompt retries provider errors on the same backoff and budget, so a
522
+ provider overload at start does not fail the task before its first turn. An abort is never
521
523
  retried, and a `noop` reply on a completed turn or a permission request parks the
522
524
  candidate rather than being treated as a resolved decision, while a `noop` on a
523
- clean Worker exit proceeds to verification. Optional alert
525
+ clean Worker exit proceeds to verification. A `stop` on a completed turn of an
526
+ unattended task without remote authority stops the Worker (never keeping it open)
527
+ and then verifies its finished work instead of discarding it (`decision_overridden`);
528
+ no repair round may follow, so a failure blocks the candidate. A `stop` on a
529
+ pending permission, on a turn the Worker has already resumed, or on a task with
530
+ remote authority stays a plain stop. Optional alert
524
531
  delivery remains independent from event-log persistence, but notification is not
525
532
  the control boundary.
526
533
 
@@ -617,6 +624,10 @@ evidence still parks.
617
624
 
618
625
  A `revise` result produces an audited repair round and sends a bounded corrective
619
626
  instruction to a still-live `repairableSession` Worker. Checks and review then run again.
627
+ The Decision Worker sees the last result tagged with the Worker turn it judged, and chooses
628
+ when to verify again; if it keeps steering instead, the Supervisor verifies on its own once
629
+ the Worker has taken three turns since that failure (`decision_overridden`), so a Decision
630
+ Worker reasoning from the stale failure cannot hold a fixed Worker in a loop until the deadline.
620
631
  The repair budget defaults to three rounds. P0/P1 findings block a `pass` but are repair
621
632
  inputs like any other concrete finding (a `pass` carrying one is treated as `revise`); a
622
633
  `human` verdict, repeated findings or an exhausted budget stop automation and park a
package/docs/testing.md CHANGED
@@ -113,6 +113,43 @@ PI_CLAUDE_SUPERVISOR_REAL_CLAUDE_PATH="$HOME/.local/share/mise/installs/claude/l
113
113
  PI_CLAUDE_SUPERVISOR_REAL_CLAUDE=1 npm run spike:tmux
114
114
  ```
115
115
 
116
+ The Decision spike runs a real Pi Decision Worker and Reviewer, on any Pi
117
+ model, against a scripted Worker that edits a temporary git repository. It
118
+ needs no Claude Code, cgroup or tmux, so it runs on hosts that cannot run the
119
+ real-Claude spikes. It is gated and excluded from normal CI:
120
+
121
+ ```bash
122
+ # Default model: google/gemini-3.5-flash-lite
123
+ PI_CLAUDE_SUPERVISOR_REAL_DECISION=1 npm run spike:decision
124
+ # Optional: another Pi model, a subset of scenarios, a per-scenario deadline,
125
+ # and keeping the temp repositories
126
+ SPIKE_DECISION_MODEL=google/gemini-3.1-flash-lite \
127
+ SPIKE_DECISION_SCENARIOS=review,stuck SPIKE_TIMEOUT_MS=600000 SPIKE_KEEP=1 \
128
+ PI_CLAUDE_SUPERVISOR_REAL_DECISION=1 npm run spike:decision
129
+ ```
130
+
131
+ Credentials come only from Pi's own sources (for example `GEMINI_API_KEY` in
132
+ the environment, or `~/.pi/agent/auth.json`); the script never reads, prints or
133
+ stores a key, and redacts what it prints. The scenarios cover:
134
+ - `review`: an incomplete first turn is caught and repaired. Two model
135
+ behaviors fail it without being regressions: a Reviewer that passes the
136
+ incomplete turn, and a Decision Worker that answers the "task is complete"
137
+ turn with `stop`, which ends the task blocked with no repair;
138
+ - `question`: a mid-task question is answered from the spec without a human;
139
+ - `stuck`: a Worker that only claims success ends `blocked` within its repair
140
+ budget.
141
+
142
+ The scripted Worker writes the full implementation only after a Supervisor
143
+ message that mentions the RangeError (or min > max). A Decision Worker answer
144
+ that never names it leaves the work undone, and the scenario fails.
145
+
146
+ Each prints a redacted summary: state, decisions, overrides, Reviewer verdicts
147
+ and answer-format failures. The script exits non-zero when a scenario misses
148
+ its expected outcome. Weak and rate-limited models are useful here, because
149
+ they exercise the deterministic guards that the prompt alone does not
150
+ guarantee. A daily quota error at startup or mid-task is expected to fail
151
+ closed (park), and is not a regression.
152
+
116
153
  The tmux spike is gated, authenticated, and excluded from normal CI. It uses
117
154
  plan mode with a fixed `opus` model, records only protocol metadata, and
118
155
  verifies three real Claude turns, exact screen-result markers, pause/resume,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -61,6 +61,7 @@
61
61
  "spike:permissions": "node scripts/spike-claude-permissions.mjs",
62
62
  "spike:signals": "node scripts/spike-claude-signals.mjs",
63
63
  "spike:automation": "node scripts/spike-claude-automation.mjs",
64
+ "spike:decision": "node scripts/spike-decision.mjs",
64
65
  "check": "npm run typecheck && npm test && npm run check:package && npm run check:docs && npm run check:automation",
65
66
  "build": "node scripts/build-package.mjs"
66
67
  },
@@ -36,6 +36,8 @@ export interface DecisionContext {
36
36
  /** A compact view of the last verification: what failed and what the Reviewer asked for. */
37
37
  export interface DecisionVerificationSummary {
38
38
  ok: boolean;
39
+ /** The Worker turn it judged: turns after it are not reflected in it. */
40
+ atTurn?: number;
39
41
  failedChecks: string[];
40
42
  reviewVerdict?: string;
41
43
  findings: string[];
@@ -92,6 +94,13 @@ export interface DecisionWorkerOptions {
92
94
  export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
93
95
 
94
96
  const DEFAULT_COMPACTION_TOKENS = 60_000;
97
+ /**
98
+ * Worker turns after a failed verification before the Supervisor verifies on
99
+ * its own: the Decision Worker only sees that failure as it was, so left to
100
+ * itself it can keep sending "it still fails" guidance to a Worker that has
101
+ * already fixed it. The first of these turns is the repair turn itself.
102
+ */
103
+ export const STALE_VERIFICATION_TURNS = 3;
95
104
  /** First retry wait; each later wait triples, capped at MAX_DECISION_RETRY_BACKOFF_MS. */
96
105
  const DEFAULT_DECISION_RETRY_BACKOFF_MS = 5_000;
97
106
  const MAX_DECISION_RETRY_BACKOFF_MS = 60_000;
@@ -111,6 +120,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
111
120
  #sessionFile?: string;
112
121
  #context: DecisionContext;
113
122
  readonly #timeoutMs: number;
123
+ /** Ends a startup retry backoff early; set only while one is waiting. */
124
+ #wakeStartupBackoff?: () => void;
114
125
  readonly #retryBackoffMs: number;
115
126
  readonly #compactionTokens: number;
116
127
  /** Set after a compaction; the next primary decision prompt re-sends the startup instructions once. */
@@ -163,11 +174,31 @@ export class PiDecisionWorker implements DecisionWorkerLike {
163
174
  await this.#options.onSessionReady({ sessionFile: this.#sessionFile, sessionId: session.sessionId, restored });
164
175
  }
165
176
  if (!restored) {
166
- try {
167
- await promptForText(session, decisionInstructions(this.#context), this.#timeoutMs, "Decision Worker startup", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
168
- } catch (error) {
169
- try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
170
- throw error;
177
+ // A provider hiccup (503 overload, 429) here would otherwise fail the
178
+ // whole task before its first turn: retry it like any decision.
179
+ const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
180
+ for (let attempt = 0; ; attempt += 1) {
181
+ try {
182
+ await promptForText(session, decisionInstructions(this.#context), this.#timeoutMs, "Decision Worker startup", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
183
+ break;
184
+ } catch (error) {
185
+ // Closed by the Supervisor (an operator stop): not a startup
186
+ // failure to report, whatever the interrupted prompt returned.
187
+ if (this.#closed) throw closedDuringStartup();
188
+ // Only a provider error is worth another attempt: a timeout already
189
+ // spent the whole prompt budget and would only multiply it.
190
+ const retryable = error instanceof Error && error.name === "DecisionWorkerApiError";
191
+ if (attempt >= maxRetries || !retryable) {
192
+ try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
193
+ throw error;
194
+ }
195
+ // close() wakes this wait, so a stop does not sit out the backoff.
196
+ await new Promise<void>((resolveWait) => {
197
+ const timer = setTimeout(() => { this.#wakeStartupBackoff = undefined; resolveWait(); }, Math.min(MAX_DECISION_RETRY_BACKOFF_MS, this.#retryBackoffMs * 3 ** (attempt + 1)));
198
+ this.#wakeStartupBackoff = () => { clearTimeout(timer); this.#wakeStartupBackoff = undefined; resolveWait(); };
199
+ });
200
+ if (this.#closed) throw closedDuringStartup();
201
+ }
171
202
  }
172
203
  }
173
204
  }
@@ -300,6 +331,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
300
331
  // Do not await #tail here: onAction may be closing the worker from inside
301
332
  // the same queued decision, which would otherwise deadlock shutdown.
302
333
  this.#closed = true;
334
+ this.#wakeStartupBackoff?.();
303
335
  const session = this.#session;
304
336
  this.#session = undefined;
305
337
  if (session) await session.abort().catch(() => {});
@@ -307,6 +339,12 @@ export class PiDecisionWorker implements DecisionWorkerLike {
307
339
  }
308
340
  }
309
341
 
342
+ function closedDuringStartup(): Error {
343
+ const error = new Error("Decision Worker was closed during startup");
344
+ error.name = "AbortError";
345
+ return error;
346
+ }
347
+
310
348
  function decisionInstructions(context: DecisionContext): string {
311
349
  return `You are the persistent Pi Decision Worker for a Claude Code implementation task.
312
350
  Your job is to inspect evidence and choose the next typed action. Do not edit files,
@@ -338,9 +376,20 @@ means the Worker's own API/model call failed mid-turn: choose retry (optionally
338
376
  corrective message) or continue to resume it, and park only after repeated failures; a subtype
339
377
  "idle" result means the turn ended without a normal stop signal, so inspect the repository and
340
378
  decide as for any other turn. CURRENT CONTEXT.lastVerification, when present, is the last acceptance
341
- and Review round (failed check ids, Reviewer verdict and findings): after a repair turn, check
342
- Claude's claim against it before choosing verify. Use verify when a turn result indicates the task is complete, even if
379
+ and Review round (failed check ids, Reviewer verdict and findings) as of Worker turn atTurn. It
380
+ does not change until verification runs again: it is not evidence that the failures remain after
381
+ Claude's later turns. Use it to judge whether Claude's reply addresses those failures; once
382
+ Claude reports them fixed, choose verify rather than re-judging the old result. (After a failed
383
+ verification the Supervisor verifies by itself once ${STALE_VERIFICATION_TURNS} Worker turns, the repair turn included,
384
+ have passed without one; do not rely on it.)
385
+ A continue, redirect or answer message is sent verbatim to Claude Code as its next instruction:
386
+ write it as a direct instruction to Claude, not a description of what you will do yourself (use
387
+ your own read-only tools to inspect the repository before deciding).
388
+ Use verify when a turn result indicates the task is complete, even if
343
389
  Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
390
+ In an unattended task without remote authority, a stop on a completed turn stops the Worker
391
+ and then still verifies its work, but no repair round can follow, so a fixable problem would
392
+ be lost: when the work looks done, choose verify.
344
393
  Use park only when the task cannot safely produce a candidate because required evidence,
345
394
  authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
346
395
  wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
package/src/redaction.ts CHANGED
@@ -8,6 +8,21 @@ export function redactSensitive(value: unknown, key?: string): unknown {
8
8
  if (typeof value === "string") {
9
9
  return value
10
10
  .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
11
+ // sk- and Google API keys, matched by their real shapes so that names
12
+ // merely starting with "sk-" (a branch sk-1234_fix_login, a path
13
+ // .../sk-dataset_2024_v2, a CSS class) stay intact: session records and
14
+ // transcript paths are rejected when redaction changes them.
15
+ // OpenAI keys (legacy, proj, svcacct, admin) all carry T3BlbkFJ, base64
16
+ // for "OpenAI"; their base64url bodies may contain - and _.
17
+ .replace(/(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]*T3BlbkFJ[A-Za-z0-9_-]*/gu, "[REDACTED]")
18
+ // OpenRouter: sk-or-v1- and 64 hex digits.
19
+ .replace(/(?<![A-Za-z0-9_-])sk-or-v1-[0-9a-f]{64}(?![A-Za-z0-9_-])/gu, "[REDACTED]")
20
+ // Other sk- providers (DeepSeek, Moonshot, …): 32+ letters and digits,
21
+ // with both. Accepted cost: a name that is exactly sk- and such a run
22
+ // (sk-<git sha>) cannot be told from a DeepSeek key and is redacted.
23
+ .replace(/(?<![A-Za-z0-9_-])sk-(?=[A-Za-z]*[0-9])(?=[0-9]*[A-Za-z])[A-Za-z0-9]{32,}(?![A-Za-z0-9_-])/gu, "[REDACTED]")
24
+ .replace(/(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/gu, "[REDACTED]")
25
+ .replace(/(?<![A-Za-z0-9_-])AQ\.[A-Za-z0-9_-]{40,}/gu, "[REDACTED]")
11
26
  .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|npm_[A-Za-z0-9]{20,})\b/gu, "[REDACTED]")
12
27
  .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
13
28
  .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
package/src/supervisor.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { EventLog, type SupervisorEvent } from "./events.ts";
4
4
  import { SupervisorStateMachine } from "./state.ts";
5
5
  import { evaluatePermission, isCommitId, isProtectedBranch, isRoutinePermission, publishCommand, pullRequestCommand, type PermissionPolicyOptions, type PolicyResult, type RemoteGrant } from "./policy.ts";
6
- import { PiDecisionWorker, type DecisionAction, type DecisionContext, type DecisionDeadlineContext, type DecisionWorkerFactory, type DecisionWorkerLike, type PiModel } from "./decision-worker.ts";
6
+ import { PiDecisionWorker, STALE_VERIFICATION_TURNS, type DecisionAction, type DecisionContext, type DecisionDeadlineContext, type DecisionWorkerFactory, type DecisionWorkerLike, type PiModel } from "./decision-worker.ts";
7
7
  import { DEFAULT_DEADLINE_GRACE_MS, DEFAULT_DEADLINE_MS, DEFAULT_DEADLINE_WARNING_MS, DEFAULT_NO_OUTPUT_TIMEOUT_MS, formatDurationMs } from "./config.ts";
8
8
  import { collectRepositoryEvidence, remoteBranchHead, remoteUrl, runReadOnly, repositoryBranch, repositoryCommitExists, repositoryHead, repositoryIsAncestor, repositoryWorkTree, verifyAll, repositoryClean, repositoryGitDirectoryIsLocal, repositorySlug, sameDestination, type RemoteBranchLookup, type RemoteDestination, type RepositoryEvidence, type VerificationCommand } from "./verifier.ts";
9
9
  import { normalizeTaskSpec } from "./acceptance.ts";
@@ -214,6 +214,10 @@ export class Supervisor {
214
214
  #lastObservedBranch?: string;
215
215
  #handle?: WorkerHandle;
216
216
  #lastVerification?: AcceptanceReport;
217
+ /** The Worker turn the last verification judged; later turns are not reflected in it. */
218
+ #lastVerificationTurn?: number;
219
+ /** Set when a Decision Worker `stop` became a verification: no repair turn may follow it. */
220
+ #stopVerification?: string;
217
221
  #workerOutput = "";
218
222
  #lastWorkerResult?: Record<string, unknown>;
219
223
  #lastTurnCompleted?: WorkerEvent;
@@ -352,6 +356,8 @@ export class Supervisor {
352
356
  this.#handle = undefined;
353
357
  this.#lastObservedBranch = undefined;
354
358
  this.#lastVerification = undefined;
359
+ this.#lastVerificationTurn = undefined;
360
+ this.#stopVerification = undefined;
355
361
  this.#workerOutput = "";
356
362
  this.#lastWorkerResult = undefined;
357
363
  this.#lastTurnCompleted = undefined;
@@ -1083,6 +1089,7 @@ export class Supervisor {
1083
1089
  if (action.action === "continue" || action.action === "redirect" || action.action === "answer") {
1084
1090
  if (await this.#decisionIsStale(event)) return;
1085
1091
  if (await this.#verifyOnTurnBudget(handle, event, action)) return;
1092
+ if (await this.#verifyStaleFailure(handle, event, action)) return;
1086
1093
  await this.#sendInternal(action.message);
1087
1094
  return;
1088
1095
  }
@@ -1136,6 +1143,7 @@ export class Supervisor {
1136
1143
  return;
1137
1144
  }
1138
1145
  if (action.action === "stop") {
1146
+ if (await this.#verifyStop(handle, event, action)) return;
1139
1147
  await this.#stopInternal(`Decision Worker: ${action.reason}`);
1140
1148
  return;
1141
1149
  }
@@ -1145,6 +1153,7 @@ export class Supervisor {
1145
1153
  const message = action.message?.trim() ? action.message : RETRY_RESUME_MESSAGE;
1146
1154
  if (await this.#decisionIsStale(event)) return;
1147
1155
  if (await this.#verifyOnTurnBudget(handle, event, action)) return;
1156
+ if (await this.#verifyStaleFailure(handle, event, action)) return;
1148
1157
  await this.#sendInternal(message);
1149
1158
  }
1150
1159
  });
@@ -1162,6 +1171,53 @@ export class Supervisor {
1162
1171
  return true;
1163
1172
  }
1164
1173
 
1174
+ /**
1175
+ * A verification failed and the Worker has since taken several turns with
1176
+ * no new one: judge the work as it is now instead of sending more guidance
1177
+ * based on the old failure. Another failure goes through the ordinary
1178
+ * repair-round budget, so a stuck task parks instead of running to its
1179
+ * deadline.
1180
+ */
1181
+ async #verifyStaleFailure(handle: WorkerHandle, event: WorkerEvent, action: DecisionAction): Promise<boolean> {
1182
+ if (event.type !== "turn_completed" || !this.#lastVerification || this.#lastVerification.ok || this.#lastVerificationTurn === undefined) return false;
1183
+ // A turn cut short by the Worker's own API error is half-done work: let
1184
+ // the Decision Worker resume it rather than judging it.
1185
+ if (event.result.is_error === true || event.result.subtype === "error") return false;
1186
+ const turnsSince = this.#turn - this.#lastVerificationTurn;
1187
+ if (turnsSince < STALE_VERIFICATION_TURNS) return false;
1188
+ await this.#appendEvent({ type: "decision_overridden", taskId: this.#task?.taskId, workerId: handle.id, data: { action: action.action, override: "verify", reason: `${turnsSince} Worker turns since the last failed verification`, eventType: event.type } }).catch(() => {});
1189
+ await this.#startVerification(handle, event, "stale failed verification");
1190
+ return true;
1191
+ }
1192
+
1193
+ /**
1194
+ * An unattended `stop` on a finished turn: models reach for it when they
1195
+ * believe the work is done, and a plain stop would end the task unverified
1196
+ * with its work unreported. The stop still happens first — the Worker is
1197
+ * stopped and never kept open — and then its finished work is judged
1198
+ * (acceptance and the Reviewer only read): a failure blocks the candidate
1199
+ * rather than starting a repair round. A stop elsewhere (a pending
1200
+ * permission, a turn the Worker has already resumed) or on a task that may
1201
+ * publish stays a plain stop.
1202
+ */
1203
+ async #verifyStop(handle: WorkerHandle, event: WorkerEvent, action: DecisionAction): Promise<boolean> {
1204
+ const task = this.#task;
1205
+ if (!task || !this.#automation || this.#humanRequired || event.type !== "turn_completed" || this.#machine.state !== "waiting") return false;
1206
+ if (task.spec.autonomy.remoteAuthority !== "none") return false;
1207
+ if (await this.#decisionIsStale(event)) return false;
1208
+ this.#stopVerification = action.reason;
1209
+ await this.#appendEvent({ type: "decision_overridden", taskId: task.taskId, workerId: handle.id, data: { action: action.action, override: "verify", reason: "the Worker is stopped and its finished work verified before the task ends; no further Worker turns", decisionReason: action.reason, eventType: event.type } }).catch(() => {});
1210
+ await this.#adapter.stop(handle, `Decision Worker: ${action.reason}`);
1211
+ await this.#pollInternal(true);
1212
+ // Re-read after the awaits: the poll moves the stopped Worker on.
1213
+ const after: string = this.#machine.state;
1214
+ // An operator stop arrived meanwhile: it owns the outcome ("stopped").
1215
+ if (this.#stopRequested !== undefined) return true;
1216
+ if (after === "verifying") await this.#verifyInternal();
1217
+ else await this.#parkCandidate(`Decision Worker stop left the task in state ${after} before verification`, event);
1218
+ return true;
1219
+ }
1220
+
1165
1221
  /**
1166
1222
  * Move an idle Worker into verification the way a `verify` decision does:
1167
1223
  * in place when the transport supports it, otherwise by stopping the
@@ -1636,6 +1692,7 @@ export class Supervisor {
1636
1692
  if (!this.#task) throw new Error("no active task");
1637
1693
  if (this.#machine.state === "waiting" && canRepairInPlace(this.#adapter)) this.#machine.transition("verifying");
1638
1694
  if (this.#machine.state !== "verifying") throw new Error(`cannot verify from ${this.#machine.state}`);
1695
+ this.#lastVerificationTurn = this.#turn;
1639
1696
  const verificationAbortController = new AbortController();
1640
1697
  this.#verificationAbortController = verificationAbortController;
1641
1698
 
@@ -2194,6 +2251,10 @@ export class Supervisor {
2194
2251
  const task = this.#task;
2195
2252
  const handle = this.#handle;
2196
2253
  if (!task || !this.#automation || this.#humanRequired) return false;
2254
+ if (this.#stopVerification !== undefined) {
2255
+ await this.#appendEvent({ type: "candidate_blocked", taskId: task.taskId, workerId: handle?.id, data: { reason: `${reason}; the Decision Worker chose stop, so no repair round is sent` } });
2256
+ return false;
2257
+ }
2197
2258
  if (this.#repairRound >= task.spec.maxRepairRounds) {
2198
2259
  await this.#appendEvent({ type: "repair_round_exhausted", taskId: task.taskId, workerId: handle?.id, data: { maxRepairRounds: task.spec.maxRepairRounds, reason } });
2199
2260
  await this.#appendEvent({ type: "candidate_blocked", taskId: task.taskId, workerId: handle?.id, data: { reason: `${reason}; automatic repair budget is exhausted` } });
@@ -2263,7 +2324,7 @@ export class Supervisor {
2263
2324
  // A completed interactive task may keep its persistent session open for
2264
2325
  // the operator instead of tearing it down; a stop requested mid-verify or
2265
2326
  // a blocked/failed outcome always falls back to today's stop behavior.
2266
- const keepOpen = !stopRequested && outcome === "completed" && result.ok
2327
+ const keepOpen = !stopRequested && this.#stopVerification === undefined && outcome === "completed" && result.ok
2267
2328
  && this.#keepWorkerOnCompletion && Boolean(this.#adapter.release) && Boolean(this.#handle);
2268
2329
  let cleanupError: unknown;
2269
2330
  let releasedInteractive = false;
@@ -2577,6 +2638,7 @@ export class Supervisor {
2577
2638
  // Worker judges a repair turn only by Claude's claim that it is done.
2578
2639
  const lastVerification = verification ? {
2579
2640
  ok: verification.ok,
2641
+ ...(this.#lastVerificationTurn !== undefined ? { atTurn: this.#lastVerificationTurn } : {}),
2580
2642
  failedChecks: verification.checks.filter((check) => check.check.required && !check.ok).map((check) => check.check.id).slice(0, 16),
2581
2643
  ...(verification.review ? { reviewVerdict: verification.review.verdict } : {}),
2582
2644
  findings: bySeverity(verification.review?.findings ?? []).slice(0, 8).map((finding) => `${finding.id} [${finding.severity}] ${finding.message}`.slice(0, 200)),