pi-goal-list-loop-audit 0.34.57 → 0.34.80
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/README.md +4 -2
- package/docs/VISION-ASSIST.md +73 -0
- package/extensions/confirm-draft.ts +130 -0
- package/extensions/goal-loop-auditor-process.ts +153 -20
- package/extensions/goal-loop-auditor.ts +4 -1
- package/extensions/goal-loop-core.ts +283 -0
- package/extensions/goal-loop-display.ts +171 -66
- package/extensions/goal-loop-shield.ts +21 -4
- package/extensions/goal-settings.ts +39 -0
- package/extensions/loops/goal.ts +1245 -79
- package/extensions/main-model-recovery.ts +30 -7
- package/extensions/reviewer.ts +24 -0
- package/extensions/settings-menu.ts +33 -0
- package/extensions/vision-assist.ts +117 -0
- package/package.json +1 -1
- package/scripts/goal-auditor-worker.mjs +5 -0
package/README.md
CHANGED
|
@@ -263,8 +263,10 @@ limits and user aborts (`non-recoverable`), plus auditor watchdog timeouts
|
|
|
263
263
|
(a hanging verification command will hang again — the stored claim waits for
|
|
264
264
|
an explicit resume).
|
|
265
265
|
A provider hint (`retry_after`/`reset_at`) is honored when it fits the
|
|
266
|
-
five-hour probe budget;
|
|
267
|
-
|
|
266
|
+
five-hour probe budget; an over-budget hint (e.g. a week-long reset) never
|
|
267
|
+
parks the goal — the bounded cadence owns the wait, and only the 24h horizon
|
|
268
|
+
ends automatic probes (a `/goal resume`/`/list resume`/`/loop resume` then
|
|
269
|
+
starts a fresh window). With global `autoResume=on`, pending
|
|
268
270
|
probes survive a session reload. For continuous work, configure ordered
|
|
269
271
|
**Main model backups** in `/glla` using a model from a different provider or
|
|
270
272
|
billing/quota pool — another model on the same exhausted plan is not a real
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Vision Assist — see with mmx, not a model switch
|
|
2
|
+
|
|
3
|
+
**v0.34.72** · note.md 2026-08-07: *"the agent is too eager when couldnt see it
|
|
4
|
+
tried to use expensive mdoels. we need to special a vision setting where it
|
|
5
|
+
called another model or cli like mmx vision to see if stuck. but not just this
|
|
6
|
+
we need to specify that it cant be too eager to switch only preapproved."*
|
|
7
|
+
|
|
8
|
+
## Policy
|
|
9
|
+
|
|
10
|
+
The executor (pi's main agent) has no eyes. When a task needs it to **look**
|
|
11
|
+
at something — a screenshot, a UI state, an error dialog, a rendered mockup —
|
|
12
|
+
it must NOT switch models to get vision. The check routes to the **mmx vision
|
|
13
|
+
CLI** (the `mmx-cli` skill, MiniMax VLM):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mmx vision describe --image <path-or-url> --prompt "<question>" --quiet --non-interactive
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- The image is usually a screenshot the user already pasted into the
|
|
20
|
+
conversation (e.g. `/home/dracon/Pictures/Screenshots/...`). Pass its path
|
|
21
|
+
straight through.
|
|
22
|
+
- Keep the question short and specific: *"What does this screenshot show?"*,
|
|
23
|
+
*"Is there an error dialog?"*, *"What is the terminal output?"*.
|
|
24
|
+
- Reading the returned description is the agent's job — no model switch
|
|
25
|
+
needed. (Verified 2026-08-07: `mmx vision describe` returns clean JSON/text
|
|
26
|
+
with `status_code: 0`.)
|
|
27
|
+
|
|
28
|
+
## The preapproval gate (model switches)
|
|
29
|
+
|
|
30
|
+
A model switch is sanctioned **only when the target is preapproved** — i.e.
|
|
31
|
+
NOT in the `forbiddenModels` policy:
|
|
32
|
+
|
|
33
|
+
- Default forbidden list: `gpt-5.5`, `sonnet`, `opus` (matched
|
|
34
|
+
case-insensitively as a substring against the `provider/id` ref).
|
|
35
|
+
- `/glla forbiddenModels=...` edits the list; `blockForbiddenModelSwitches`
|
|
36
|
+
(default on) reverts a forbidden selection to the previous model.
|
|
37
|
+
- Every switch to a forbidden model is ledgered as `forbidden_model_switch`
|
|
38
|
+
(with `blocked: true|false`).
|
|
39
|
+
- With vision assist on (default), the same event also appends a
|
|
40
|
+
`vision_assist` ledger entry — the routing alternative: `{ route:
|
|
41
|
+
"mmx-vision", blockedSwitch: <ref>, reason: "forbidden_model_switch" }`.
|
|
42
|
+
|
|
43
|
+
Even a preapproved vision-capable model is a second choice: mmx vision is the
|
|
44
|
+
default for every vision check.
|
|
45
|
+
|
|
46
|
+
## The setting
|
|
47
|
+
|
|
48
|
+
`visionAssist` (default **on** — opt-out):
|
|
49
|
+
|
|
50
|
+
- **on** → every continuation prompt carries the `## VISION-ASSIST — SEE WITH
|
|
51
|
+
MMX, NOT A MODEL SWITCH` directive (`extensions/vision-assist.ts`
|
|
52
|
+
`VISION_ASSIST_GUIDANCE`), and a forbidden switch also records the
|
|
53
|
+
`vision_assist` routing entry.
|
|
54
|
+
- **off** → no vision guidance is injected; the `forbiddenModels` gate still
|
|
55
|
+
stands (forbidden switches remain blocked/ledgered).
|
|
56
|
+
|
|
57
|
+
Edit: `/glla` → Keep-going → Vision assist, or `/glla visionAssist=off`.
|
|
58
|
+
|
|
59
|
+
## Implementation map
|
|
60
|
+
|
|
61
|
+
| Piece | Where |
|
|
62
|
+
|---|---|
|
|
63
|
+
| Guidance block (single source of truth) | `extensions/vision-assist.ts` → `VISION_ASSIST_GUIDANCE` |
|
|
64
|
+
| Command builder | `visionDescribeCommand(imagePath, question?)` |
|
|
65
|
+
| Routing rule (pure) | `routeVisionCheck(request)` — mmx by default; forbidden target → mmx + `blockedSwitch`; preapproved target → `model-switch` allowed |
|
|
66
|
+
| Ledger payload builder | `visionAssistLedger(route, request)` |
|
|
67
|
+
| Continuation injection | `extensions/loops/goal.ts` `continuationPrompt()` (gated on `visionAssist !== false`) |
|
|
68
|
+
| Forbidden-switch hook | `observeModelChange()` forbidden branch → `vision_assist` entry |
|
|
69
|
+
| Setting | `extensions/goal-settings.ts` (default true), menu row in `extensions/settings-menu.ts`, editor + `/glla` row in `extensions/loops/goal.ts` |
|
|
70
|
+
| Tests | `tests/vision-assist.test.ts` |
|
|
71
|
+
|
|
72
|
+
The `vision_assist` ledger type is the audit trail: every entry says where the
|
|
73
|
+
check routed and (when a switch was blocked) which model was refused.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.2.0
|
|
2
|
+
// extensions/confirm-draft.ts
|
|
3
|
+
//
|
|
4
|
+
// v0.34.78 (GitHub #4): the draft-class confirm dialog as a real TUI
|
|
5
|
+
// component. ctx.ui.select renders plain text with no wrapping; this
|
|
6
|
+
// component renders the SAME title/body as Markdown (objective + contract
|
|
7
|
+
// readable at full width) with a SelectList for the Yes / Yes-and-always /
|
|
8
|
+
// No choices. Kept in its own file so tests can construct and render it
|
|
9
|
+
// without dragging in the whole goal loop.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type Component,
|
|
13
|
+
Container,
|
|
14
|
+
Markdown,
|
|
15
|
+
type MarkdownTheme,
|
|
16
|
+
type SelectItem,
|
|
17
|
+
SelectList,
|
|
18
|
+
type SelectListTheme,
|
|
19
|
+
Spacer,
|
|
20
|
+
Text,
|
|
21
|
+
} from "@earendil-works/pi-tui";
|
|
22
|
+
import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
export interface ConfirmDraftFactoryDeps {
|
|
25
|
+
title: string;
|
|
26
|
+
body: string;
|
|
27
|
+
options: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structural type for the KeybindingsManager — mirrors settings-menu.ts. */
|
|
31
|
+
export interface KeybindingsManagerLike {
|
|
32
|
+
matches(data: string, key: string): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Pure: the markdown rendered in the dialog. The title is the H1, the
|
|
36
|
+
* body (objective + verification contract) is the content. */
|
|
37
|
+
export function buildConfirmDraftMarkdown(title: string, body: string): string {
|
|
38
|
+
return `# ${title}\n\n${body}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a MarkdownTheme from the runtime Theme's fg()/bold() primitives.
|
|
42
|
+
* Uses the theme's own md* colors so the dialog follows the active theme. */
|
|
43
|
+
function markdownTheme(theme: Theme): MarkdownTheme {
|
|
44
|
+
const fg = (color: Parameters<Theme["fg"]>[0]) => (t: string) => theme.fg(color, t);
|
|
45
|
+
return {
|
|
46
|
+
heading: (t) => theme.bold(fg("mdHeading")(t)),
|
|
47
|
+
link: (t) => fg("mdLink")(t),
|
|
48
|
+
linkUrl: (t) => fg("mdLinkUrl")(t),
|
|
49
|
+
code: (t) => fg("mdCode")(t),
|
|
50
|
+
codeBlock: (t) => fg("mdCodeBlock")(t),
|
|
51
|
+
codeBlockBorder: (t) => fg("mdCodeBlockBorder")(t),
|
|
52
|
+
quote: (t) => fg("mdQuote")(t),
|
|
53
|
+
quoteBorder: (t) => fg("mdQuoteBorder")(t),
|
|
54
|
+
hr: (t) => fg("mdHr")(t),
|
|
55
|
+
listBullet: (t) => fg("mdListBullet")(t),
|
|
56
|
+
bold: (t) => theme.bold(t),
|
|
57
|
+
italic: (t) => t,
|
|
58
|
+
strikethrough: (t) => t,
|
|
59
|
+
underline: (t) => t,
|
|
60
|
+
codeBlockIndent: " ",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function selectListTheme(theme: Theme): SelectListTheme {
|
|
65
|
+
return {
|
|
66
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
67
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
68
|
+
description: (t) => theme.fg("muted", t),
|
|
69
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
70
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The confirm dialog: DynamicBorder frame, markdown title+body, spacer, the
|
|
76
|
+
* three-choice SelectList, and a help line. Exported so tests can construct
|
|
77
|
+
* it with a fake theme and assert the rendered lines.
|
|
78
|
+
*/
|
|
79
|
+
export class ConfirmDraftComponent implements Component {
|
|
80
|
+
private readonly md: Markdown;
|
|
81
|
+
private readonly selectList: SelectList;
|
|
82
|
+
private readonly requestRender: () => void;
|
|
83
|
+
private readonly theme: Theme;
|
|
84
|
+
private readonly keybindings: KeybindingsManagerLike;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
deps: ConfirmDraftFactoryDeps,
|
|
88
|
+
requestRender: () => void,
|
|
89
|
+
theme: Theme,
|
|
90
|
+
keybindings: KeybindingsManagerLike,
|
|
91
|
+
done: (value: string | undefined) => void,
|
|
92
|
+
) {
|
|
93
|
+
this.requestRender = requestRender;
|
|
94
|
+
this.theme = theme;
|
|
95
|
+
this.keybindings = keybindings;
|
|
96
|
+
this.md = new Markdown(buildConfirmDraftMarkdown(deps.title, deps.body), 1, 1, markdownTheme(theme));
|
|
97
|
+
const items: SelectItem[] = deps.options.map((o) => ({ value: o, label: o }));
|
|
98
|
+
this.selectList = new SelectList(items, Math.min(items.length, 10), selectListTheme(theme));
|
|
99
|
+
this.selectList.onSelect = (item) => done(item.value);
|
|
100
|
+
this.selectList.onCancel = () => done(undefined);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render(width: number): string[] {
|
|
104
|
+
const container = new Container();
|
|
105
|
+
container.addChild(new DynamicBorder((s: string) => this.theme.fg("borderAccent", s)));
|
|
106
|
+
container.addChild(this.md);
|
|
107
|
+
container.addChild(new Spacer(1));
|
|
108
|
+
container.addChild(this.selectList);
|
|
109
|
+
container.addChild(new Spacer(1));
|
|
110
|
+
container.addChild(new Text(this.theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
111
|
+
container.addChild(new DynamicBorder((s: string) => this.theme.fg("borderAccent", s)));
|
|
112
|
+
return container.render(width);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
invalidate(): void {
|
|
116
|
+
this.md.invalidate();
|
|
117
|
+
this.selectList.invalidate();
|
|
118
|
+
this.requestRender();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
handleInput(data: string): void {
|
|
122
|
+
this.selectList.handleInput(data);
|
|
123
|
+
this.requestRender();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Exposed for tests. */
|
|
127
|
+
getSelectedItem(): string | null {
|
|
128
|
+
return this.selectList.getSelectedItem()?.value ?? null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -13,7 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
|
|
16
|
-
import { stripThinkBlocks, type Goal } from "./goal-loop-core.js";
|
|
16
|
+
import { stripThinkBlocks, captureGoalRevision, type Goal, type GoalRevisionToken } from "./goal-loop-core.js";
|
|
17
17
|
import { buildGoalAuditorPrompt } from "./goal-loop-auditor.js";
|
|
18
18
|
import { checkRegressionShield, parseAuditorVerdict } from "./goal-loop-shield.js";
|
|
19
19
|
|
|
@@ -28,6 +28,12 @@ export interface GoalAuditorResult {
|
|
|
28
28
|
error?: string;
|
|
29
29
|
regressionShieldPassed?: boolean;
|
|
30
30
|
regressionShieldMissing?: string[];
|
|
31
|
+
/** v0.34.59: focus revision token echoed from request.json. The parent
|
|
32
|
+
* compares this against the current state.goal.revision after the audit
|
|
33
|
+
* finishes; mismatch → the verdict is treated as stale-refused, not a
|
|
34
|
+
* silent overwrite. The caller decides what to do (typically: skip the
|
|
35
|
+
* verdict, log stale_revision_refused, surface the refusal in the HUD). */
|
|
36
|
+
goalRevision?: GoalRevisionToken;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export interface AuditorProgress {
|
|
@@ -56,6 +62,19 @@ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "bash"] as const;
|
|
|
56
62
|
const PROTOCOL_VERSION = 1;
|
|
57
63
|
const DEFAULT_WALL_TIMEOUT_MS = 30 * 60_000;
|
|
58
64
|
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
65
|
+
/** v0.34.57 (steal-list #7 / bug #1.4): heartbeat-without-progress watchdog.
|
|
66
|
+
* A worker heartbeat (`lastActivityAt`) fresher than this is "activity";
|
|
67
|
+
* older than this is "silence" (the worker's own GLLA_AUDITOR_STALL_MS
|
|
68
|
+
* brake owns that case — the parent watchdog must not double-fire it). */
|
|
69
|
+
const DEFAULT_HEARTBEAT_FRESH_MS = 60_000;
|
|
70
|
+
/** v0.34.57: if the heartbeat stays fresh but no NEW tool call or report
|
|
71
|
+
* output arrives for this long, the worker is alive but wedged (auto-retry
|
|
72
|
+
* loop, empty stream, hung tool). Demote to quiet, emit `auditor_stalled`,
|
|
73
|
+
* and auto-cancel the detached job. Mirrors the worker's 10m default brake
|
|
74
|
+
* on the complementary axis: silence→worker cancels, activity-without-
|
|
75
|
+
* progress→parent cancels. Both are far inside the 30m wall bound and the
|
|
76
|
+
* observed 1h50m stuck case. */
|
|
77
|
+
const DEFAULT_HEARTBEAT_NO_PROGRESS_MS = 10 * 60_000;
|
|
59
78
|
const ATTEMPT_ID_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
60
79
|
const activeChildren = new Map<string, ChildProcess>();
|
|
61
80
|
|
|
@@ -104,6 +123,11 @@ interface AuditorRequest {
|
|
|
104
123
|
thinkingLevel: string;
|
|
105
124
|
createdAt: string;
|
|
106
125
|
wallDeadlineAt: number;
|
|
126
|
+
/** v0.34.59: focus revision token captured at dispatch. Echoed in
|
|
127
|
+
* result.json; the parent re-validates against current disk state
|
|
128
|
+
* before applying the verdict. Mismatch → stale-refusal, not a silent
|
|
129
|
+
* overwrite. */
|
|
130
|
+
goalRevision?: GoalRevisionToken;
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
interface AuditorToolCall {
|
|
@@ -122,6 +146,10 @@ interface AuditorResultFile {
|
|
|
122
146
|
thinkingLevel: string;
|
|
123
147
|
toolCalls: AuditorToolCall[];
|
|
124
148
|
error?: string;
|
|
149
|
+
/** v0.34.59: focus revision token echoed from request.json. The parent
|
|
150
|
+
* compares this against the current state.goal.revision; mismatch → the
|
|
151
|
+
* verdict is treated as stale-refused, not a silent overwrite. */
|
|
152
|
+
goalRevision?: GoalRevisionToken;
|
|
125
153
|
}
|
|
126
154
|
|
|
127
155
|
interface AuditorProgressFile {
|
|
@@ -156,12 +184,34 @@ export interface AuditorProcessRuntime {
|
|
|
156
184
|
wallTimeoutMs?: number;
|
|
157
185
|
now?: () => number;
|
|
158
186
|
attemptId?: () => string;
|
|
187
|
+
/** v0.34.57: watchdog window — cancel the detached job when the worker's
|
|
188
|
+
* heartbeat stays fresh but no new tool call or report output arrives for
|
|
189
|
+
* this long (default 10m). Tests shrink this. */
|
|
190
|
+
heartbeatNoProgressMs?: number;
|
|
191
|
+
/** v0.34.57: freshness horizon for `lastActivityAt` — only heartbeats
|
|
192
|
+
* younger than this count as "activity" for the watchdog (default 60s). */
|
|
193
|
+
heartbeatFreshMs?: number;
|
|
159
194
|
/** Environment is inherited by default; useful for a fake pi binary in tests. */
|
|
160
195
|
env?: NodeJS.ProcessEnv;
|
|
161
196
|
}
|
|
162
197
|
|
|
163
198
|
export type AuditorProgressCallback = (progress: AuditorProgress) => void;
|
|
164
199
|
|
|
200
|
+
/** v0.34.57: payload for the heartbeat-without-progress watchdog. The parent
|
|
201
|
+
* persists this as the `auditor_stalled` ledger event. */
|
|
202
|
+
export interface AuditorStalledInfo {
|
|
203
|
+
/** When the watchdog fired. */
|
|
204
|
+
at: number;
|
|
205
|
+
/** Age of the last worker heartbeat at detection (`now - lastActivityAt`).
|
|
206
|
+
* Fresh (≤ heartbeatFreshMs) by construction — this is activity without
|
|
207
|
+
* progress, not silence. */
|
|
208
|
+
heartbeatAgeMs: number;
|
|
209
|
+
/** How long the no-progress streak had been running (≥ heartbeatNoProgressMs). */
|
|
210
|
+
noProgressMs: number;
|
|
211
|
+
/** The worker phase in the last progress snapshot. */
|
|
212
|
+
phase: AuditorProgress["phase"];
|
|
213
|
+
}
|
|
214
|
+
|
|
165
215
|
/** Return a stable JSON representation for request-hash validation. */
|
|
166
216
|
export function stableJson(value: unknown): string {
|
|
167
217
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
@@ -246,8 +296,28 @@ function asProgress(file: AuditorProgressFile, startedAt: number): AuditorProgre
|
|
|
246
296
|
};
|
|
247
297
|
}
|
|
248
298
|
|
|
249
|
-
|
|
250
|
-
|
|
299
|
+
/** v0.34.57: the progress-bearing subset of a worker snapshot. Heartbeat
|
|
300
|
+
* events refresh `lastActivityAt` and may oscillate `phase` (running ↔
|
|
301
|
+
* thinking on message_start/agent_start) without delivering progress — this
|
|
302
|
+
* signature deliberately excludes both, so only a NEW finished tool call,
|
|
303
|
+
* new report output, or a NEW tool start counts as progress. */
|
|
304
|
+
function progressSignature(file: AuditorProgressFile): string {
|
|
305
|
+
const calls = file.toolCalls;
|
|
306
|
+
const lastToolFinishedAt = calls.length > 0 ? (calls[calls.length - 1]?.finishedAt ?? 0) : 0;
|
|
307
|
+
return `${calls.length}|${lastToolFinishedAt}|${file.recentOutput.join("\u0000")}|${file.currentTool ?? ""}|${file.currentToolStartedAt ?? 0}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function infra(model: string, thinkingLevel: string, error: string, output = "", capturedToken?: GoalRevisionToken): GoalAuditorResult {
|
|
311
|
+
return { approved: false, disapproved: false, output, model, thinkingLevel, error, ...(capturedToken ? { goalRevision: capturedToken } : {}) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** v0.34.59: stamp the captured focus revision onto a successful verdict
|
|
315
|
+
* result so the parent can re-validate before applying. Mismatched tokens
|
|
316
|
+
* cause the verdict to be refused (logged as stale_revision_refused in the
|
|
317
|
+
* parent) rather than silently overwriting a goal that moved on. */
|
|
318
|
+
function stampToken<T extends GoalAuditorResult>(result: T, capturedToken: GoalRevisionToken | undefined): T {
|
|
319
|
+
if (!capturedToken) return result;
|
|
320
|
+
return { ...result, goalRevision: capturedToken };
|
|
251
321
|
}
|
|
252
322
|
|
|
253
323
|
/**
|
|
@@ -264,6 +334,10 @@ export async function runDetachedGoalCompletionAuditor(args: {
|
|
|
264
334
|
thinkingLevel?: string;
|
|
265
335
|
signal?: AbortSignal;
|
|
266
336
|
onProgress?: AuditorProgressCallback;
|
|
337
|
+
/** v0.34.57: fired once when the heartbeat-without-progress watchdog
|
|
338
|
+
* detects a wedged worker and auto-cancels the detached job. The parent
|
|
339
|
+
* persists this as the `auditor_stalled` ledger event. */
|
|
340
|
+
onStalled?: (info: AuditorStalledInfo) => void;
|
|
267
341
|
runtime?: AuditorProcessRuntime;
|
|
268
342
|
}): Promise<GoalAuditorResult> {
|
|
269
343
|
const runtime = args.runtime ?? {};
|
|
@@ -274,11 +348,18 @@ export async function runDetachedGoalCompletionAuditor(args: {
|
|
|
274
348
|
const now = runtime.now ?? Date.now;
|
|
275
349
|
const wallTimeoutMs = runtime.wallTimeoutMs ?? DEFAULT_WALL_TIMEOUT_MS;
|
|
276
350
|
const pollIntervalMs = Math.max(10, runtime.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
351
|
+
const heartbeatFreshMs = Math.max(10, runtime.heartbeatFreshMs ?? DEFAULT_HEARTBEAT_FRESH_MS);
|
|
352
|
+
const heartbeatNoProgressMs = Math.max(50, runtime.heartbeatNoProgressMs ?? DEFAULT_HEARTBEAT_NO_PROGRESS_MS);
|
|
277
353
|
const attemptId = runtime.attemptId?.() ?? `${Date.now().toString(36)}-${randomUUID()}`;
|
|
354
|
+
// v0.34.59: capture the focus revision token at dispatch. Every result
|
|
355
|
+
// shape returned to the parent carries this token so the parent can
|
|
356
|
+
// re-validate before applying a verdict. Pre-revision goals pass through
|
|
357
|
+
// unchanged (captured is null).
|
|
358
|
+
const capturedRevisionToken: GoalRevisionToken | undefined = captureGoalRevision(args.goal) ?? undefined;
|
|
278
359
|
try {
|
|
279
360
|
assertAttemptId(attemptId);
|
|
280
361
|
} catch (error) {
|
|
281
|
-
return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error));
|
|
362
|
+
return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error), "", capturedRevisionToken);
|
|
282
363
|
}
|
|
283
364
|
|
|
284
365
|
const jobDir = path.resolve(args.cwd, ".pi-glla", "audit-jobs", attemptId);
|
|
@@ -292,6 +373,13 @@ export async function runDetachedGoalCompletionAuditor(args: {
|
|
|
292
373
|
let lockHeld = false;
|
|
293
374
|
let child: ChildProcess | undefined;
|
|
294
375
|
let lastProgressSerialized = "";
|
|
376
|
+
// v0.34.57: heartbeat-without-progress watchdog state. `lastProgressAt` is
|
|
377
|
+
// reset whenever the progress signature changes; the watchdog fires when
|
|
378
|
+
// the worker heartbeat stays fresh but the signature has not changed for
|
|
379
|
+
// `heartbeatNoProgressMs` — the worker is alive but wedged.
|
|
380
|
+
let lastProgressAt = startedAt;
|
|
381
|
+
let lastProgressSignature = "";
|
|
382
|
+
let lastProgress: AuditorProgressFile | undefined;
|
|
295
383
|
|
|
296
384
|
try {
|
|
297
385
|
await fs.mkdir(jobsRoot, { recursive: true, mode: 0o700 });
|
|
@@ -308,6 +396,11 @@ export async function runDetachedGoalCompletionAuditor(args: {
|
|
|
308
396
|
thinkingLevel,
|
|
309
397
|
createdAt: new Date(startedAt).toISOString(),
|
|
310
398
|
wallDeadlineAt,
|
|
399
|
+
// v0.34.59: capture the focus revision token at dispatch. The
|
|
400
|
+
// worker echoes it in result.json; the parent re-validates before
|
|
401
|
+
// applying the verdict. A stale-handle ghost can no longer silently
|
|
402
|
+
// overwrite a goal that moved on.
|
|
403
|
+
goalRevision: capturedRevisionToken,
|
|
311
404
|
};
|
|
312
405
|
const request: AuditorRequest = { ...requestWithoutHash, requestHash: requestHash(requestWithoutHash) };
|
|
313
406
|
await writeAtomicJson(requestPath, request);
|
|
@@ -336,37 +429,38 @@ export async function runDetachedGoalCompletionAuditor(args: {
|
|
|
336
429
|
args.signal?.addEventListener("abort", abort, { once: true });
|
|
337
430
|
try {
|
|
338
431
|
while (true) {
|
|
339
|
-
if (args.signal?.aborted) return infra(model, thinkingLevel, "Auditor aborted.");
|
|
432
|
+
if (args.signal?.aborted) return infra(model, thinkingLevel, "Auditor aborted.", "", capturedRevisionToken);
|
|
340
433
|
if (now() >= wallDeadlineAt) {
|
|
341
434
|
if (childAlive(child)) child.kill("SIGTERM");
|
|
342
|
-
return infra(model, thinkingLevel, `Auditor exceeded its ${Math.round(wallTimeoutMs / 60_000)}m wall-clock bound and was aborted
|
|
435
|
+
return infra(model, thinkingLevel, `Auditor exceeded its ${Math.round(wallTimeoutMs / 60_000)}m wall-clock bound and was aborted.`, "", capturedRevisionToken);
|
|
343
436
|
}
|
|
344
437
|
try {
|
|
345
438
|
const progress = await readJson<AuditorProgressFile>(progressPath);
|
|
346
439
|
if (progress.protocolVersion !== PROTOCOL_VERSION || progress.attemptId !== attemptId || progress.requestHash !== request.requestHash) {
|
|
347
|
-
return infra(model, thinkingLevel, "auditor progress identity/request-hash mismatch");
|
|
440
|
+
return infra(model, thinkingLevel, "auditor progress identity/request-hash mismatch", "", capturedRevisionToken);
|
|
348
441
|
}
|
|
442
|
+
lastProgress = progress;
|
|
349
443
|
const serialized = stableJson(progress);
|
|
350
444
|
if (serialized !== lastProgressSerialized) {
|
|
351
445
|
lastProgressSerialized = serialized;
|
|
352
446
|
args.onProgress?.(asProgress(progress, startedAt));
|
|
353
447
|
}
|
|
354
448
|
} catch (error) {
|
|
355
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor progress: ${error instanceof Error ? error.message : String(error)}
|
|
449
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor progress: ${error instanceof Error ? error.message : String(error)}`, "", capturedRevisionToken);
|
|
356
450
|
}
|
|
357
451
|
try {
|
|
358
452
|
const result = await readJson<AuditorResultFile>(resultPath);
|
|
359
453
|
if (result.protocolVersion !== PROTOCOL_VERSION || result.attemptId !== attemptId || result.requestHash !== request.requestHash) {
|
|
360
|
-
return infra(model, thinkingLevel, "auditor result identity/request-hash mismatch");
|
|
454
|
+
return infra(model, thinkingLevel, "auditor result identity/request-hash mismatch", "", capturedRevisionToken);
|
|
361
455
|
}
|
|
362
456
|
const output = stripThinkBlocks(result.output);
|
|
363
|
-
if (!result.ok) return infra(model, thinkingLevel, result.error || "detached auditor failed", output);
|
|
364
|
-
if (!output.trim()) return infra(model, thinkingLevel, "auditor produced no output");
|
|
457
|
+
if (!result.ok) return infra(model, thinkingLevel, result.error || "detached auditor failed", output, capturedRevisionToken);
|
|
458
|
+
if (!output.trim()) return infra(model, thinkingLevel, "auditor produced no output", output, capturedRevisionToken);
|
|
365
459
|
const parsed = parseAuditorVerdict(output);
|
|
366
|
-
if (!parsed.approved && !parsed.disapproved && !parsed.impossible) return infra(model, thinkingLevel, "auditor produced no verdict marker");
|
|
460
|
+
if (!parsed.approved && !parsed.disapproved && !parsed.impossible) return infra(model, thinkingLevel, "auditor produced no verdict marker", output, capturedRevisionToken);
|
|
367
461
|
const usedReadTool = result.toolCalls.some((call) => (READ_ONLY_TOOLS as readonly string[]).includes(call.name));
|
|
368
462
|
if (parsed.approved && !usedReadTool) {
|
|
369
|
-
return { approved: false, disapproved: true, output, model, thinkingLevel, error: "Auditor approved without calling any read-only tool; treated as disapproved." };
|
|
463
|
+
return stampToken({ approved: false, disapproved: true, output, model, thinkingLevel, error: "Auditor approved without calling any read-only tool; treated as disapproved." }, capturedRevisionToken);
|
|
370
464
|
}
|
|
371
465
|
if (parsed.approved && args.goal.verificationContract?.trim()) {
|
|
372
466
|
const shield = checkRegressionShield(output, args.goal.verificationContract);
|
|
@@ -375,27 +469,66 @@ export async function runDetachedGoalCompletionAuditor(args: {
|
|
|
375
469
|
// regression shield blocked acceptance because the report did
|
|
376
470
|
// not cite every contract item. Keep that outcome distinct from
|
|
377
471
|
// both a work disapproval and infrastructure failure.
|
|
378
|
-
return {
|
|
472
|
+
return stampToken({
|
|
379
473
|
approved: true, disapproved: false, output, model, thinkingLevel,
|
|
380
474
|
regressionShieldPassed: false, regressionShieldMissing: shield.missingItems,
|
|
381
|
-
};
|
|
475
|
+
}, capturedRevisionToken);
|
|
382
476
|
}
|
|
383
477
|
args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls, unmatchedToolStarts: [], unmatchedToolEnds: [] });
|
|
384
|
-
return { approved: true, disapproved: false, output, model, thinkingLevel, regressionShieldPassed: true };
|
|
478
|
+
return stampToken({ approved: true, disapproved: false, output, model, thinkingLevel, regressionShieldPassed: true }, capturedRevisionToken);
|
|
385
479
|
}
|
|
386
480
|
args.onProgress?.({ phase: "complete", elapsedMs: now() - startedAt, recentOutput: output.split("\n").filter(Boolean).slice(-8), toolCalls: result.toolCalls, unmatchedToolStarts: [], unmatchedToolEnds: [] });
|
|
387
|
-
return { approved: parsed.approved, disapproved: parsed.disapproved, impossible: parsed.impossible, impossibleReason: parsed.impossibleReason, output, model, thinkingLevel };
|
|
481
|
+
return stampToken({ approved: parsed.approved, disapproved: parsed.disapproved, impossible: parsed.impossible, impossibleReason: parsed.impossibleReason, output, model, thinkingLevel }, capturedRevisionToken);
|
|
388
482
|
} catch (error) {
|
|
389
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor result: ${error instanceof Error ? error.message : String(error)}
|
|
483
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return infra(model, thinkingLevel, `invalid auditor result: ${error instanceof Error ? error.message : String(error)}`, "", capturedRevisionToken);
|
|
484
|
+
}
|
|
485
|
+
// v0.34.57: heartbeat-without-progress watchdog (steal-list #7 /
|
|
486
|
+
// bug #1.4). The worker's own stall brake only fires on TOTAL silence
|
|
487
|
+
// (and skips it while a read-only tool is running); a worker that
|
|
488
|
+
// keeps emitting RPC events — auto-retry loops, empty message
|
|
489
|
+
// updates, a hung tool — refreshes `lastActivityAt` forever without
|
|
490
|
+
// delivering any new tool call or report output. That is the 1h50m
|
|
491
|
+
// "alive but wedged" class: fail fast instead.
|
|
492
|
+
if (lastProgress && lastProgress.lastActivityAt !== undefined && now() - lastProgress.lastActivityAt <= heartbeatFreshMs) {
|
|
493
|
+
const signature = progressSignature(lastProgress);
|
|
494
|
+
if (signature !== lastProgressSignature) {
|
|
495
|
+
lastProgressSignature = signature;
|
|
496
|
+
lastProgressAt = now();
|
|
497
|
+
}
|
|
498
|
+
const noProgressMs = now() - lastProgressAt;
|
|
499
|
+
if (noProgressMs >= heartbeatNoProgressMs) {
|
|
500
|
+
// Demote to quiet first: a final progress snapshot WITHOUT the
|
|
501
|
+
// live heartbeat, so the HUD cannot render LIVE + "worker activity
|
|
502
|
+
// 0s ago" for the wedged worker.
|
|
503
|
+
args.onProgress?.({
|
|
504
|
+
phase: "running",
|
|
505
|
+
elapsedMs: now() - startedAt,
|
|
506
|
+
recentOutput: lastProgress.recentOutput,
|
|
507
|
+
toolCalls: lastProgress.toolCalls,
|
|
508
|
+
unmatchedToolStarts: lastProgress.unmatchedToolStarts ?? [],
|
|
509
|
+
unmatchedToolEnds: lastProgress.unmatchedToolEnds ?? [],
|
|
510
|
+
});
|
|
511
|
+
const stallLabel = heartbeatNoProgressMs >= 60_000
|
|
512
|
+
? `${Math.max(1, Math.round(heartbeatNoProgressMs / 60_000))}m`
|
|
513
|
+
: `${Math.max(1, Math.round(heartbeatNoProgressMs / 1_000))}s`;
|
|
514
|
+
args.onStalled?.({
|
|
515
|
+
at: now(),
|
|
516
|
+
heartbeatAgeMs: now() - lastProgress.lastActivityAt,
|
|
517
|
+
noProgressMs,
|
|
518
|
+
phase: lastProgress.phase,
|
|
519
|
+
});
|
|
520
|
+
if (child && childAlive(child)) child.kill("SIGTERM");
|
|
521
|
+
return infra(model, thinkingLevel, `Auditor stalled — heartbeats without progress for ${stallLabel} (no new tool call or output); the detached job was auto-cancelled.`, "", capturedRevisionToken);
|
|
522
|
+
}
|
|
390
523
|
}
|
|
391
|
-
if (child && !childAlive(child)) return infra(model, thinkingLevel, "auditor worker exited without an atomic result");
|
|
524
|
+
if (child && !childAlive(child)) return infra(model, thinkingLevel, "auditor worker exited without an atomic result", "", capturedRevisionToken);
|
|
392
525
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
393
526
|
}
|
|
394
527
|
} finally {
|
|
395
528
|
args.signal?.removeEventListener("abort", abort);
|
|
396
529
|
}
|
|
397
530
|
} catch (error) {
|
|
398
|
-
return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error));
|
|
531
|
+
return infra(model, thinkingLevel, error instanceof Error ? error.message : String(error), "", capturedRevisionToken);
|
|
399
532
|
} finally {
|
|
400
533
|
activeChildren.delete(childKey(args.cwd, attemptId));
|
|
401
534
|
if (lockHeld) await fs.unlink(lockPath).catch(() => {});
|
|
@@ -259,7 +259,10 @@ export function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | n
|
|
|
259
259
|
"<raw command output here>",
|
|
260
260
|
"</evidence>",
|
|
261
261
|
"",
|
|
262
|
-
"
|
|
262
|
+
"v0.34.77: quote each item VERBATIM in the contract's ORIGINAL language — a translated",
|
|
263
|
+
"or paraphrased item (e.g. an English gloss of a Chinese line) cannot be matched by the",
|
|
264
|
+
"shield and the approval will be rejected automatically. An approval without a complete",
|
|
265
|
+
"<evidence> section will be rejected automatically.",
|
|
263
266
|
]
|
|
264
267
|
: []),
|
|
265
268
|
].join("\n");
|