pi-goal-list-loop-audit 0.26.3 → 0.26.5
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/extensions/goal-loop-backoff.ts +38 -0
- package/extensions/loops/goal.ts +72 -26
- package/extensions/reviewer.ts +18 -5
- package/package.json +1 -1
|
@@ -90,6 +90,44 @@ export const MEASURE_TIMEOUT_MS = 10 * 60_000;
|
|
|
90
90
|
* disapprove, never hang the completion gate forever. */
|
|
91
91
|
export const AUDITOR_STALL_MS = 10 * 60_000;
|
|
92
92
|
|
|
93
|
+
/** v0.26.5: pending-latch watchdog threshold. Field-observed failure: a
|
|
94
|
+
* continuation sent right at compaction was ACCEPTED by pi (sendMessage
|
|
95
|
+
* returned) but the turn trigger was dropped — pi's pending-message flag
|
|
96
|
+
* then stayed set forever. sessionIdle (= isIdle && !hasPendingMessages)
|
|
97
|
+
* never went true, so the heartbeat refire path AND the stall escalation
|
|
98
|
+
* were both suppressed: 22 minutes of total silence until a manual nudge.
|
|
99
|
+
* The wedge alert was blind too (22m < 30m threshold, and its "hung
|
|
100
|
+
* command" framing would be wrong anyway). This watchdog owns that
|
|
101
|
+
* shape: idle + pending + silent >= threshold = the latch is stuck. */
|
|
102
|
+
export const PENDING_LATCH_STUCK_MS = 3 * 60_000;
|
|
103
|
+
|
|
104
|
+
export interface PendingLatchInput {
|
|
105
|
+
/** A goal is active (autoContinue) or a loop is running. */
|
|
106
|
+
supervising: boolean;
|
|
107
|
+
/** ctx.isIdle() — the session is NOT mid-turn. */
|
|
108
|
+
idle: boolean;
|
|
109
|
+
/** ctx.hasPendingMessages() — pi believes a message is still queued. */
|
|
110
|
+
pending: boolean;
|
|
111
|
+
/** A continuation or loop timer is already scheduled. */
|
|
112
|
+
timerPending: boolean;
|
|
113
|
+
/** Milliseconds since the last observed agent activity. */
|
|
114
|
+
silentMs: number;
|
|
115
|
+
/** Threshold in ms; 0 disables the watchdog. */
|
|
116
|
+
thresholdMs: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Should the pending-latch watchdog count a stall right now? It never
|
|
120
|
+
* re-sends: the message is ALREADY queued pi-side, and the hegemon
|
|
121
|
+
* zombie proved re-sends don't unstick a dropped trigger (619 sends,
|
|
122
|
+
* zero turns). Count + notify + escalate to a loud stop instead. */
|
|
123
|
+
export function shouldFirePendingLatchWatchdog(input: PendingLatchInput): boolean {
|
|
124
|
+
if (!input.supervising) return false;
|
|
125
|
+
if (!input.idle || !input.pending) return false;
|
|
126
|
+
if (input.timerPending) return false;
|
|
127
|
+
if (input.thresholdMs <= 0) return false;
|
|
128
|
+
return input.silentMs >= input.thresholdMs;
|
|
129
|
+
}
|
|
130
|
+
|
|
93
131
|
export interface WedgeInput {
|
|
94
132
|
/** A goal is active (autoContinue) or a loop is running. */
|
|
95
133
|
supervising: boolean;
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -160,6 +160,8 @@ import {
|
|
|
160
160
|
MEASURE_TIMEOUT_MS,
|
|
161
161
|
WEDGE_ALERT_DEFAULT_MINUTES,
|
|
162
162
|
shouldWedgeAlert,
|
|
163
|
+
PENDING_LATCH_STUCK_MS,
|
|
164
|
+
shouldFirePendingLatchWatchdog,
|
|
163
165
|
} from "../goal-loop-backoff.js";
|
|
164
166
|
|
|
165
167
|
// =================================================================
|
|
@@ -280,15 +282,74 @@ function startUITicker(): void {
|
|
|
280
282
|
uiTicker.unref?.();
|
|
281
283
|
}
|
|
282
284
|
|
|
285
|
+
/** v0.26.5: shared loud-stop for both stall paths (refire streak and
|
|
286
|
+
* pending-latch streak). Returns true when it escalated. */
|
|
287
|
+
function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
|
|
288
|
+
if (!shouldEscalateStall(consecutiveStalls, threshold)) return false;
|
|
289
|
+
consecutiveStalls = 0;
|
|
290
|
+
appendLedger(ctx.cwd, "stall_escalated", { threshold, kind: isLoopActive() ? "loop" : "goal" });
|
|
291
|
+
if (isLoopActive()) {
|
|
292
|
+
clearLoopTimer();
|
|
293
|
+
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${threshold} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Restart pi, then /loop start again.` };
|
|
294
|
+
persistState(ctx);
|
|
295
|
+
ctx.ui.notify(`Loop stopped: ${threshold} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
|
|
296
|
+
notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (state.goal && state.goal.status === "active") {
|
|
300
|
+
updateGoal({
|
|
301
|
+
status: "paused",
|
|
302
|
+
pauseReason: `stalled: ${threshold} continuation refires landed no turn`,
|
|
303
|
+
pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
|
|
304
|
+
}, ctx);
|
|
305
|
+
ctx.ui.notify(`Goal paused: ${threshold} refires produced no turn. Restart pi, then /goal resume.`, "warning");
|
|
306
|
+
notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
283
312
|
function heartbeatTick(): void {
|
|
284
313
|
const ctx = freshCtx();
|
|
285
314
|
if (!ctx) return;
|
|
286
|
-
let
|
|
315
|
+
let idle = false;
|
|
316
|
+
let pending = false;
|
|
287
317
|
try {
|
|
288
|
-
|
|
318
|
+
idle = ctx.isIdle();
|
|
319
|
+
pending = ctx.hasPendingMessages();
|
|
289
320
|
} catch {
|
|
290
321
|
return;
|
|
291
322
|
}
|
|
323
|
+
const sessionIdle = idle && !pending;
|
|
324
|
+
// v0.26.5: pending-latch watchdog — a queued continuation whose turn
|
|
325
|
+
// trigger was dropped (field-observed post-compaction: continuation
|
|
326
|
+
// ACCEPTED at compact+0s, then 22 minutes of silence). The stuck latch
|
|
327
|
+
// keeps sessionIdle false, which suppresses the refire path AND the
|
|
328
|
+
// stall escalation below — without this branch the session is silent
|
|
329
|
+
// forever. We never re-send here (the message is already queued
|
|
330
|
+
// pi-side; hegemon proved re-sends don't unstick a dropped trigger) —
|
|
331
|
+
// count, notify, escalate to a loud stop.
|
|
332
|
+
const latchSilentMs = Date.now() - lastActivityAt;
|
|
333
|
+
if (
|
|
334
|
+
shouldFirePendingLatchWatchdog({
|
|
335
|
+
supervising: isSupervising(),
|
|
336
|
+
idle,
|
|
337
|
+
pending,
|
|
338
|
+
timerPending: continuationTimer !== null || loopTimer !== null,
|
|
339
|
+
silentMs: latchSilentMs,
|
|
340
|
+
thresholdMs: PENDING_LATCH_STUCK_MS,
|
|
341
|
+
})
|
|
342
|
+
) {
|
|
343
|
+
consecutiveStalls++;
|
|
344
|
+
appendLedger(ctx.cwd, "pending_latch_stuck", { consecutiveStalls, silentMs: latchSilentMs });
|
|
345
|
+
noteActivity(); // re-arm the 3-minute cadence; never resets the stall streak
|
|
346
|
+
const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
|
|
347
|
+
if (escalateStallNow(ctx, stallEscalation)) return;
|
|
348
|
+
const msg = `Heartbeat: a queued continuation never started its turn for ${Math.round(latchSilentMs / 60_000)}m — pi's pending-message latch appears stuck (known post-compaction failure; stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}). If this repeats, restart pi.`;
|
|
349
|
+
ctx.ui.notify(msg, "warning");
|
|
350
|
+
notifyExternal(ctx, msg);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
292
353
|
const fire = shouldHeartbeatRefire({
|
|
293
354
|
supervising: isSupervising(),
|
|
294
355
|
sessionIdle,
|
|
@@ -305,7 +366,9 @@ function heartbeatTick(): void {
|
|
|
305
366
|
if (
|
|
306
367
|
shouldWedgeAlert({
|
|
307
368
|
supervising: isSupervising(),
|
|
308
|
-
|
|
369
|
+
// v0.26.5: !idle, not !sessionIdle — an idle session with a stuck
|
|
370
|
+
// pending latch is the watchdog's job above, not a "hung command".
|
|
371
|
+
sessionBusy: !idle,
|
|
309
372
|
silentMs: Date.now() - lastActivityAt,
|
|
310
373
|
msSinceLastAlert: Date.now() - lastWedgeAlertAt,
|
|
311
374
|
thresholdMs: wedgeMinutes * 60_000,
|
|
@@ -338,28 +401,7 @@ function heartbeatTick(): void {
|
|
|
338
401
|
// this — they count turns, and a zombie runs none. Escalate to a loud,
|
|
339
402
|
// actionable stop instead of spinning silently forever.
|
|
340
403
|
const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
|
|
341
|
-
if (
|
|
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
|
-
}
|
|
404
|
+
if (escalateStallNow(ctx, stallEscalation)) return;
|
|
363
405
|
ctx.ui.notify(`Heartbeat: supervisor active but session stalled — re-firing continuation (stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}).`, "info");
|
|
364
406
|
if (isLoopActive()) {
|
|
365
407
|
scheduleLoopTick(ctx);
|
|
@@ -595,8 +637,12 @@ function fireReviewer(
|
|
|
595
637
|
} catch {
|
|
596
638
|
/* archive md may not exist for manual review of a live goal */
|
|
597
639
|
}
|
|
640
|
+
// v0.26.4 source curation: an APPROVED audit report is the executor's
|
|
641
|
+
// own completion claims — meta-text with zero finding signal (the
|
|
642
|
+
// 0.26.2/0.26.3 misfires both mined it). Disapprovals/errors carry the
|
|
643
|
+
// independent auditor's required-fixes — the real findings.
|
|
598
644
|
const auditTexts = readAuditLog(ctx.cwd)
|
|
599
|
-
.filter((e) => e.goalId === source.goalId)
|
|
645
|
+
.filter((e) => e.goalId === source.goalId && (e.verdict === "disapproved" || e.verdict === "error"))
|
|
600
646
|
.map((e) => e.report);
|
|
601
647
|
for (const t of auditTexts) sources.push({ name: "audit", text: t });
|
|
602
648
|
let ledgerEntries: Array<{ type: string; at?: string; value?: any }> = [];
|
package/extensions/reviewer.ts
CHANGED
|
@@ -80,11 +80,13 @@ const CLASS_PATTERNS: Array<{ class: FindingClass; re: RegExp }> = [
|
|
|
80
80
|
* the reviewer's own report/config vocabulary. Observed false positives
|
|
81
81
|
* from the 0.26.2 completion: a test("…architectural…") name, the
|
|
82
82
|
* INSTALL.md mode-matrix row, and ship-doc prose. */
|
|
83
|
-
const SKIP_LINE = /^\s*(test|it|describe|assert|expect)\s*\(|^\s*(const|let|var|function|import|export|require)\b|\{\s*\.\.\.\s*\}|,\s*\.\.\.$|^\s
|
|
84
|
-
const REVIEWER_VOCAB = /architectural-class|bug-class|refactor-class|strategic-class|reviewer found|cascade step|\*\*Mode\*\*|problems\s*\/\s
|
|
83
|
+
const SKIP_LINE = /^\s*(test|it|describe|assert|expect)\s*\(|^\s*(const|let|var|function|import|export|require)\b|\{\s*\.\.\.\s*\}|,\s*\.\.\.$|^\s*\||^\s*[{\[\]}]|^\s*['"]|^\s*ℹ/; // ℹ = test-runner/status noise ("ℹ todo 0" was enqueued as a /list item by the 0.26.2 reviewer)
|
|
84
|
+
const REVIEWER_VOCAB = /architectural-class|bug-class|refactor-class|strategic-class|reviewer found|cascade step|\*\*Mode\*\*|problems\s*\/\s*\(?(improvements|architectural)/i;
|
|
85
85
|
|
|
86
86
|
export function classifyFindingText(line: string): FindingClass | undefined {
|
|
87
|
-
|
|
87
|
+
// Strip list markers here too (extractFindings already does, but direct
|
|
88
|
+
// callers/tests pass raw report lines like "- ℹ todo 0").
|
|
89
|
+
const t = line.trim().replace(/^[-*>\s\[\]x]+/, "");
|
|
88
90
|
if (t.length < 8) return undefined;
|
|
89
91
|
if (SKIP_LINE.test(t) || REVIEWER_VOCAB.test(t)) return undefined;
|
|
90
92
|
for (const { class: cls, re } of CLASS_PATTERNS) {
|
|
@@ -93,12 +95,23 @@ export function classifyFindingText(line: string): FindingClass | undefined {
|
|
|
93
95
|
return undefined;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
/**
|
|
98
|
+
/** v0.26.4: remove fenced code blocks and inline code spans. Quoted
|
|
99
|
+
* code is how completion summaries leak vocabulary into extraction —
|
|
100
|
+
* the 0.26.3 misfire matched a backticked reviewer.ts line containing
|
|
101
|
+
* four architectural patterns. */
|
|
102
|
+
export function stripCodeSpans(text: string): string {
|
|
103
|
+
return text
|
|
104
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
105
|
+
.replace(/`[^`\n]*`/g, " ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Scan source texts line-by-line for finding-shaped content. Code
|
|
109
|
+
* spans are stripped first (v0.26.4) — findings live in prose. */
|
|
97
110
|
export function extractFindings(sources: Array<{ name: string; text: string }>, max: number): Finding[] {
|
|
98
111
|
const out: Finding[] = [];
|
|
99
112
|
const seen = new Set<string>();
|
|
100
113
|
for (const { name, text } of sources) {
|
|
101
|
-
for (const line of text.split("\n")) {
|
|
114
|
+
for (const line of stripCodeSpans(text).split("\n")) {
|
|
102
115
|
const cls = classifyFindingText(line);
|
|
103
116
|
if (!cls) continue;
|
|
104
117
|
const clean = line.trim().replace(/^[-*>\s\[\]x]+/, "").slice(0, 200);
|
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.5",
|
|
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",
|