pi-goal-list-loop-audit 0.28.24 → 0.28.26

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,8 @@ export interface HeartbeatInput {
162
162
  /** Milliseconds since the last observed agent activity. */
163
163
  msSinceActivity: number;
164
164
  stallMs?: number;
165
+ /** v0.28.25: consecutive stall refires so far — spaces refires exponentially. */
166
+ consecutiveStalls?: number;
165
167
  }
166
168
 
167
169
  /** Should the heartbeat re-fire the continuation right now? */
@@ -169,7 +171,15 @@ export function shouldHeartbeatRefire(input: HeartbeatInput): boolean {
169
171
  if (!input.supervising) return false;
170
172
  if (!input.sessionIdle) return false;
171
173
  if (input.timerPending) return false;
172
- return input.msSinceActivity >= (input.stallMs ?? HEARTBEAT_STALL_MS);
174
+ // v0.28.25: exponential spacing between stall refires — 1m, 2m, 4m, 8m
175
+ // (cap 8×). Field-observed in junk-runner: the flat 60s gate burned all
176
+ // 5 refires in ~4 minutes into a just-compacted session, pausing a
177
+ // resumable goal instead of giving the provider/queue time to recover.
178
+ // noteActivity() runs at each refire, so msSinceActivity measures the
179
+ // silence SINCE the last refire — scaling the threshold scales the gap.
180
+ const stallMs = input.stallMs ?? HEARTBEAT_STALL_MS;
181
+ const scale = 2 ** Math.min(input.consecutiveStalls ?? 0, 3);
182
+ return input.msSinceActivity >= stallMs * scale;
173
183
  }
174
184
 
175
185
  /**
@@ -176,6 +176,15 @@ export interface Goal {
176
176
  * At 3 the goal pauses loudly — a broken auditor model must not spin a
177
177
  * silent retry-forever loop. Cleared on any real auditor run. */
178
178
  auditInfraStreak?: number;
179
+ /** v0.28.26: the completion claim captured when an audit attempt is
180
+ * quota-blocked. The quota retry re-runs the AUDITOR directly with this
181
+ * stored claim instead of re-engaging the agent — re-engaging produced a
182
+ * hallucinated-closure repetition loop in the field (π-games 2026-07-29:
183
+ * the agent concluded the goal was closed, stopped calling complete_goal,
184
+ * and repeated the same essay until the stall brake fired). Cleared when
185
+ * the retry resolves. Only consumed while paused with an "auditor quota:"
186
+ * reason, so a stale value is unreachable by construction. */
187
+ pendingCompletion?: { completionSummary?: string; verificationSummary?: string; at: string };
179
188
  /** v0.25.0 (contract item 22): auditor objections extracted as TODOs when
180
189
  * aggressiveMode keeps the goal active past the disapproval cap. Rendered
181
190
  * into every continuation prompt until the next audit clears them. */
@@ -471,6 +471,17 @@ let loopRearmStreak = 0;
471
471
  // after the compact instead of giving pi room to recover.
472
472
  let compactionGraceUntil = 0;
473
473
  const COMPACTION_GRACE_MS = 3 * 60_000;
474
+ // v0.28.25: provider-error retry cadence. Field-observed in dracon-utilities
475
+ // (kimi, 19-session fleet on one provider account): a "concurrent request
476
+ // limit" 403 storm got 5 retries BACK-TO-BACK (delay 0 after each errored
477
+ // turn — the session is idle at agent_end, so scheduleContinuation fired
478
+ // instantly) and the brake then cycled on a flat 60s cooldown for 1h 38m.
479
+ // The condition clears on a minutes-to-fleet scale, not milliseconds:
480
+ // ladder the inter-error retries (5s, 15s, 45s, 90s, 3m — the 5-retry
481
+ // budget now spans ~5.5m) and escalate the brake cooldown per consecutive
482
+ // brake (1m, 2m, 4m, 8m, 16m cap). A successful turn resets both.
483
+ const ERROR_RETRY_LADDER_MS = [5_000, 15_000, 45_000, 90_000, 180_000];
484
+ let errorBrakeStreak = 0;
474
485
  const SEND_REARM_LEDGER_EVERY = 600; // 600 × 50ms = 30s
475
486
  const SEND_REARM_ESCALATE_AT = 6000; // 6000 × 50ms = 5 minutes
