pi-goal-list-loop-audit 0.26.4 → 0.26.6
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.
|
@@ -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;
|
|
@@ -917,6 +917,8 @@ export function isAutoCommitPaused(cwd: string): boolean {
|
|
|
917
917
|
/** Suppress the heartbeat when work shipped very recently — a session
|
|
918
918
|
* that just committed is transitioning, not stalled. Pure; the tick
|
|
919
919
|
* gathers the timestamps. */
|
|
920
|
+
/** @deprecated v0.26.6: no longer called by the heartbeat (self-sustaining
|
|
921
|
+
* under ledger writes / auto-commit daemons). Kept for API compatibility. */
|
|
920
922
|
export function shouldSuppressHeartbeatForRecentShip(args: {
|
|
921
923
|
nowMs: number;
|
|
922
924
|
lastShippedAtMs: number | null;
|
|
@@ -930,6 +932,10 @@ export function shouldSuppressHeartbeatForRecentShip(args: {
|
|
|
930
932
|
/** Best-effort "when did work last ship" for a repo: newest of the HEAD
|
|
931
933
|
* commit time and the .pi-glla state file mtime. Null when unknown. */
|
|
932
934
|
export function lastShippedAtMs(cwd: string): number | null {
|
|
935
|
+
// v0.26.6: the .pi-glla/active.jsonl MTIME term was REMOVED — the
|
|
936
|
+
// heartbeat's own ledger writes refreshed it every 15s, which made the
|
|
937
|
+
// 0.25.0 ship-suppression self-sustaining (darklord: 9.1h / 2,184
|
|
938
|
+
// suppressed ticks). Only a real git commit counts as a ship now.
|
|
933
939
|
let best: number | null = null;
|
|
934
940
|
try {
|
|
935
941
|
const out = execSync("git log -1 --format=%ct", { cwd, stdio: ["ignore", "pipe", "ignore"] })
|
|
@@ -940,12 +946,6 @@ export function lastShippedAtMs(cwd: string): number | null {
|
|
|
940
946
|
} catch {
|
|
941
947
|
/* not a git repo or no commits */
|
|
942
948
|
}
|
|
943
|
-
try {
|
|
944
|
-
const mtime = fs.statSync(path.join(cwd, ".pi-glla", "active.jsonl")).mtimeMs;
|
|
945
|
-
if (best === null || mtime > best) best = mtime;
|
|
946
|
-
} catch {
|
|
947
|
-
/* no state file yet */
|
|
948
|
-
}
|
|
949
949
|
return best;
|
|
950
950
|
}
|
|
951
951
|
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
classifyImpossibleReason,
|
|
39
39
|
extractPendingTasks,
|
|
40
40
|
isFullAuditObjective,
|
|
41
|
-
lastShippedAtMs,
|
|
42
41
|
resolveEffectiveAggressiveSettings,
|
|
43
42
|
appendAuditLog,
|
|
44
43
|
computeListDepth,
|
|
@@ -52,7 +51,6 @@ import {
|
|
|
52
51
|
crossRecommendMode,
|
|
53
52
|
formatListDepth,
|
|
54
53
|
shouldEscalateStall,
|
|
55
|
-
shouldSuppressHeartbeatForRecentShip,
|
|
56
54
|
mergeSettings,
|
|
57
55
|
parseListImport,
|
|
58
56
|
|
|
@@ -160,6 +158,8 @@ import {
|
|
|
160
158
|
MEASURE_TIMEOUT_MS,
|
|
161
159
|
WEDGE_ALERT_DEFAULT_MINUTES,
|
|
162
160
|
shouldWedgeAlert,
|
|
161
|
+
PENDING_LATCH_STUCK_MS,
|
|
162
|
+
shouldFirePendingLatchWatchdog,
|
|
163
163
|
} from "../goal-loop-backoff.js";
|
|
164
164
|
|
|
165
165
|
// =================================================================
|
|
@@ -238,6 +238,10 @@ let heartbeatNudges = 0;
|
|
|
238
238
|
// refire's own noteActivity, which is what made the hegemon zombie spin
|
|
239
239
|
// self-sustaining (619 refires / 23.5h / zero turns).
|
|
240
240
|
let consecutiveStalls = 0;
|
|
241
|
+
// v0.26.6: precise replacement for the removed ship-recency suppression —
|
|
242
|
+
// set while complete_goal's isolated audit runs, so the heartbeat never
|
|
243
|
+
// refires into an in-flight completion.
|
|
244
|
+
let completionAuditInFlight = false;
|
|
241
245
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
242
246
|
|
|
243
247
|
function noteActivity(real = false): void {
|
|
@@ -280,15 +284,74 @@ function startUITicker(): void {
|
|
|
280
284
|
uiTicker.unref?.();
|
|
281
285
|
}
|
|
282
286
|
|
|
287
|
+
/** v0.26.5: shared loud-stop for both stall paths (refire streak and
|
|
288
|
+
* pending-latch streak). Returns true when it escalated. */
|
|
289
|
+
function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
|
|
290
|
+
if (!shouldEscalateStall(consecutiveStalls, threshold)) return false;
|
|
291
|
+
consecutiveStalls = 0;
|
|
292
|
+
appendLedger(ctx.cwd, "stall_escalated", { threshold, kind: isLoopActive() ? "loop" : "goal" });
|
|
293
|
+
if (isLoopActive()) {
|
|
294
|
+
clearLoopTimer();
|
|
295
|
+
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.` };
|
|
296
|
+
persistState(ctx);
|
|
297
|
+
ctx.ui.notify(`Loop stopped: ${threshold} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
|
|
298
|
+
notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
if (state.goal && state.goal.status === "active") {
|
|
302
|
+
updateGoal({
|
|
303
|
+
status: "paused",
|
|
304
|
+
pauseReason: `stalled: ${threshold} continuation refires landed no turn`,
|
|
305
|
+
pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
|
|
306
|
+
}, ctx);
|
|
307
|
+
ctx.ui.notify(`Goal paused: ${threshold} refires produced no turn. Restart pi, then /goal resume.`, "warning");
|
|
308
|
+
notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
283
314
|
function heartbeatTick(): void {
|
|
284
315
|
const ctx = freshCtx();
|
|
285
316
|
if (!ctx) return;
|
|
286
|
-
let
|
|
317
|
+
let idle = false;
|
|
318
|
+
let pending = false;
|
|
287
319
|
try {
|
|
288
|
-
|
|
320
|
+
idle = ctx.isIdle();
|
|
321
|
+
pending = ctx.hasPendingMessages();
|
|
289
322
|
} catch {
|
|
290
323
|
return;
|
|
291
324
|
}
|
|
325
|
+
const sessionIdle = idle && !pending;
|
|
326
|
+
// v0.26.5: pending-latch watchdog — a queued continuation whose turn
|
|
327
|
+
// trigger was dropped (field-observed post-compaction: continuation
|
|
328
|
+
// ACCEPTED at compact+0s, then 22 minutes of silence). The stuck latch
|
|
329
|
+
// keeps sessionIdle false, which suppresses the refire path AND the
|
|
330
|
+
// stall escalation below — without this branch the session is silent
|
|
331
|
+
// forever. We never re-send here (the message is already queued
|
|
332
|
+
// pi-side; hegemon proved re-sends don't unstick a dropped trigger) —
|
|
333
|
+
// count, notify, escalate to a loud stop.
|
|
334
|
+
const latchSilentMs = Date.now() - lastActivityAt;
|
|
335
|
+
if (
|
|
336
|
+
shouldFirePendingLatchWatchdog({
|
|
337
|
+
supervising: isSupervising(),
|
|
338
|
+
idle,
|
|
339
|
+
pending,
|
|
340
|
+
timerPending: continuationTimer !== null || loopTimer !== null,
|
|
341
|
+
silentMs: latchSilentMs,
|
|
342
|
+
thresholdMs: PENDING_LATCH_STUCK_MS,
|
|
343
|
+
})
|
|
344
|
+
) {
|
|
345
|
+
consecutiveStalls++;
|
|
346
|
+
appendLedger(ctx.cwd, "pending_latch_stuck", { consecutiveStalls, silentMs: latchSilentMs });
|
|
347
|
+
noteActivity(); // re-arm the 3-minute cadence; never resets the stall streak
|
|
348
|
+
const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
|
|
349
|
+
if (escalateStallNow(ctx, stallEscalation)) return;
|
|
350
|
+
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.`;
|
|
351
|
+
ctx.ui.notify(msg, "warning");
|
|
352
|
+
notifyExternal(ctx, msg);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
292
355
|
const fire = shouldHeartbeatRefire({
|
|
293
356
|
supervising: isSupervising(),
|
|
294
357
|
sessionIdle,
|
|
@@ -305,7 +368,9 @@ function heartbeatTick(): void {
|
|
|
305
368
|
if (
|
|
306
369
|
shouldWedgeAlert({
|
|
307
370
|
supervising: isSupervising(),
|
|
308
|
-
|
|
371
|
+
// v0.26.5: !idle, not !sessionIdle — an idle session with a stuck
|
|
372
|
+
// pending latch is the watchdog's job above, not a "hung command".
|
|
373
|
+
sessionBusy: !idle,
|
|
309
374
|
silentMs: Date.now() - lastActivityAt,
|
|
310
375
|
msSinceLastAlert: Date.now() - lastWedgeAlertAt,
|
|
311
376
|
thresholdMs: wedgeMinutes * 60_000,
|
|
@@ -318,18 +383,16 @@ function heartbeatTick(): void {
|
|
|
318
383
|
notifyExternal(ctx, msg);
|
|
319
384
|
}
|
|
320
385
|
if (!fire) return;
|
|
321
|
-
// v0.25.0
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
386
|
+
// v0.26.6: the 0.25.0 "recent ship (<5m)" suppression was REMOVED. It fed
|
|
387
|
+
// lastShippedAtMs, which read the state-file MTIME — and the heartbeat's
|
|
388
|
+
// own suppressed-tick ledger writes refreshed that mtime every 15s,
|
|
389
|
+
// making the suppression self-sustaining forever (field-observed in
|
|
390
|
+
// darklord: 2,184 suppressed ticks over 9.1h after a post-compaction
|
|
391
|
+
// send failure; the completed list item never closed). Under an
|
|
392
|
+
// auto-committing daemon the git-head term self-sustains too. The legit
|
|
393
|
+
// windows are already covered precisely — busy mid-turn, pending
|
|
394
|
+
// messages, scheduled timers — plus the audit-in-flight flag below.
|
|
395
|
+
if (completionAuditInFlight) return;
|
|
333
396
|
noteActivity();
|
|
334
397
|
consecutiveStalls++;
|
|
335
398
|
appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges, consecutiveStalls });
|
|
@@ -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);
|
|
@@ -1852,13 +1894,20 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
|
1852
1894
|
// retry with backoff before we report "auditor infrastructure error
|
|
1853
1895
|
// (retried once)". Neither attempt is a verdict on the work.
|
|
1854
1896
|
const auditStartMs = Date.now();
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1897
|
+
completionAuditInFlight = true;
|
|
1898
|
+
let result: Awaited<ReturnType<typeof runAudit>>;
|
|
1899
|
+
let retriedOnce = false;
|
|
1900
|
+
try {
|
|
1901
|
+
({ result, retriedOnce } = await runWithInfraRetry(runAudit, {
|
|
1902
|
+
onRetry: (err) => {
|
|
1903
|
+
latestAuditProgress = { label: `infra error (${err.slice(0, 40)}) — retrying once`, lastEventAt: Date.now() };
|
|
1904
|
+
refreshUI(ctx);
|
|
1905
|
+
appendLedger(ctx.cwd, "audit_infra_retry", { goalId: state.goal?.id, error: err.slice(0, 200) });
|
|
1906
|
+
},
|
|
1907
|
+
}));
|
|
1908
|
+
} finally {
|
|
1909
|
+
completionAuditInFlight = false;
|
|
1910
|
+
}
|
|
1862
1911
|
const auditDurationMs = Date.now() - auditStartMs;
|
|
1863
1912
|
latestAuditProgress = null;
|
|
1864
1913
|
// Audit history: record REAL verdicts only — a non-empty report is the
|
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*\||^\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
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) {
|
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.6",
|
|
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",
|