pi-goal-x 0.29.0 → 0.30.0

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.
@@ -6,6 +6,7 @@ import {
6
6
  goalEventMessageId,
7
7
  hasAbortedAssistantMessage,
8
8
  hasErrorAssistantMessage,
9
+ hasNetworkErrorAssistantMessage,
9
10
  isAbortedAssistantMessage,
10
11
  isErrorAssistantMessage,
11
12
  isMeaningfulProgressToolCall,
@@ -92,6 +93,7 @@ export function compactGoalCheckpointContext(
92
93
  export function registerGoalEvents(core: GoalCore): void {
93
94
  const { pi } = core;
94
95
  let continuationAfterSettleFor: string | null = null;
96
+ let networkErrorRecoveryAfterSettleFor: string | null = null;
95
97
 
96
98
  pi.on("context", async (event) => {
97
99
  const messages = compactGoalCheckpointContext(event.messages, core.state.goal);
@@ -355,7 +357,10 @@ export function registerGoalEvents(core: GoalCore): void {
355
357
  // evaluating whether the checkpoint is actionable.
356
358
  core.reconcileFocusedGoalFromDisk(ctx);
357
359
  core.runtime.setCheckpoint(incomingGoalId);
358
- core.clearContinuationState();
360
+ // This can be the hidden checkpoint dispatched by the network-error
361
+ // timer. Clear ordinary continuation bookkeeping but retain the
362
+ // consecutive recovery count for a later failed retry.
363
+ core.clearContinuationState(false);
359
364
  if (!core.isActionableContinuationGoal(incomingGoalId)) {
360
365
  try {
361
366
  ctx.abort?.();
@@ -372,6 +377,7 @@ export function registerGoalEvents(core: GoalCore): void {
372
377
  // autoContinue nudge state so the user always gets a fresh chain.
373
378
  core.runtime.setCheckpoint(null);
374
379
  core.clearContinuationState();
380
+ networkErrorRecoveryAfterSettleFor = null;
375
381
  }
376
382
 
377
383
  if (!core.state.goal) {
@@ -471,6 +477,7 @@ export function registerGoalEvents(core: GoalCore): void {
471
477
  const endedGoalId = core.runningGoalId;
472
478
  core.runningGoalId = null;
473
479
  continuationAfterSettleFor = null;
480
+ networkErrorRecoveryAfterSettleFor = null;
474
481
 
475
482
  // Account for any tokens from aborted in-flight assistant messages so
476
483
  // they are not silently lost (but charge them to the original goal).
@@ -481,7 +488,9 @@ export function registerGoalEvents(core: GoalCore): void {
481
488
  core.accountProgress(ctx, { completedTurnTokens: abortedTokens });
482
489
  }
483
490
 
484
- core.runtime.clearContinuationState();
491
+ // Keep any prior recovery attempt while Pi finishes its own automatic
492
+ // retries. A user-driven path resets it through the default argument.
493
+ core.runtime.clearContinuationState(false);
485
494
  if (!core.state.goal || core.state.goal.status !== "active" || !core.state.goal.autoContinue) return;
486
495
  if (endedGoalId && core.state.goal.id !== endedGoalId) return;
487
496
  if (!core.reconcileFocusedGoalFromDisk(ctx)) return;
@@ -492,11 +501,18 @@ export function registerGoalEvents(core: GoalCore): void {
492
501
  // Provider failures are not completed work: persist and refresh the
493
502
  // display, but never queue a continuation for a run whose messages
494
503
  // include an assistant error (danim47c pattern).
504
+ if (hasNetworkErrorAssistantMessage(event.messages)) {
505
+ core.persist(ctx);
506
+ core.updateUI(ctx);
507
+ networkErrorRecoveryAfterSettleFor = core.state.goal.id;
508
+ return;
509
+ }
495
510
  if (hasErrorAssistantMessage(event.messages)) {
496
511
  core.persist(ctx);
497
512
  core.updateUI(ctx);
498
513
  return;
499
514
  }
515
+ core.runtime.clearNetworkErrorBackoff();
500
516
  core.persist(ctx);
501
517
  core.updateUI(ctx);
502
518
  // agent_end runs before pi finishes retries, compaction, terminating-tool
@@ -510,14 +526,32 @@ export function registerGoalEvents(core: GoalCore): void {
510
526
  pi.on("agent_settled", async (_event, ctx) => {
511
527
  const goalId = continuationAfterSettleFor;
512
528
  continuationAfterSettleFor = null;
513
- if (!goalId || !core.isActionableContinuationGoal(goalId)) return;
514
- core.queueContinuation(ctx, true);
529
+ const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
530
+ networkErrorRecoveryAfterSettleFor = null;
531
+ if (goalId && core.isActionableContinuationGoal(goalId)) {
532
+ core.queueContinuation(ctx, true);
533
+ return;
534
+ }
535
+ if (!networkErrorGoalId || !core.isActionableContinuationGoal(networkErrorGoalId)) return;
536
+ const plan = core.runtime.scheduleNetworkErrorRetry(ctx, core.state.goal!);
537
+ if (plan) {
538
+ ctx.ui.notify(
539
+ `Provider network error. Retrying the goal in ${Math.round(plan.delayMs / 1000)}s (recovery ${plan.attempt}/${plan.maxAttempts}).`,
540
+ "warning",
541
+ );
542
+ return;
543
+ }
544
+ ctx.ui.notify(
545
+ "Provider network errors persisted after all recovery attempts. The goal remains active; resume it when the provider is healthy.",
546
+ "warning",
547
+ );
515
548
  });
516
549
 
517
550
  pi.on("session_shutdown", async (_event, ctx) => {
518
551
  continuationAfterSettleFor = null;
552
+ networkErrorRecoveryAfterSettleFor = null;
519
553
  core.accountProgress(ctx);
520
- core.clearContinuationTimer();
554
+ core.clearContinuationState();
521
555
  core.terminalInputUnsubscribe?.();
522
556
  core.terminalInputUnsubscribe = null;
523
557
  if (core.state.goal) core.persist(ctx);
@@ -226,6 +226,16 @@ export function isErrorAssistantMessage(message: unknown): boolean {
226
226
  return raw?.role === "assistant" && raw.stopReason === "error";
227
227
  }
228
228
 
229
+ /** A provider-declared network failure is safe for the bounded goal backoff. */
230
+ export function isNetworkErrorAssistantMessage(message: unknown): boolean {
231
+ if (!isErrorAssistantMessage(message)) return false;
232
+ const raw = asRecord(message);
233
+ const details = [raw?.rawStopReason, raw?.errorMessage]
234
+ .filter((value): value is string => typeof value === "string")
235
+ .join(" ");
236
+ return /\bnetwork[_\s-]?error\b/i.test(details);
237
+ }
238
+
229
239
  export function isToolUseAssistantMessage(message: unknown): boolean {
230
240
  const raw = asRecord(message);
231
241
  return raw?.role === "assistant" && raw.stopReason === "toolUse";
@@ -239,6 +249,10 @@ export function hasErrorAssistantMessage(messages: unknown[]): boolean {
239
249
  return messages.some(isErrorAssistantMessage);
240
250
  }
241
251
 
252
+ export function hasNetworkErrorAssistantMessage(messages: unknown[]): boolean {
253
+ return messages.some(isNetworkErrorAssistantMessage);
254
+ }
255
+
242
256
  export function usageChannelTokens(value: unknown): number {
243
257
  if (typeof value !== "number" || !Number.isFinite(value)) return 0;
244
258
  return Math.max(0, Math.trunc(value));
@@ -12,6 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import type { GoalCheckpointDetailsV2, GoalRecord } from "./goal-record.ts";
13
13
  import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
14
14
  import { POST_STOP_ALLOWED_TOOLS } from "./goal-tool-names.ts";
15
+ import { networkErrorBackoffPlan, type NetworkErrorBackoffPlan } from "./network-error-backoff.ts";
15
16
 
16
17
  export const CONTINUATION_IDLE_RETRY_MS = 50;
17
18
 
@@ -31,6 +32,9 @@ export class GoalRuntime {
31
32
  private continuationQueuedFor: string | null = null;
32
33
  private continuationScheduledFor: string | null = null;
33
34
  private continuationTimer: ReturnType<typeof setTimeout> | null = null;
35
+ private networkErrorRetryGoalId: string | null = null;
36
+ private networkErrorRetryAttempt = 0;
37
+ private networkErrorRetryTimer: ReturnType<typeof setTimeout> | null = null;
34
38
 
35
39
  // ── turn-stop guard ──────────────────────────────────────────────────
36
40
  private turnSeq = 0;
@@ -54,9 +58,10 @@ export class GoalRuntime {
54
58
 
55
59
  // ── continuation scheduling ──────────────────────────────────────────
56
60
 
57
- clearContinuationState(): void {
61
+ clearContinuationState(resetNetworkErrorBackoff = true): void {
58
62
  this.clearContinuationTimer();
59
63
  this.continuationQueuedFor = null;
64
+ if (resetNetworkErrorBackoff) this.clearNetworkErrorBackoff();
60
65
  }
61
66
 
62
67
  /** Clear the pending timer but keep the queued marker (used at session shutdown). */
@@ -98,6 +103,40 @@ export class GoalRuntime {
98
103
  cancelContinuationFor(goalId: string): void {
99
104
  if (this.continuationQueuedFor === goalId) this.continuationQueuedFor = null;
100
105
  if (this.continuationScheduledFor === goalId) this.clearContinuationState();
106
+ if (this.networkErrorRetryGoalId === goalId) this.clearNetworkErrorBackoff();
107
+ }
108
+
109
+ /**
110
+ * Schedule the next bounded recovery after Pi's built-in provider retries
111
+ * have failed. The counter stays in memory and is cleared on a successful
112
+ * turn or any user-owned cancellation path.
113
+ */
114
+ scheduleNetworkErrorRetry(ctx: ExtensionContext, goal: GoalRecord): NetworkErrorBackoffPlan | null {
115
+ if (goal.status !== "active" || !goal.autoContinue || this.networkErrorRetryTimer) return null;
116
+ if (this.networkErrorRetryGoalId !== goal.id) {
117
+ this.networkErrorRetryGoalId = goal.id;
118
+ this.networkErrorRetryAttempt = 0;
119
+ }
120
+ const plan = networkErrorBackoffPlan(this.networkErrorRetryAttempt + 1);
121
+ if (!plan) return null;
122
+ this.networkErrorRetryAttempt = plan.attempt;
123
+ this.networkErrorRetryTimer = setTimeout(() => {
124
+ this.networkErrorRetryTimer = null;
125
+ if (!this.hooks.isActionable(goal.id)) return;
126
+ const currentGoal = this.hooks.getGoal();
127
+ if (!currentGoal || currentGoal.id !== goal.id) return;
128
+ this.queueContinuation(ctx, currentGoal, true);
129
+ }, plan.delayMs);
130
+ this.networkErrorRetryTimer.unref?.();
131
+ return plan;
132
+ }
133
+
134
+ /** Cancel and forget all goal-level network-error recovery state. */
135
+ clearNetworkErrorBackoff(): void {
136
+ if (this.networkErrorRetryTimer) clearTimeout(this.networkErrorRetryTimer);
137
+ this.networkErrorRetryTimer = null;
138
+ this.networkErrorRetryGoalId = null;
139
+ this.networkErrorRetryAttempt = 0;
101
140
  }
102
141
 
103
142
  /**
@@ -90,7 +90,7 @@ export interface GoalCore {
90
90
  stopAuditAnimation(): void;
91
91
  abortAudit(ctx: ExtensionContext): void;
92
92
  clearContinuationTimer(): void;
93
- clearContinuationState(): void;
93
+ clearContinuationState(resetNetworkErrorBackoff?: boolean): void;
94
94
  clearActiveAccounting(): void;
95
95
  advanceTurnSeq(): void;
96
96
  currentTurnStoppedGoalId(): string | null;
@@ -357,8 +357,8 @@ export function createGoalCore(
357
357
  runtime.clearContinuationTimer();
358
358
  }
359
359
 
360
- function clearContinuationState(): void {
361
- runtime.clearContinuationState();
360
+ function clearContinuationState(resetNetworkErrorBackoff = true): void {
361
+ runtime.clearContinuationState(resetNetworkErrorBackoff);
362
362
  }
363
363
 
364
364
  function clearActiveAccounting(): void {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Bounded, goal-level recovery after Pi has exhausted its provider retries.
3
+ *
4
+ * Pi owns the immediate request retries. These longer delays are deliberately
5
+ * small in number and only apply to terminal `network_error` failures, so an
6
+ * unavailable provider never becomes an unbounded auto-continue loop.
7
+ */
8
+
9
+ export const NETWORK_ERROR_BACKOFF_DELAYS_MS = [5_000, 10_000, 20_000, 40_000, 80_000] as const;
10
+
11
+ export interface NetworkErrorBackoffPlan {
12
+ attempt: number;
13
+ maxAttempts: number;
14
+ delayMs: number;
15
+ }
16
+
17
+ /** Return the recovery plan for a one-based attempt, or undefined after the cap. */
18
+ export function networkErrorBackoffPlan(attempt: number): NetworkErrorBackoffPlan | undefined {
19
+ if (!Number.isInteger(attempt) || attempt < 1 || attempt > NETWORK_ERROR_BACKOFF_DELAYS_MS.length) return undefined;
20
+ return {
21
+ attempt,
22
+ maxAttempts: NETWORK_ERROR_BACKOFF_DELAYS_MS.length,
23
+ delayMs: NETWORK_ERROR_BACKOFF_DELAYS_MS[attempt - 1]!,
24
+ };
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",