476
487
 
@@ -592,6 +603,7 @@ function heartbeatTick(): void {
592
603
  timerPending: continuationTimer !== null || loopTimer !== null,
593
604
  msSinceActivity: Date.now() - lastActivityAt,
594
605
  stallMs: HEARTBEAT_STALL_MS,
606
+ consecutiveStalls,
595
607
  });
596
608
  // Wedge alert (v0.23.2): session BUSY but silent for the threshold —
597
609
  // the classic hung-command case (a test suite that never exits holds
@@ -688,7 +700,7 @@ function freshCtx(): ExtensionContext | null {
688
700
  }
689
701
  }
690
702
 
691
- function scheduleContinuation(ctx: ExtensionContext, force = false): void {
703
+ function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: number): void {
692
704
  if (!isActionableGoal()) return;
693
705
  rememberCtx(ctx);
694
706
  const goalId = state.goal!.id;
@@ -696,7 +708,7 @@ function scheduleContinuation(ctx: ExtensionContext, force = false): void {
696
708
  clearContinuationTimer();
697
709
  let delay = 0;
698
710
  try {
699
- delay = ctx.isIdle() && !ctx.hasPendingMessages() ? 0 : BACKOFF_IDLE_RETRY_MS;
711
+ delay = delayMs ?? (ctx.isIdle() && !ctx.hasPendingMessages() ? 0 : BACKOFF_IDLE_RETRY_MS);
700
712
  } catch {
701
713
  return;
702
714
  }
@@ -935,6 +947,146 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
935
947
  }
936
948
  }
937
949
 
