pi-goal-list-loop-audit 0.31.9 → 0.32.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.
@@ -338,6 +338,9 @@ export async function runGoalCompletionAuditor(args: {
338
338
  } finally {
339
339
  clearInterval(stallTimer);
340
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?.();
341
344
  }
342
345
 
343
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;
@@ -644,6 +668,16 @@ let loopRearmStreak = 0;
644
668
  // whose turn trigger was still dead — pausing a resumable goal 4 minutes
645
669
  // after the compact instead of giving pi room to recover.
646
670
  let compactionGraceUntil = 0;
671
+ // v0.32.1 (pi-goal-x's lesson — "recover from compacts smarter"): a compact
672
+ // leaves a RESUME DEBT, not just two fixed-offset settle probes that can both
673
+ // lose (field: hellhunter 4-min dangle 2026-07-31; polis stall same day).
674
+ // postCompactResumeOwed discharges only when a real turn starts (agent_start);
675
+ // every heartbeat tick past grace retries it. postCompactResyncPending arms a
676
+ // deterministic [POST-COMPACTION RESYNC] block on the next continuation/loop
677
+ // message (pi-goal-x's #5) so the compacted agent re-anchors on artifact
678
+ // state instead of lost chat history.
679
+ let postCompactResumeOwed = false;
680
+ let postCompactResyncPending = false;
647
681
  const COMPACTION_GRACE_MS = 3 * 60_000;
648
682
  // v0.28.25: provider-error retry cadence. Field-observed in dracon-utilities
649
683
  // (kimi, 19-session fleet on one provider account): a "concurrent request
@@ -803,6 +837,24 @@ function heartbeatTick(): void {
803
837
  if (!absorbStaleIfSuperseded(ctx)) goStaleTerminal(ctx, "heartbeat probe");
804
838
  return;
805
839
  }
840
+ // v0.32.1: post-compaction resume debt — retry on every heartbeat tick
841
+ // past grace until a turn actually starts. Fixed-offset settles alone
842
+ // can both lose (pi busy at 2s AND at grace+2s = a dangling chain).
843
+ if (postCompactResumeOwed && isSupervising() && !abortedStandDown) {
844
+ try {
845
+ if (ctx.isIdle() && !ctx.hasPendingMessages() && continuationTimer === null && loopTimer === null) {
846
+ if (isLoopActive()) {
847
+ appendLedger(ctx.cwd, "compaction_resume_owed_refire", { kind: "loop" });
848
+ scheduleLoopTick(ctx);
849
+ } else if (isActionableGoal()) {
850
+ appendLedger(ctx.cwd, "compaction_resume_owed_refire", { kind: "goal" });
851
+ scheduleContinuation(ctx, true);
852
+ } else {
853
+ postCompactResumeOwed = false; // nothing to resume — discharge
854
+ }
855
+ }
856
+ } catch { /* next tick */ }
857
+ }
806
858
  // v0.29.16: zombie-run watchdog. pi reports BUSY (a run is "active") but
807
859
  // zero stream events for 20 min = the provider stream hung silently —
808
860
  // queued continuations can't land, and every other watchdog stays quiet
@@ -1005,6 +1057,9 @@ function sendContinuation(goalId: string): void {
1005
1057
  if (!isActionableGoal()) return;
1006
1058
  const ctx = freshCtx();
1007
1059
  if (!ctx) {
1060
+ // v0.32.0: a stale handle must not spin a flat 50ms re-arm below every
1061
+ // watchdog — the heartbeat's terminal path does the theatre; we just stop.
1062
+ if (probeExtensionApiStale()) return;
1008
1063
  // No live ctx — retry shortly; the next session event will refresh it.
1009
1064
  continuationScheduledFor = goalId;
1010
1065
  continuationTimer = setTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
@@ -1021,11 +1076,13 @@ function sendContinuation(goalId: string): void {
1021
1076
  }
1022
1077
  if (!extensionApi || extensionApiStale) return;
1023
1078
  try {
1079
+ const resync = postCompactResyncPending ? buildPostCompactResync() : "";
1024
1080
  extensionApi.sendMessage({
1025
1081
  customType: GOAL_EVENT_ENTRY,
1026
- content: continuationPrompt(state.goal!),
1082
+ content: resync + continuationPrompt(state.goal!),
1027
1083
  display: false,
1028
1084
  }, { triggerTurn: true, deliverAs: "followUp" });
1085
+ if (resync) postCompactResyncPending = false; // consumed only by a landed send
1029
1086
  continuationRearmStreak = 0; continuationRearmSince = 0; // v0.28.5 (E3): a landed send clears the storm
1030
1087
  appendLedger(ctx.cwd, "goal_continuation_sent", { goalId });
1031
1088
  } catch (err) {
@@ -1078,6 +1135,25 @@ function sendLengthContinue(ctx: ExtensionContext, consecutive: number): void {
1078
1135
  }
1079
1136
  }
1080
1137
 
1138
+ /** v0.32.1: deterministic post-compaction re-anchor (pi-goal-x's #5) —
1139
+ * prepended to the first continuation/loop message after a compact. */
1140
+ function buildPostCompactResync(): string {
1141
+ const lines: string[] = [
1142
+ "[POST-COMPACTION RESYNC] The transcript was just compacted. Trust the artifacts on disk and .pi-glla/ state — NOT your memory of the prior chat. Re-read files before editing them.",
1143
+ ];
1144
+ if (state.goal) {
1145
+ lines.push(`Goal ${state.goal.id} — status ${state.goal.status}`);
1146
+ lines.push(`Objective: ${state.goal.objective.slice(0, 200)}`);
1147
+ const next = findNextPendingTask(state.goal.taskList?.tasks ?? []);
1148
+ if (next) lines.push(`Next pending task: \`${next.id}\` — ${next.title}`);
1149
+ const lastAudit = state.goal.auditHistory?.[state.goal.auditHistory.length - 1];
1150
+ if (lastAudit) lines.push(`Last audit: ${lastAudit.approved ? "APPROVED" : lastAudit.impossible ? "IMPOSSIBLE" : "disapproved"} (${lastAudit.at})`);
1151
+ } else if (state.loop?.active) {
1152
+ lines.push(`Loop: ${state.loop.target.slice(0, 160)} — iteration ${state.loop.iteration}`);
1153
+ }
1154
+ return lines.join("\n") + "\n\n";
1155
+ }
1156
+
1081
1157
  function continuationPrompt(goal: Goal): string {
1082
1158
  // Read the .md file as the template, then substitute {{tokens}}.
1083
1159
  // For v0.1.0 we inline-substitute so we don't need fs at runtime.
@@ -1251,7 +1327,9 @@ async function fanOutListAuditFindings(ctx: ExtensionContext): Promise<void> {
1251
1327
  // Dedupe against the live queue (a re-run must not double-queue a finding
1252
1328
  // that's already waiting) — match on the finding text's first 60 chars.
1253
1329
  const queuedText = listQueue().map((i) => i.objective).join("\n");
1254
- const fresh = open.filter((f) => !queuedText.includes(f.text.slice(0, 60)));
1330
+ // v0.32.0: cap one fan-out a runaway findings file must not enqueue
1331
+ // hundreds of items on a single Confirm.
1332
+ const fresh = open.filter((f) => !queuedText.includes(f.text.slice(0, 60))).slice(0, 50);
1255
1333
  const alreadyQueued = open.length - fresh.length;
1256
1334
  const decideNote =
1257
1335
  decisions.length > 0
@@ -1440,6 +1518,14 @@ async function retryStoredCompletionAudit(ctx: ExtensionContext, origin: "quota-
1440
1518
  pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
1441
1519
  }, liveCtx);
1442
1520
  appendLedger(liveCtx.cwd, "goal_paused", { reason: `auditor quota: retry in ${quota.retryAfterSec}s (stored-claim retry)` });
1521
+ // v0.32.0: terminal cap — a permanently dead auditor key must not spawn
1522
+ // one auditor per hour forever. 5 consecutive quota retries → hold.
1523
+ quotaRetryStreak++;
1524
+ if (quotaRetryStreak >= 5) {
1525
+ appendLedger(liveCtx.cwd, "quota_retry_capped", { streak: quotaRetryStreak });
1526
+ liveCtx.ui.notify(`Auditor quota retry gave up after ${quotaRetryStreak} consecutive attempts — the claim stays stored; /goal resume retries by hand.`, "warning");
1527
+ return;
1528
+ }
1443
1529
  liveCtx.ui.notify(`Auditor still quota-limited — next auto-retry in ${retryMin}m (your completion claim is stored; no action needed).`, "warning");
1444
1530
  scheduleQuotaRetry(liveCtx, quota.retryAfterSec, result.error, () => {
1445
1531
  if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("auditor quota:") && state.goal.pendingCompletion) {
@@ -1452,6 +1538,7 @@ async function retryStoredCompletionAudit(ctx: ExtensionContext, origin: "quota-
1452
1538
  // Any other outcome — disapproved, impossible, non-quota infra — belongs
1453
1539
  // to the agent: resume and let the continuation drive the next step. The
1454
1540
  // verdict is durable in auditHistory + /goal status.
1541
+ quotaRetryStreak = 0;
1455
1542
  updateGoal({
1456
1543
  status: "active",
1457
1544
  auditHistory: history,
@@ -2579,11 +2666,13 @@ function sendLoopTurn(): void {
2579
2666
  // instruction (metricless loops; metric loops already vary via values).
2580
2667
  const variantNote = metricless ? continueVariant(loop.iteration) : "";
2581
2668
  try {
2669
+ const loopResync = postCompactResyncPending ? buildPostCompactResync() : "";
2582
2670
  extensionApi.sendMessage({
2583
2671
  customType: GOAL_EVENT_ENTRY,
2584
- content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
2672
+ content: loopResync + loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
2585
2673
  display: false,
2586
2674
  }, { triggerTurn: true, deliverAs: "followUp" });
2675
+ if (loopResync) postCompactResyncPending = false; // consumed only by a landed send
2587
2676
  // v0.26.1: the send path is ledgered — the hegemon zombie spun 619
2588
2677
  // refires with zero visibility into whether sends were landing.
2589
2678
  loopRearmStreak = 0; loopRearmSince = 0; // v0.28.5 (E3): a landed turn clears the storm
@@ -4265,35 +4354,45 @@ function resolveAuditorModel(ctx: ExtensionContext, ref?: string, fallbackRef?:
4265
4354
  return matches[0] ? { model: matches[0] } : { reason: "no available model matching" };
4266
4355
  };
4267
4356
  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);
4357
+ // v0.32.0: per-pin source labels when the primary is unset, pins[0] IS
4358
+ // the fallback and the old i===0→"setting" map mislabeled it.
4359
+ const pins: Array<{ pin: string; src: "setting" | "fallback-pin" }> = [];
4360
+ if (ref?.trim()) pins.push({ pin: ref.trim(), src: "setting" });
4361
+ if (fallbackRef?.trim()) pins.push({ pin: fallbackRef.trim(), src: "fallback-pin" });
4269
4362
  for (let i = 0; i < pins.length; i++) {
4270
- const pin = pins[i]!;
4363
+ const { pin } = pins[i]!;
4271
4364
  const r = tryRef(pin);
4272
4365
  if (!r.model) {
4273
4366
  // Unavailable pin → cascade: next pin, then the session model (LOUD).
4274
4367
  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");
4368
+ // v0.32.0: the last pin no longer pre-announces the session fallback
4369
+ // the post-loop block does that (it notified twice before).
4370
+ 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
4371
  continue;
4277
4372
  }
4278
4373
  if (sameSessionSwap && isSession(r.model) && i + 1 < pins.length) {
4279
4374
  // The pin IS the session model — the verifier would be the executor's
4280
4375
  // 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");
4376
+ appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: pins[i + 1]!.pin });
4377
+ 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
4378
  continue;
4284
4379
  }
4285
- if (sameSessionSwap && isSession(r.model) && !fallbackRef?.trim()) {
4380
+ // v0.32.0: the nudge must fire when the LAST pin stands on the session
4381
+ // model — the old `!fallbackRef` guard went SILENT when the fallback pin
4382
+ // itself resolved to the session model (verifier == executor, and hop 0's
4383
+ // notify had just claimed "auto-swapped so the verifier differs" — false).
4384
+ if (sameSessionSwap && isSession(r.model) && i + 1 >= pins.length) {
4286
4385
  // Last resort reached and it IS the session model, with no fallback
4287
4386
  // ever pinned — the model stands (the session IS the last resort);
4288
4387
  // one loud nudge so the user can wire the swap.
4289
4388
  appendLedger(ctx.cwd, "auditor_model_same_as_session", { model: `${r.model.provider}/${r.model.id}`, fallback: null });
4290
4389
  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
4390
  }
4292
- return { model: r.model, via: i === 0 ? "setting" : "fallback-pin" };
4391
+ return { model: r.model, via: pins[i]!.src };
4293
4392
  }
4294
4393
  if (sessionModel) {
4295
4394
  if (pins.length > 0) {
4296
- appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.join(" → "), reason: "all pins exhausted" });
4395
+ appendLedger(ctx.cwd, "auditor_model_fallback", { configured: pins.map((p) => p.pin).join(" → "), reason: "all pins exhausted" });
4297
4396
  ctx.ui.notify("All pinned auditor models are unavailable — falling back to the session model. Fix via /glla → Auditor model.", "warning");
4298
4397
  return { model: sessionModel, via: "session-fallback" };
4299
4398
  }
@@ -5660,6 +5759,11 @@ export default function (pi: ExtensionAPI): void {
5660
5759
  continuationRearmStreak = 0; continuationRearmSince = 0;
5661
5760
  loopRearmStreak = 0; loopRearmSince = 0;
5662
5761
  compactionGraceUntil = Date.now() + COMPACTION_GRACE_MS;
5762
+ // v0.32.1: arm the resume debt + the resync block (the settle probes
5763
+ // below stay as the fast path; the heartbeat now retries the debt on
5764
+ // EVERY post-grace tick until agent_start discharges it).
5765
+ postCompactResumeOwed = true;
5766
+ postCompactResyncPending = true;
5663
5767
  const settle = setTimeout(() => {
5664
5768
  const c = freshCtx();
5665
5769
  if (!c) return;
@@ -6303,6 +6407,9 @@ export default function (pi: ExtensionAPI): void {
6303
6407
  });
6304
6408
  pi.on("agent_start", () => {
6305
6409
  lastStreamActivityAt = Date.now();
6410
+ // v0.32.1: a real turn started — the post-compaction resume debt is
6411
+ // discharged (the heartbeat stops retrying it).
6412
+ postCompactResumeOwed = false;
6306
6413
  });
6307
6414
  pi.on("turn_start", () => {
6308
6415
  lastStreamActivityAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.31.9",
3
+ "version": "0.32.1",
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",