pi-goal-list-loop-audit 0.34.12 → 0.34.13

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.
@@ -73,6 +73,14 @@ export interface Settings {
73
73
  * assertActive). Default true; only acts when TMUX and TMUX_PANE are
74
74
  * set — pi outside tmux just gets the manual warning. */
75
75
  autoReloadOnStale?: boolean;
76
+ /** v0.34.13: auto-recovery ladder — when a wedge is detected that only a
77
+ * /reload cures (unanswered continuation, send-retry storm), inject the
78
+ * /reload ITSELF via the v0.29.13 tmux/WezTerm transport and resume the
79
+ * goal/loop after the rebuild (sidecar marker — autoresume=off is a
80
+ * restore-time setting, not a recovery veto). Default true. The one
81
+ * class it does NOT cross: pi restart (transcript-writer dead) — that
82
+ * stays a loud stop for the human. */
83
+ autoRecovery?: boolean;
76
84
  /** v0.26.1: consecutive heartbeat refires without a real turn before
77
85
  * the goal pauses / loop stops (default 5; 0 = never escalate). */
78
86
  stallEscalationRefires?: number;
@@ -207,6 +215,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
207
215
  "stuckMaxInterventions",
208
216
  "stallEscalationRefires",
209
217
  "autoReloadOnStale",
218
+ "autoRecovery",
210
219
  "stallShortWords",
211
220
  "stallSimilarityThreshold",
212
221
  "postaudit",
@@ -346,9 +346,9 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
346
346
  * fires from the entry-probe path (the most common stale discovery).
347
347
  * Opt out: autoReloadOnStale=false. Best-effort: the manual warning
348
348
  * already fired, so failures cost nothing. */