950
+ /**
951
+ * v0.28.26: quota-window retry for a STORED completion claim. The auditor
952
+ * was quota-blocked at complete_goal time; the claim (completionSummary +
953
+ * verificationSummary) was persisted on the goal, and when the quota window
954
+ * elapses we re-run the AUDITOR directly — no agent turn. Re-engaging the
955
+ * agent to re-submit an unchanged claim produced a hallucinated-closure
956
+ * repetition loop in the field (π-games: the model concluded the goal was
957
+ * closed, repeated the same essay 4×+, stormed continuations, compacted 14×
958
+ * in 35 minutes, and burned the stall brake).
959
+ *
960
+ * Outcomes: approved → close + cascade (archiveCurrentGoal handles list
961
+ * advance + reviewer); quota again → re-pause with a fresh scheduled retry
962
+ * (claim preserved); anything else (disapproved, impossible, non-quota
963
+ * infra) → hand back to the agent: resume active + continuation, verdict
964
+ * durable in auditHistory.
965
+ */
966
+ async function retryStoredCompletionAudit(ctx: ExtensionContext): Promise<void> {
967
+ const goal = state.goal;
968
+ if (!goal?.pendingCompletion) return;
969
+ if (completionAuditInFlight) return;
970
+ const liveCtx = freshCtx() ?? ctx;
971
+ const claim = goal.pendingCompletion;
972
+ updateGoal({ status: "auditing" }, liveCtx);
973
+ appendLedger(liveCtx.cwd, "goal_resumed", { via: "quota-retry-direct-audit" });
974
+ liveCtx.ui.notify("Auditor quota window elapsed — retrying the audit with your stored completion claim (no agent turn needed).", "info");
975
+ const settings = loadSettings(liveCtx.cwd);
976
+ const { model: auditorModel, error: modelError, via } = resolveAuditorModel(liveCtx, settings.auditorModel);
977
+ if (modelError) liveCtx.ui.notify(`Auditor model issue: ${modelError}`, "warning");
978
+ latestAuditProgress = { label: "quota-retry", lastEventAt: Date.now() };
979
+ completionAuditInFlight = true;
980
+ const auditStartMs = Date.now();
981
+ let result: Awaited<ReturnType<typeof runGoalCompletionAuditor>>;
982
+ try {
983
+ ({ result } = await runWithInfraRetry(
984
+ () =>
985
+ runGoalCompletionAuditor({
986
+ ctx: liveCtx,
987
+ goal: state.goal!,
988
+ completionSummary: claim.completionSummary,
989
+ verificationSummary: claim.verificationSummary,
990
+ model: auditorModel,
991
+ thinkingLevel: settings.auditorThinkingLevel ?? getSessionThinkingLevel(),
992
+ onProgress: (progress) => {
993
+ latestAuditProgress = { currentTool: progress.currentTool, label: progress.label, elapsedMs: progress.elapsedMs, lastEventAt: Date.now() };
994
+ refreshUI(liveCtx);
995
+ },
996
+ }),
997
+ { onRetry: (err) => appendLedger(liveCtx.cwd, "audit_infra_retry", { goalId: state.goal?.id, error: err.slice(0, 200) }) },
998
+ ));
999
+ } finally {
1000
+ completionAuditInFlight = false;
1001
+ latestAuditProgress = null;
1002
+ }
1003
+ if (!state.goal) return; // aborted mid-audit
1004
+
1005
+ // Record the run in history (same compact shape as the tool path).
1006
+ const auditorRan = result.output.trim().length > 0;
1007
+ const history = state.goal.auditHistory ?? [];
1008
+ if (auditorRan) {
1009
+ result.output = stripThinkBlocks(result.output);
1010
+ history.push({
1011
+ at: nowIso(),
1012
+ approved: result.approved,
1013
+ disapproved: result.disapproved,
1014
+ impossible: result.impossible,
1015
+ impossibleReason: result.impossibleReason,
1016
+ model: result.model,
1017
+ thinkingLevel: result.thinkingLevel,
1018
+ report: result.output,
1019
+ error: result.error,
1020
+ regressionShieldPassed: result.regressionShieldPassed,
1021
+ regressionShieldMissing: result.regressionShieldMissing,
1022
+ durationMs: Date.now() - auditStartMs,
1023
+ } as any);
1024
+ if (history.length > 20) history.splice(0, history.length - 20);
1025
+ }
1026
+
1027
+ if (result.approved) {
1028
+ updateGoal({ auditHistory: history, pendingCompletion: undefined }, liveCtx);
1029
+ const objective = state.goal.objective;
1030
+ archiveCurrentGoal(liveCtx, "complete", `auditor ${result.model} approved (quota-retry)`);
1031
+ liveCtx.ui.notify(`Goal complete — auditor ${result.model} approved on the quota retry.`, "info");
1032
+ notifyExternal(liveCtx, `Goal complete (auditor approved on quota-retry): ${objective.slice(0, 120)}`);
1033
+ return;
1034
+ }
1035
+
1036
+ if (result.error && !result.disapproved && isQuotaError(result.error)) {
1037
+ // Still quota'd — re-pause with a fresh window, claim preserved.
1038
+ const settingsNow = loadSettings(liveCtx.cwd);
1039
+ const defaultSec = (settingsNow.quotaRetryMinutes ?? DEFAULT_QUOTA_RETRY_MINUTES) * 60;
1040
+ const quota = parseQuotaError(result.error, defaultSec);
1041
+ const retryMin = Math.max(1, Math.round(quota.retryAfterSec / 60));
1042
+ updateGoal({
1043
+ status: "paused",
1044
+ auditHistory: history,
1045
+ pauseKind: "wait",
1046
+ pauseResumeAt: new Date(Date.now() + quota.retryAfterSec * 1000).toISOString(),
1047
+ pauseReason: `auditor quota: ${result.error}`,
1048
+ pauseSuggestedAction: `Quota auto-retry in ${retryMin}m — or /goal resume to retry now`,
1049
+ }, liveCtx);
1050
+ appendLedger(liveCtx.cwd, "goal_paused", { reason: `auditor quota: retry in ${quota.retryAfterSec}s (stored-claim retry)` });
1051
+ liveCtx.ui.notify(`Auditor still quota-limited — next auto-retry in ${retryMin}m (your completion claim is stored; no action needed).`, "warning");
1052
+ scheduleQuotaRetry(liveCtx, quota.retryAfterSec, result.error, () => {
1053
+ if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("auditor quota:") && state.goal.pendingCompletion) {
1054
+ void retryStoredCompletionAudit(liveCtx);
1055
+ }
1056
+ });
1057
+ return;
1058
+ }
1059
+
1060
+ // Any other outcome — disapproved, impossible, non-quota infra — belongs
1061
+ // to the agent: resume and let the continuation drive the next step. The
1062
+ // verdict is durable in auditHistory + /goal status.
1063
+ updateGoal({
1064
+ status: "active",
1065
+ auditHistory: history,
1066
+ pendingCompletion: undefined,
1067
+ pauseReason: result.disapproved
1068
+ ? `auditor disapproved on quota-retry — see /goal status`
1069
+ : result.impossible
1070
+ ? `auditor verdict: IMPOSSIBLE on quota-retry — ${(result.impossibleReason ?? "").slice(0, 120)}`
1071
+ : `auditor infrastructure error on quota-retry: ${(result.error ?? "").slice(0, 120)}`,
1072
+ }, liveCtx);
1073
+ liveCtx.ui.notify(
1074
+ result.disapproved
1075
+ ? `Auditor (quota-retry) DISAPPROVED — resuming; the report is in /goal status.`
1076
+ : result.impossible
1077
+ ? `Auditor (quota-retry): goal IMPOSSIBLE — ${(result.impossibleReason ?? "").slice(0, 100)}. Resuming; consider /goal tweak.`
1078
+ : `Auditor (quota-retry) hit an infrastructure error — resuming; re-call complete_goal when ready.`,
1079
+ "warning",
1080
+ );
1081
+ appendLedger(liveCtx.cwd, "quota_retry_audit_verdict", {
1082
+ approved: false,
1083
+ disapproved: result.disapproved,
1084
+ impossible: result.impossible,
1085
+ error: result.error?.slice(0, 160),
1086
+ });
1087
+ scheduleContinuation(liveCtx, true);
1088
+ }
1089
+
938
1090
  /**
939
1091
  * v0.26.0: bind the reviewer to the live session. Sources for finding
940
1092
  * extraction: the archived goal markdown + its audit reports + the
@@ -2562,6 +2714,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2562
2714
  status: "paused",
2563
2715
  auditHistory: history,
2564
2716
  auditInfraStreak: undefined, // quota reached the auditor — infra streak broken
2717
+ // v0.28.26: store the claim — the quota retry re-runs the
2718
+ // auditor DIRECTLY with it (no agent turn to confuse).
2719
+ pendingCompletion: { completionSummary: p.completionSummary, verificationSummary: p.verificationSummary, at: nowIso() },
2565
2720
  pauseKind: "wait",
2566
2721
  pauseResumeAt: new Date(Date.now() + quota.retryAfterSec * 1000).toISOString(),
2567
2722
  pauseReason: `auditor quota: ${result.error}`,
@@ -2572,6 +2727,13 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2572
2727
  // Re-check: only auto-resume if STILL paused for the quota
2573
2728
  // reason (a user /goal pause during the window is not stomped).
2574
2729
  if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("auditor quota:")) {
2730
+ // v0.28.26: a stored claim retries the AUDITOR directly — the
2731
+ // agent is not needed to re-submit an unchanged claim, and
2732
+ // re-engaging it produced hallucinated-closure loops.
2733
+ if (state.goal.pendingCompletion) {
2734
+ void retryStoredCompletionAudit(ctx);
2735
+ return;
2736
+ }
2575
2737
  updateGoal({ status: "active" }, ctx);
2576
2738
  appendLedger(ctx.cwd, "goal_resumed", { via: "quota-retry" });
2577
2739
  if (resolveEffectiveAggressiveSettings(loadSettings(ctx.cwd)).aggressiveMode) {
@@ -4821,33 +4983,46 @@ export default function (pi: ExtensionAPI): void {
4821
4983
  if (consecutiveErrorIterations >= 5) {
4822
4984
  // v0.28.5 (E8): carry the REAL error text — the pause used to say
4823
4985
  // literally "5 consecutive errors: error" (stopReason, not the
4824
- // provider error). And give transient flakes ONE capped auto-resume
4825
- // (60s, reason re-checked) — the E8 incident lost 1.5h to a
4986
+ // provider error). And give transient flakes ONE auto-resume per brake
4987
+ // (escalating cooldown, reason re-checked) — the E8 incident lost 1.5h to a
4826
4988
  // 60-second provider hiccup waiting on a manual /goal resume.
4827
4989
  const detail = text.trim() ? ` (last: ${text.trim().replace(/\s+/g, " ").slice(0, 160)})` : "";
4828
4990
  const reason = `5 consecutive errors${detail}`;
4991
+ // v0.28.25: the cooldown escalates per CONSECUTIVE brake — a fleet-wide
4992
+ // 403 window is not cleared by re-braking every 60 seconds.
4993
+ const cooldownMs = 60_000 * 2 ** Math.min(errorBrakeStreak, 4);
4994
+ const cooldownMin = Math.round(cooldownMs / 60_000);
4995
+ errorBrakeStreak++;
4829
4996
  updateGoal({
4830
4997
  status: "paused",
4831
4998
  pauseKind: "wait",
4832
- pauseResumeAt: new Date(Date.now() + 60_000).toISOString(),
4999
+ pauseResumeAt: new Date(Date.now() + cooldownMs).toISOString(),
4833
5000
  pauseReason: reason,
4834
- pauseSuggestedAction: "Transient provider flake? The goal auto-resumes once in 60s if still paused for this reason — or /goal resume now.",
5001
+ pauseSuggestedAction: `Transient provider flake? The goal auto-resumes once in ${cooldownMin}m if still paused for this reason — or /goal resume now.`,
4835
5002
  }, ctx);
4836
5003
  ctx.ui.notify(`Goal paused: ${reason}.`, "warning");
4837
5004
  notifyExternal(ctx, `Goal paused: ${reason}.`);
4838
5005
  appendLedger(ctx.cwd, "goal_paused", { reason });
4839
- scheduleQuotaRetry(ctx, 60, reason, () => {
5006
+ scheduleQuotaRetry(ctx, cooldownMs / 1000, reason, () => {
4840
5007
  // Re-check: only auto-resume if STILL paused for the error brake
4841
5008
  // (a user /goal pause during the window is not stomped).
4842
5009
  if (state.goal && state.goal.status === "paused" && (state.goal.pauseReason ?? "").startsWith("5 consecutive errors")) {
4843
5010
  updateGoal({ status: "active" }, ctx);
4844
5011
  appendLedger(ctx.cwd, "goal_resumed", { via: "error-brake-retry" });
4845
- ctx.ui.notify("Auto-resumed after the 5-error brake (60s cooldown).", "info");
5012
+ ctx.ui.notify("Auto-resumed after the 5-error brake (cooldown elapsed).", "info");
4846
5013
  scheduleContinuation(ctx, true);
4847
5014
  }
4848
5015
  }, "5 consecutive errors — auto-retry");
4849
5016
  return;
4850
5017
  }
5018
+ // v0.28.25: under the brake, the retry rides the exponential ladder —
5019
+ // NOT the immediate scheduleContinuation at the bottom of this handler
5020
+ // (an errored turn leaves the session idle, so the default delay is 0:
5021
+ // exactly how 5 retries fired back-to-back in dracon-utilities).
5022
+ const retryDelayMs = ERROR_RETRY_LADDER_MS[Math.min(consecutiveErrorIterations - 1, ERROR_RETRY_LADDER_MS.length - 1)];
5023
+ appendLedger(ctx.cwd, "error_retry_backoff", { attempt: consecutiveErrorIterations, delayMs: retryDelayMs });
5024
+ scheduleContinuation(ctx, true, retryDelayMs);
5025
+ return;
4851
5026
  } else if (stopReason === "aborted") {
4852
5027
  // v0.28.5 (E8): user aborts are not provider errors. Separate brake,
4853
5028
  // honest message, and NO auto-resume — aborting five turns in a row
@@ -4868,6 +5043,7 @@ export default function (pi: ExtensionAPI): void {
4868
5043
  } else {
4869
5044
  consecutiveErrorIterations = 0;
4870
5045
  consecutiveAbortIterations = 0;
5046
+ errorBrakeStreak = 0; // v0.28.25: a healthy turn clears the brake cooldown
4871
5047
  }
4872
5048
 
4873
5049
  // No wall-clock cap by design: a goal ends via completion, explicit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.28.24",
3
+ "version": "0.28.26",
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",
@@ -53,6 +53,7 @@
53
53
  "interruptedAt": { "type": "string" },
54
54
  "interruptedReason": { "type": "string" },
55
55
  "auditInfraStreak": { "type": "number" },
56
+ "pendingCompletion": { "type": "object" },
56
57
  "activePath": { "type": "string" },
57
58
  "archivedPath": { "type": "string" },
58
59
  "usage": {