@monotykamary/pi-retry 0.8.6 → 0.9.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.
- package/README.md +37 -15
- package/package.json +1 -1
- package/retry.ts +121 -50
- package/src/config.ts +172 -0
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -21,11 +21,11 @@ This extension automatically detects and retries **all** errors by default, with
|
|
|
21
21
|
|
|
22
22
|
| Error Type | Retry Behavior | Use Case |
|
|
23
23
|
|------------|----------------|----------|
|
|
24
|
-
| **Any retryable error** (catch-all) | **
|
|
25
|
-
| HTTP 400/413 | **
|
|
26
|
-
| Credit / payment errors | **
|
|
24
|
+
| **Any retryable error** (catch-all) | **Capped exponential retry** | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
|
|
25
|
+
| HTTP 400/413 | **Capped** with exponential backoff, NO compaction | Transient context overflow that might resolve |
|
|
26
|
+
| Credit / payment errors | **Capped** with exponential backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
|
|
27
27
|
| **Quota / session-limit / budget exhaustion** | **Not retried** — notify + stop | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
|
|
28
|
-
| Connection errors | **
|
|
28
|
+
| Connection errors | **Capped** with exponential backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
|
|
29
29
|
| Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
|
|
30
30
|
| Empty / think-only stop (`stopReason: "stop"` with no text or tool calls) | **Nudge once** with a hidden continuation, then give up | Model ends its turn with no usable output (Anthropic empty responses with end_turn, thinking-only turns) |
|
|
31
31
|
|
|
@@ -43,7 +43,7 @@ By default, pi has built-in retry for some errors (rate limits, 5xx, overloaded)
|
|
|
43
43
|
|
|
44
44
|
## The Solution
|
|
45
45
|
|
|
46
|
-
This extension provides
|
|
46
|
+
This extension provides automatic retry for all errors with configurable exponential backoff and a maximum-delay failure limit (2s → 4s → 8s → ... → 60s by default).
|
|
47
47
|
|
|
48
48
|
**Philosophy: retry EVERYTHING by default.** The only things we skip are a tiny blacklist of known permanent failures (invalid API key, model not found, unsupported model, etc.).
|
|
49
49
|
|
|
@@ -51,9 +51,9 @@ This extension provides **automatic** infinite retry with sensible exponential b
|
|
|
51
51
|
- **Catch-all retry** — Any `stopReason: "error"` is retried, regardless of error message
|
|
52
52
|
- Automatic detection of 400/413, connection, credit, and stream exhaustion errors
|
|
53
53
|
- **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, hidden from the TUI
|
|
54
|
-
- **
|
|
54
|
+
- **Retry cutoff** — Keeps retrying until success, abort, or the configured number of failures at the maximum delay
|
|
55
55
|
- **Auto-stop on quota/budget exhaustion** — Session limits, plan quotas, and budget caps ("You've hit your limit", "out of budget", `insufficient_quota`, suspended accounts) are detected and **not** retried, with a notification explaining why
|
|
56
|
-
- Exponential backoff with cap
|
|
56
|
+
- Exponential backoff with configurable base delay, cap, multiplier, and maximum-delay failure count
|
|
57
57
|
- **Hidden triggers** — provider-valid custom messages use `display: false`, so retries do not add TUI clutter
|
|
58
58
|
- Manual controls via unified `/retry` command
|
|
59
59
|
- Non-retryable errors are explicitly logged so you know why we didn't retry
|
|
@@ -121,15 +121,35 @@ Once loaded, the extension **automatically** detects and retries all errors.
|
|
|
121
121
|
|
|
122
122
|
## Configuration
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
The extension reads a `piRetry` object from Pi's settings files:
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
- `~/.pi/agent/settings.json` applies globally.
|
|
127
|
+
- `.pi/settings.json` overrides matching global values for the current project.
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"piRetry": {
|
|
132
|
+
"baseDelayMs": 10000,
|
|
133
|
+
"maxDelayMs": 3600000,
|
|
134
|
+
"multiplier": 2,
|
|
135
|
+
"maxRetriesAtMaxDelay": 3
|
|
136
|
+
}
|
|
137
|
+
}
|
|
131
138
|
```
|
|
132
139
|
|
|
140
|
+
The example above waits 10 seconds before the first retry, doubles each delay, caps the delay at one hour, and stops after three failed retries at that cap. Supported values are:
|
|
141
|
+
|
|
142
|
+
| Setting | Default | Description |
|
|
143
|
+
|---------|---------|-------------|
|
|
144
|
+
| `baseDelayMs` | `2000` | Delay before the first retry, in milliseconds |
|
|
145
|
+
| `maxDelayMs` | `60000` | Maximum delay between retries, in milliseconds |
|
|
146
|
+
| `multiplier` | `2` | Exponential backoff multiplier; must be at least `1` |
|
|
147
|
+
| `maxRetriesAtMaxDelay` | `3` | Failed ordinary retries allowed after the delay reaches `maxDelayMs` |
|
|
148
|
+
|
|
149
|
+
`piRetry` is separate from Pi's built-in `retry` object so the two retry policies do not share ambiguous settings. The extension disables Pi's native retry scheduler while it is loaded, while preserving Pi's compaction handling, so only one retry loop owns the backoff schedule.
|
|
150
|
+
|
|
151
|
+
Settings are read when the extension starts. Restart pi or use `/reload` after editing them.
|
|
152
|
+
|
|
133
153
|
---
|
|
134
154
|
|
|
135
155
|
## How It Works
|
|
@@ -234,11 +254,13 @@ npm run lint:dead
|
|
|
234
254
|
.
|
|
235
255
|
├── retry.ts # Main unified extension
|
|
236
256
|
├── src/ # Shared utilities (testable, DRY)
|
|
257
|
+
│ ├── config.ts # Settings-backed retry configuration
|
|
237
258
|
│ ├── error-patterns.ts # Error pattern matching, custom types, hasMaxTokensStop
|
|
238
259
|
│ ├── retry-logic.ts # Retry utilities (calculateDelay, RetryState, ContinuationState, etc.)
|
|
239
260
|
│ └── index.ts # Barrel exports
|
|
240
261
|
├── __tests__/ # Unit tests
|
|
241
262
|
│ └── unit/
|
|
263
|
+
│ ├── config.test.ts
|
|
242
264
|
│ ├── error-patterns.test.ts
|
|
243
265
|
│ └── retry-logic.test.ts
|
|
244
266
|
├── vitest.config.ts # Test configuration
|
|
@@ -249,7 +271,7 @@ npm run lint:dead
|
|
|
249
271
|
|
|
250
272
|
```bash
|
|
251
273
|
# Run all quality checks
|
|
252
|
-
npm test #
|
|
274
|
+
npm test # 224 tests
|
|
253
275
|
npm run typecheck # TypeScript type checking
|
|
254
276
|
npm run lint:dead # Dead code detection with knip
|
|
255
277
|
```
|
|
@@ -302,7 +324,7 @@ pi install npm:@georgebashi/pi-retry
|
|
|
302
324
|
|
|
303
325
|
## Limitations
|
|
304
326
|
|
|
305
|
-
-
|
|
327
|
+
- Pi's native retry scheduler is disabled while this extension is loaded so native and extension retries cannot interleave; Pi's compaction check still runs normally
|
|
306
328
|
- Error messages remain in the session history (but are invisible to the LLM)
|
|
307
329
|
- May hit the same error repeatedly if the issue is persistent (use `Ctrl+C` to abort)
|
|
308
330
|
- **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large
|
package/package.json
CHANGED
package/retry.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isAssistantMessage,
|
|
17
17
|
getLastAssistantMessage,
|
|
18
18
|
calculateDelay,
|
|
19
|
+
loadPiRetryConfig,
|
|
19
20
|
formatDuration,
|
|
20
21
|
getErrorCategory,
|
|
21
22
|
RetryState,
|
|
@@ -32,7 +33,8 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
|
|
|
32
33
|
* Unified retry extension — retries EVERY error by default.
|
|
33
34
|
*
|
|
34
35
|
* Philosophy: any assistant message with stopReason === "error" is retried
|
|
35
|
-
*
|
|
36
|
+
* with exponential backoff capped by settings, then stops after the configured
|
|
37
|
+
* number of failures at the maximum delay, except a small blacklist of known
|
|
36
38
|
* permanent failures and hard-stop conditions (invalid API key, model not
|
|
37
39
|
* found, quota/session-limit/budget exhaustion, suspended accounts, etc.).
|
|
38
40
|
*
|
|
@@ -41,7 +43,7 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
|
|
|
41
43
|
*
|
|
42
44
|
* Features:
|
|
43
45
|
* - Automatic detection and retry for ALL errors (catch-all)
|
|
44
|
-
* -
|
|
46
|
+
* - Retry with exponential backoff and a configurable cap/failure limit
|
|
45
47
|
* - Auto-continuation when model hits max output tokens (stopReason "length")
|
|
46
48
|
* - Retry triggers are hidden in the TUI and serialized as provider-valid user turns
|
|
47
49
|
* - Unified manual controls via /retry command
|
|
@@ -73,25 +75,15 @@ Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
|
|
|
73
75
|
return _origSubscribe.apply(this, args);
|
|
74
76
|
};
|
|
75
77
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
// When _continueInProgress is true (pi-retry is running), _prepareRetry
|
|
83
|
-
// returns false immediately, so _handlePostAgentRun falls through to
|
|
84
|
-
// the compaction check and the while loop in _runAgentPrompt exits
|
|
85
|
-
// cleanly. No auto_retry_start/end events, no "Retry failed" message.
|
|
86
|
-
//
|
|
87
|
-
// When _continueInProgress is false (pi-retry is not active), the
|
|
88
|
-
// built-in retry works normally as a fallback.
|
|
78
|
+
// AgentSession runs _prepareRetry inside _runAgentPrompt before it emits
|
|
79
|
+
// agent_end. If native retry remains enabled, the first error is retried
|
|
80
|
+
// before pi-retry can claim it, so the two retry loops interleave and produce
|
|
81
|
+
// non-monotonic delays. pi-retry owns retry scheduling for the whole session;
|
|
82
|
+
// returning false still lets AgentSession run its compaction check.
|
|
83
|
+
let _piRetryActive = false;
|
|
89
84
|
const _origPrepareRetry = (AgentSession.prototype as any)._prepareRetry;
|
|
90
85
|
(AgentSession.prototype as any)._prepareRetry = function(this: any, message: any) {
|
|
91
|
-
if (
|
|
92
|
-
_continueInProgress &&
|
|
93
|
-
_continueInputGeneration === _inputGeneration
|
|
94
|
-
) {
|
|
86
|
+
if (_piRetryActive) {
|
|
95
87
|
return Promise.resolve(false);
|
|
96
88
|
}
|
|
97
89
|
return _origPrepareRetry.call(this, message);
|
|
@@ -170,20 +162,20 @@ function interruptibleSleep(
|
|
|
170
162
|
// Same technique used by the built-in retry in _prepareRetry — the error
|
|
171
163
|
// message stays in the session journal for history but is removed from the
|
|
172
164
|
// agent's live transcript so the LLM receives a clean context on retry.
|
|
173
|
-
function removeErrorFromAgentState(): void {
|
|
174
|
-
if (!
|
|
175
|
-
const messages =
|
|
165
|
+
function removeErrorFromAgentState(agent: Agent | null = _agent): void {
|
|
166
|
+
if (!agent) return;
|
|
167
|
+
const messages = agent.state.messages;
|
|
176
168
|
const lastMsg = messages[messages.length - 1];
|
|
177
169
|
if (lastMsg?.role === 'assistant' && lastMsg.stopReason === 'error') {
|
|
178
|
-
|
|
170
|
+
agent.state.messages = messages.slice(0, -1);
|
|
179
171
|
}
|
|
180
172
|
}
|
|
181
173
|
|
|
182
174
|
type HiddenTurnKind = "retry" | "continue" | "empty";
|
|
183
175
|
|
|
184
|
-
function getHiddenTurnKind(): HiddenTurnKind | null {
|
|
185
|
-
if (!
|
|
186
|
-
const messages =
|
|
176
|
+
function getHiddenTurnKind(agent: Agent | null = _agent): HiddenTurnKind | null {
|
|
177
|
+
if (!agent) return null;
|
|
178
|
+
const messages = agent.state.messages;
|
|
187
179
|
const lastMsg = messages[messages.length - 1];
|
|
188
180
|
if (lastMsg?.role !== "assistant") return null;
|
|
189
181
|
if (lastMsg.stopReason === "error") return "retry";
|
|
@@ -197,6 +189,11 @@ function lastMessageIsRetryableError(): boolean {
|
|
|
197
189
|
}
|
|
198
190
|
|
|
199
191
|
export default function (pi: ExtensionAPI) {
|
|
192
|
+
let _notifyFn: ((message: string, level: "info" | "warning" | "error") => void) | null = null;
|
|
193
|
+
|
|
194
|
+
// Mark native retry as owned by this extension before the first agent turn.
|
|
195
|
+
_piRetryActive = true;
|
|
196
|
+
const retryConfig = loadPiRetryConfig();
|
|
200
197
|
|
|
201
198
|
pi.on("input", () => {
|
|
202
199
|
_inputGeneration++;
|
|
@@ -423,10 +420,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
420
|
|
|
424
421
|
// Config
|
|
425
422
|
status += "Configuration:\n";
|
|
426
|
-
status += ` Base delay:
|
|
427
|
-
status += ` Max delay:
|
|
428
|
-
status += ` Backoff multiplier:
|
|
429
|
-
status += `
|
|
423
|
+
status += ` Base delay: ${retryConfig.baseDelayMs}ms\n`;
|
|
424
|
+
status += ` Max delay: ${retryConfig.maxDelayMs}ms\n`;
|
|
425
|
+
status += ` Backoff multiplier: ${retryConfig.multiplier}\n`;
|
|
426
|
+
status += ` Max-delay failures: ${retryConfig.maxRetriesAtMaxDelay}\n`;
|
|
427
|
+
status += " Retry loop: until success, abort, or the max-delay failure limit\n\n";
|
|
430
428
|
|
|
431
429
|
// Last assistant info
|
|
432
430
|
if (lastAssistant && isAssistantMessage(lastAssistant)) {
|
|
@@ -541,6 +539,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
541
539
|
}
|
|
542
540
|
});
|
|
543
541
|
|
|
542
|
+
// Handle session replacement before Pi invalidates this extension runtime.
|
|
543
|
+
// A retry loop may still be awaiting a timer or AgentSession turn when the
|
|
544
|
+
// old session shuts down, so release its ownership and invalidate its
|
|
545
|
+
// generation before the captured pi object becomes stale.
|
|
546
|
+
pi.on("session_shutdown", () => {
|
|
547
|
+
_sessionGeneration++;
|
|
548
|
+
_userAborted = true;
|
|
549
|
+
_continueInProgress = false;
|
|
550
|
+
_continueGeneration = null;
|
|
551
|
+
_continueInputGeneration = null;
|
|
552
|
+
_notifyFn = null;
|
|
553
|
+
_terminalInputUnsubscribe?.();
|
|
554
|
+
_terminalInputUnsubscribe = null;
|
|
555
|
+
});
|
|
556
|
+
|
|
544
557
|
// Initialize
|
|
545
558
|
pi.on("session_start", async (_event, ctx) => {
|
|
546
559
|
// Bump the generation counter so any in-flight retry loop from a
|
|
@@ -558,6 +571,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
558
571
|
// finally block releases its owner token. Resetting it here could allow
|
|
559
572
|
// a second loop to start before the old one has settled.
|
|
560
573
|
_userAborted = false;
|
|
574
|
+
_notifyFn = null;
|
|
561
575
|
|
|
562
576
|
_terminalInputUnsubscribe?.();
|
|
563
577
|
_terminalInputUnsubscribe = null;
|
|
@@ -600,7 +614,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
600
614
|
// agent.state.messages so the LLM receives a clean context (same
|
|
601
615
|
// technique as the built-in retry's _prepareRetry).
|
|
602
616
|
async function triggerInvisibleContinue(initialKind: HiddenTurnKind) {
|
|
603
|
-
|
|
617
|
+
// Keep the AgentSession that started this loop. A replacement session can
|
|
618
|
+
// update the module-level reference before an old loop has unwound.
|
|
619
|
+
const myAgent = _agent;
|
|
620
|
+
if (!myAgent) return;
|
|
604
621
|
|
|
605
622
|
// Guard: if the user aborted, do not queue another retry turn.
|
|
606
623
|
if (_userAborted) return;
|
|
@@ -610,7 +627,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
610
627
|
_continueInProgress = true;
|
|
611
628
|
const retryLifecycleId = ++_retryLifecycleId;
|
|
612
629
|
let didRetryComplete = false;
|
|
613
|
-
pi.events.emit(RETRY_STARTED_EVENT, { retryId: retryLifecycleId });
|
|
614
630
|
|
|
615
631
|
// Capture the current session generation. If /new fires while we're
|
|
616
632
|
// looping, _sessionGeneration will increment and the loop will exit.
|
|
@@ -620,9 +636,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
620
636
|
_continueInputGeneration = myInputGeneration;
|
|
621
637
|
|
|
622
638
|
try {
|
|
639
|
+
emitRetryLifecycleEvent(RETRY_STARTED_EVENT, retryLifecycleId);
|
|
640
|
+
|
|
623
641
|
// Wait for the current run to finish (activeRun resolves in
|
|
624
642
|
// finishRun() after agent_end listeners return).
|
|
625
|
-
await
|
|
643
|
+
await myAgent.waitForIdle();
|
|
626
644
|
|
|
627
645
|
// Re-check after waitForIdle: the user may have aborted or the
|
|
628
646
|
// session may have changed while we were waiting.
|
|
@@ -634,9 +652,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
634
652
|
|
|
635
653
|
let attempt = 0;
|
|
636
654
|
let hiddenTurnKind: HiddenTurnKind | null = initialKind;
|
|
637
|
-
// Empty-stop nudges are bounded:
|
|
638
|
-
//
|
|
655
|
+
// Empty-stop nudges are bounded: a model that produced no usable output
|
|
656
|
+
// and answers the nudge with another empty turn is decided, not stalled.
|
|
657
|
+
// Stop after MAX_EMPTY_CONTINUATIONS rather than looping forever.
|
|
639
658
|
let emptyNudges = 0;
|
|
659
|
+
// Only ordinary retries count toward the maximum-delay cutoff; token
|
|
660
|
+
// continuations intentionally remain uncapped.
|
|
661
|
+
let maxDelayRetries = 0;
|
|
640
662
|
|
|
641
663
|
// Loop until success, abort, or session change.
|
|
642
664
|
while (true) {
|
|
@@ -653,14 +675,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
653
675
|
didRetryComplete = true;
|
|
654
676
|
return;
|
|
655
677
|
}
|
|
656
|
-
removeErrorFromAgentState();
|
|
678
|
+
removeErrorFromAgentState(myAgent);
|
|
657
679
|
|
|
658
680
|
// Empty-stop cap: a model that produced no usable output and answers
|
|
659
681
|
// the nudge with another empty turn is decided, not stalled. Stop
|
|
660
682
|
// after MAX_EMPTY_CONTINUATIONS rather than looping forever.
|
|
661
683
|
if (hiddenTurnKind === "empty") {
|
|
662
684
|
if (emptyNudges >= MAX_EMPTY_CONTINUATIONS) {
|
|
663
|
-
|
|
685
|
+
notifySafely(
|
|
664
686
|
`Empty response after ${emptyNudges} continuation(s) - giving up (model keeps ending the turn with no output).`,
|
|
665
687
|
"warning",
|
|
666
688
|
);
|
|
@@ -670,7 +692,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
670
692
|
}
|
|
671
693
|
|
|
672
694
|
attempt++;
|
|
673
|
-
const delay = calculateDelay(attempt);
|
|
695
|
+
const delay = calculateDelay(attempt, retryConfig);
|
|
696
|
+
const isMaxDelay = delay >= retryConfig.maxDelayMs;
|
|
697
|
+
if (hiddenTurnKind === "retry" && isMaxDelay) {
|
|
698
|
+
maxDelayRetries++;
|
|
699
|
+
}
|
|
674
700
|
|
|
675
701
|
// Notify the user about the upcoming retry attempt.
|
|
676
702
|
_notifyRetryAttempt(attempt, delay);
|
|
@@ -678,7 +704,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
678
704
|
// Interruptible sleep with backoff BEFORE the retry attempt.
|
|
679
705
|
// Polls _userAborted and _sessionGeneration every 100ms so ESC
|
|
680
706
|
// and /new take effect within 100ms instead of waiting for the
|
|
681
|
-
// full backoff (up to
|
|
707
|
+
// full backoff (up to the configured maximum delay).
|
|
682
708
|
const interrupted = await interruptibleSleep(
|
|
683
709
|
delay,
|
|
684
710
|
myGeneration,
|
|
@@ -707,7 +733,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
707
733
|
// low-level run synchronously before returning. Waiting on Agent
|
|
708
734
|
// keeps this retry loop intact without bypassing session state.
|
|
709
735
|
await Promise.resolve();
|
|
710
|
-
await
|
|
736
|
+
await myAgent.waitForIdle();
|
|
711
737
|
} catch {
|
|
712
738
|
return;
|
|
713
739
|
}
|
|
@@ -722,21 +748,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
722
748
|
|
|
723
749
|
// The hidden AgentSession turn completed. Both errors and output
|
|
724
750
|
// length stops need another turn; all other terminal states are done.
|
|
725
|
-
hiddenTurnKind = getHiddenTurnKind();
|
|
751
|
+
hiddenTurnKind = getHiddenTurnKind(myAgent);
|
|
726
752
|
if (!hiddenTurnKind) {
|
|
727
753
|
didRetryComplete = true;
|
|
728
754
|
return;
|
|
729
755
|
}
|
|
756
|
+
|
|
757
|
+
// Stop ordinary retries after the configured number of failures at
|
|
758
|
+
// the cap. The failed capped turns have already been delivered; this
|
|
759
|
+
// check prevents scheduling one more capped request.
|
|
760
|
+
if (
|
|
761
|
+
hiddenTurnKind === "retry" &&
|
|
762
|
+
maxDelayRetries >= retryConfig.maxRetriesAtMaxDelay
|
|
763
|
+
) {
|
|
764
|
+
notifySafely(
|
|
765
|
+
`Retry failed ${retryConfig.maxRetriesAtMaxDelay} times at the maximum backoff (${formatDuration(retryConfig.maxDelayMs)}); giving up.`,
|
|
766
|
+
"warning",
|
|
767
|
+
);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
730
770
|
}
|
|
731
771
|
} finally {
|
|
732
|
-
// Release the mutex only if this loop still owns it.
|
|
772
|
+
// Release the mutex only if this loop still owns it. If the session was
|
|
773
|
+
// replaced, session_shutdown clears ownership and suppresses the event
|
|
774
|
+
// because the captured pi.events bus is stale by this point.
|
|
733
775
|
if (_continueGeneration === myGeneration) {
|
|
734
|
-
|
|
735
|
-
retryId: retryLifecycleId,
|
|
736
|
-
});
|
|
776
|
+
const sessionIsCurrent = _sessionGeneration === myGeneration;
|
|
737
777
|
_continueInProgress = false;
|
|
738
778
|
_continueGeneration = null;
|
|
739
779
|
_continueInputGeneration = null;
|
|
780
|
+
if (sessionIsCurrent) {
|
|
781
|
+
emitRetryLifecycleEvent(
|
|
782
|
+
didRetryComplete ? RETRY_COMPLETED_EVENT : RETRY_CANCELLED_EVENT,
|
|
783
|
+
retryLifecycleId,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
740
786
|
}
|
|
741
787
|
}
|
|
742
788
|
}
|
|
@@ -745,7 +791,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
745
791
|
// ctx.ui.notify is only available inside event handlers, not inside
|
|
746
792
|
// triggerInvisibleContinue. We capture a fresh reference from the
|
|
747
793
|
// most recent handler invocation so it's always current.
|
|
748
|
-
|
|
794
|
+
|
|
795
|
+
function isStaleContextError(error: unknown): boolean {
|
|
796
|
+
return error instanceof Error && error.message.includes("This extension ctx is stale");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function emitRetryLifecycleEvent(event: string, retryId: number): void {
|
|
800
|
+
try {
|
|
801
|
+
pi.events.emit(event, { retryId });
|
|
802
|
+
} catch (error) {
|
|
803
|
+
// Session replacement invalidates the old event bus while a retry loop
|
|
804
|
+
// can still be unwinding. There is no live listener to notify then.
|
|
805
|
+
if (!isStaleContextError(error)) throw error;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function notifySafely(message: string, level: "info" | "warning" | "error"): void {
|
|
810
|
+
if (!_notifyFn) return;
|
|
811
|
+
try {
|
|
812
|
+
_notifyFn(message, level);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
// The notification closure can outlive the session that supplied its ctx.
|
|
815
|
+
if (isStaleContextError(error)) {
|
|
816
|
+
_notifyFn = null;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
throw error;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
749
822
|
|
|
750
823
|
// Refresh on every handler that carries a ctx — stale references
|
|
751
824
|
// break after session switches (the old ctx becomes invalid).
|
|
@@ -760,9 +833,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
760
833
|
});
|
|
761
834
|
|
|
762
835
|
function _notifyRetryAttempt(attempt: number, delayMs: number) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
_notifyFn(`Retry attempt ${attempt} (backoff ${duration})...`, "info");
|
|
766
|
-
}
|
|
836
|
+
const duration = formatDuration(delayMs);
|
|
837
|
+
notifySafely(`Retry attempt ${attempt} (backoff ${duration})...`, "info");
|
|
767
838
|
}
|
|
768
839
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings-backed configuration for the pi-retry extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_BACKOFF_CONFIG,
|
|
10
|
+
type BackoffConfig,
|
|
11
|
+
} from "./retry-logic.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Complete retry configuration used by the extension.
|
|
15
|
+
*/
|
|
16
|
+
export interface PiRetryConfig extends BackoffConfig {
|
|
17
|
+
/** Number of failed retries allowed after the delay reaches maxDelayMs. */
|
|
18
|
+
maxRetriesAtMaxDelay: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default values preserve the extension's existing behavior while making it
|
|
23
|
+
* possible to tune the schedule through settings.json.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_RETRY_CONFIG: PiRetryConfig = {
|
|
26
|
+
...DEFAULT_BACKOFF_CONFIG,
|
|
27
|
+
maxRetriesAtMaxDelay: 3,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Root settings key owned by this extension. */
|
|
31
|
+
export const PI_RETRY_SETTINGS_KEY = "piRetry";
|
|
32
|
+
|
|
33
|
+
type SettingsObject = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check whether a decoded JSON value can be read as a settings object.
|
|
37
|
+
*
|
|
38
|
+
* @param value Decoded JSON value to inspect.
|
|
39
|
+
* @returns True when the value is a non-array object.
|
|
40
|
+
*/
|
|
41
|
+
function isSettingsObject(value: unknown): value is SettingsObject {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read one numeric setting and fall back when it fails validation.
|
|
47
|
+
*
|
|
48
|
+
* @param settings Settings namespace to inspect.
|
|
49
|
+
* @param key Setting name to read.
|
|
50
|
+
* @param fallback Default value used for invalid or missing input.
|
|
51
|
+
* @param isValid Predicate for the accepted numeric range.
|
|
52
|
+
* @returns A validated number.
|
|
53
|
+
*/
|
|
54
|
+
function readNumberSetting(
|
|
55
|
+
settings: SettingsObject,
|
|
56
|
+
key: string,
|
|
57
|
+
fallback: number,
|
|
58
|
+
isValid: (value: number) => boolean,
|
|
59
|
+
): number {
|
|
60
|
+
const value = settings[key];
|
|
61
|
+
if (value === undefined) return fallback;
|
|
62
|
+
if (typeof value === "number" && Number.isFinite(value) && isValid(value)) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Invalid settings should not prevent pi from starting; use the field's
|
|
67
|
+
// default and make the configuration error visible to the user.
|
|
68
|
+
console.warn(
|
|
69
|
+
`[pi-retry] Ignoring invalid ${PI_RETRY_SETTINGS_KEY}.${key} value: ${String(value)}`,
|
|
70
|
+
);
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract the extension namespace from a decoded settings file.
|
|
76
|
+
*
|
|
77
|
+
* @param settings Decoded settings file contents.
|
|
78
|
+
* @returns The piRetry namespace, or an empty object when absent/invalid.
|
|
79
|
+
*/
|
|
80
|
+
function getRetrySettings(settings: unknown): SettingsObject {
|
|
81
|
+
if (!isSettingsObject(settings)) return {};
|
|
82
|
+
const retrySettings = settings[PI_RETRY_SETTINGS_KEY];
|
|
83
|
+
return isSettingsObject(retrySettings) ? retrySettings : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merge global and project namespaces, then validate every supported field.
|
|
88
|
+
*
|
|
89
|
+
* @param globalSettings Decoded global settings.json contents.
|
|
90
|
+
* @param projectSettings Decoded project settings.json contents.
|
|
91
|
+
* @returns The validated effective retry configuration.
|
|
92
|
+
*
|
|
93
|
+
* TEST:__tests__/unit/config.test.ts[resolvePiRetryConfig]
|
|
94
|
+
*/
|
|
95
|
+
export function resolvePiRetryConfig(
|
|
96
|
+
globalSettings: unknown,
|
|
97
|
+
projectSettings: unknown,
|
|
98
|
+
): PiRetryConfig {
|
|
99
|
+
const mergedSettings = {
|
|
100
|
+
...getRetrySettings(globalSettings),
|
|
101
|
+
...getRetrySettings(projectSettings),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
baseDelayMs: readNumberSetting(
|
|
106
|
+
mergedSettings,
|
|
107
|
+
"baseDelayMs",
|
|
108
|
+
DEFAULT_RETRY_CONFIG.baseDelayMs,
|
|
109
|
+
value => value >= 0,
|
|
110
|
+
),
|
|
111
|
+
maxDelayMs: readNumberSetting(
|
|
112
|
+
mergedSettings,
|
|
113
|
+
"maxDelayMs",
|
|
114
|
+
DEFAULT_RETRY_CONFIG.maxDelayMs,
|
|
115
|
+
value => value >= 0,
|
|
116
|
+
),
|
|
117
|
+
multiplier: readNumberSetting(
|
|
118
|
+
mergedSettings,
|
|
119
|
+
"multiplier",
|
|
120
|
+
DEFAULT_RETRY_CONFIG.multiplier,
|
|
121
|
+
value => value >= 1,
|
|
122
|
+
),
|
|
123
|
+
maxRetriesAtMaxDelay: readNumberSetting(
|
|
124
|
+
mergedSettings,
|
|
125
|
+
"maxRetriesAtMaxDelay",
|
|
126
|
+
DEFAULT_RETRY_CONFIG.maxRetriesAtMaxDelay,
|
|
127
|
+
value => Number.isInteger(value) && value >= 1,
|
|
128
|
+
),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Load and merge Pi's global and project settings files.
|
|
134
|
+
*
|
|
135
|
+
* @param cwd Project working directory containing .pi/settings.json.
|
|
136
|
+
* @param homeDirectory Home directory containing .pi/agent/settings.json.
|
|
137
|
+
* @returns The validated effective retry configuration.
|
|
138
|
+
*
|
|
139
|
+
* TEST:__tests__/unit/config.test.ts[loadPiRetryConfig]
|
|
140
|
+
*/
|
|
141
|
+
export function loadPiRetryConfig(
|
|
142
|
+
cwd: string = process.cwd(),
|
|
143
|
+
homeDirectory: string = homedir(),
|
|
144
|
+
): PiRetryConfig {
|
|
145
|
+
const globalSettings = readSettingsFile(
|
|
146
|
+
join(homeDirectory, ".pi", "agent", "settings.json"),
|
|
147
|
+
);
|
|
148
|
+
const projectSettings = readSettingsFile(join(cwd, ".pi", "settings.json"));
|
|
149
|
+
return resolvePiRetryConfig(globalSettings, projectSettings);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read a settings file without making startup dependent on optional files.
|
|
154
|
+
*
|
|
155
|
+
* @param filePath Absolute settings file path.
|
|
156
|
+
* @returns Decoded JSON contents, or an empty object when unavailable.
|
|
157
|
+
*/
|
|
158
|
+
function readSettingsFile(filePath: string): unknown {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const code = isSettingsObject(error) && typeof error.code === "string"
|
|
163
|
+
? error.code
|
|
164
|
+
: undefined;
|
|
165
|
+
if (code !== "ENOENT") {
|
|
166
|
+
console.warn(
|
|
167
|
+
`[pi-retry] Could not read settings file ${filePath}: ${String(error)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|