pi-goal-list-loop-audit 0.35.69 → 0.35.71

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.71 — bounded Pi-core retry containment (2026-08-26)
4
+
5
+ ### Changed
6
+ GLLA now exposes a configurable zero-stream retry budget (default 3,
7
+ allowed range 0–10). When Pi remains BUSY without stream activity, GLLA
8
+ still aborts and durably parks the owner, then re-dispatches repeatedly
9
+ within that finite budget before requiring explicit resume. A real stream
10
+ starts a fresh budget; supervisor pause, generation fences, and durable
11
+ recovery semantics remain intact. This contains the Pi-core retry-sleeper
12
+ failure at GLLA's public boundary without modifying Pi or any other plugin.
13
+
14
+ ### Tests
15
+ Added repeated-silence exhaustion, budget persistence/validation, settings
16
+ menu/editor coverage, and existing lifecycle/park/no-storm regressions.
17
+
18
+ ## 0.35.70 — optional-provider boundary and recovery alignment (2026-08-26)
19
+
20
+ ### Changed
21
+ GLLA now delegates the managed subagent agent-directory lookup to pi's
22
+ host resolver, honoring custom `PI_CODING_AGENT_DIR` values instead of
23
+ assuming `~/.pi/agent`. The core extension remains independent of the
24
+ optional `@tintinweb/pi-subagents` provider.
25
+
26
+ ### Tests
27
+ Added a genuine no-provider load smoke test that forbids the optional
28
+ provider import while loading the extension, plus custom-agent-directory
29
+ coverage. The existing context-overflow recovery path remains the single
30
+ configured fallback route; no hardcoded free model was introduced.
31
+
3
32
  ## 0.35.69 — metricless-loop cadence (2026-08-26)
4
33
 
5
34
  ### Added
