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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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.
|
|
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
|
/**
|
package/extensions/goal-state.ts
CHANGED
|
@@ -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.
|
|
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",
|