@monotykamary/pi-retry 0.3.5 → 0.3.6

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 +1 -1
  2. package/retry.ts +45 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
package/retry.ts CHANGED
@@ -43,14 +43,48 @@ import {
43
43
  // Capture the live Agent instance when AgentSession subscribes to it.
44
44
  // subscribe() is called during AgentSession construction — fires on both
45
45
  // fresh sessions and session resumes.
46
+ //
47
+ // We also monkey-patch continue() so the session's loop can never race
48
+ // our retry. Without this, observing isStreaming is a heuristic that
49
+ // misses the narrow window between our check and the session's call.
46
50
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
51
  let _agent: Agent | null = null;
52
+
48
53
  const _origSubscribe = Agent.prototype.subscribe as (...args: any[]) => any;
49
54
  Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
50
55
  _agent = this;
51
56
  return _origSubscribe.apply(this, args);
52
57
  };
53
58
 
59
+ // Monkey-patch continue() so the session's built-in retry loop cooperates
60
+ // with our _continueInProgress mutex instead of throwing "Agent is already
61
+ // processing" when we beat it to the punch.
62
+ const _origContinue = Agent.prototype.continue as (this: Agent) => Promise<unknown>;
63
+ Agent.prototype.continue = function (this: Agent) {
64
+ const self = this;
65
+ return (async () => {
66
+ // Wait while pi-retry is driving the agent so we don't double-dip.
67
+ while (_continueInProgress) {
68
+ await new Promise(r => setTimeout(r, 10));
69
+ }
70
+ try {
71
+ return await _origContinue.call(self);
72
+ } catch (e: any) {
73
+ // After pi-retry finishes, the transcript ends with a fresh
74
+ // assistant message. The session's continue() sees this and
75
+ // would throw "Cannot continue from an assistant message".
76
+ // Catch it — the while-loop will poll _handlePostAgentRun()
77
+ // again, find no error, and exit cleanly.
78
+ const msg = e?.message ?? '';
79
+ if (msg.includes('Cannot continue from an assistant message') ||
80
+ msg.includes('Agent is already processing')) {
81
+ return;
82
+ }
83
+ throw e;
84
+ }
85
+ })();
86
+ };
87
+
54
88
  // Per-category retry state (for diagnostics / messaging)
55
89
  const state400 = new RetryState();
56
90
  const stateCredit = new RetryState();
@@ -304,22 +338,11 @@ export default function (pi: ExtensionAPI) {
304
338
  // Resume the agent loop invisibly — no message injected into context.
305
339
  // The LLM sees the exact same message list it had before.
306
340
  //
307
- // CRITICAL: We must wait until the SESSION has fully finished before we
308
- // call prompt(). The agent_end event fires inside the current run, and
309
- // the session's _runAgentPrompt loop may still call continue() after the
310
- // run resolves (for built-in retries, compaction, etc.). If we call
311
- // prompt([]) while the session's continue() is about to run, one of
312
- // them gets "Agent is already processing".
313
- //
314
- // The solution: wait for isStreaming to be false AND stay false across
315
- // a microtask yield. This ensures the session's loop has fully exited
316
- // and won't call continue() under our feet.
317
- //
318
- // GUARDS (four layers):
319
- // 1. _continueInProgress mutex — prevents concurrent calls from racing
320
- // 2. waitForIdle + settle loop — waits for session to fully finish
321
- // 3. isStreaming pre-flight — detects user-initiated runs before prompt()
322
- // 4. .catch() on prompt() — final safety net, swallows rejected promises
341
+ // The continue() monkey-patch at the top of this file ensures the
342
+ // session's built-in retry loop can never race us. While
343
+ // _continueInProgress is true, the session's continue() waits.
344
+ // When we finish, it wakes, finds the transcript already updated,
345
+ // gracefully no-ops, and the session loop exits.
323
346
  async function triggerInvisibleContinue() {
324
347
  if (!_agent) return;
325
348
 
@@ -328,44 +351,13 @@ export default function (pi: ExtensionAPI) {
328
351
  _continueInProgress = true;
329
352
 
330
353
  try {
331
- // Guard 2: wait for the current run to finish, then verify the
332
- // session has no further continue() calls pending.
333
- //
334
- // The session's _runAgentPrompt loop:
335
- // await agent.prompt(messages);
336
- // while (await _handlePostAgentRun()) { await agent.continue(); }
337
- //
338
- // waitForIdle() resolves when the FIRST run's activeRun is cleared.
339
- // But the session may immediately start a new run via continue()
340
- // (built-in retry, compaction). We must keep waiting until
341
- // isStreaming stays false across a microtask yield — that proves
342
- // the session's loop has exited.
343
- const MAX_SETTLE_ATTEMPTS = 50;
344
- for (let i = 0; i < MAX_SETTLE_ATTEMPTS; i++) {
345
- await _agent.waitForIdle();
346
- if (!_agent.state.isStreaming) break;
347
- // Session started another run (built-in retry, compaction) — wait
348
- // for it to finish before checking again.
349
- }
350
- // Yield once more: if the session's _handlePostAgentRun returns true
351
- // synchronously, the while loop will call continue() on the next
352
- // microtask. By yielding, we give it a chance to start that run
353
- // so our next isStreaming check catches it.
354
- await new Promise(r => setTimeout(r, 0));
355
- if (_agent.state.isStreaming) {
356
- // Session is handling the retry/compaction itself. We don't need
357
- // to do anything — it will emit its own agent_end when done, which
358
- // may trigger this handler again for further retries.
359
- return;
360
- }
361
-
362
- // Guard 3: pre-flight — the user may have sent a message while we
363
- // waited. agent.state.isStreaming is authoritative.
364
- if (_agent.state.isStreaming) return;
354
+ // Wait for the current run to finish (activeRun resolves in
355
+ // finishRun() after agent_end listeners return).
356
+ await _agent.waitForIdle();
365
357
 
366
- // Guard 4: .catch() swallows the "already processing" error as a
367
- // last resort. agent.prompt() is async, so errors become rejected
368
- // Promises a try/catch around an un-awaited call catches nothing.
358
+ // Fire-and-forget with .catch() as final safety net.
359
+ // The continue() monkey-patch means the session can't collide
360
+ // with us even if it tries but .catch() is belt-and-suspenders.
369
361
  _agent.prompt([]).catch(() => {});
370
362
  } finally {
371
363
  _continueInProgress = false;