pi-goal-list-loop-audit 0.26.0 → 0.26.1
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.
|
@@ -11,6 +11,17 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
13
13
|
|
|
14
|
+
/** v0.26.1: consecutive heartbeat refires without a real agent turn
|
|
15
|
+
* before the supervisor gives up (pauses the goal / stops the loop).
|
|
16
|
+
* 0 = never escalate (legacy silent-spin behavior). */
|
|
17
|
+
export const DEFAULT_STALL_ESCALATION_REFIRES = 5;
|
|
18
|
+
|
|
19
|
+
/** v0.26.1: pure gate — has the refire streak hit the escalation
|
|
20
|
+
* threshold? threshold 0 disables escalation entirely. */
|
|
21
|
+
export function shouldEscalateStall(consecutiveStalls: number, threshold: number): boolean {
|
|
22
|
+
return threshold > 0 && consecutiveStalls >= threshold;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
// =================================================================
|
|
15
26
|
// Types
|
|
16
27
|
// =================================================================
|
|
@@ -81,17 +81,20 @@ export interface AuditDisplayProgress {
|
|
|
81
81
|
* One-line status for ctx.ui.setStatus("pi-glla", …).
|
|
82
82
|
* Returns undefined when nothing is being supervised (clears the segment).
|
|
83
83
|
*/
|
|
84
|
-
export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme): string | undefined {
|
|
84
|
+
export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, extras?: { stalls?: number }): string | undefined {
|
|
85
85
|
if (state.loop?.active) {
|
|
86
86
|
const l = state.loop;
|
|
87
|
+
// v0.26.1: surface the refire streak — a spinning supervisor is the
|
|
88
|
+
// zombie signature (hegemon incident: 619 refires, 0 turns).
|
|
89
|
+
const stallSuffix = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
|
|
87
90
|
// v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
|
|
88
91
|
if (!l.measureCmd) {
|
|
89
|
-
return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless`;
|
|
92
|
+
return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless${stallSuffix}`;
|
|
90
93
|
}
|
|
91
94
|
const arrow = paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
|
|
92
95
|
const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
|
|
93
96
|
const stall = l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText;
|
|
94
|
-
return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}`;
|
|
97
|
+
return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}${stallSuffix}`;
|
|
95
98
|
}
|
|
96
99
|
const g = state.goal;
|
|
97
100
|
if (!g) return undefined;
|
|
@@ -146,8 +149,8 @@ function countTotal(g: Goal): number {
|
|
|
146
149
|
* Widget lines for ctx.ui.setWidget("pi-glla", lines).
|
|
147
150
|
* Returns undefined when nothing is worth showing.
|
|
148
151
|
*/
|
|
149
|
-
export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number): string[] | undefined {
|
|
150
|
-
if (state.loop?.active) return loopLines(state.loop, now, theme, width);
|
|
152
|
+
export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
|
|
153
|
+
if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
|
|
151
154
|
const g = state.goal;
|
|
152
155
|
if (!g) return undefined;
|
|
153
156
|
if (g.status === "complete" || g.status === "aborted") return undefined;
|
|
@@ -202,12 +205,14 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
|
|
|
202
205
|
return lines;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number): string[] {
|
|
208
|
+
function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] {
|
|
209
|
+
// v0.26.1: the refire streak, shown only while nonzero.
|
|
210
|
+
const stallNote = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
|
|
206
211
|
// v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
|
|
207
212
|
if (!l.measureCmd) {
|
|
208
213
|
const lines = [
|
|
209
214
|
`${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
|
|
210
|
-
`├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
|
|
215
|
+
`├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}${stallNote}`,
|
|
211
216
|
`└─ ${paint(theme, "dim", "metricless — work the spec (no plateau)")}`,
|
|
212
217
|
];
|
|
213
218
|
if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
|
|
@@ -49,6 +49,9 @@ export interface Settings {
|
|
|
49
49
|
/** Consecutive stuck interventions before a loop stops (default 5,
|
|
50
50
|
* 10 under aggressiveMode). */
|
|
51
51
|
stuckMaxInterventions?: number;
|
|
52
|
+
/** v0.26.1: consecutive heartbeat refires without a real turn before
|
|
53
|
+
* the goal pauses / loop stops (default 5; 0 = never escalate). */
|
|
54
|
+
stallEscalationRefires?: number;
|
|
52
55
|
/** on → propose_* drafts activate WITHOUT the Confirm dialog and the
|
|
53
56
|
* interview floor is skipped — the seed carries the intent (unattended
|
|
54
57
|
* rigs). Default off: nothing activates before the user confirms. */
|
|
@@ -127,6 +130,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
|
|
|
127
130
|
"aggressiveMode",
|
|
128
131
|
"quotaRetryMinutes",
|
|
129
132
|
"stuckMaxInterventions",
|
|
133
|
+
"stallEscalationRefires",
|
|
130
134
|
];
|
|
131
135
|
|
|
132
136
|
/** Where each effective setting comes from (for the /glla display). */
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
auditFeedbackExcerpt,
|
|
34
34
|
DEFAULT_AUDIT_FEEDBACK_CHARS,
|
|
35
35
|
DEFAULT_QUOTA_RETRY_MINUTES,
|
|
36
|
+
DEFAULT_STALL_ESCALATION_REFIRES,
|
|
36
37
|
DEFAULT_TOKEN_LIMIT,
|
|
37
38
|
classifyImpossibleReason,
|
|
38
39
|
extractPendingTasks,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
ledgerPath,
|
|
51
52
|
crossRecommendMode,
|
|
52
53
|
formatListDepth,
|
|
54
|
+
shouldEscalateStall,
|
|
53
55
|
shouldSuppressHeartbeatForRecentShip,
|
|
54
56
|
mergeSettings,
|
|
55
57
|
parseListImport,
|
|
@@ -231,10 +233,16 @@ const countedLoopTokenMessages = new Set<string>();
|
|
|
231
233
|
let lastActivityAt = Date.now();
|
|
232
234
|
let lastWedgeAlertAt = 0;
|
|
233
235
|
let heartbeatNudges = 0;
|
|
236
|
+
// v0.26.1: consecutive heartbeat refires that produced NO real agent turn.
|
|
237
|
+
// Resets only on real activity (agent_end / tool_call) — never on the
|
|
238
|
+
// refire's own noteActivity, which is what made the hegemon zombie spin
|
|
239
|
+
// self-sustaining (619 refires / 23.5h / zero turns).
|
|
240
|
+
let consecutiveStalls = 0;
|
|
234
241
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
235
242
|
|
|
236
|
-
function noteActivity(): void {
|
|
243
|
+
function noteActivity(real = false): void {
|
|
237
244
|
lastActivityAt = Date.now();
|
|
245
|
+
if (real) consecutiveStalls = 0;
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
function isSupervising(): boolean {
|
|
@@ -256,8 +264,8 @@ function refreshUI(ctx: ExtensionContext): void {
|
|
|
256
264
|
// Terminal width for truncation budgets: on wide terminals the widget
|
|
257
265
|
// uses the room instead of cutting at fixed ~60-char floors.
|
|
258
266
|
const width = process.stdout.columns || 80;
|
|
259
|
-
ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme));
|
|
260
|
-
ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width));
|
|
267
|
+
ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme, { stalls: consecutiveStalls }));
|
|
268
|
+
ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width, { stalls: consecutiveStalls }));
|
|
261
269
|
} catch {
|
|
262
270
|
// stale ctx — next event refreshes
|
|
263
271
|
}
|
|
@@ -323,8 +331,36 @@ function heartbeatTick(): void {
|
|
|
323
331
|
return;
|
|
324
332
|
}
|
|
325
333
|
noteActivity();
|
|
326
|
-
|
|
327
|
-
ctx.
|
|
334
|
+
consecutiveStalls++;
|
|
335
|
+
appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges, consecutiveStalls });
|
|
336
|
+
// v0.26.1: a refire streak means the continuation is NOT landing (wedged
|
|
337
|
+
// message queue, stale API handle, dead turn trigger). Nudges can't catch
|
|
338
|
+
// this — they count turns, and a zombie runs none. Escalate to a loud,
|
|
339
|
+
// actionable stop instead of spinning silently forever.
|
|
340
|
+
const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
|
|
341
|
+
if (shouldEscalateStall(consecutiveStalls, stallEscalation)) {
|
|
342
|
+
consecutiveStalls = 0;
|
|
343
|
+
appendLedger(ctx.cwd, "stall_escalated", { threshold: stallEscalation, kind: isLoopActive() ? "loop" : "goal" });
|
|
344
|
+
if (isLoopActive()) {
|
|
345
|
+
clearLoopTimer();
|
|
346
|
+
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${stallEscalation} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Restart pi, then /loop start again.` };
|
|
347
|
+
persistState(ctx);
|
|
348
|
+
ctx.ui.notify(`Loop stopped: ${stallEscalation} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
|
|
349
|
+
notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (state.goal && state.goal.status === "active") {
|
|
353
|
+
updateGoal({
|
|
354
|
+
status: "paused",
|
|
355
|
+
pauseReason: `stalled: ${stallEscalation} continuation refires landed no turn`,
|
|
356
|
+
pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
|
|
357
|
+
}, ctx);
|
|
358
|
+
ctx.ui.notify(`Goal paused: ${stallEscalation} refires produced no turn. Restart pi, then /goal resume.`, "warning");
|
|
359
|
+
notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
ctx.ui.notify(`Heartbeat: supervisor active but session stalled — re-firing continuation (stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}).`, "info");
|
|
328
364
|
if (isLoopActive()) {
|
|
329
365
|
scheduleLoopTick(ctx);
|
|
330
366
|
} else {
|
|
@@ -415,7 +451,9 @@ function sendContinuation(goalId: string): void {
|
|
|
415
451
|
content: continuationPrompt(state.goal!),
|
|
416
452
|
display: false,
|
|
417
453
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
418
|
-
|
|
454
|
+
appendLedger(ctx.cwd, "goal_continuation_sent", { goalId });
|
|
455
|
+
} catch (err) {
|
|
456
|
+
appendLedger(ctx.cwd, "goal_continuation_send_failed", { goalId, error: err instanceof Error ? err.message : String(err) });
|
|
419
457
|
// API went stale mid-flight; next agent_end/session_start will reschedule.
|
|
420
458
|
}
|
|
421
459
|
}
|
|
@@ -1300,8 +1338,13 @@ function sendLoopTurn(): void {
|
|
|
1300
1338
|
content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
|
|
1301
1339
|
display: false,
|
|
1302
1340
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1303
|
-
|
|
1304
|
-
//
|
|
1341
|
+
// v0.26.1: the send path is ledgered — the hegemon zombie spun 619
|
|
1342
|
+
// refires with zero visibility into whether sends were landing.
|
|
1343
|
+
appendLedger(ctx.cwd, "loop_turn_sent", { iteration: loop.iteration });
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
// stale API — next agent_end reschedules (but if none comes, the
|
|
1346
|
+
// heartbeat's stall escalation stops the spin — v0.26.1).
|
|
1347
|
+
appendLedger(ctx.cwd, "loop_turn_send_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1305
1348
|
}
|
|
1306
1349
|
}
|
|
1307
1350
|
|
|
@@ -3153,6 +3196,19 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
3153
3196
|
ctx.ui.notify(`quotaretryminutes must be a positive integer, got: ${value}`, "warning");
|
|
3154
3197
|
}
|
|
3155
3198
|
}
|
|
3199
|
+
} else if (key === "stallescalation" || key === "stallescalationrefires") {
|
|
3200
|
+
if (["unset", "default"].includes(value)) {
|
|
3201
|
+
patch.stallEscalationRefires = undefined;
|
|
3202
|
+
changed = true;
|
|
3203
|
+
} else {
|
|
3204
|
+
const n = Number.parseInt(value, 10);
|
|
3205
|
+
if (Number.isInteger(n) && n >= 0) {
|
|
3206
|
+
patch.stallEscalationRefires = n;
|
|
3207
|
+
changed = true;
|
|
3208
|
+
} else {
|
|
3209
|
+
ctx.ui.notify(`stallescalation must be a non-negative integer (0 = never escalate), got: ${value}`, "warning");
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3156
3212
|
} else if (key === "stuckmax" || key === "stuckmaxinterventions") {
|
|
3157
3213
|
if (["unset", "default"].includes(value)) {
|
|
3158
3214
|
patch.stuckMaxInterventions = undefined;
|
|
@@ -3302,6 +3358,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3302
3358
|
["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
|
|
3303
3359
|
["quotaretryminutes=", "N: minutes before auto-retrying a quota-exhausted auditor (default 60)"],
|
|
3304
3360
|
["stuckmax=", "N: consecutive stuck interventions before a loop stops (default 5)"],
|
|
3361
|
+
["stallescalation=", "N: heartbeat refires without a turn before goal pauses / loop stops (default 5, 0 = never)"],
|
|
3305
3362
|
["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
|
|
3306
3363
|
["audits", "audit-log browser: /glla audits [N|full] — recent verdicts from .pi-glla/audits.jsonl"],
|
|
3307
3364
|
["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
|
|
@@ -3367,6 +3424,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3367
3424
|
}
|
|
3368
3425
|
}
|
|
3369
3426
|
|
|
3427
|
+
// v0.26.1: compaction ends WITHOUT an agent_end (the compaction turn is
|
|
3428
|
+
// not an agent turn), so the continuation chain can dangle until the
|
|
3429
|
+
// 60s heartbeat notices. Re-arm it as soon as pi settles post-compact.
|
|
3430
|
+
pi.on("session_compact", async (_event: any, ctx: ExtensionContext) => {
|
|
3431
|
+
if (isForeignCtx(ctx)) return;
|
|
3432
|
+
rememberCtx(ctx);
|
|
3433
|
+
if (!isSupervising()) return;
|
|
3434
|
+
appendLedger(ctx.cwd, "session_compact", {});
|
|
3435
|
+
const settle = setTimeout(() => {
|
|
3436
|
+
const c = freshCtx();
|
|
3437
|
+
if (!c) return;
|
|
3438
|
+
try {
|
|
3439
|
+
if (c.isIdle() && !c.hasPendingMessages() && continuationTimer === null && loopTimer === null && isSupervising()) {
|
|
3440
|
+
appendLedger(c.cwd, "compaction_refire", {});
|
|
3441
|
+
if (isLoopActive()) scheduleLoopTick(c);
|
|
3442
|
+
else scheduleContinuation(c, true);
|
|
3443
|
+
}
|
|
3444
|
+
} catch {
|
|
3445
|
+
/* settle race — the 60s heartbeat covers it */
|
|
3446
|
+
}
|
|
3447
|
+
}, 2000);
|
|
3448
|
+
settle.unref?.();
|
|
3449
|
+
});
|
|
3450
|
+
|
|
3370
3451
|
pi.on("message_start", async (event: any, _ctx: ExtensionContext) => {
|
|
3371
3452
|
// v0.14.0 drafting floor: count real user replies while drafting. Our
|
|
3372
3453
|
// own injected draft prompt arrives as a user message — skip that one.
|
|
@@ -3549,7 +3630,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3549
3630
|
// v0.23.8: a subagent finishing must not drive the main session's
|
|
3550
3631
|
// continuation loop.
|
|
3551
3632
|
if (isForeignCtx(ctx)) return;
|
|
3552
|
-
noteActivity();
|
|
3633
|
+
noteActivity(true);
|
|
3553
3634
|
// v0.25.2: per-goal turn telemetry (/glla stats).
|
|
3554
3635
|
if (state.goal && state.goal.status === "active") {
|
|
3555
3636
|
const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
|
|
@@ -3650,7 +3731,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3650
3731
|
|
|
3651
3732
|
pi.on("tool_call", () => {
|
|
3652
3733
|
toolCallsThisTurn++;
|
|
3653
|
-
noteActivity();
|
|
3734
|
+
noteActivity(true);
|
|
3654
3735
|
// v0.24.0: count loop-iteration tool calls (narration-only detection).
|
|
3655
3736
|
if (isLoopActive()) {
|
|
3656
3737
|
state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.1",
|
|
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",
|