package/INSTALL.md CHANGED
@@ -147,7 +147,10 @@ Useful controls:
147
147
  ```
148
148
 
149
149
  `/glla pause` freezes supervisor automation without killing active work.
150
- `/glla resume` releases it. A user abort means stop; recovery is not silently
150
+ `/glla resume` releases it. A BUSY/no-stream Pi turn is aborted and parked by
151
+ GLLA, then automatically re-dispatched within the **Zero-stream retries**
152
+ budget (default 3, configurable from 0–10); exhaustion requires an explicit
153
+ mode-correct resume. A user abort means stop; recovery is not silently
151
154
  re-fired behind your back.
152
155
 
153
156
  ## Auditor model requirement
package/README.md CHANGED
@@ -329,7 +329,9 @@ that silence means success. Error text is **not trusted** to pick a retry policy
329
329
  failure wording is retained as bounded diagnostics, not interpreted as
330
330
  proof of a quota or billing state.
331
331
 
332
- - automatic retries are bounded and visible;
332
+ - automatic retries are bounded and visible; a BUSY/no-stream turn is parked
333
+ and re-dispatched within the configurable **Zero-stream retries** budget
334
+ (default 3, range 0–10), then requires explicit resume;
333
335
  - `/goal resume`, `/list resume`, and `/loop resume` are explicit recovery
334
336
  paths;
335
337
  - a user abort means stop, not “try again behind my back”;
@@ -358,6 +360,8 @@ Open `/glla` for the settings table. The most important choices are:
358
360
  choices win;
359
361
  - **Subagent hang escalation:** warning-only at `0`, or one child-specific
360
362
  action after a confirmed frozen interval;
363
+ - **Zero-stream retries:** automatic GLLA recovery attempts after a busy,
364
+ stream-silent Pi turn; `0` keeps recovery manual and `1–10` bounds repeats;
361
365
  - **Audit cap and retry cadence:** bounds for repeated objections and
362
366
  infrastructure recovery.
363
367
 
package/docs/DESIGN.md CHANGED
@@ -354,6 +354,23 @@ replacement without delivering a successor `session_start`:
354
354
  and normalize old files. Canonical persisted state uses `retry-waiting`,
355
355
  `retryAttempts`, `retryFirstAt`, and `retryUntil`.
356
356
 
357
+ ## Addendum v0.35.71 (GLLA-only external-failure containment)
358
+
359
+ GLLA is a workflow supervisor, not a maintenance fork of its host ecosystem.
360
+ The repository changes only GLLA-owned code. Pi-core, OS, provider, and
361
+ third-party-plugin failures may be observed through public hooks and contained
362
+ by durable GLLA state (for example, a BUSY/no-stream turn can be aborted,
363
+ parked, and retried within a finite configured budget), but GLLA never edits
364
+ those external implementations or claims to repair them. Reports whose only
365
+ code lives outside GLLA are explicitly documented and disposed of as
366
+ out-of-scope.
367
+
368
+ The zero-stream budget defaults to three automatic re-dispatches and accepts
369
+ 0–10. Each uninterrupted silent attempt consumes one slot; a real stream
370
+ starts a fresh episode. Exhaustion leaves the goal/list item/loop parked for
371
+ explicit resume. Generation, supervisor-pause, persistence, and dispatch
372
+ ownership fences remain authoritative throughout.
373
+
357
374
  ## Addendum v0.35.0 (long-running judgment, Designer, and drafting chain)
358
375
 
359
376
  - **Judgment is a prompt-level contract**: drafting and continuation preserve
@@ -2413,6 +2413,7 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
2413
2413
  fmt("aggressiveMode", "aggressiveMode"),
2414
2414
  fmt("stuckMaxInterventions", "stuckMaxInterventions"),
2415
2415
  fmt("stallEscalationRefires", "stallEscalation"),
2416
+ fmt("zombieRetryMaxAttempts", "zombieRetryMaxAttempts"),
2416
2417
  fmt("subagentHangEscalationMinutes", "subagentHangActionMinutes"),
2417
2418
  fmt("wedgeAlertMinutes", "wedgeAlert"),
2418
2419
  fmt("stallShortWords", "stallShortWords"),
@@ -1036,7 +1036,7 @@ function heartbeatTick(): void {
1036
1036
  // v0.29.16/v0.35.x: zombie-run watchdog. pi reports BUSY (a run is
1037
1037
  // "active") but zero stream events for the warning window — the provider
1038
1038
  // stream has likely hung silently, and queued continuations cannot land.
1039
- // Keep the first warning human-readable, then apply one bounded automatic
1039
+ // Keep the first warning human-readable, then apply bounded automatic
1040
1040
  // abort/cleanup after the grace window. A notification-only Esc instruction
1041
1041
  // left list items ACTIVE for 85–96 minutes in the field; the bounded abort
1042
1042
  // now parks the item and leaves /list resume + /list cancel as explicit,
@@ -248,11 +248,17 @@ export function accountTurnForNudgesRich(
248
248
  // Field (note.md Next §1, screenshot 20260821_152311): turns dispatched by
249
249
  // ACCEPTING a Confirm dialog hang with zero provider stream activity often
250
250
  // enough that users repeatedly return to "action needed - this won't fix
251
- // itself" parks. The watchdog's abort is correct; the missing half is ONE
252
- // bounded automatic re-dispatch per silence streak. This pure decision is
253
- // the one-retry bound; the timer that consumes it lives in goal-activation.ts.
251
+ // itself" parks. The watchdog's abort is correct; the missing half is a
252
+ // finite repeated re-dispatch budget per silence streak. This pure decision
253
+ // is consumed by the timer in goal-activation.ts.
254
254
 
255
255
  export const ZOMBIE_RETRY_DELAY_MS = 90_000;
256
+ /** Maximum automatic re-dispatches after one uninterrupted zero-stream
257
+ * episode. Three attempts give a transient Pi-core retry sleeper several
258
+ * chances to recover without turning the watchdog into an infinite loop. */
259
+ export const DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS = 3;
260
+ /** Hand-edited settings are bounded to keep recovery finite and reviewable. */
261
+ export const MAX_ZOMBIE_RETRY_ATTEMPTS = 10;
256
262
 
257
263
  export interface ZombieRetryStreak {
258
264
  key: string;
@@ -262,21 +268,26 @@ export interface ZombieRetryStreak {
262
268
  }
263
269
 
264
270
  /** A new owner key OR stream activity newer than the recorded abort point
265
- * starts a fresh streak; otherwise the streak deepens and the SECOND
266
- * consecutive silence is refused (no retry storm). A retried turn that
267
- * actually streams advances lastStreamActivityAt past the previous abort's
268
- * observation point, so any LATER independent hang earns its own single
269
- * retry. */
271
+ * starts a fresh streak; otherwise the streak deepens until the caller's
272
+ * finite retry budget is exhausted. A retried turn that actually streams
273
+ * advances lastStreamActivityAt past the previous abort's observation point,
274
+ * so any later independent hang earns a fresh budget. */
270
275
  export function zombieRetryDecision(
271
276
  observedStreamAt: number,
272
277
  key: string,
273
278
  prev: ZombieRetryStreak,
279
+ maxAttempts = DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
274
280
  ): { retry: boolean; streak: ZombieRetryStreak } {
281
+ const limit = Number.isInteger(maxAttempts) && maxAttempts >= 0
282
+ ? Math.min(maxAttempts, MAX_ZOMBIE_RETRY_ATTEMPTS)
283
+ : DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
275
284
  const sameEpisode = key === prev.key && observedStreamAt <= prev.lastAbortStreamAt;
285
+ // Keep one terminal refusal count beyond the retry budget so repeated
286
+ // heartbeat observations remain cheap and deterministic.
276
287
  const streak: ZombieRetryStreak = sameEpisode
277
- ? { key, count: Math.min(prev.count + 1, 2), lastAbortStreamAt: observedStreamAt }
288
+ ? { key, count: Math.min(prev.count + 1, MAX_ZOMBIE_RETRY_ATTEMPTS + 1), lastAbortStreamAt: observedStreamAt }
278
289
  : { key, count: 1, lastAbortStreamAt: observedStreamAt };
279
- return { retry: streak.count === 1, streak };
290
+ return { retry: streak.count <= limit, streak };
280
291
  }
281
292
 
282
293
  // =================================================================
@@ -25,8 +25,8 @@
25
25
  // to add embedded copies here.
26
26
 
27
27
  import * as fs from "node:fs";
28
- import * as os from "node:os";
29
28
  import * as path from "node:path";
29
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
30
30
 
31
31
  /** Frontmatter marker identifying files this module wrote. Files without it
32
32
  * are user-owned: never modified, never deleted. */
@@ -178,9 +178,11 @@ const EMBEDDED_DEFAULTS: Record<string, EmbeddedAgentDefault> = {
178
178
  export const OVERRIDABLE_AGENT_TYPES = Object.keys(EMBEDDED_DEFAULTS);
179
179
 
180
180
  /** Default global agent dir (pi-subagents reads $PI_CODING_AGENT_DIR/agents,
181
- * default ~/.pi/agent/agents). Parameterized in sync for tests. */
181
+ * default ~/.pi/agent/agents). Delegate to pi's runtime resolver so custom
182
+ * `PI_CODING_AGENT_DIR` and future app-specific config-dir names stay aligned
183
+ * with the host rather than being silently ignored by glla. */
182
184
  export function defaultAgentDir(): string {
183
- return path.join(os.homedir(), ".pi", "agent");
185
+ return getAgentDir();
184
186
  }
185
187
 
186
188
  /** Build the override .md file content. With `model`, the pin is written;
@@ -30,6 +30,10 @@ import {
30
30
  DEFAULT_MAIN_MODEL_PRIMARY_PROBE_MINUTES,
31
31
  normalizeMainModelFallbackRefs,
32
32
  } from "./main-model-recovery.js";
33
+ import {
34
+ DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
35
+ MAX_ZOMBIE_RETRY_ATTEMPTS,
36
+ } from "./goal-loop-backoff.js";
33
37
 
34
38
  export interface Settings {
35
39
  /** Where glla's durable state directory lives. This is global-only because
@@ -169,6 +173,9 @@ export interface Settings {
169
173
  /** v0.27.3: a turn with no tool calls AND fewer words than this is a
170
174
  * nudge. Default 15 words. Higher = stricter (more pauses). */
171
175
  stallShortWords?: number;
176
+ /** Maximum automatic re-dispatches after one uninterrupted busy/no-stream
177
+ * episode (default 3; 0 = manual resume only; max 10). */
178
+ zombieRetryMaxAttempts?: number;
172
179
  /** v0.27.3: a turn with no tool calls whose text trigram-similarity to
173
180
  * the prior assistant turn exceeds this is a nudge. Default 0.6. Higher
174
181
  * = stricter (more pauses). */
@@ -281,6 +288,9 @@ export const DEFAULT_SETTINGS: Settings = {
281
288
  // v0.35.64: warn at the short detection thresholds, then take one
282
289
  // child-specific action after a much longer confirmed frozen interval.
283
290
  subagentHangEscalationMinutes: 30,
291
+ // v0.35.x: repeated Pi-core busy/no-stream recovery is useful for a
292
+ // transient retry sleeper, but must remain finite and user-configurable.
293
+ zombieRetryMaxAttempts: DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
284
294
  };
285
295
 
286
296
  // Re-exported for compatibility; the dependency-free state-root module owns
@@ -329,6 +339,12 @@ function normalizeLoadedSettings(settings: Settings): Settings {
329
339
  || (settings.subagentHangEscalationMinutes !== 0 && settings.subagentHangEscalationMinutes < 5)) {
330
340
  settings.subagentHangEscalationMinutes = 30;
331
341
  }
342
+ if (typeof settings.zombieRetryMaxAttempts !== "number"
343
+ || !Number.isInteger(settings.zombieRetryMaxAttempts)
344
+ || settings.zombieRetryMaxAttempts < 0
345
+ || settings.zombieRetryMaxAttempts > MAX_ZOMBIE_RETRY_ATTEMPTS) {
346
+ settings.zombieRetryMaxAttempts = DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
347
+ }
332
348
  // v0.34.142: these old policy knobs no longer control recovery. Drop
333
349
  // them from the effective object so stale files cannot resurrect the old
334
350
  // behavior or make the settings UI imply that quota inspection exists.
@@ -416,6 +432,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
416
432
  "stuckMaxInterventions",
417
433
  "subagentHangEscalationMinutes",
418
434
  "stallEscalationRefires",
435
+ "zombieRetryMaxAttempts",
419
436
  "stallShortWords",
420
437
  "stallSimilarityThreshold",
421
438
  "postaudit",
@@ -350,6 +350,7 @@ import {
350
350
  PENDING_LATCH_STUCK_MS,
351
351
  shouldFirePendingLatchWatchdog,
352
352
  AUDITOR_WALL_TIMEOUT_MS,
353
+ DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
353
354
  ZOMBIE_RETRY_DELAY_MS,
354
355
  zombieRetryDecision,
355
356
  type ZombieRetryStreak,
@@ -427,29 +428,20 @@ export function enqueueFaultRepairTask(ctx: ExtensionContext, objective: string,
427
428
  appendLedger(ctx.cwd, "faulty_objective_repair_promoted", { goalId: repair.id, targetId: target?.id, position: 1, source: target?.source ?? "unknown" });
428
429
  }
429
430
 
430
- /** v0.35.17 — bounded automatic retry after a confirmed zero-stream abort.
431
+ /** v0.35.x — bounded repeated automatic recovery after a confirmed
432
+ * zero-stream abort.
431
433
  *
432
- * Field evidence (note.md Next §1, screenshot 20260821_152311): turns
433
- * dispatched by ACCEPTING a Confirm dialog hang with zero provider stream
434
- * activity often enough that users repeatedly return to "Goal paused after
435
- * 30m ... action needed - this won't fix itself" sessions. The watchdog's
436
- * abort is correct (it stops the token bleed); what was missing is the
437
- * self-heal half. This adds ONE bounded automatic re-dispatch per silent
438
- * streak, mirroring main-model recovery's timer pattern:
434
+ * A Pi-core retry sleeper can keep the host BUSY long after the useful stream
435
+ * has stopped. GLLA owns the safe boundary: abort the stale host turn, park
436
+ * durable work, and re-dispatch while the configured zero-stream retry budget
437
+ * remains. A real stream resets the episode; uninterrupted silence eventually
438
+ * reaches the budget and stays parked instead of producing an infinite retry
439
+ * storm.
439
440
  *
440
- * - exactly ONE automatic retry per zero-stream STREAK: a retried turn
441
- * that actually streams advances lastStreamActivityAt past the previous
442
- * abort's observation point, so any LATER independent hang starts a
443
- * fresh streak and earns its own single retry;
444
- * - a SECOND consecutive silence (retry hung too, or the original turn
445
- * never streamed again) parks permanently — no retry storm;
446
- * - `/glla pause` freezes the retry like every other automatic
447
- * side-effect: the parked claim stays durable for manual resume;
448
- * - manual /goal resume · /loop resume remain available at all times.
449
- *
450
- * The streak counter is process-memory by design: if the session restarts
451
- * inside the retry window the park stands and manual resume applies — an
452
- * honest degradation, not a phantom promise.
441
+ * `/glla pause` freezes the retry like every other automatic side-effect, and
442
+ * manual /goal resume · /loop resume remain available at all times. The streak
443
+ * counter is process-memory by design: a session restart inside the retry
444
+ * window leaves the park standing for manual resume.
453
445
  */
454
446
  const ZOMBIE_PAUSE_REASON = "automatic zero-stream abort — no provider activity was observed";
455
447
 
@@ -466,25 +458,32 @@ function clearInBandProviderFailure(): void {
466
458
  inBandProviderFailureRaw = null;
467
459
  }
468
460
 
469
- /** Arm the one-shot automatic re-dispatch after a successful zombie abort.
470
- * Returns true when a retry was scheduled (the caller adjusts its user-facing
471
- * copy accordingly). */
461
+ /** Arm the next automatic re-dispatch after a successful zombie abort.
462
+ * The configured retry budget keeps repeated recovery finite; the caller gets
463
+ * the attempt number so user-facing copy and ledger evidence stay truthful. */
472
464
  function scheduleZombieAutoRetry(
473
465
  ctx: ExtensionContext,
474
466
  generation: number,
475
467
  goalId: string | undefined,
476
468
  observedStreamAt: number,
477
469
  silentMinutes: number,
478
- ): boolean {
470
+ ): { scheduled: boolean; attempt: number; maxAttempts: number } {
479
471
  const key = goalId ?? "loop";
480
- const { retry, streak } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak);
472
+ const maxAttempts = zombieRetryMaxAttemptsOverride
473
+ ?? loadSettings(ctx.cwd).zombieRetryMaxAttempts
474
+ ?? DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
475
+ const { retry, streak } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak, maxAttempts);
481
476
  zombieRetryStreak = streak;
477
+ const plan = { scheduled: retry, attempt: streak.count, maxAttempts };
482
478
  appendLedger(ctx.cwd, retry ? "zombie_auto_retry_scheduled" : "zombie_auto_retry_refused_streak", {
483
479
  goalId,
484
480
  streakCount: streak.count,
481
+ maxAttempts,
485
482
  delayMs: zombieRetryDelayOverride ?? ZOMBIE_RETRY_DELAY_MS,
486
483
  });
487
- if (!retry) return false;
484
+ if (!retry) return plan;
485
+ const retryAttempt = plan.attempt;
486
+ const retryMaxAttempts = plan.maxAttempts;
488
487
  if (zombieRetryTimer) {
489
488
  clearTimeout(zombieRetryTimer);
490
489
  zombieRetryTimer = null;
@@ -513,10 +512,15 @@ function scheduleZombieAutoRetry(
513
512
  pauseSuggestedAction: undefined,
514
513
  ...(usage ? { usage } : {}),
515
514
  }, fresh);
516
- appendLedger(fresh.cwd, "zombie_auto_retry_dispatched", { goalId: goal.id, policy: goal.policy });
515
+ appendLedger(fresh.cwd, "zombie_auto_retry_dispatched", {
516
+ goalId: goal.id,
517
+ policy: goal.policy,
518
+ attempt: retryAttempt,
519
+ maxAttempts: retryMaxAttempts,
520
+ });
517
521
  releaseZombieAbortKey();
518
522
  try {
519
- fresh.ui.notify(`Automatic retry starting — the earlier ${goal.policy === "list" ? "list item" : "goal"} turn was aborted after ${silentMinutes}m of zero stream activity. One bounded retry is dispatched now; if it hangs again the ${goal.policy === "list" ? "item" : "goal"} stays paused for ${activeGoalSurfaceCommand("resume")}.`, "info");
523
+ fresh.ui.notify(`Automatic retry ${retryAttempt}/${retryMaxAttempts} starting — the earlier ${goal.policy === "list" ? "list item" : "goal"} turn was aborted after ${silentMinutes}m of zero stream activity. If it hangs again, GLLA keeps retrying within this budget before requiring ${activeGoalSurfaceCommand("resume")}.`, "info");
520
524
  } catch { /* best-effort */ }
521
525
  scheduleContinuation(fresh, true);
522
526
  return;
@@ -529,14 +533,19 @@ function scheduleZombieAutoRetry(
529
533
  loop.active = true;
530
534
  loop.stopReason = undefined;
531
535
  persistState(fresh);
532
- appendLedger(fresh.cwd, "zombie_auto_retry_dispatched", { kind: "loop", iteration: loop.iteration });
536
+ appendLedger(fresh.cwd, "zombie_auto_retry_dispatched", {
537
+ kind: "loop",
538
+ iteration: loop.iteration,
539
+ attempt: retryAttempt,
540
+ maxAttempts: retryMaxAttempts,
541
+ });
533
542
  releaseZombieAbortKey();
534
543
  try {
535
- fresh.ui.notify(`Automatic retry starting — the loop was stopped after a zero-stream abort. One bounded retry ticks now; if it hangs again it stays stopped for /loop resume.`, "info");
544
+ fresh.ui.notify(`Automatic retry ${retryAttempt}/${retryMaxAttempts} starting — the loop was stopped after a zero-stream abort. GLLA keeps retrying within this budget; if it hangs again after the budget, it stays stopped for /loop resume.`, "info");
536
545
  } catch { /* best-effort */ }
537
546
  scheduleLoopTick(fresh);
538
547
  }, zombieRetryDelayOverride ?? ZOMBIE_RETRY_DELAY_MS);
539
- return true;
548
+ return plan;
540
549
  }
541
550
 
542
551
  /** Test-only: reset the module-global retry streak/timer state. */
@@ -546,9 +555,11 @@ export function __testOnlyResetZombieAutoRetry(): void {
546
555
  zombieRetryTimer = null;
547
556
  }
548
557
  zombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
558
+ zombieRetryMaxAttemptsOverride = null;
549
559
  }
550
560
 
551
561
  let zombieRetryDelayOverride: number | null = null;
562
+ let zombieRetryMaxAttemptsOverride: number | null = null;
552
563
 
553
564
  /** Test-only: shrink the automatic-retry delay (null restores the 90s
554
565
  * production default). Never called by production code. */
@@ -556,15 +567,19 @@ export function __testOnlySetZombieRetryDelay(ms: number | null): void {
556
567
  zombieRetryDelayOverride = ms;
557
568
  }
558
569
 
559
- /** v0.35.x (amended v0.35.17): terminate one confirmed zero-stream host turn
560
- * and park the owning goal/list item before asking pi to abort. This is
561
- * deliberately an activation-owned operation: it can clear the durable
562
- * continuation sidecar, stand down re-arms, and validate the current
563
- * generation before touching the host. A later /goal or /list resume
564
- * explicitly clears the stand-down and starts a new attempt — and since
565
- * v0.35.17, the FIRST silence of a streak additionally arms ONE bounded
566
- * automatic retry (scheduleZombieAutoRetry below); only a second consecutive
567
- * zero-stream abort leaves the park purely manual. */
570
+ /** Test-only: pin the retry budget without depending on the caller's global
571
+ * settings file. Null restores the loaded setting/default. */
572
+ export function __testOnlySetZombieRetryMaxAttempts(attempts: number | null): void {
573
+ zombieRetryMaxAttemptsOverride = attempts;
574
+ }
575
+
576
+ /** v0.35.x: terminate one confirmed zero-stream host turn and park the
577
+ * owning goal/list item before asking pi to abort. This is deliberately an
578
+ * activation-owned operation: it can clear the durable continuation sidecar,
579
+ * stand down re-arms, and validate the current generation before touching the
580
+ * host. A later /goal or /list resume explicitly clears the stand-down and
581
+ * starts a new attempt; each uninterrupted silence can also consume the
582
+ * configured bounded retry budget through scheduleZombieAutoRetry below. */
568
583
  export function abortZombieRun(
569
584
  ctx: ExtensionContext,
570
585
  generation: number,
@@ -596,7 +611,7 @@ export function abortZombieRun(
596
611
  status: "paused",
597
612
  pauseKind: "error",
598
613
  pauseReason: reason,
599
- pauseSuggestedAction: `The stalled ${noun} turn was aborted safely and the work is saved. ${activeGoalSurfaceCommand("resume")} retries it once; ${activeGoalSurfaceCommand("cancel")} discards it.`,
614
+ pauseSuggestedAction: `The stalled ${noun} turn was aborted safely and the work is saved. Automatic retries use the configured zero-stream budget; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it.`,
600
615
  }, current);
601
616
  } else if (loop) {
602
617
  clearLoopTimer();
@@ -621,14 +636,14 @@ export function abortZombieRun(
621
636
  });
622
637
  const resume = goal ? activeGoalSurfaceCommand("resume") : "/loop resume";
623
638
  const cancel = goal ? activeGoalSurfaceCommand("cancel") : "/loop stop";
624
- // v0.35.17: ONE bounded automatic retry per zero-stream streak the park
625
- // becomes a 90-second waystation instead of a dead end for the first
626
- // silence. A refused streak (second consecutive hang) keeps the manual copy.
627
- const retryScheduled = scheduleZombieAutoRetry(current, generation, goal?.id, observedStreamAt, silentMinutes);
639
+ // v0.35.x: keep the park as a bounded waystation for repeated
640
+ // zero-stream attempts. Once the configured budget is exhausted the
641
+ // refusal remains manual and durable.
642
+ const retryPlan = scheduleZombieAutoRetry(current, generation, goal?.id, observedStreamAt, silentMinutes);
628
643
  const message = `${goal ? goal.policy === "list" ? "List item" : "Goal" : "Loop"} paused after ${silentMinutes}m with zero stream activity; the zombie turn was aborted and queued retries were stopped. Work is saved. ${
629
- retryScheduled
630
- ? `An automatic retry starts in ~90s; ${resume} retries immediately instead.`
631
- : `${resume} retries it, or ${cancel} discards/stops it.`
644
+ retryPlan.scheduled
645
+ ? `Automatic retry ${retryPlan.attempt}/${retryPlan.maxAttempts} starts in ~90s; ${resume} retries immediately instead.`
646
+ : `${resume} retries it, or ${cancel} discards/stops it; the automatic retry budget is exhausted.`
632
647
  }`;
633
648
  current.ui.notify(message, abortError ? "warning" : "info");
634
649
  notifyExternal(current, message);
@@ -333,6 +333,7 @@ import {
333
333
  PENDING_LATCH_STUCK_MS,
334
334
  shouldFirePendingLatchWatchdog,
335
335
  AUDITOR_WALL_TIMEOUT_MS,
336
+ MAX_ZOMBIE_RETRY_ATTEMPTS,
336
337
  } from "../goal-loop-backoff.js";
337
338
 
338
339
  import {
@@ -1322,6 +1323,21 @@ export async function handleSettingChoice(id: string, ctx: ExtensionContext): Pr
1322
1323
  }
1323
1324
  return;
1324
1325
  }
1326
+ case "zombieRetryMaxAttempts": {
1327
+ const v = await ctx.ui.input("Automatic retries after a busy zero-stream abort", "integer from 0 to 10; 0 = manual resume only; empty = default 3");
1328
+ if (v !== undefined) {
1329
+ const raw = v.trim();
1330
+ const n = Number.parseInt(raw, 10);
1331
+ if (/^\d+$/.test(raw) && Number.isInteger(n) && n >= 0 && n <= MAX_ZOMBIE_RETRY_ATTEMPTS) {
1332
+ saveSettings("global", ctx.cwd, { zombieRetryMaxAttempts: n });
1333
+ } else if (!raw) {
1334
+ saveSettings("global", ctx.cwd, { zombieRetryMaxAttempts: undefined });
1335
+ } else {
1336
+ ctx.ui.notify(`Automatic retry count must be an integer from 0 to ${MAX_ZOMBIE_RETRY_ATTEMPTS}: ${v}`, "warning");
1337
+ }
1338
+ }
1339
+ return;
1340
+ }
1325
1341
  case "stallShortWords": {
1326
1342
  const v = await ctx.ui.input("Stall short words threshold", "non-negative integer; 0 = off, empty = default 15");
1327
1343
  if (v !== undefined) {
@@ -37,6 +37,7 @@ import {
37
37
  import {
38
38
  DEFAULT_STALL_SIM_THRESHOLD,
39
39
  DEFAULT_STALL_SHORT_WORDS,
40
+ DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
40
41
  } from "./goal-loop-backoff.ts";
41
42
  import type { Settings } from "./goal-settings.ts";
42
43
  import { MAX_MAIN_MODEL_FALLBACKS } from "./main-model-recovery.ts";
@@ -453,6 +454,14 @@ export function buildSettingsRows(
453
454
  description:
454
455
  "heartbeat refires with no turn before the goal pauses / loop stops (0 = never)",
455
456
  },
457
+ {
458
+ id: "zombieRetryMaxAttempts",
459
+ section: "stall-brakes",
460
+ label: "Zero-stream retries",
461
+ valueText: show("zombieRetryMaxAttempts", `${DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS}`),
462
+ sourceText: src("zombieRetryMaxAttempts"),
463
+ description: "automatic retries after a busy zero-stream abort (0 = manual only; max 10)",
464
+ },
456
465
  {
457
466
  id: "stallShortWords",
458
467
  section: "stall-brakes",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.35.69",
3
+ "version": "0.35.71",
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. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",