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 +8 -0
- package/docs/architecture.md +13 -2
- package/docs/testing.md +37 -0
- package/package.json +2 -1
- package/src/decision-worker.ts +56 -7
- package/src/redaction.ts +15 -0
- package/src/supervisor.ts +64 -2
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
},
|
package/src/decision-worker.ts
CHANGED
|
@@ -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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
342
|
-
|
|
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)),
|