@monotykamary/pi-retry 0.3.0 → 0.3.1

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 +51 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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
@@ -60,6 +60,12 @@ const stateOther = new RetryState();
60
60
  // Max_tokens continuation state (indefinite — no cap needed)
61
61
  const stateContinuation = new ContinuationState();
62
62
 
63
+ // Mutex: only one triggerInvisibleContinue may be in-flight at a time.
64
+ // Without this, concurrent agent_end events (or a manual /retry during an
65
+ // automatic retry) race through waitForIdle() and both call prompt([]),
66
+ // producing "Agent is already processing".
67
+ let _continueInProgress = false;
68
+
63
69
  // Sleep helper
64
70
  function sleep(ms: number): Promise<void> {
65
71
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -110,7 +116,8 @@ export default function (pi: ExtensionAPI) {
110
116
  `Max tokens reached — auto-continuing (continuation ${stateContinuation.getCount()})...`,
111
117
  "info",
112
118
  );
113
- triggerInvisibleContinue();
119
+ // Must NOT await — see triggerInvisibleContinue() for explanation
120
+ void triggerInvisibleContinue();
114
121
  stateContinuation.endContinuation();
115
122
  return;
116
123
  }
@@ -143,7 +150,8 @@ export default function (pi: ExtensionAPI) {
143
150
  const delay = calculateDelay(state.getAttempt());
144
151
 
145
152
  await sleep(delay);
146
- triggerInvisibleContinue();
153
+ // Must NOT await — see triggerInvisibleContinue() for explanation
154
+ void triggerInvisibleContinue();
147
155
  state.endRetry();
148
156
  return;
149
157
  }
@@ -244,7 +252,7 @@ export default function (pi: ExtensionAPI) {
244
252
  // Auto-detect: max_tokens continuation takes priority
245
253
  if (hasMaxTokensStop(lastAssistant)) {
246
254
  ctx.ui.notify("Manually continuing after max_tokens...", "info");
247
- triggerInvisibleContinue();
255
+ void triggerInvisibleContinue();
248
256
  return;
249
257
  }
250
258
 
@@ -252,21 +260,21 @@ export default function (pi: ExtensionAPI) {
252
260
  if (has400or413Error(lastAssistant)) {
253
261
  ctx.ui.notify("Manually retrying 400/413 error...", "info");
254
262
  state400.reset();
255
- triggerInvisibleContinue();
263
+ void triggerInvisibleContinue();
256
264
  return;
257
265
  }
258
266
 
259
267
  if (hasCreditError(lastAssistant)) {
260
268
  ctx.ui.notify("Manually retrying credit error...", "info");
261
269
  stateCredit.reset();
262
- triggerInvisibleContinue();
270
+ void triggerInvisibleContinue();
263
271
  return;
264
272
  }
265
273
 
266
274
  if (hasConnectionError(lastAssistant)) {
267
275
  ctx.ui.notify("Manually retrying connection error...", "info");
268
276
  stateConnection.reset();
269
- triggerInvisibleContinue();
277
+ void triggerInvisibleContinue();
270
278
  return;
271
279
  }
272
280
 
@@ -274,7 +282,7 @@ export default function (pi: ExtensionAPI) {
274
282
  if (hasRetryableError(lastAssistant)) {
275
283
  ctx.ui.notify("Manually retrying error...", "info");
276
284
  stateOther.reset();
277
- triggerInvisibleContinue();
285
+ void triggerInvisibleContinue();
278
286
  return;
279
287
  }
280
288
 
@@ -290,12 +298,46 @@ export default function (pi: ExtensionAPI) {
290
298
  stateConnection.reset();
291
299
  stateOther.reset();
292
300
  stateContinuation.reset();
301
+ _continueInProgress = false;
293
302
  });
294
303
 
295
304
  // Resume the agent loop invisibly — no message injected into context.
296
305
  // The LLM sees the exact same message list it had before.
297
- function triggerInvisibleContinue() {
306
+ //
307
+ // IMPORTANT: We must wait for the agent to become idle before calling
308
+ // prompt(). The agent_end event fires while the active run is still
309
+ // set (it's cleared in the finally block, after all listeners settle).
310
+ // Calling prompt() inside an agent_end listener would otherwise throw
311
+ // "Agent is already processing a prompt" because activeRun is truthy.
312
+ //
313
+ // GUARDS (three layers):
314
+ // 1. _continueInProgress mutex — prevents concurrent calls from racing
315
+ // 2. isStreaming pre-flight — detects user-initiated runs before prompt()
316
+ // 3. try/catch — final safety net, swallows the error gracefully
317
+ async function triggerInvisibleContinue() {
298
318
  if (!_agent) return;
299
- _agent.prompt([]);
319
+
320
+ // Guard 1: mutex — if a previous continue is still in-flight, skip
321
+ if (_continueInProgress) return;
322
+ _continueInProgress = true;
323
+
324
+ try {
325
+ await _agent.waitForIdle();
326
+
327
+ // Guard 2: pre-flight — the user may have sent a message while we
328
+ // waited for idle. agent.state.isStreaming is authoritative (read
329
+ // directly from the activeRun field, no TOCTOU beyond the next line).
330
+ if (_agent.state.isStreaming) return;
331
+
332
+ try {
333
+ _agent.prompt([]);
334
+ } catch {
335
+ // Guard 3: catch the "already processing" error as a last resort.
336
+ // This handles the remaining microtask TOCTOU gap between the
337
+ // isStreaming check and prompt() acquiring the lock.
338
+ }
339
+ } finally {
340
+ _continueInProgress = false;
341
+ }
300
342
  }
301
343
  }