@monotykamary/pi-retry 0.5.0 → 0.6.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/retry.ts +58 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Extension suite for pi coding agent that handles 400/413 errors and connection errors with automatic retry",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
@@ -40,7 +40,7 @@
40
40
  "lint:dead": "knip --no-gitignore"
41
41
  },
42
42
  "devDependencies": {
43
- "@earendil-works/pi-agent-core": "^0.75.4",
43
+ "@earendil-works/pi-agent-core": "^0.79.1",
44
44
  "@earendil-works/pi-coding-agent": "0.75.4",
45
45
  "@types/node": "25.9.1",
46
46
  "@vitest/coverage-v8": "4.1.7",
package/retry.ts CHANGED
@@ -169,10 +169,30 @@ let _continueInProgress = false;
169
169
  // triggerInvisibleContinue just ran and the session's continue() unblocks.
170
170
  let _lastInvisibleContinueTime = 0;
171
171
 
172
- // Sleep helper (non-abortable used inside the retry loop outside
173
- // processEvents where no abort signal is available)
174
- function sleep(ms: number): Promise<void> {
175
- return new Promise(resolve => setTimeout(resolve, ms));
172
+ // Session generation counter: incremented on every session_start.
173
+ // The retry loop captures the current generation when it starts and exits
174
+ // when it changes — this handles /new and other session switches.
175
+ let _sessionGeneration = 0;
176
+
177
+ // Interruptible sleep: polls _userAborted and _sessionGeneration every
178
+ // 100ms. Returns true if interrupted (abort or session change), false if
179
+ // the full delay elapsed normally.
180
+ function interruptibleSleep(ms: number, generation: number): Promise<boolean> {
181
+ if (ms <= 0) return Promise.resolve(false);
182
+ return new Promise(resolve => {
183
+ const checkInterval = 100;
184
+ let elapsed = 0;
185
+ const timer = setInterval(() => {
186
+ elapsed += checkInterval;
187
+ if (_userAborted || _sessionGeneration !== generation) {
188
+ clearInterval(timer);
189
+ resolve(true);
190
+ } else if (elapsed >= ms) {
191
+ clearInterval(timer);
192
+ resolve(false);
193
+ }
194
+ }, checkInterval);
195
+ });
176
196
  }
177
197
 
178
198
  // Remove the error assistant message at the end of agent state, if present.
@@ -442,12 +462,20 @@ export default function (pi: ExtensionAPI) {
442
462
 
443
463
  // Initialize
444
464
  pi.on("session_start", async () => {
465
+ // Bump the generation counter so any in-flight retry loop from a
466
+ // previous session exits on its next checkpoint (within 100ms during
467
+ // backoff sleep, or immediately after prompt([]) returns).
468
+ _sessionGeneration++;
469
+
445
470
  state400.reset();
446
471
  stateCredit.reset();
447
472
  stateConnection.reset();
448
473
  stateOther.reset();
449
474
  stateContinuation.reset();
450
- _continueInProgress = false;
475
+ // Do NOT reset _continueInProgress here — the in-flight loop's
476
+ // finally block handles it conditionally (only if the generation
477
+ // hasn't changed since the loop started). Resetting it here would
478
+ // break the mutex invariant and could allow a second loop to start.
451
479
  _lastInvisibleContinueTime = 0;
452
480
  _userAborted = false;
453
481
  });
@@ -477,20 +505,24 @@ export default function (pi: ExtensionAPI) {
477
505
  if (_continueInProgress) return;
478
506
  _continueInProgress = true;
479
507
 
508
+ // Capture the current session generation. If /new fires while we're
509
+ // looping, _sessionGeneration will increment and the loop will exit.
510
+ const myGeneration = _sessionGeneration;
511
+
480
512
  try {
481
513
  // Wait for the current run to finish (activeRun resolves in
482
514
  // finishRun() after agent_end listeners return).
483
515
  await _agent.waitForIdle();
484
516
 
485
- // Re-check after waitForIdle: the user may have aborted while
486
- // we were waiting for the agent to become idle.
487
- if (_userAborted) return;
517
+ // Re-check after waitForIdle: the user may have aborted or the
518
+ // session may have changed while we were waiting.
519
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
488
520
 
489
521
  let attempt = 0;
490
522
 
491
- // Loop until success or abort.
523
+ // Loop until success, abort, or session change.
492
524
  while (true) {
493
- if (_userAborted) return;
525
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
494
526
 
495
527
  // Remove the error assistant message from agent state so
496
528
  // prompt([]) sends a clean context to the LLM.
@@ -502,14 +534,12 @@ export default function (pi: ExtensionAPI) {
502
534
  // Notify the user about the upcoming retry attempt.
503
535
  _notifyRetryAttempt(attempt, delay);
504
536
 
505
- // Sleep with backoff BEFORE the retry attempt.
506
- // This matches the built-in retry's UX: "Retrying (attempt N) in Xs..."
507
- // The sleep is safe: we are outside processEvents, the agent is
508
- // idle, and the user can press Escape to abort.
509
- await sleep(delay);
510
-
511
- // Re-check after sleep — user may have aborted during backoff.
512
- if (_userAborted) return;
537
+ // Interruptible sleep with backoff BEFORE the retry attempt.
538
+ // Polls _userAborted and _sessionGeneration every 100ms so ESC
539
+ // and /new take effect within 100ms instead of waiting for the
540
+ // full backoff (up to 60s).
541
+ const interrupted = await interruptibleSleep(delay, myGeneration);
542
+ if (interrupted) return;
513
543
 
514
544
  try {
515
545
  await _agent.prompt([]);
@@ -519,6 +549,10 @@ export default function (pi: ExtensionAPI) {
519
549
  return;
520
550
  }
521
551
 
552
+ // Re-check after prompt: the user may have hit ESC during the
553
+ // prompt, or /new may have fired — don't keep retrying.
554
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
555
+
522
556
  // prompt([]) completed. Check the result.
523
557
  if (!lastMessageIsRetryableError()) {
524
558
  // Success or non-error terminal state — exit the loop.
@@ -528,7 +562,12 @@ export default function (pi: ExtensionAPI) {
528
562
  // Error again — loop back for another attempt.
529
563
  }
530
564
  } finally {
531
- _continueInProgress = false;
565
+ // Only reset the mutex if the session hasn't changed since we
566
+ // started. If /new fired, a new retry loop may already own the
567
+ // mutex — resetting it here would clobber that.
568
+ if (_sessionGeneration === myGeneration) {
569
+ _continueInProgress = false;
570
+ }
532
571
  _lastInvisibleContinueTime = Date.now();
533
572
  }
534
573
  }