pi-goal-list-loop-audit 0.23.1 → 0.23.3
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 +15 -0
- package/extensions/goal-loop-auditor.ts +28 -0
- package/extensions/goal-loop-backoff.ts +45 -0
- package/extensions/loops/goal.ts +53 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,6 +184,7 @@ No external watchdog plugin needed.
|
|
|
184
184
|
/glla notify='cmd "$1"' # push on complete/pause/stop → GLOBAL
|
|
185
185
|
/glla tokenlimit=10000000 # per-goal token budget (default: off) → GLOBAL
|
|
186
186
|
/glla tokenlimit=0 # explicitly no cap (the default)
|
|
187
|
+
/glla wedgealert=30 # hung-command alert minutes (default: 30, 0 = off)
|
|
187
188
|
/glla project tokenlimit=500 # rare per-project override
|
|
188
189
|
```
|
|
189
190
|
|
|
@@ -202,6 +203,20 @@ value like 10000000 is a runaway threshold, not a big-goal threshold
|
|
|
202
203
|
this cap — it has its own brakes
|
|
203
204
|
(max iterations + plateau).
|
|
204
205
|
|
|
206
|
+
## Wedge alert
|
|
207
|
+
|
|
208
|
+
The turn-based watchdogs can't see one failure shape: the session is busy
|
|
209
|
+
but silent for a long stretch because ONE unbounded command (a test suite
|
|
210
|
+
that never exits, a dev server) is holding the whole goal hostage. The
|
|
211
|
+
heartbeat watches the wall clock: busy + no activity for 30 minutes →
|
|
212
|
+
in-session warning + your configured notify push, once per interval while
|
|
213
|
+
it persists. Tune with `/glla wedgealert=<minutes>` (0 = off).
|
|
214
|
+
|
|
215
|
+
Every other wait is bounded too: continuation retries are milliseconds,
|
|
216
|
+
stuck backoff caps at 5 minutes then pauses, measure commands get a 10m
|
|
217
|
+
hard timeout, and the auditor aborts after 10m with zero session activity
|
|
218
|
+
(infrastructure error, never a verdict).
|
|
219
|
+
|
|
205
220
|
## Compatibility (what goes well, what conflicts)
|
|
206
221
|
|
|
207
222
|
**The Two-Driver Rule**: any plugin that drives agent turns on `agent_end`
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
|
|
26
26
|
import type { Goal } from "./goal-loop-core.js";
|
|
27
27
|
import { renderGoalMarkdown } from "./goal-loop-core.js";
|
|
28
|
+
import { AUDITOR_STALL_MS } from "./goal-loop-backoff.js";
|
|
28
29
|
|
|
29
30
|
// =================================================================
|
|
30
31
|
// Result type
|
|
@@ -239,7 +240,22 @@ export async function runGoalCompletionAuditor(args: {
|
|
|
239
240
|
tools: ["read", "grep", "find", "ls", "bash"],
|
|
240
241
|
});
|
|
241
242
|
let streamError: string | undefined;
|
|
243
|
+
// v0.23.3 stall watchdog: any session event is activity. An auditor
|
|
244
|
+
// with NO events for AUDITOR_STALL_MS is wedged (dead stream, hung
|
|
245
|
+
// provider call) — abort it and return an ERROR, never a verdict,
|
|
246
|
+
// and never let the completion gate hang forever (the pi-goal-x
|
|
247
|
+
// failure shape: unbounded silent waits).
|
|
248
|
+
let lastEventAt = Date.now();
|
|
249
|
+
let stalled = false;
|
|
250
|
+
const stallTimer = setInterval(() => {
|
|
251
|
+
if (Date.now() - lastEventAt > AUDITOR_STALL_MS) {
|
|
252
|
+
stalled = true;
|
|
253
|
+
void session.abort();
|
|
254
|
+
}
|
|
255
|
+
}, 15_000);
|
|
256
|
+
stallTimer.unref?.();
|
|
242
257
|
const unsub = session.subscribe((event) => {
|
|
258
|
+
lastEventAt = Date.now();
|
|
243
259
|
// Capture provider/stream errors (401/403/429/credits) — these often
|
|
244
260
|
// arrive as events rather than thrown exceptions, and without this they
|
|
245
261
|
// surface as a silent empty report that looks like a disapproval.
|
|
@@ -306,9 +322,21 @@ export async function runGoalCompletionAuditor(args: {
|
|
|
306
322
|
}
|
|
307
323
|
await session.prompt(buildGoalAuditorPrompt(args.goal, args.completionSummary, args.verificationSummary));
|
|
308
324
|
} finally {
|
|
325
|
+
clearInterval(stallTimer);
|
|
309
326
|
unsub();
|
|
310
327
|
}
|
|
311
328
|
|
|
329
|
+
if (stalled) {
|
|
330
|
+
return {
|
|
331
|
+
approved: false,
|
|
332
|
+
disapproved: false,
|
|
333
|
+
output: outputParts.join("\n\n"),
|
|
334
|
+
model: modelLabel(model),
|
|
335
|
+
thinkingLevel,
|
|
336
|
+
error: `Auditor stalled — no session activity for ${Math.round(AUDITOR_STALL_MS / 60_000)}m, aborted. This is an infrastructure failure, not a verdict; retry completion (check the auditor model with /glla model=).`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
312
340
|
const output = outputParts.join("\n\n");
|
|
313
341
|
|
|
314
342
|
// SILENT-FAILURE GUARD (v0.9.9, wild-caught): an auditor that produced
|
|
@@ -68,6 +68,51 @@ export function humanMs(ms: number): string {
|
|
|
68
68
|
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
69
69
|
export const HEARTBEAT_STALL_MS = 60_000;
|
|
70
70
|
export const HEARTBEAT_MAX_NUDGES = 3;
|
|
71
|
+
/** v0.23.2: default wall-clock wedge threshold — a busy session with no
|
|
72
|
+
* activity for this long is almost always a hung unbounded command
|
|
73
|
+
* (test suite / dev server) holding the whole goal hostage. The
|
|
74
|
+
* turn-based watchdogs are blind to it (it's ONE long turn); only the
|
|
75
|
+
* wall clock sees it. 0 in settings = off.
|
|
76
|
+
* v0.23.3: 45 → 30. The alert is notification-only, so a false positive
|
|
77
|
+
* costs one notification while a false negative costs hours — that
|
|
78
|
+
* asymmetry argues tight. (pi-goal-x, the cautionary tale, had NO wall
|
|
79
|
+
* clock at all: a wedged session was silent forever.) */
|
|
80
|
+
export const WEDGE_ALERT_DEFAULT_MINUTES = 30;
|
|
81
|
+
/** v0.23.3: hard cap on one measure command. An unbounded measure is the
|
|
82
|
+
* same wedge shape as an unbounded test suite — it freezes the loop tick
|
|
83
|
+
* forever. Timeout → measure failure (null) → stall path → plateau stop,
|
|
84
|
+
* never a silent hang. Matches pi-loop-mode's --check-timeout default. */
|
|
85
|
+
export const MEASURE_TIMEOUT_MS = 10 * 60_000;
|
|
86
|
+
/** v0.23.3: auditor inactivity abort. The auditor legitimately runs the
|
|
87
|
+
* project's own verification (test suites!), so the bound is on
|
|
88
|
+
* INACTIVITY (no session events at all), not wall time. An auditor with
|
|
89
|
+
* no events for this long is wedged — abort and report an error, never
|
|
90
|
+
* disapprove, never hang the completion gate forever. */
|
|
91
|
+
export const AUDITOR_STALL_MS = 10 * 60_000;
|
|
92
|
+
|
|
93
|
+
export interface WedgeInput {
|
|
94
|
+
/** A goal is active (autoContinue) or a loop is running. */
|
|
95
|
+
supervising: boolean;
|
|
96
|
+
/** Session is BUSY (mid-turn). An idle quiet session is the
|
|
97
|
+
* heartbeat's job, not the wedge alert's. */
|
|
98
|
+
sessionBusy: boolean;
|
|
99
|
+
/** Milliseconds since the last observed agent activity. */
|
|
100
|
+
silentMs: number;
|
|
101
|
+
/** Milliseconds since the last wedge alert fired (throttle). */
|
|
102
|
+
msSinceLastAlert: number;
|
|
103
|
+
/** Threshold in ms; 0 disables the alert entirely. */
|
|
104
|
+
thresholdMs: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Should the wedge alert fire right now? Alerts at most once per
|
|
108
|
+
* threshold interval while the wedge persists; any activity re-arms. */
|
|
109
|
+
export function shouldWedgeAlert(input: WedgeInput): boolean {
|
|
110
|
+
if (!input.supervising) return false;
|
|
111
|
+
if (!input.sessionBusy) return false;
|
|
112
|
+
if (input.thresholdMs <= 0) return false;
|
|
113
|
+
if (input.silentMs < input.thresholdMs) return false;
|
|
114
|
+
return input.msSinceLastAlert >= input.thresholdMs;
|
|
115
|
+
}
|
|
71
116
|
|
|
72
117
|
export interface HeartbeatInput {
|
|
73
118
|
/** A goal is active (autoContinue) or a loop is running. */
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -77,6 +77,9 @@ import {
|
|
|
77
77
|
HEARTBEAT_MAX_NUDGES,
|
|
78
78
|
HEARTBEAT_STALL_MS,
|
|
79
79
|
shouldHeartbeatRefire,
|
|
80
|
+
MEASURE_TIMEOUT_MS,
|
|
81
|
+
WEDGE_ALERT_DEFAULT_MINUTES,
|
|
82
|
+
shouldWedgeAlert,
|
|
80
83
|
} from "../goal-loop-backoff.js";
|
|
81
84
|
|
|
82
85
|
// =================================================================
|
|
@@ -122,6 +125,7 @@ const countedLoopTokenMessages = new Set<string>();
|
|
|
122
125
|
|
|
123
126
|
// Heartbeat self-watchdog state: liveness is the loop's own job.
|
|
124
127
|
let lastActivityAt = Date.now();
|
|
128
|
+
let lastWedgeAlertAt = 0;
|
|
125
129
|
let heartbeatNudges = 0;
|
|
126
130
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
127
131
|
|
|
@@ -180,6 +184,27 @@ function heartbeatTick(): void {
|
|
|
180
184
|
msSinceActivity: Date.now() - lastActivityAt,
|
|
181
185
|
stallMs: HEARTBEAT_STALL_MS,
|
|
182
186
|
});
|
|
187
|
+
// Wedge alert (v0.23.2): session BUSY but silent for the threshold —
|
|
188
|
+
// the classic hung-command case (a test suite that never exits holds
|
|
189
|
+
// the entire goal hostage; field-observed at 5,056s and 6,800s on the
|
|
190
|
+
// same wedged tool call). Independent of the refire path, which only
|
|
191
|
+
// watches idle sessions.
|
|
192
|
+
const wedgeMinutes = loadSettings(ctx.cwd).wedgeAlertMinutes ?? WEDGE_ALERT_DEFAULT_MINUTES;
|
|
193
|
+
if (
|
|
194
|
+
shouldWedgeAlert({
|
|
195
|
+
supervising: isSupervising(),
|
|
196
|
+
sessionBusy: !sessionIdle,
|
|
197
|
+
silentMs: Date.now() - lastActivityAt,
|
|
198
|
+
msSinceLastAlert: Date.now() - lastWedgeAlertAt,
|
|
199
|
+
thresholdMs: wedgeMinutes * 60_000,
|
|
200
|
+
})
|
|
201
|
+
) {
|
|
202
|
+
lastWedgeAlertAt = Date.now();
|
|
203
|
+
const msg = `Goal appears wedged: no activity for ${Math.round((Date.now() - lastActivityAt) / 60_000)}m while the session is busy — likely a hung command (test/build/dev server without a timeout). Check the session; Esc kills a stuck tool call.`;
|
|
204
|
+
appendLedger(ctx.cwd, "wedge_alert", { silentMs: Date.now() - lastActivityAt });
|
|
205
|
+
ctx.ui.notify(msg, "warning");
|
|
206
|
+
notifyExternal(ctx, msg);
|
|
207
|
+
}
|
|
183
208
|
if (!fire) return;
|
|
184
209
|
noteActivity();
|
|
185
210
|
appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges });
|
|
@@ -934,7 +959,7 @@ function isLoopActive(): boolean {
|
|
|
934
959
|
async function runMeasure(ctx: ExtensionContext, cmd: string): Promise<number | null> {
|
|
935
960
|
if (!extensionApi) return null;
|
|
936
961
|
try {
|
|
937
|
-
const result = await extensionApi.exec("bash", ["-c", cmd], { cwd: ctx.cwd });
|
|
962
|
+
const result = await extensionApi.exec("bash", ["-c", cmd], { cwd: ctx.cwd, timeout: MEASURE_TIMEOUT_MS });
|
|
938
963
|
const stdout = (result as any)?.stdout ?? "";
|
|
939
964
|
return parseMetric(String(stdout));
|
|
940
965
|
} catch {
|
|
@@ -1976,6 +2001,9 @@ interface Settings {
|
|
|
1976
2001
|
/** Per-goal token budget; crossing it pauses the goal. Off by default
|
|
1977
2002
|
* (opt-in guard, v0.12.0): unset/0 = no budget. */
|
|
1978
2003
|
tokenLimit?: number;
|
|
2004
|
+
/** v0.23.2: minutes of busy-but-silent before the wedge alert fires
|
|
2005
|
+
* (hung-command detector). Unset = 45; 0 = off. */
|
|
2006
|
+
wedgeAlertMinutes?: number;
|
|
1979
2007
|
/** on → restored goals/loops/lists auto-resume even in fresh sessions
|
|
1980
2008
|
* (unattended rigs). Default off: restore holds until /goal resume. */
|
|
1981
2009
|
autoResume?: boolean;
|
|
@@ -2023,7 +2051,7 @@ function settingsProvenance(cwd: string): Record<keyof Settings, { value: unknow
|
|
|
2023
2051
|
const glob = readSettingsFile(globalSettingsPath());
|
|
2024
2052
|
const effective = loadSettings(cwd);
|
|
2025
2053
|
const out: Record<string, { value: unknown; source: "project" | "global" | "default" }> = {};
|
|
2026
|
-
const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit", "autoResume"];
|
|
2054
|
+
const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit", "wedgeAlertMinutes", "autoResume"];
|
|
2027
2055
|
for (const k of keys) {
|
|
2028
2056
|
if ((proj as Record<string, unknown>)[k] !== undefined) out[k] = { value: (proj as any)[k], source: "project" };
|
|
2029
2057
|
else if ((glob as Record<string, unknown>)[k] !== undefined) out[k] = { value: (glob as any)[k], source: "global" };
|
|
@@ -2122,6 +2150,7 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
|
2122
2150
|
`Auditor thinking — ${show("auditorThinkingLevel", "(session, floor high)")}`,
|
|
2123
2151
|
`Notify command — ${show("notifyCmd", "(off)")}`,
|
|
2124
2152
|
`Token limit per goal — ${show("tokenLimit", "(off)")}`,
|
|
2153
|
+
`Wedge alert minutes — ${show("wedgeAlertMinutes", `(${WEDGE_ALERT_DEFAULT_MINUTES}m default)`)}`,
|
|
2125
2154
|
"Done",
|
|
2126
2155
|
],
|
|
2127
2156
|
);
|
|
@@ -2147,6 +2176,14 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
|
2147
2176
|
else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
|
|
2148
2177
|
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
2149
2178
|
}
|
|
2179
|
+
} else if (choice.startsWith("Wedge alert")) {
|
|
2180
|
+
const v = await ctx.ui.input("Wedge alert threshold (minutes)", "non-negative integer; 0 = off, empty = default 45");
|
|
2181
|
+
if (v !== undefined) {
|
|
2182
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
2183
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: n });
|
|
2184
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { wedgeAlertMinutes: undefined });
|
|
2185
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
2186
|
+
}
|
|
2150
2187
|
}
|
|
2151
2188
|
} catch {
|
|
2152
2189
|
return;
|
|
@@ -2161,6 +2198,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2161
2198
|
// /glla thinking=high write to GLOBAL config
|
|
2162
2199
|
// /glla notify='cmd $1' write to GLOBAL config
|
|
2163
2200
|
// /glla tokenlimit=2000000 write to GLOBAL config
|
|
2201
|
+
// /glla wedgealert=30 hung-command alert minutes (0=off, unset=30)
|
|
2164
2202
|
// /glla project model=... write to PROJECT override (rare)
|
|
2165
2203
|
// /glla model=unset remove key (from global; project model=unset for project)
|
|
2166
2204
|
const trimmed = args.trim();
|
|
@@ -2221,6 +2259,19 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2221
2259
|
} else {
|
|
2222
2260
|
ctx.ui.notify(`tokenlimit must be a positive integer, got: ${value}`, "warning");
|
|
2223
2261
|
}
|
|
2262
|
+
} else if (key === "wedgealert") {
|
|
2263
|
+
if (value === "unset") {
|
|
2264
|
+
patch.wedgeAlertMinutes = undefined;
|
|
2265
|
+
changed = true;
|
|
2266
|
+
} else {
|
|
2267
|
+
const n = Number.parseInt(value, 10);
|
|
2268
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
2269
|
+
patch.wedgeAlertMinutes = n; // 0 = off; unset = default 45
|
|
2270
|
+
changed = true;
|
|
2271
|
+
} else {
|
|
2272
|
+
ctx.ui.notify(`wedgealert must be a non-negative integer (minutes, 0 = off), got: ${value}`, "warning");
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2224
2275
|
} else if (key === "autoresume") {
|
|
2225
2276
|
if (["on", "true", "1", "yes"].includes(value)) {
|
|
2226
2277
|
patch.autoResume = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.3",
|
|
4
4
|
"description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dracon",
|