pi-goal-list-loop-audit 0.28.4 → 0.28.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.
@@ -162,6 +162,10 @@ export interface Goal {
162
162
  * on that auto-resume. */
163
163
  interruptedAt?: string;
164
164
  interruptedReason?: string;
165
+ /** v0.28.5 (E2): trailing auditor INFRA-structure errors (not verdicts).
166
+ * At 3 the goal pauses loudly — a broken auditor model must not spin a
167
+ * silent retry-forever loop. Cleared on any real auditor run. */
168
+ auditInfraStreak?: number;
165
169
  /** v0.25.0 (contract item 22): auditor objections extracted as TODOs when
166
170
  * aggressiveMode keeps the goal active past the disapproval cap. Rendered
167
171
  * into every continuation prompt until the next audit clears them. */
@@ -494,10 +498,60 @@ export function ensureDirs(cwd: string): void {
494
498
  fs.mkdirSync(archiveDir(cwd), { recursive: true });
495
499
  }
496
500
 
501
+ // =================================================================
502
+ // Persistence degradation (v0.28.6, audit E1)
503
+ // =================================================================
504
+ // A disk failure (ENOSPC, EACCES, a wedged mount) used to THROW out of
505
+ // appendLedger/writeGoalMd mid-handler — killing the orchestrator turn and
506
+ // silently diverging RAM from disk. Now every persistence step runs
507
+ // through runPersistStep: failures are caught, the session-wide degraded
508
+ // flag latches (the TUI shows it; the first failure notifies loudly), RAM
509
+ // state stays authoritative, and the next SUCCESSFUL step auto-clears the
510
+ // flag (self-healing — the "dirty" marker that write-then-mutate ordering
511
+ // cannot otherwise provide).
512
+
513
+ export interface PersistenceFailure {
514
+ what: string;
515
+ error: string;
516
+ at: string;
517
+ }
518
+
519
+ let persistenceDegraded = false;
520
+ let lastFailure: PersistenceFailure | null = null;
521
+
522
+ export function isPersistenceDegraded(): boolean {
523
+ return persistenceDegraded;
524
+ }
525
+
526
+ export function lastPersistenceFailure(): PersistenceFailure | null {
527
+ return lastFailure;
528
+ }
529
+
530
+ /** Run one persistence step. On failure: latch the degraded flag, remember
531
+ * the error, return undefined (NEVER throw into an orchestrator handler).
532
+ * On success: clear the flag — a landing write means the disk is back. */
533
+ export function runPersistStep<T>(what: string, fn: () => T): T | undefined {
534
+ try {
535
+ const out = fn();
536
+ if (persistenceDegraded) {
537
+ persistenceDegraded = false;
538
+ lastFailure = null;
539
+ }
540
+ return out;
541
+ } catch (err) {
542
+ persistenceDegraded = true;
543
+ lastFailure = { what, error: err instanceof Error ? err.message : String(err), at: new Date().toISOString() };
544
+ return undefined;
545
+ }
546
+ }
547
+
497
548
  export function readState(cwd: string): State {
498
549
  const file = ledgerPath(cwd);
499
- if (!fs.existsSync(file)) return { ...DEFAULT_STATE };
500
- const lines = fs.readFileSync(file, "utf-8").split("\n").filter(Boolean);
550
+ // v0.28.6 (E1): an unreadable ledger (EACCES, EIO) degrades loudly
551
+ // instead of throwing out of session_start.
552
+ const raw = runPersistStep("readState", () => (fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : ""));
553
+ if (raw === undefined || raw === "") return { ...DEFAULT_STATE };
554
+ const lines = raw.split("\n").filter(Boolean);
501
555
  if (lines.length === 0) return { ...DEFAULT_STATE };
502
556
  let parsed: Partial<State> = {};
503
557
  for (const line of lines) {
@@ -505,7 +559,8 @@ export function readState(cwd: string): State {
505
559
  const evt = JSON.parse(line);
506
560
  if (evt.type === "state") parsed = { ...parsed, ...evt.value };
507
561
  } catch {
508
- // skip malformed lines
562
+ // skip malformed lines — a truncated trailing line (mid-write kill)
563
+ // must not lose the rest of the state
509
564
  }
510
565
  }
511
566
  return {
@@ -516,16 +571,23 @@ export function readState(cwd: string): State {
516
571
  }
517
572
 
518
573
  export function appendLedger(cwd: string, type: string, value: unknown): void {
519
- ensureDirs(cwd);
520
- const line = JSON.stringify({ type, value, at: new Date().toISOString() });
521
- fs.appendFileSync(ledgerPath(cwd), line + "\n");
574
+ // v0.28.6 (E1): guarded — a disk failure degrades loudly, never throws
575
+ // into an orchestrator handler.
576
+ runPersistStep("appendLedger", () => {
577
+ ensureDirs(cwd);
578
+ const line = JSON.stringify({ type, value, at: new Date().toISOString() });
579
+ fs.appendFileSync(ledgerPath(cwd), line + "\n");
580
+ });
522
581
  }
523
582
 
524
583
  export function writeGoalMd(cwd: string, goal: Goal): string {
525
- ensureDirs(cwd);
526
584
  const file = goalMdPath(cwd, goal.id);
527
- const md = renderGoalMarkdown(goal);
528
- fs.writeFileSync(file, md);
585
+ runPersistStep("writeGoalMd", () => {
586
+ ensureDirs(cwd);
587
+ fs.writeFileSync(file, renderGoalMarkdown(goal));
588
+ });
589
+ // Return the intended path even on failure so activePath stays sane —
590
+ // the degraded flag carries the truth that the write did not land.
529
591
  return file;
530
592
  }
531
593
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Goal, State } from "./goal-loop-core.js";
14
+ import { isPersistenceDegraded, lastPersistenceFailure } from "./goal-loop-core.js";
14
15
  import type { LoopState } from "./goal-loop-forever.js";
15
16
 
16
17
  // ---- formatters ----
@@ -185,6 +186,17 @@ function countTotal(g: Goal): number {
185
186
  * Returns undefined when nothing is worth showing.
186
187
  */
187
188
  export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
189
+ const inner = buildWidgetLinesInner(state, audit, now, theme, width, extras);
190
+ // v0.28.6 (E1): a persistence failure outranks everything — first line,
191
+ // on every render, until a write lands again.
192
+ if (inner && isPersistenceDegraded()) {
193
+ const err = lastPersistenceFailure();
194
+ return [paint(theme, "error", `⚠ persistence degraded — .pi-glla writes failing (${truncate(err?.error ?? "disk error", 40)}); state in RAM`), ...inner];
195
+ }
196
+ return inner;
197
+ }
198
+
199
+ function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
188
200
  if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
189
201
  const g = state.goal;
190
202
  if (!g) return undefined;
@@ -85,6 +85,9 @@ import {
85
85
  statusLabel,
86
86
  writeGoalMd,
87
87
  missingGllaTools,
88
+ runPersistStep,
89
+ isPersistenceDegraded,
90
+ lastPersistenceFailure,
88
91
  } from "../goal-loop-core.js";
89
92
  import {
90
93
  LENGTH_CONTINUE_MAX,
@@ -360,6 +363,55 @@ function startUITicker(): void {
360
363
 
361
364
  /** v0.26.5: shared loud-stop for both stall paths (refire streak and
362
365
  * pending-latch streak). Returns true when it escalated. */
366
+ // v0.28.5 (E3): send-retry re-arm accounting. The 50ms BACKOFF_IDLE_RETRY
367
+ // re-arm loop used to spin for HOURS with zero ledger events while the idle
368
+ // watchdogs stayed suppressed. Now: counted, ledgered (start + every 30s),
369
+ // and escalated loudly past 5 minutes.
370
+ let continuationRearmStreak = 0;
371
+ let loopRearmStreak = 0;
372
+ const SEND_REARM_LEDGER_EVERY = 600; // 600 × 50ms = 30s
373
+ const SEND_REARM_ESCALATE_AT = 6000; // 6000 × 50ms = 5 minutes
374
+
375
+ function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "loop"): void {
376
+ const streak = kind === "continuation" ? ++continuationRearmStreak : ++loopRearmStreak;
377
+ if (streak === 1) {
378
+ appendLedger(ctx.cwd, "send_rearm_start", { kind });
379
+ return;
380
+ }
381
+ if (streak % SEND_REARM_LEDGER_EVERY === 0) {
382
+ appendLedger(ctx.cwd, "send_rearm_storm", { kind, streak, minutes: Math.round((streak * BACKOFF_IDLE_RETRY_MS) / 60000) });
383
+ }
384
+ if (streak >= SEND_REARM_ESCALATE_AT) {
385
+ if (kind === "continuation") continuationRearmStreak = 0; else loopRearmStreak = 0;
386
+ escalateSendRearmStorm(ctx, kind);
387
+ }
388
+ }
389
+
390
+ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "loop"): void {
391
+ // Same loud-terminal shape as escalateStallNow (v0.24.7): a 5-minute
392
+ // re-arm storm means the session never goes idle for us — wedged queue
393
+ // or a busy-forever session. Bounded and surfaced, not silent.
394
+ const mins = Math.round((SEND_REARM_ESCALATE_AT * BACKOFF_IDLE_RETRY_MS) / 60000);
395
+ appendLedger(ctx.cwd, "send_rearm_escalated", { kind, streak: SEND_REARM_ESCALATE_AT });
396
+ if (kind === "loop" && isLoopActive()) {
397
+ clearLoopTimer();
398
+ state.loop = { ...state.loop!, active: false, stopReason: `send-retry storm: ${mins}m of 50ms re-arms — the session never went idle for the loop turn. Restart pi, then /loop start again.` };
399
+ persistState(ctx);
400
+ ctx.ui.notify(`Loop stopped: send-retry storm (${mins}m). Restart pi and /loop start.`, "warning");
401
+ notifyExternal(ctx, "Loop stopped: send-retry storm.");
402
+ return;
403
+ }
404
+ if (state.goal && state.goal.status === "active") {
405
+ updateGoal({
406
+ status: "paused",
407
+ pauseReason: `send-retry storm: ${mins}m of 50ms re-arms — the session never went idle for the continuation`,
408
+ pauseSuggestedAction: "The session never went idle for the send (wedged queue or permanently busy). Restart pi, then /goal resume.",
409
+ }, ctx);
410
+ ctx.ui.notify(`Goal paused: send-retry storm (${mins}m). Restart pi, then /goal resume.`, "warning");
411
+ notifyExternal(ctx, "Goal paused: send-retry storm.");
412
+ }
413
+ }
414
+
363
415
  function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
364
416
  if (!shouldEscalateStall(consecutiveStalls, threshold)) return false;
365
417
  consecutiveStalls = 0;
@@ -494,6 +546,9 @@ let continuationScheduledFor: string | null = null;
494
546
  let iterationCounter = 0;
495
547
  let toolCallsThisTurn = 0;
496
548
  let consecutiveErrorIterations = 0;
549
+ // v0.28.5 (E8): user aborts are NOT provider errors — separate counter,
550
+ // separate brake message, and no auto-resume (aborting is user intent).
551
+ let consecutiveAbortIterations = 0;
497
552
  let consecutiveNoToolIterations = 0;
498
553
 
499
554
  // =================================================================
@@ -555,6 +610,8 @@ function sendContinuation(goalId: string): void {
555
610
  return;
556
611
  }
557
612
  if (!ctx.isIdle() || ctx.hasPendingMessages()) {
613
+ // v0.28.5 (E3): count + ledger + escalate the re-arm storm.
614
+ accountSendRearm(ctx, "continuation");
558
615
  continuationScheduledFor = goalId;
559
616
  continuationTimer = setTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
560
617
  continuationTimer.unref?.();
@@ -567,6 +624,7 @@ function sendContinuation(goalId: string): void {
567
624
  content: continuationPrompt(state.goal!),
568
625
  display: false,
569
626
  }, { triggerTurn: true, deliverAs: "followUp" });
627
+ continuationRearmStreak = 0; // v0.28.5 (E3): a landed send clears the storm
570
628
  appendLedger(ctx.cwd, "goal_continuation_sent", { goalId });
571
629
  } catch (err) {
572
630
  appendLedger(ctx.cwd, "goal_continuation_send_failed", { goalId, error: err instanceof Error ? err.message : String(err) });
@@ -685,9 +743,29 @@ function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "
685
743
 
686
744
  function persistState(ctx: ExtensionContext): void {
687
745
  appendLedger(ctx.cwd, "state", { goal: state.goal, list: state.list ?? [], loop: state.loop ?? null });
746
+ notifyPersistenceState(ctx); // v0.28.6 (E1): loud on the first failure, all-clear on recovery
688
747
  refreshUI(ctx); // every state transition flows through here → the TUI is always current
689
748
  }
690
749
 
750
+ // v0.28.6 (E1): persistence-degradation notify — once per failure streak,
751
+ // once per recovery. The TUI flag (buildWidgetLines) carries the standing
752
+ // state; these notifies are the LOUD part.
753
+ let persistenceDegradedNotified = false;
754
+ function notifyPersistenceState(ctx: ExtensionContext): void {
755
+ if (isPersistenceDegraded() && !persistenceDegradedNotified) {
756
+ persistenceDegradedNotified = true;
757
+ const err = lastPersistenceFailure();
758
+ ctx.ui.notify(
759
+ `⚠ Persistence degraded: ${err?.what ?? "disk write"} failed (${(err?.error ?? "unknown").slice(0, 80)}). State lives in RAM and re-syncs on the next successful write — .pi-glla may be missing recent entries. Fix the disk (space/permissions) and it self-heals.`,
760
+ "warning",
761
+ );
762
+ notifyExternal(ctx, "pi-goal-list-loop-audit: persistence degraded — .pi-glla writes failing.");
763
+ } else if (!isPersistenceDegraded() && persistenceDegradedNotified) {
764
+ persistenceDegradedNotified = false;
765
+ ctx.ui.notify("Persistence recovered — .pi-glla writes are landing again.", "info");
766
+ }
767
+ }
768
+
691
769
  function setGoal(goal: Goal, ctx: ExtensionContext): void {
692
770
  state = { goal, list: state.list ?? [] }; // preserve the list!
693
771
  const file = writeGoalMd(ctx.cwd, goal);
@@ -710,9 +788,16 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
710
788
  ensureDirs(ctx.cwd);
711
789
  const target = archivedGoalPath(ctx.cwd, goal.id);
712
790
  const md = renderGoalMarkdown({ ...goal, status, stopReason });
713
- fs.writeFileSync(target, md);
714
- // Remove active md file
715
- try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
791
+ // v0.28.6 (E1): guarded — and the active md is only removed when the
792
+ // archive actually LANDED (degraded mode must not destroy the only copy).
793
+ const archived = runPersistStep("archiveCurrentGoal", () => {
794
+ ensureDirs(ctx.cwd);
795
+ fs.writeFileSync(target, md);
796
+ return true;
797
+ }) === true;
798
+ if (archived) {
799
+ try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
800
+ }
716
801
  state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
717
802
  appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
718
803
  persistState(ctx);
@@ -834,6 +919,7 @@ function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
834
919
  setGoal(goal, ctx);
835
920
  iterationCounter = 0;
836
921
  consecutiveErrorIterations = 0;
922
+ consecutiveAbortIterations = 0;
837
923
  ctx.ui.notify(`List item #${n} activated (${rest.length} remaining): ${goal.objective.slice(0, 80)}`, "info");
838
924
  scheduleContinuation(ctx, true);
839
925
  return true;
@@ -981,6 +1067,7 @@ async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): P
981
1067
  // Reset counters
982
1068
  iterationCounter = 0;
983
1069
  consecutiveErrorIterations = 0;
1070
+ consecutiveAbortIterations = 0;
984
1071
  consecutiveNoToolIterations = 0;
985
1072
  if (staleEntry) {
986
1073
  // v0.28.1 (S3): the goal is persisted — mark the interrupt so the next
@@ -1506,6 +1593,7 @@ function sendLoopTurn(): void {
1506
1593
  if (!isLoopActive() || !extensionApi) return;
1507
1594
  const ctx = freshCtx();
1508
1595
  if (!ctx || !ctx.isIdle() || ctx.hasPendingMessages()) {
1596
+ if (ctx) accountSendRearm(ctx, "loop"); // v0.28.5 (E3)
1509
1597
  loopTimer = setTimeout(() => sendLoopTurn(), BACKOFF_IDLE_RETRY_MS);
1510
1598
  loopTimer.unref?.();
1511
1599
  return;
@@ -1553,6 +1641,7 @@ function sendLoopTurn(): void {
1553
1641
  }, { triggerTurn: true, deliverAs: "followUp" });
1554
1642
  // v0.26.1: the send path is ledgered — the hegemon zombie spun 619
1555
1643
  // refires with zero visibility into whether sends were landing.
1644
+ loopRearmStreak = 0; // v0.28.5 (E3): a landed turn clears the storm
1556
1645
  appendLedger(ctx.cwd, "loop_turn_sent", { iteration: loop.iteration });
1557
1646
  } catch (err) {
1558
1647
  // stale API — next agent_end reschedules (but if none comes, the
@@ -2083,6 +2172,8 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2083
2172
  // (abort, auth failure, no model) are surfaced via pauseReason, not
2084
2173
  // logged as disapprovals.
2085
2174
  const auditorRan = result.output.trim().length > 0;
2175
+ // v0.28.5 (E2): a REAL auditor run clears the infra-error streak.
2176
+ if (auditorRan && (state.goal.auditInfraStreak ?? 0) > 0) updateGoal({ auditInfraStreak: undefined }, ctx);
2086
2177
  const history = state.goal.auditHistory ?? [];
2087
2178
  if (auditorRan) {
2088
2179
  // v0.25.4: strip think-block leakage (MiniMax-M3 `</think>`
@@ -2229,6 +2320,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2229
2320
  updateGoal({
2230
2321
  status: "paused",
2231
2322
  auditHistory: history,
2323
+ auditInfraStreak: undefined, // quota reached the auditor — infra streak broken
2232
2324
  pauseReason: `auditor quota: ${result.error}`,
2233
2325
  pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
2234
2326
  }, ctx);
@@ -2253,9 +2345,34 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2253
2345
  details: {},
2254
2346
  };
2255
2347
  }
2348
+ // v0.28.5 (E2): bound the silent retry-forever. Each infra error
2349
+ // used to reschedule a continuation unconditionally — a broken
2350
+ // auditor model spun forever (the 39-error incident). At 3 trailing
2351
+ // infra errors the model is broken, not unlucky: pause LOUDLY.
2352
+ const infraStreak = (state.goal.auditInfraStreak ?? 0) + 1;
2353
+ if (infraStreak >= 3) {
2354
+ updateGoal({
2355
+ status: "paused",
2356
+ auditHistory: history,
2357
+ auditInfraStreak: infraStreak,
2358
+ pauseReason: `auditor infrastructure failed ${infraStreak}× in a row — the auditor model is likely broken (last: ${result.error.slice(0, 120)})`,
2359
+ pauseSuggestedAction: "Fix the auditor model (/glla model=provider/id) or restart pi, then /goal resume. Your work was NOT judged.",
2360
+ }, ctx);
2361
+ appendLedger(ctx.cwd, "goal_paused", { reason: `auditor infra streak ${infraStreak}: ${result.error.slice(0, 120)}` });
2362
+ ctx.ui.notify(`Goal paused: auditor infrastructure failed ${infraStreak}× in a row. Fix the auditor model (/glla model=...), then /goal resume.`, "warning");
2363
+ notifyExternal(ctx, `Goal paused: auditor infrastructure ${infraStreak}× — model likely broken.`);
2364
+ return {
2365
+ content: [{
2366
+ type: "text",
2367
+ text: `The auditor has now failed ${infraStreak} times in a row with infrastructure errors (NOT verdicts; last: ${result.error}). The goal is PAUSED — the retry-forever loop stops here. Fix the auditor model with /glla model=provider/id (or restart pi), then /goal resume and call complete_goal again. Do not change your deliverable for this.`,
2368
+ }],
2369
+ details: {},
2370
+ };
2371
+ }
2256
2372
  updateGoal({
2257
2373
  status: "active",
2258
2374
  auditHistory: history,
2375
+ auditInfraStreak: infraStreak,
2259
2376
  pauseReason: `auditor infrastructure${retriedOnce ? " (retried once)" : ""}: ${result.error}`,
2260
2377
  pauseSuggestedAction: "Fix the auditor model (/glla model=provider/id) and call complete_goal again — your work was NOT judged",
2261
2378
  }, ctx);
@@ -2599,6 +2716,7 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2599
2716
  setGoal(goal, liveCtx);
2600
2717
  iterationCounter = 0;
2601
2718
  consecutiveErrorIterations = 0;
2719
+ consecutiveAbortIterations = 0;
2602
2720
  scheduleContinuation(liveCtx, true);
2603
2721
  return {
2604
2722
  content: [{ type: "text", text: `Goal confirmed and activated (id ${goal.id}). Begin work now; call complete_goal only when the objective is genuinely satisfied.` }],
@@ -4318,20 +4436,56 @@ export default function (pi: ExtensionAPI): void {
4318
4436
  updateGoal({ usage: { tokensUsed: used, tokensLimit: limit } }, ctx);
4319
4437
  }
4320
4438
 
4321
- if (stopReason === "error" || stopReason === "aborted") {
4439
+ if (stopReason === "error") {
4322
4440
  consecutiveErrorIterations++;
4441
+ consecutiveAbortIterations = 0;
4323
4442
  if (consecutiveErrorIterations >= 5) {
4443
+ // v0.28.5 (E8): carry the REAL error text — the pause used to say
4444
+ // literally "5 consecutive errors: error" (stopReason, not the
4445
+ // provider error). And give transient flakes ONE capped auto-resume
4446
+ // (60s, reason re-checked) — the E8 incident lost 1.5h to a
4447
+ // 60-second provider hiccup waiting on a manual /goal resume.
4448
+ const detail = text.trim() ? ` (last: ${text.trim().replace(/\s+/g, " ").slice(0, 160)})` : "";
4449
+ const reason = `5 consecutive errors${detail}`;
4450
+ updateGoal({
4451
+ status: "paused",
4452
+ pauseReason: reason,
4453
+ pauseSuggestedAction: "Transient provider flake? The goal auto-resumes once in 60s if still paused for this reason — or /goal resume now.",
4454
+ }, ctx);
4455
+ ctx.ui.notify(`Goal paused: ${reason}.`, "warning");
4456
+ notifyExternal(ctx, `Goal paused: ${reason}.`);
4457
+ appendLedger(ctx.cwd, "goal_paused", { reason });
4458
+ scheduleQuotaRetry(ctx, 60, reason, () => {
4459
+ // Re-check: only auto-resume if STILL paused for the error brake
4460
+ // (a user /goal pause during the window is not stomped).
4461
+ if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("5 consecutive errors")) {
4462
+ updateGoal({ status: "active" }, ctx);
4463
+ appendLedger(ctx.cwd, "goal_resumed", { via: "error-brake-retry" });
4464
+ ctx.ui.notify("Auto-resumed after the 5-error brake (60s cooldown).", "info");
4465
+ scheduleContinuation(ctx, true);
4466
+ }
4467
+ }, "5 consecutive errors — auto-retry");
4468
+ return;
4469
+ }
4470
+ } else if (stopReason === "aborted") {
4471
+ // v0.28.5 (E8): user aborts are not provider errors. Separate brake,
4472
+ // honest message, and NO auto-resume — aborting five turns in a row
4473
+ // is the user telling the goal to stop; we stay stopped.
4474
+ consecutiveAbortIterations++;
4475
+ consecutiveErrorIterations = 0;
4476
+ if (consecutiveAbortIterations >= 5) {
4324
4477
  updateGoal({
4325
4478
  status: "paused",
4326
- pauseReason: `5 consecutive errors: ${stopReason}`,
4327
- pauseSuggestedAction: "Use /goal resume to retry, or /goal cancel to abort.",
4479
+ pauseReason: "5 consecutive aborts (user interrupted)",
4480
+ pauseSuggestedAction: "You interrupted 5 turns in a row — the goal stays paused until you /goal resume (or /goal cancel).",
4328
4481
  }, ctx);
4329
- ctx.ui.notify("Goal paused: 5 consecutive errors.", "warning");
4330
- notifyExternal(ctx, "Goal paused: 5 consecutive errors.");
4482
+ ctx.ui.notify("Goal paused: 5 consecutive aborts (user interrupted).", "warning");
4483
+ appendLedger(ctx.cwd, "goal_paused", { reason: "5 consecutive aborts (user interrupted)" });
4331
4484
  return;
4332
4485
  }
4333
4486
  } else {
4334
4487
  consecutiveErrorIterations = 0;
4488
+ consecutiveAbortIterations = 0;
4335
4489
  }
4336
4490
 
4337
4491
  // No wall-clock cap by design: a goal ends via completion, explicit
@@ -65,12 +65,14 @@ export function cancelQuotaRetry(): void {
65
65
  /** Schedule a one-shot auto-resume after the quota window. The fire
66
66
  * callback re-checks the goal is STILL paused for the quota reason before
67
67
  * resuming (contract item 10/12 — a user /goal pause during the window
68
- * must not be stomped). */
68
+ * must not be stomped). v0.28.5: `label` generalizes the notify so the
69
+ * 5-consecutive-errors brake can reuse the same capped one-shot machinery. */
69
70
  export function scheduleQuotaRetry(
70
71
  ctx: ExtensionContext,
71
72
  retryAfterSec: number,
72
73
  reason: string,
73
74
  fire: () => void,
75
+ label = "Auditor quota exhausted — auto-retry",
74
76
  ): void {
75
77
  cancelQuotaRetry();
76
78
  const ms = Math.max(1_000, retryAfterSec * 1_000);
@@ -84,7 +86,7 @@ export function scheduleQuotaRetry(
84
86
  }, ms);
85
87
  quotaRetryTimer.unref?.();
86
88
  ctx.ui.notify(
87
- `Auditor quota exhausted — auto-retry in ${Math.round(retryAfterSec / 60)}m (${reason.slice(0, 80)}). /goal resume retries now.`,
89
+ `${label} in ${Math.round(retryAfterSec / 60)}m (${reason.slice(0, 80)}). /goal resume retries now.`,
88
90
  "info",
89
91
  );
90
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.4",
3
+ "version": "0.28.6",
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",
@@ -48,6 +48,7 @@
48
48
  "pauseSuggestedAction": { "type": "string" },
49
49
  "interruptedAt": { "type": "string" },
50
50
  "interruptedReason": { "type": "string" },
51
+ "auditInfraStreak": { "type": "number" },
51
52
  "activePath": { "type": "string" },
52
53
  "archivedPath": { "type": "string" },
53
54
  "usage": {