349
- function attemptAutoReload(ctx: ExtensionContext, where: string): void {
349
+ function attemptAutoReload(ctx: ExtensionContext, where: string): boolean {
350
350
  try {
351
- if (loadSettings(ctx.cwd).autoReloadOnStale === false) return;
351
+ if (loadSettings(ctx.cwd).autoReloadOnStale === false) return false;
352
352
  const tmuxPane = process.env.TMUX_PANE;
353
353
  const wezPane = process.env.WEZTERM_PANE;
354
354
  let transport: "tmux" | "wezterm";
@@ -372,13 +372,58 @@ function attemptAutoReload(ctx: ExtensionContext, where: string): void {
372
372
  cmd = `wezterm cli send-text --pane-id ${pane} --no-paste '/reload\r'`;
373
373
  } else {
374
374
  appendLedger(ctx.cwd, "auto_reload_skipped", { where, reason: "no supported multiplexer (tmux/WezTerm) in env" });
375
- return;
375
+ return false;
376
376
  }
377
377
  appendLedger(ctx.cwd, "auto_reload_injected", { where, transport, pane });
378
378
  ctx.ui.notify("glla: injecting /reload into this pane — extensions rebuild in place and the fresh instance holds state. /glla resume after (automatic with autoresume=on).", "info");
379
379
  exec(cmd, () => { /* best-effort */ });
380
+ return true;
380
381
  } catch {
381
382
  /* never let recovery take the warning path down */
383
+ return false;
384
+ }
385
+ }
386
+
387
+ /** v0.34.13: one rung of the auto-recovery ladder. Returns true when a
388
+ * /reload was injected (caller should NOT also pause/alert the manual
389
+ * cure). Writes the sidecar resume marker FIRST so the fresh instance
390
+ * resumes the goal/loop even with autoresume=off. Throttled — a second
391
+ * wedge inside the window is the pi-restart class and must reach the
392
+ * user as a loud stop, not another reload. */
393
+ let lastAutoRecoveryAt = 0;
394
+ function attemptAutoRecovery(ctx: ExtensionContext, where: string): boolean {
395
+ if (loadSettings(ctx.cwd).autoRecovery === false) return false;
396
+ const now = Date.now();
397
+ if (now - lastAutoRecoveryAt < AUTO_RECOVERY_THROTTLE_MS) return false;
398
+ // Inject FIRST — the throttle stamp + marker must reflect a real
399
+ // recovery, not a no-transport skip (else the watchdog would mislabel
400
+ // the next wedge as the pi-restart class).
401
+ if (!attemptAutoReload(ctx, where)) return false;
402
+ lastAutoRecoveryAt = now;
403
+ try {
404
+ fs.writeFileSync(
405
+ path.join(piGlaDir(ctx.cwd), RECOVERY_RESUME_MARKER),
406
+ JSON.stringify({ at: new Date(now).toISOString(), where }),
407
+ );
408
+ } catch { /* best-effort — the reload still helps; restore just holds */ }
409
+ appendLedger(ctx.cwd, "auto_recovery_reload", { where });
410
+ ctx.ui.notify(`glla auto-recovery: ${where} — the ${isLoopActive() ? "loop" : "goal/list item"} resumes ITSELF after the rebuild (no /glla resume needed; the sidecar marker carries the consent). If this recurs within ${Math.round(AUTO_RECOVERY_THROTTLE_MS / 60_000)}m it's the pi-restart class and you'll get a loud stop.`, "warning");
411
+ return true;
412
+ }
413
+
414
+ /** v0.34.13: consume the sidecar marker on session restore. Single-use,
415
+ * freshness-bounded — a stale marker from an abandoned recovery must not
416
+ * surprise-resume a later session. */
417
+ function consumeRecoveryResume(cwd: string): boolean {
418
+ try {
419
+ const p = path.join(piGlaDir(cwd), RECOVERY_RESUME_MARKER);
420
+ if (!fs.existsSync(p)) return false;
421
+ const raw = fs.readFileSync(p, "utf-8");
422
+ fs.unlinkSync(p);
423
+ const at = Date.parse((JSON.parse(raw) as { at?: string }).at ?? "");
424
+ return !Number.isNaN(at) && Date.now() - at < RECOVERY_RESUME_FRESH_MS;
425
+ } catch {
426
+ return false;
382
427
  }
383
428
  }
384
429
 
@@ -649,6 +694,17 @@ const CONTINUATION_UNANSWERED_THROTTLE_MS = 300_000;
649
694
  // cycle. Sending 2.5s AFTER agent_end lets teardown settle; the send lands
650
695
  // and the next turn starts immediately. 2.5s per turn beats 60s per turn.
651
696
  const EAGER_CONTINUATION_SETTLE_MS = Number(process.env.GLLA_EAGER_SETTLE_MS ?? 2_500);
697
+ // v0.34.13: auto-recovery ladder ("keep going unless we MUST stop — a
698
+ // question, or done" — user directive 2026-08-01). A wedge that only a
699
+ // /reload cures should /reload ITSELF: inject the keystrokes (v0.29.13
700
+ // transport) with a sidecar marker so the fresh instance RESUMES even when
701
+ // autoresume=off (the consent came from autoRecovery at recovery time, not
702
+ // the restore-time setting). Throttled to one attempt per window: a
703
+ // recurrence inside the window is the transcript-writer-dead class, which
704
+ // only a pi restart cures — that one stays a loud stop for the human.
705
+ const AUTO_RECOVERY_THROTTLE_MS = 600_000;
706
+ const RECOVERY_RESUME_MARKER = "recovery-resume.json";
707
+ const RECOVERY_RESUME_FRESH_MS = 300_000;
652
708
  // v0.29.19: dead-turn caps (agent_end exemption path). 6 consecutive
653
709
  // provider-error turns = a real outage, not bad luck — stop honestly.
654
710
  // 3 consecutive user aborts = the user means it (user aborts mean STOP).
@@ -825,6 +881,10 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
825
881
  const silent = Math.round(SEND_REARM_ESCALATE_SILENT_MS / 60000);
826
882
  appendLedger(ctx.cwd, "send_rearm_escalated", { kind, afterMinutes: mins, silentMinutes: silent });
827
883
  if (kind === "loop" && isLoopActive()) {
884
+ if (attemptAutoRecovery(ctx, "send-retry storm")) {
885
+ appendLedger(ctx.cwd, "send_rearm_escalated_suppressed", { reason: "auto-recovery reload" });
886
+ return;
887
+ }
828
888
  clearLoopTimer();
829
889
  state.loop = { ...state.loop!, active: false, stopReason: `send-retry storm: ${mins}m of re-arms with no session activity for ${silent}m — the session is wedged. Press Escape to cancel the stuck run (pi's own rate-limit retry holds it; pi prints "escape to cancel"), then /loop resume — the loop holds on restore. If still wedged: /reload rebuilds extensions in place, then /loop resume again. Restart pi only if /reload itself fails.` };
830
890
  persistState(ctx);
@@ -848,6 +908,14 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
848
908
  return;
849
909
  }
850
910
  if (state.goal && state.goal.status === "active") {
911
+ // v0.34.13: keep going unless we MUST stop — try the auto-recovery
912
+ // /reload before spending the user's attention on a pause. A reload
913
+ // that fails to cure throttles the next attempt, and the pause below
914
+ // fires then as today.
915
+ if (attemptAutoRecovery(ctx, "send-retry storm")) {
916
+ appendLedger(ctx.cwd, "send_rearm_escalated_suppressed", { reason: "auto-recovery reload" });
917
+ return;
918
+ }
851
919
  updateGoal({
852
920
  status: "paused",
853
921
  pauseKind: "error",
@@ -863,6 +931,12 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
863
931
  if (!shouldEscalateStall(consecutiveStalls, threshold)) return false;
864
932
  consecutiveStalls = 0;
865
933
  appendLedger(ctx.cwd, "stall_escalated", { threshold, kind: isLoopActive() ? "loop" : "goal" });
934
+ // v0.34.13: recovery before stop — usually throttled (the 2.5min
935
+ // watchdog already tried), in which case the stop proceeds as today.
936
+ if (attemptAutoRecovery(ctx, "stall escalation")) {
937
+ appendLedger(ctx.cwd, "stall_escalated_suppressed", { reason: "auto-recovery reload", threshold });
938
+ return true;
939
+ }
866
940
  if (isLoopActive()) {
867
941
  clearLoopTimer();
868
942
  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). Press Escape to cancel any stuck run, then /loop resume — the loop holds on restore. If the handle is stale, /reload rebuilds extensions in place; restart pi only if /reload fails.` };
@@ -972,9 +1046,17 @@ function heartbeatTick(): void {
972
1046
  ) {
973
1047
  lastUnansweredAlertAt = Date.now();
974
1048
  appendLedger(ctx.cwd, "continuation_unanswered", { silentMs: Date.now() - lastContinuationSentAt });
975
- const msg = `glla: pi accepted the continuation ${Math.round((Date.now() - lastContinuationSentAt) / 60_000)}m ago but NO turn has started — no tool calls, no tokens, transcript frozen (the turn trigger is wedged; same pi failure family as the post-compaction blackhole). Re-sends don't unstick it. Cure: /reload — autoresume re-fires the ${isLoopActive() ? "loop" : "goal/list item"} automatically.`;
976
- ctx.ui.notify(msg, "warning");
977
- notifyExternal(ctx, msg);
1049
+ // v0.34.13: recover, don't just report. Only the failure modes reach
1050
+ // the user: throttled (a reload already failed to cure → the
1051
+ // pi-restart class) or recovery unavailable (setting off / no pane).
1052
+ if (!attemptAutoRecovery(ctx, "continuation unanswered")) {
1053
+ const mins = Math.round((Date.now() - lastContinuationSentAt) / 60_000);
1054
+ const msg = lastAutoRecoveryAt > 0
1055
+ ? `glla: auto-recovery /reload did NOT unstick this session — still no turn ${mins}m after the continuation. This class kills pi's transcript writer; only a pi RESTART cures it: restart pi in this tab, then /glla resume (the goal holds in .pi-glla state).`
1056
+ : `glla: pi accepted the continuation ${mins}m ago but NO turn has started — no tool calls, no tokens, transcript frozen (the turn trigger is wedged). Cure: /reload — autoresume re-fires the ${isLoopActive() ? "loop" : "goal/list item"}. (auto-recovery is off or no tmux/WezTerm pane — /glla settings autoRecovery=on enables the automatic form.)`;
1057
+ ctx.ui.notify(msg, "warning");
1058
+ notifyExternal(ctx, msg);
1059
+ }
978
1060
  }
979
1061
  // v0.29.1: stranded-audit recovery. A goal left in "auditing" with NO
980
1062
  // in-flight audit means the auditor's result never landed (wedged queue
@@ -6333,9 +6415,12 @@ export default function (pi: ExtensionAPI): void {
6333
6415
  persistState(ctx);
6334
6416
  appendLedger(ctx.cwd, "audit_loop_target_migrated", { from: "audit-every-iteration", to: "fix-first" });
6335
6417
  }
6418
+ // v0.34.13: an auto-recovery /reload carries its own resume consent —
6419
+ // the sidecar marker overrides autoresume=off for THIS restore only.
6420
+ const recoveryResume = consumeRecoveryResume(ctx.cwd);
6336
6421
  if (isLoopActive()) {
6337
6422
  const l = state.loop!;
6338
- if (autoResume) {
6423
+ if (autoResume || recoveryResume) {
6339
6424
  ctx.ui.notify(
6340
6425
  `Resuming loop (iteration ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"}, best ${l.bestValue ?? "n/a"}, stall ${l.stallCount}/${l.plateauWindow}): ${l.target.slice(0, 60)}`,
6341
6426
  "info",
@@ -6356,7 +6441,7 @@ export default function (pi: ExtensionAPI): void {
6356
6441
  // "load it but not auto start it"). Interrupted goals hold like
6357
6442
  // everything else; autoresume=on (unattended rigs) still auto-resumes
6358
6443
  // them, and the marker is cleared only on that promised auto-resume.
6359
- if (autoResume) {
6444
+ if (autoResume || recoveryResume) {
6360
6445
  // v0.28.1 (S2): clear the stale-handle interrupt marker — this IS
6361
6446
  // the auto-resume the marker promised.
6362
6447
  if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.34.12",
3
+ "version": "0.34.13",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. An isolated extension-less auditor re-verifies every completion with raw evidence; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",