pi-goal-list-loop-audit 0.31.8 → 0.32.0

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.
@@ -163,6 +163,7 @@ function buildGoalAuditorPrompt(goal: Goal, completionSummary: string | null | u
163
163
  "6. When you disapprove, end the report body with a '## Required fixes' section: one line per blocking gap, each an actionable instruction the executor can complete (most critical first). This tail is what the executor sees first — make it self-sufficient.",
164
164
  "7. Write the report in English, and never emit <think> blocks or fragments — your reasoning stays private; the report is the verdict plus evidence.",
165
165
  "8. End with exactly <approved/> only if the objective is truly complete; <impossible>reason</impossible> if it can never be satisfied as stated; otherwise end with exactly <disapproved/>.",
166
+ "9. Reject-class pattern (v0.31.9, field-observed fork bomb): a test that invokes the project's whole test runner from INSIDE the suite (a test file spawning `bun test`/`npm test`/`pytest`/etc. on a path the runner itself collects) is unbounded recursion — 521 processes, load 28, a full system crash; a `timeout` wrapper kills processes, not recursion depth. Disapprove unless the recursion is provably depth-capped (e.g. an env sentinel checked before spawning).",
166
167
  ...(goal.verificationContract?.trim()
167
168
  ? [
168
169
  "",
@@ -337,6 +338,9 @@ export async function runGoalCompletionAuditor(args: {
337
338
  } finally {
338
339
  clearInterval(stallTimer);
339
340
  unsub();
341
+ // v0.32.0: dispose the auditor session — each complete_goal leaked one
342
+ // session's subscriptions/stream resources for the parent's lifetime.
343
+ (session as any).dispose?.();
340
344
  }
341
345
 
342
346
  if (stalled) {
@@ -188,6 +188,8 @@ export function loadGlobalSettings(): Settings {
188
188
  /** Every provenance-tracked key (the /glla headless display + UI). */
189
189
  export const SETTINGS_KEYS: Array<keyof Settings> = [
190
190
  "auditorModel",
191
+ "auditorModelFallback",
192
+ "auditorSameSessionSwap",
191
193
  "auditorThinkingLevel",
192
194
  "notifyCmd",
193
195
  "tokenLimit",
@@ -223,6 +223,14 @@ let extensionApi: ExtensionAPI | null = null;
223
223
  // throws FOREVER in this process — retrying for hours is the hegemon
224
224
  // failure shape. Detect the stale signature once and go terminally loud.
225
225
  let extensionApiStale = false;
226
+ // v0.32.0: CRITICAL — goStaleTerminal must gate on its OWN flag, not
227
+ // extensionApiStale: probeExtensionApiStale() sets extensionApiStale on
228
+ // detection, so the heartbeat's `probe → goStaleTerminal` sequence always
229
+ // found the flag already true and returned silently — orphan-stale recovery
230
+ // (ledger, loop stop, interruptedAt, warn, AUTO-RELOAD SELF-HEAL) was dead
231
+ // code since v0.29.11. Field proof: hegemon sat stale for days and the
232
+ // wezterm self-heal never fired.
233
+ let staleTerminalDone = false;
226
234
 
227
235
  /** v0.26.7: a stale api is terminal for this process — go loudly with
228
236
  * restart guidance instead of retrying sends that can never land.
@@ -292,16 +300,24 @@ function absorbStaleIfSuperseded(ctx: ExtensionContext): boolean {
292
300
  extensionApiStale = true; // silence the send paths WITHOUT the terminal theatre
293
301
  clearLoopTimer();
294
302
  if (continuationTimer) { clearTimeout(continuationTimer); continuationTimer = null; }
303
+ // v0.32.0: the superseded module's heartbeat + UI ticker were IMMORTAL
304
+ // (clearInterval appeared nowhere) — N /reloads = N×2 zombie tickers.
305
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
306
+ if (uiTicker) { clearInterval(uiTicker); uiTicker = null; }
295
307
  return true;
296
308
  }
297
309
  return false;
298
310
  }
299
311
 
300
312
  function goStaleTerminal(ctx: ExtensionContext, where: string): void {
301
- if (extensionApiStale) return; // already terminal — don't re-spam
313
+ if (staleTerminalDone) return; // already terminal — don't re-spam
314
+ staleTerminalDone = true;
302
315
  extensionApiStale = true;
303
316
  appendLedger(ctx.cwd, "extension_api_stale", { where, kind: isLoopActive() ? "loop" : "goal" });
304
317
  const guidance = "pi invalidated this session's extension handle (session replacement — the session was disposed and this process's sends can never land). Run /reload — extensions rebuild IN PLACE, no pi restart needed — then /glla resume (autoresume=on resumes for you). Restart pi only if /reload itself fails.";
318
+ // v0.32.0: kill the continuation re-arm too — otherwise an orphaned goal
319
+ // keeps spinning a flat 50ms retry below every watchdog.
320
+ if (continuationTimer) { clearTimeout(continuationTimer); continuationTimer = null; continuationScheduledFor = null; }
305
321
  if (isLoopActive()) {
306
322
  clearLoopTimer();
307
323
  state.loop = { ...state.loop!, active: false, stopReason: `extension api stale: ${guidance}` };
@@ -400,6 +416,12 @@ function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
400
416
  // v0.30.0: a successor may already own this session (e.g. /reload
401
417
  // re-imported the modules) — the user's command belongs to the fresh
402
418
  // instance; say so softly instead of demanding a reload.
419
+ // v0.32.0: the rebind window means a fresh instance is COMING, not here —
420
+ // the old message claimed "handled there" while nothing owned the session.
421
+ if (Date.now() < sessionReplacementUntil) {
422
+ ctx.ui.notify(`glla: this session is rebinding after /reload — ${what} will be handled by the refreshed instance; retry in a moment if it doesn't.`, "info");
423
+ return true;
424
+ }
403
425
  if (absorbStaleIfSuperseded(ctx)) {
404
426
  ctx.ui.notify(`glla: a refreshed instance owns this session — ${what} is handled there; nothing to do.`, "info");
405
427
  return true;
@@ -579,6 +601,8 @@ let carryoverResolved = true;
579
601
  // set while complete_goal's isolated audit runs, so the heartbeat never
580
602
  // refires into an in-flight completion.
581
603
  let completionAuditInFlight = false;
604
+ // v0.32.0: consecutive stored-claim quota retries (capped at 5, then hold).
605
+ let quotaRetryStreak = 0;
582
606
  let heartbeatTimer: NodeJS.Timeout | null = null;
583
607
 
584
608
  const ZOMBIE_RUN_SILENT_MS = 20 * 60_000;
@@ -1005,6 +1029,9 @@ function sendContinuation(goalId: string): void {
1005
1029
  if (!isActionableGoal()) return;
1006
1030
  const ctx = freshCtx();
1007
1031
  if (!ctx) {
1032
+ // v0.32.0: a stale handle must not spin a flat 50ms re-arm below every
1033
+ // watchdog — the heartbeat's terminal path does the theatre; we just stop.
1034
+ if (probeExtensionApiStale()) return;
1008
1035
  // No live ctx — retry shortly; the next session event will refresh it.
1009
1036
  continuationScheduledFor = goalId;
1010
1037
  continuationTimer = setTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
@@ -1251,7 +1278,9 @@ async function fanOutListAuditFindings(ctx: ExtensionContext): Promise<void> {
1251
1278
  // Dedupe against the live queue (a re-run must not double-queue a finding
1252
1279
  // that's already waiting) — match on the finding text's first 60 chars.
1253
1280
  const queuedText = listQueue().map((i) => i.objective).join("\n");
1254
- const fresh = open.filter((f) => !queuedText.includes(f.text.slice(0, 60)));
1281
+ // v0.32.0: cap one fan-out a runaway findings file must not enqueue
1282
+ // hundreds of items on a single Confirm.
1283
+ const fresh = open.filter((f) => !queuedText.includes(f.text.slice(0, 60))).slice(0, 50);
1255
1284
  const alreadyQueued = open.length - fresh.length;
1256
1285
  const decideNote =
1257
1286
  decisions.length > 0
@@ -1440,6 +1469,14 @@ async function retryStoredCompletionAudit(ctx: ExtensionContext, origin: "quota-
1440
1469
  pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
1441
1470
  }, liveCtx);
1442
1471
  appendLedger(liveCtx.cwd, "goal_paused", { reason: `auditor quota: retry in ${quota.retryAfterSec}s (stored-claim retry)` });
1472
+ // v0.32.0: terminal cap — a permanently dead auditor key must not spawn
1473
+ // one auditor per hour forever. 5 consecutive quota retries → hold.
1474
+ quotaRetryStreak++;
1475
+ if (quotaRetryStreak >= 5) {
1476
+ appendLedger(liveCtx.cwd, "quota_retry_capped", { streak: quotaRetryStreak });
1477
+ liveCtx.ui.notify(`Auditor quota retry gave up after ${quotaRetryStreak} consecutive attempts — the claim stays stored; /goal resume retries by hand.`, "warning");
1478
+ return;
1479
+ }
1443
1480
  liveCtx.ui.notify(`Auditor still quota-limited — next auto-retry in ${retryMin}m (your completion claim is stored; no action needed).`, "warning");
1444
1481
  scheduleQuotaRetry(liveCtx, quota.retryAfterSec, result.error, () => {
1445
1482
  if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("auditor quota:") && state.goal.pendingCompletion) {
@@ -1452,6 +1489,7 @@ async function retryStoredCompletionAudit(ctx: ExtensionContext, origin: "quota-
1452
1489
  // Any other outcome — disapproved, impossible, non-quota infra — belongs
1453
1490
  // to the agent: resume and let the continuation drive the next step. The
1454
1491
  // verdict is durable in auditHistory + /goal status.
1492
+ quotaRetryStreak = 0;
1455
1493
  updateGoal({
1456
1494
  status: "active",
1457
1495
  auditHistory: history,
@@ -4265,35 +4303,45 @@ function resolveAuditorModel(ctx: ExtensionContext, ref?: string, fallbackRef?:
4265
4303
  return matches[0] ? { model: matches[0] } : { reason: "no available model matching" };
4266
4304
  };
4267
4305
  const isSession = (m: any) => sessionModel && m.provider === sessionModel.provider && m.id === sessionModel.id;
4268
- const pins = [ref, fallbackRef].map((r) => r?.trim()).filter((r): r is string => !!r);
4306
+ // v0.32.0: per-pin source labels when the primary is unset, pins[0] IS
4307
+ // the fallback and the old i===0→"setting" map mislabeled it.
4308
+ const pins: Array<{ pin: string; src: "setting" | "fallback-pin" }> = [];
4309
+ if (ref?.trim()) pins.push({ pin: ref.trim(), src: "setting" });
4310
+ if (fallbackRef?.trim()) pins.push({ pin: fallbackRef.trim(), src: "fallback-pin" });
4269
4311
  for (let i = 0; i < pins.length; i++) {
4270
- const pin = pins[i]!;
4312
+ const { pin } = pins[i]!;
4271
4313
  const r = tryRef(pin);
4272
4314
  if (!r.model) {
4273
4315
  // Unavailable pin → cascade: next pin, then the session model (LOUD).
4274
4316
  appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pin, reason: r.reason });
4275
- ctx.ui.notify(`Auditor model "${pin}" is unavailable (${r.reason}) — ${i + 1 < pins.length ? "trying the fallback pin" : "falling back to the session model"}. Fix via /glla → Auditor model.`, "warning");
4317
+ // v0.32.0: the last pin no longer pre-announces the session fallback
4318
+ // the post-loop block does that (it notified twice before).
4319
+ ctx.ui.notify(`Auditor model "${pin}" is unavailable (${r.reason})${i + 1 < pins.length ? " — trying the fallback pin" : ""}. Fix via /glla → Auditor model.`, "warning");
4276
4320
  continue;
4277
4321
  }
4278
4322
  if (sameSessionSwap && isSession(r.model) && i + 1 < pins.length) {
4279
4323
  // The pin IS the session model — the verifier would be the executor's
4280
4324
  // own model; auto-swap down the chain (the user's move).
4281
- appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: pins[i + 1] });
4282
- ctx.ui.notify(`Session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — auditor auto-swapped to ${pins[i + 1]} so the verifier differs.`, "info");
4325
+ appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: pins[i + 1]!.pin });
4326
+ ctx.ui.notify(`Session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — auditor auto-swapped to ${pins[i + 1]!.pin} so the verifier differs.`, "info");
4283
4327
  continue;
4284
4328
  }
4285
- if (sameSessionSwap && isSession(r.model) && !fallbackRef?.trim()) {
4329
+ // v0.32.0: the nudge must fire when the LAST pin stands on the session
4330
+ // model — the old `!fallbackRef` guard went SILENT when the fallback pin
4331
+ // itself resolved to the session model (verifier == executor, and hop 0's
4332
+ // notify had just claimed "auto-swapped so the verifier differs" — false).
4333
+ if (sameSessionSwap && isSession(r.model) && i + 1 >= pins.length) {
4286
4334
  // Last resort reached and it IS the session model, with no fallback
4287
4335
  // ever pinned — the model stands (the session IS the last resort);
4288
4336
  // one loud nudge so the user can wire the swap.
4289
4337
  appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: null });
4290
4338
  ctx.ui.notify(`The session model IS the pinned auditor (${r.model.provider}/${r.model.id}) — pin a different /glla → Auditor fallback model so the verifier can differ.`, "warning");
4291
4339
  }
4292
- return { model: r.model, via: i === 0 ? "setting" : "fallback-pin" };
4340
+ return { model: r.model, via: pins[i]!.src };
4293
4341
  }
4294
4342
  if (sessionModel) {
4295
4343
  if (pins.length > 0) {
4296
- appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.join(" → "), reason: "all pins exhausted" });
4344
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.map((p) => p.pin).join(" → "), reason: "all pins exhausted" });
4297
4345
  ctx.ui.notify("All pinned auditor models are unavailable — falling back to the session model. Fix via /glla → Auditor model.", "warning");
4298
4346
  return { model: sessionModel, via: "session-fallback" };
4299
4347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.31.8",
3
+ "version": "0.32.0",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 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 \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",
@@ -151,6 +151,7 @@ When the user must CHOOSE between paths, use `pause_goal` with `kind="decision"`
151
151
  - **Do not modify the objective silently.** The objective is the user's; if it has drifted from what makes sense, use `complete_goal`'s `newObjective` at completion time, or `pause_goal` and propose a `/goal tweak` mid-flight — never just work on something else and claim the original.
152
152
  - **Do not pretend completion.** If verification evidence is missing, call `pause_goal` instead of `complete_goal`.
153
153
  - **Git discipline: never touch identity or branches.** Commit with the repo's configured identity exactly as-is — no `git config user.*`, no per-commit `git -c user.name=…` overrides, no invented identities like `<task>-agent <…@local>` (field-observed: a phase agent branded itself `phase-e-agent <phase-e@local>` and polluted the history). No creating or switching branches either — commit on the branch you found (usually `main`) and push to its upstream. If git refuses a commit for a missing identity, STOP and ask the user — never invent one.
154
+ - **Never run the suite from inside the suite.** A test must not spawn the project's whole test runner (`bun test`, `npm test`, `pytest`…) from a file the runner itself collects — unbounded recursion is a fork bomb (field-observed 2026-07-31: 521 processes, load 28, a full system crash). Count test files or parse manifests; never re-invoke the runner on its own suite.
154
155
  - **Do not polish doorknobs.** If you are out of work and the goal is satisfied, call `complete_goal` instead of inventing a side-improvement.
155
156
  - **Do not give up early.** If a task is hard, run it down properly. The auditor will catch doorknobs; the agent's job is to do the real work.
156
157
 
@@ -54,3 +54,5 @@ ${STRATEGY_NOTE}
54
54
  - The spec is ALIVE: if the target needs sharpening, call
55
55
  propose_loop_refine with your rationale — the user confirms or rejects.
56
56
  ${BOUNDS_NOTE}
57
+
58
+ - **Never run the suite from inside the suite.** A test must not spawn the project's whole test runner (`bun test`, `npm test`, `pytest`…) from a file the runner itself collects — unbounded recursion is a fork bomb (field-observed 2026-07-31: 521 processes, load 28, a full system crash). Count test files or parse manifests; never re-invoke the runner on its own suite.
@@ -57,3 +57,5 @@ ${STRATEGY_NOTE}
57
57
  when the metric plateaus. Keep making real improvements.
58
58
  - If the measure command itself is broken (errors, no number), fix whatever
59
59
  your last change broke — that counts as a stall.
60
+
61
+ - **Never run the suite from inside the suite.** A test must not spawn the project's whole test runner (`bun test`, `npm test`, `pytest`…) from a file the runner itself collects — unbounded recursion is a fork bomb (field-observed 2026-07-31: 521 processes, load 28, a full system crash). Count test files or parse manifests; never re-invoke the runner on its own suite.