@loicngr/kobo 1.11.42 → 1.11.44

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 (148) hide show
  1. package/AGENTS.md +49 -4
  2. package/CHANGELOG.md +10 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +31 -74
  4. package/dist/mcp-server/kobo-tasks-server.js +51 -4
  5. package/dist/server/db/auto-loop-schema.js +44 -0
  6. package/dist/server/db/migrations.js +4 -0
  7. package/dist/server/db/review-return-schema.js +10 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/index.js +10 -9
  10. package/dist/server/routes/auto-loop-messages.js +51 -0
  11. package/dist/server/routes/workspaces.js +46 -136
  12. package/dist/server/services/agent/engines/claude-code/engine.js +2 -0
  13. package/dist/server/services/agent/engines/codex/engine.js +91 -31
  14. package/dist/server/services/agent/orchestrator.js +242 -99
  15. package/dist/server/services/agent/session-controller.js +42 -2
  16. package/dist/server/services/auto-loop-message-service.js +79 -0
  17. package/dist/server/services/auto-loop-service.js +225 -86
  18. package/dist/server/services/auto-loop-state-service.js +30 -0
  19. package/dist/server/services/cron-service.js +30 -16
  20. package/dist/server/services/quota-backoff-service.js +42 -10
  21. package/dist/server/services/review-return-service.js +64 -0
  22. package/dist/server/services/review-service.js +187 -0
  23. package/dist/server/services/settings-service.js +3 -3
  24. package/dist/server/services/task-mutations.js +186 -0
  25. package/dist/server/services/wakeup-service.js +16 -1
  26. package/dist/server/services/workspace-message-service.js +16 -2
  27. package/dist/server/services/workspace-service.js +25 -36
  28. package/dist/shared/auto-loop-prompts.js +22 -11
  29. package/dist/shared/auto-loop-types.js +1 -0
  30. package/dist/shared/review.js +6 -0
  31. package/dist/shared/task-verification.js +11 -0
  32. package/package.json +12 -12
  33. package/src/client/dist/pwa/assets/{ActivityFeed-CyCGH0Zr.js → ActivityFeed-D_y4aikp.js} +1 -1
  34. package/src/client/dist/pwa/assets/{ChangelogPage-B6xjI-I4.js → ChangelogPage-DSKCvpr2.js} +1 -1
  35. package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
  36. package/src/client/dist/pwa/assets/{ComparisonPanel-BmRzCu1c.js → ComparisonPanel-DDxcsxyC.js} +1 -1
  37. package/src/client/dist/pwa/assets/{CreatePage-DHe0x63K.js → CreatePage-DKVEknbL.js} +2 -2
  38. package/src/client/dist/pwa/assets/{DashboardPage-CRTkx9P-.js → DashboardPage-Dg2s9nnv.js} +1 -1
  39. package/src/client/dist/pwa/assets/{DiffViewer-BJfLiYGs.js → DiffViewer-BSzNScIr.js} +4 -4
  40. package/src/client/dist/pwa/assets/{DocumentsPanel-B9jjzoqY.js → DocumentsPanel-DfEoI_f7.js} +1 -1
  41. package/src/client/dist/pwa/assets/{DrawerToggleButton-CBZpPa30.js → DrawerToggleButton-D85PwhKb.js} +1 -1
  42. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +3 -0
  43. package/src/client/dist/pwa/assets/{HealthPage-9VEvoR_4.js → HealthPage-CaLCpejR.js} +1 -1
  44. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +2 -0
  45. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +1 -0
  46. package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
  48. package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
  49. package/src/client/dist/pwa/assets/{QBtnDropdown-Cnkq_5eu.js → QBtnDropdown-qJWGcZpC.js} +1 -1
  50. package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
  51. package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
  53. package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +1 -0
  54. package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
  55. package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
  56. package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
  57. package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
  58. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +1 -0
  59. package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QTooltip-WIoDeLoZ.js → QTooltip-cDK5AOQ5.js} +1 -1
  61. package/src/client/dist/pwa/assets/{SchedulePanel-tVggDx-C.js → SchedulePanel-LvHiZ6ZH.js} +1 -1
  62. package/src/client/dist/pwa/assets/{SearchPage-l4m1qxKs.js → SearchPage-BO-J4aNc.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SettingsPage-msHMBS7q.js → SettingsPage-Aw89fRSq.js} +2 -2
  64. package/src/client/dist/pwa/assets/{SplitWorkspacePage-AXMYd-9b.js → SplitWorkspacePage-BIEceNwH.js} +1 -1
  65. package/src/client/dist/pwa/assets/{TerminalPanel-CENXl8po.js → TerminalPanel-DitOCad1.js} +1 -1
  66. package/src/client/dist/pwa/assets/{TouchPan-DaEpS0Vf.js → TouchPan-DvPAhZsa.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TourReplayButton-U3mwT0fV.js → TourReplayButton-CJYdH3Jf.js} +1 -1
  68. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CpTA6VGb.js → WorkspaceAttentionLabels-CY-9z9nw.js} +1 -1
  69. package/src/client/dist/pwa/assets/WorkspacePage-DAVo-E4p.css +1 -0
  70. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +4 -0
  71. package/src/client/dist/pwa/assets/{browser-B4pgwnXH.js → browser-BsB6cFv4.js} +1 -1
  72. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +1 -0
  73. package/src/client/dist/pwa/assets/de-Mu7biceO.js +17 -0
  74. package/src/client/dist/pwa/assets/documents-SCd3w8xV.js +1 -0
  75. package/src/client/dist/pwa/assets/{editor.api-qQNqM6Y6.js → editor.api-BUdM7I6y.js} +1 -1
  76. package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
  77. package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +9 -0
  78. package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
  79. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +1 -0
  80. package/src/client/dist/pwa/assets/es-BY5IfNI2.js +17 -0
  81. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +1 -0
  82. package/src/client/dist/pwa/assets/{format-BVgznNN8.js → format-BjQxf4pe.js} +3 -3
  83. package/src/client/dist/pwa/assets/{formatters-DV1VAk5B.js → formatters-BejhmNTV.js} +1 -1
  84. package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +17 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-Nt_Z9A9p.js → freemarker2-5d5GhyRp.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-sJjXCA98.js → handlebars-DaafEsDK.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-B2wF0NKO.js → html-CwHBDd2A.js} +1 -1
  88. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +1 -0
  89. package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
  90. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +20 -0
  91. package/src/client/dist/pwa/assets/it-ByPZQK_K.js +17 -0
  92. package/src/client/dist/pwa/assets/{javascript-BPbCoYVZ.js → javascript-DSNIEEH5.js} +1 -1
  93. package/src/client/dist/pwa/assets/{liquid-DBwLDEnw.js → liquid-Dma1UpA1.js} +1 -1
  94. package/src/client/dist/pwa/assets/{mdx-D_Sphj-P.js → mdx-D8rqEAws.js} +1 -1
  95. package/src/client/dist/pwa/assets/{monaco-contributions-BBjimLYs.js → monaco-contributions-2E7O2zoi.js} +1 -1
  96. package/src/client/dist/pwa/assets/{monaco.contribution-DNP7HNKq.js → monaco.contribution-DqOgo72G.js} +2 -2
  97. package/src/client/dist/pwa/assets/{network-auth-BLGlHVxR.js → network-auth-DNmhJTZn.js} +1 -1
  98. package/src/client/dist/pwa/assets/{notify-theme-tvaTlaFw.js → notify-theme-B6Hiwvwb.js} +1 -1
  99. package/src/client/dist/pwa/assets/{pending-diff-open-CxtcrwVN.js → pending-diff-open-CeiVIFZn.js} +1 -1
  100. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +36 -0
  101. package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
  102. package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
  103. package/src/client/dist/pwa/assets/{project-color-D75XmBNA.js → project-color-BCp9SWoI.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BdBTal4f.js → python-f-9NqRlI.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-gJNlRRte.js → razor-DsCmoX0h.js} +1 -1
  106. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
  107. package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +67 -0
  108. package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-DAGixRVW.js → typescript-cEHg4L10.js} +1 -1
  110. package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
  111. package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
  113. package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
  114. package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +2 -0
  115. package/src/client/dist/pwa/assets/{workspace-templates-BoGUaV1C.js → workspace-templates-DUWDLMIX.js} +1 -1
  116. package/src/client/dist/pwa/assets/{xml-BC9pt2ZZ.js → xml-Plt-Y7we.js} +1 -1
  117. package/src/client/dist/pwa/assets/{yaml-CQpOdW-l.js → yaml-ScEmaAeN.js} +1 -1
  118. package/src/client/dist/pwa/index.html +10 -10
  119. package/src/client/dist/pwa/sw.js +1 -1
  120. package/src/mcp-server/kobo-tasks-handlers.ts +68 -104
  121. package/src/mcp-server/kobo-tasks-server.ts +56 -4
  122. package/src/client/dist/pwa/assets/GitPanel-gqySqt7G.js +0 -3
  123. package/src/client/dist/pwa/assets/MainLayout-BM14JTBB.js +0 -2
  124. package/src/client/dist/pwa/assets/MainLayout-Dhj28mOe.css +0 -1
  125. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
  126. package/src/client/dist/pwa/assets/QDrawer-C3b-lbmv.js +0 -1
  127. package/src/client/dist/pwa/assets/QSelect-DJL6yy1E.js +0 -1
  128. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
  129. package/src/client/dist/pwa/assets/WorkspacePage-DE6-wlf7.js +0 -4
  130. package/src/client/dist/pwa/assets/build-path-tree-6ENXqh7O.js +0 -1
  131. package/src/client/dist/pwa/assets/de-D9gPXN_a.js +0 -17
  132. package/src/client/dist/pwa/assets/documents-D88lOIxR.js +0 -1
  133. package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
  134. package/src/client/dist/pwa/assets/efforts-CMQi0Rp2.js +0 -9
  135. package/src/client/dist/pwa/assets/engineFeatures-BPcXoXFd.js +0 -1
  136. package/src/client/dist/pwa/assets/es-B4Q1ncyP.js +0 -17
  137. package/src/client/dist/pwa/assets/expand-template-DuMdsD-k.js +0 -1
  138. package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  139. package/src/client/dist/pwa/assets/fr-Qv-edVJn.js +0 -17
  140. package/src/client/dist/pwa/assets/i18n-eiyc92m_.js +0 -1
  141. package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
  142. package/src/client/dist/pwa/assets/index-DtsrXdzx.js +0 -20
  143. package/src/client/dist/pwa/assets/it-D-qzfo9T.js +0 -17
  144. package/src/client/dist/pwa/assets/permissionModes-CESk18Vk.js +0 -36
  145. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
  146. package/src/client/dist/pwa/assets/render-chat-markdown-K3tUW_1p.js +0 -67
  147. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
  148. package/src/client/dist/pwa/assets/use-tours-CS5oBFk6.js +0 -2
@@ -4,12 +4,14 @@ import { getDb } from '../../db/index.js';
4
4
  import { assertAgentStopped } from '../../utils/agent-stop-result.js';
5
5
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
6
6
  import { assertWorkspaceLifecycleAvailable } from '../../utils/workspace-lifecycle-guard.js';
7
+ import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
7
8
  import * as autoLoopService from '../auto-loop-service.js';
8
9
  import * as cleanupScriptService from '../cleanup-script-service.js';
9
10
  import * as cronService from '../cron-service.js';
10
11
  import { resolveForge } from '../forge/resolve.js';
11
12
  import * as lifecycleHookService from '../lifecycle-hook-service.js';
12
13
  import * as quotaBackoffService from '../quota-backoff-service.js';
14
+ import { buildReviewReturnPrompt, getReviewReturn, restoreReviewConfiguration } from '../review-return-service.js';
13
15
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
14
16
  import { refreshNow } from '../usage/poller.js';
15
17
  import * as wakeupService from '../wakeup-service.js';
@@ -41,6 +43,8 @@ export function getBackendPort() {
41
43
  }
42
44
  /** workspaceId -> SessionController */
43
45
  const controllers = new Map();
46
+ const pendingTerminalControllers = new WeakSet();
47
+ const finalizedControllers = new WeakSet();
44
48
  /** Replacements waiting for the previous controller to release its worktree. */
45
49
  const pendingStarts = new Map();
46
50
  let shuttingDown = false;
@@ -261,6 +265,10 @@ let availableSkills = (() => {
261
265
  })();
262
266
  /** workspaceId -> retry count (for quota backoff) */
263
267
  const retryCounts = new Map();
268
+ const retryReasons = new Map();
269
+ const quotaLookups = new Map();
270
+ /** Multiple error surfaces and the terminal watchdog can describe one incident. */
271
+ const transientRecoveryControllers = new WeakSet();
264
272
  /** Tracks agent sessions that failed due to a stale --resume session ID. */
265
273
  const resumeFailedSessions = new Map();
266
274
  // ── Watchdog ──────────────────────────────────────────────────────────────────
@@ -285,7 +293,7 @@ function runWatchdog() {
285
293
  // window) must not be raced here: doing so would report a false "Agent
286
294
  // process died unexpectedly", force the workspace to `error`, and set an
287
295
  // unread badge — all on a stop the user explicitly asked for.
288
- if (ctrl.status === 'stopping')
296
+ if (ctrl.status === 'stopping' || pendingTerminalControllers.has(ctrl))
289
297
  continue;
290
298
  const ep = ctrl.engineProcess;
291
299
  if (!ep) {
@@ -294,6 +302,17 @@ function runWatchdog() {
294
302
  if (Date.now() - ctrl.startedAt < CONTROLLER_STARTUP_GRACE_MS)
295
303
  continue;
296
304
  console.error(`[watchdog] Controller for workspace '${workspaceId}' produced no engine process within ${CONTROLLER_STARTUP_GRACE_MS}ms — cleaning up`);
305
+ // The start may still complete and acquire a process. Keep ownership
306
+ // until its stop is confirmed instead of dispatching a second writer.
307
+ void stopAgentAndWait(workspaceId, STOP_AGENT_TIMEOUT_MS, 'replacement').then((outcome) => {
308
+ if (outcome === 'timeout' || outcome === 'failed') {
309
+ autoLoopService.block(workspaceId, 'engine-start-timeout');
310
+ }
311
+ else if (getWs(workspaceId)?.autoLoop && !shuttingDown) {
312
+ scheduleTransientAutoLoopRecovery(workspaceId, ctrl);
313
+ }
314
+ });
315
+ continue;
297
316
  }
298
317
  else {
299
318
  // D1 — the pid is never a liveness criterion. The Claude engine exposes
@@ -304,50 +323,23 @@ function runWatchdog() {
304
323
  continue;
305
324
  console.error(`[watchdog] Agent engine for workspace '${workspaceId}' reports dead — cleaning up`);
306
325
  }
307
- // Emit an error + session:ended AgentEvent pair so clients can react uniformly
326
+ // Use the same terminal path as an engine watchdog: it preserves the
327
+ // loop's retry intent and releases capacity only after actual closure.
308
328
  try {
309
- routeEvent(workspaceId, ctrl.agentSessionId, {
329
+ handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
310
330
  kind: 'error',
311
331
  category: 'other',
312
332
  message: 'Agent process died unexpectedly',
313
333
  });
314
- routeEvent(workspaceId, ctrl.agentSessionId, {
334
+ handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
315
335
  kind: 'session:ended',
316
- reason: 'killed',
336
+ reason: getWs(workspaceId)?.autoLoop ? 'watchdog' : 'error',
317
337
  exitCode: null,
318
338
  });
319
339
  }
320
340
  catch (err) {
321
- console.warn('[watchdog] Failed to route death notification events:', err);
322
- }
323
- if (controllers.get(workspaceId) === ctrl) {
324
- controllers.delete(workspaceId);
325
- notifyCapacityAvailable(workspaceId);
326
- }
327
- preCompactionStatus.delete(ctrl);
328
- retryCounts.delete(workspaceId);
329
- // This end never goes through handleEvent → onSessionEnded, so the user's
330
- // hook would otherwise miss precisely the case it is most useful for.
331
- fireSessionEndedHook(workspaceId, ctrl.agentSessionId, 'killed', null, ctrl);
332
- try {
333
- const db = getDb();
334
- db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
335
- }
336
- catch (err) {
337
- console.error('[watchdog] Failed to update agent_sessions:', err);
338
- }
339
- try {
340
- updateWorkspaceStatus(workspaceId, 'error');
341
- }
342
- catch (err) {
343
- console.warn('[watchdog] Failed to transition workspace to error (likely invalid transition):', err);
344
- }
345
- try {
346
- markWorkspaceUnread(workspaceId);
347
- emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
348
- }
349
- catch (err) {
350
- console.warn('[watchdog] Failed to mark workspace unread:', err);
341
+ console.warn('[watchdog] Failed to recover a dead agent:', err);
342
+ autoLoopService.block(workspaceId, 'engine-recovery-failed');
351
343
  }
352
344
  }
353
345
  }
@@ -531,7 +523,7 @@ function buildAgentPrompt(prompt, projectPath) {
531
523
  prompt,
532
524
  ].join('\n');
533
525
  }
534
- function resolveSessionForResume(workspaceId, existingSessionId, model) {
526
+ function resolveSessionForResume(workspaceId, existingSessionId, model, engineId) {
535
527
  const db = getDb();
536
528
  let lastSession;
537
529
  if (existingSessionId) {
@@ -542,13 +534,18 @@ function resolveSessionForResume(workspaceId, existingSessionId, model) {
542
534
  throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
543
535
  'session not found or has no associated engine conversation');
544
536
  }
537
+ if ((lastSession.engine ?? 'claude-code') !== engineId) {
538
+ throw new Error(`Cannot resume session '${existingSessionId}' with engine '${engineId}': the conversation belongs to '${lastSession.engine}'`);
539
+ }
545
540
  }
546
541
  else {
547
542
  lastSession = db
548
- .prepare('SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
549
- .get(workspaceId);
543
+ .prepare("SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND COALESCE(engine, 'claude-code') = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1")
544
+ .get(workspaceId, engineId);
550
545
  }
551
- const engineSessionId = lastSession?.engine_session_id ?? (existingSessionId ? undefined : sessionIds.get(workspaceId));
546
+ // A workspace can now contain conversations from multiple engines. The
547
+ // unscoped in-memory ID may belong to the reviewer, so only trust its DB row.
548
+ const engineSessionId = lastSession?.engine_session_id;
552
549
  if (engineSessionId) {
553
550
  const existingId = lastSession?.id ??
554
551
  db
@@ -615,6 +612,10 @@ function captureTaskProgressBaseline(workspaceId, agentSessionId) {
615
612
  getDb()
616
613
  .prepare('UPDATE agent_sessions SET task_progress_baseline = ? WHERE id = ?')
617
614
  .run(JSON.stringify(snapshot), agentSessionId);
615
+ const baseline = JSON.parse(snapshot.stateSignature || '[]');
616
+ const insert = getDb().prepare('INSERT OR IGNORE INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)');
617
+ for (const task of baseline)
618
+ insert.run(workspaceId, task.id, taskMilestone(task.status));
618
619
  }
619
620
  catch (err) {
620
621
  console.warn('[orchestrator] Failed to persist task progress baseline:', err);
@@ -687,7 +688,7 @@ function getTaskStateSignature(workspaceId) {
687
688
  try {
688
689
  const db = getDb();
689
690
  const rows = db
690
- .prepare('SELECT id, status, updated_at FROM tasks WHERE workspace_id = ? ORDER BY id')
691
+ .prepare('SELECT id, status FROM tasks WHERE workspace_id = ? ORDER BY id')
691
692
  .all(workspaceId);
692
693
  return JSON.stringify(rows);
693
694
  }
@@ -696,6 +697,42 @@ function getTaskStateSignature(workspaceId) {
696
697
  return '';
697
698
  }
698
699
  }
700
+ function taskMilestone(status) {
701
+ return status === 'done' ? 2 : status === 'in_progress' ? 1 : 0;
702
+ }
703
+ /** Credit each forward task milestone only once during the current loop cycle. */
704
+ function consumeMonotoneTaskProgress(workspaceId, snapshot) {
705
+ if (!snapshot)
706
+ return 0;
707
+ try {
708
+ const before = new Map(JSON.parse(snapshot.stateSignature || '[]').map((task) => [
709
+ task.id,
710
+ taskMilestone(task.status),
711
+ ]));
712
+ const db = getDb();
713
+ return db.transaction(() => {
714
+ const rows = db
715
+ .prepare(`SELECT tasks.id, tasks.status, COALESCE(auto_loop_progress.milestone, 0) AS milestone
716
+ FROM tasks LEFT JOIN auto_loop_progress ON auto_loop_progress.workspace_id = tasks.workspace_id
717
+ AND auto_loop_progress.task_id = tasks.id WHERE tasks.workspace_id = ?`)
718
+ .all(workspaceId);
719
+ const record = db.prepare(`INSERT INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)
720
+ ON CONFLICT(workspace_id, task_id) DO UPDATE SET milestone = MAX(milestone, excluded.milestone)`);
721
+ let progressed = false;
722
+ for (const task of rows) {
723
+ const milestone = taskMilestone(task.status);
724
+ if (milestone > (before.get(task.id) ?? 0) && milestone > task.milestone)
725
+ progressed = true;
726
+ record.run(workspaceId, task.id, milestone);
727
+ }
728
+ return progressed ? 1 : 0;
729
+ })();
730
+ }
731
+ catch (err) {
732
+ console.warn('[orchestrator] Failed to record monotone task progress:', err);
733
+ return 0;
734
+ }
735
+ }
699
736
  /** Clear the in-memory done-count snapshot for a workspace (called on delete). */
700
737
  export function forgetTasksDoneSnapshot(workspaceId) {
701
738
  taskProgressSnapshots.delete(workspaceId);
@@ -732,7 +769,42 @@ export function forgetSessionId(workspaceId) {
732
769
  sessionIds.delete(workspaceId);
733
770
  sessionLifecycleOwners.delete(workspaceId);
734
771
  }
735
- function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
772
+ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminalAfterClose = false) {
773
+ if (sourceController && !terminalAfterClose) {
774
+ if (ev.kind === 'session:ended') {
775
+ if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController))
776
+ return;
777
+ // Real engines may announce their logical result before their writer
778
+ // exits. Delay all terminal effects, including review return and quota
779
+ // retries, until SessionController confirms closure.
780
+ if (!sourceController.isClosed && (sourceController.engineProcess?.closed || !sourceController.engineProcess)) {
781
+ pendingTerminalControllers.add(sourceController);
782
+ const closeDeadline = setTimeout(() => {
783
+ if (controllers.get(workspaceId) === sourceController && !sourceController.isClosed) {
784
+ autoLoopService.block(workspaceId, 'engine-stop-unconfirmed');
785
+ }
786
+ }, STOP_AGENT_TIMEOUT_MS);
787
+ closeDeadline.unref?.();
788
+ void sourceController.closed
789
+ .then(() => {
790
+ clearTimeout(closeDeadline);
791
+ pendingTerminalControllers.delete(sourceController);
792
+ finalizedControllers.add(sourceController);
793
+ handleEvent(workspaceId, agentSessionId, sourceController, ev, true);
794
+ })
795
+ .catch((err) => {
796
+ clearTimeout(closeDeadline);
797
+ console.error('[orchestrator] Terminal recovery failed:', err);
798
+ autoLoopService.block(workspaceId, 'engine-recovery-failed');
799
+ });
800
+ return;
801
+ }
802
+ finalizedControllers.add(sourceController);
803
+ }
804
+ else if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController)) {
805
+ return;
806
+ }
807
+ }
736
808
  const registeredController = controllers.get(workspaceId);
737
809
  const hasReplacement = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
738
810
  const sourceNoLongerOwnsWorkspace = sourceController !== undefined && registeredController !== sourceController;
@@ -789,7 +861,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
789
861
  if (ev.kind === 'tool:call' && !sourceControllerIsStopping && getWs(workspaceId)?.status === 'quota') {
790
862
  updateWorkspaceStatus(workspaceId, 'executing');
791
863
  quotaBackoffService.cancel(workspaceId, 'completed');
792
- retryCounts.delete(workspaceId);
864
+ resetAutoLoopRetries(workspaceId);
793
865
  emitEphemeral(workspaceId, 'agent:quota-recovered', {});
794
866
  }
795
867
  if (ev.kind === 'rate_limit') {
@@ -886,15 +958,19 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
886
958
  console.error('[orchestrator] Failed to transition to executing:', err);
887
959
  }
888
960
  }
889
- if (ev.kind === 'error' && ev.category === 'quota' && !sourceControllerIsStopping) {
961
+ const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
962
+ const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
963
+ if (quotaError && !sourceControllerIsStopping && !returningReview) {
890
964
  void handleQuota(workspaceId, agentSessionId);
891
965
  }
892
966
  if (ev.kind === 'error' &&
893
- ev.category === 'other' &&
967
+ !quotaError &&
968
+ ev.category !== 'resume_failed' &&
894
969
  TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
970
+ !returningReview &&
895
971
  getWs(workspaceId)?.autoLoop &&
896
972
  !sourceControllerIsStopping) {
897
- void handleTransientAutoLoopFailure(workspaceId);
973
+ scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
898
974
  }
899
975
  if (ev.kind === 'error' && ev.category === 'resume_failed') {
900
976
  rememberResumeFailed(workspaceId, agentSessionId);
@@ -908,12 +984,6 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
908
984
  markSessionLifecycleOwnerEnded(workspaceId, agentSessionId, sourceController);
909
985
  const isResumeFailed = consumeResumeFailed(workspaceId, agentSessionId);
910
986
  const snapshot = consumeTaskProgressSnapshot(workspaceId, agentSessionId);
911
- const before = snapshot?.doneCount ?? getDoneTaskCount(workspaceId);
912
- const after = getDoneTaskCount(workspaceId);
913
- const completedDelta = Math.max(0, after - before);
914
- const taskStateBefore = snapshot?.stateSignature ?? getTaskStateSignature(workspaceId);
915
- const taskStateAfter = getTaskStateSignature(workspaceId);
916
- const progressDelta = completedDelta > 0 || taskStateBefore !== taskStateAfter ? 1 : 0;
917
987
  clearPendingForSession(workspaceId, agentSessionId);
918
988
  // A completed/failed SDK session cannot resolve canUseTool anymore. Drop
919
989
  // its persisted prompts too, otherwise a reconnect resurrects a panel
@@ -932,7 +1002,11 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
932
1002
  // dead-engine branch evicts a controller from the map WITHOUT ever
933
1003
  // calling `.stop()` on it, so an old controller can still be 'running'
934
1004
  // by the time its own drain watchdog reports a late `session:ended`.
935
- const watchdogRecovery = !hasReplacement && ev.reason === 'watchdog' && getWs(workspaceId)?.autoLoop === true;
1005
+ const watchdogRecovery = !returningReview &&
1006
+ !hasReplacement &&
1007
+ ev.reason === 'watchdog' &&
1008
+ getWs(workspaceId)?.autoLoop === true &&
1009
+ autoLoopService.getStatus(workspaceId).state !== 'blocked';
936
1010
  // A drain watchdog can fire mid-`stop()`: `stopController` already cancelled
937
1011
  // any pending quota backoff (`quotaBackoffService.cancel(id, 'user')`), and
938
1012
  // re-arming one here would resurrect a session the user just stopped — the
@@ -943,14 +1017,58 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
943
1017
  // could matter.
944
1018
  if (watchdogRecovery && !sourceControllerIsStopping) {
945
1019
  console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
946
- void handleTransientAutoLoopFailure(workspaceId);
1020
+ scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
947
1021
  }
948
1022
  // Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
949
1023
  // startAgent, otherwise startAgent throws "Agent already running" because
950
1024
  // the just-ended controller is still in the map.
951
1025
  const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
1026
+ // A technical stop deliberately skips normal loop continuation, but its
1027
+ // instructions still need a definitive delivery state after closure.
1028
+ // A superseded controller must never settle a replacement's instructions.
1029
+ let instructionIntake = false;
1030
+ if (!hasReplacement) {
1031
+ instructionIntake = settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed') > 0;
1032
+ if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown')) {
1033
+ restoreReviewConfiguration(workspaceId, agentSessionId);
1034
+ autoLoopService.block(workspaceId, 'message-delivery-unknown');
1035
+ if (ownsWorkspaceLifecycle)
1036
+ notifyCapacityAvailable(workspaceId);
1037
+ return;
1038
+ }
1039
+ }
952
1040
  if (!ownsWorkspaceLifecycle)
953
1041
  return;
1042
+ const progressDelta = consumeMonotoneTaskProgress(workspaceId, snapshot);
1043
+ if (returningReview) {
1044
+ const pending = restoreReviewConfiguration(workspaceId, agentSessionId);
1045
+ const workspace = getWs(workspaceId);
1046
+ if (pending &&
1047
+ workspace &&
1048
+ !shuttingDown &&
1049
+ !workspace.archivedAt &&
1050
+ !workspace.worktreePurgedAt &&
1051
+ autoLoopService.getStatus(workspaceId).state !== 'blocked' &&
1052
+ (ev.reason === 'completed' || ev.reason === 'watchdog') &&
1053
+ (ev.exitCode === null || ev.exitCode === 0)) {
1054
+ try {
1055
+ const prompt = buildReviewReturnPrompt(workspaceId, agentSessionId);
1056
+ const original = pending.original;
1057
+ startAgent(workspaceId, workspace.worktreePath, prompt, original.sessionModel ?? original.model, true, original.agentPermissionMode, pending.originalSessionId, original.reasoningEffort);
1058
+ updateWorkspaceStatus(workspaceId, 'executing');
1059
+ emit(workspaceId, 'user:message', { content: prompt, sender: 'system-prompt' }, pending.originalSessionId);
1060
+ }
1061
+ catch (err) {
1062
+ routeEvent(workspaceId, agentSessionId, {
1063
+ kind: 'error',
1064
+ category: 'other',
1065
+ message: `Could not return to the original session: ${err instanceof Error ? err.message : String(err)}`,
1066
+ });
1067
+ }
1068
+ }
1069
+ notifyCapacityAvailable(workspaceId);
1070
+ return;
1071
+ }
954
1072
  if (watchdogRecovery) {
955
1073
  notifyCapacityAvailable(workspaceId);
956
1074
  return;
@@ -963,7 +1081,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
963
1081
  // disable() clears it, and the cleanup hook needs to know whether this was
964
1082
  // a mid-loop session (never cleans) or a standalone one.
965
1083
  const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
966
- autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
1084
+ autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta, instructionIntake);
967
1085
  // A slot just freed. Any auto-loop workspace parked on the concurrency
968
1086
  // limit has no session of its own to bring it back; this one does it.
969
1087
  autoLoopService.resumeWaitingWorkspaces();
@@ -1083,7 +1201,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1083
1201
  controllers.delete(workspaceId);
1084
1202
  }
1085
1203
  if (!preserveQuotaBackoff) {
1086
- retryCounts.delete(workspaceId);
1204
+ resetAutoLoopRetries(workspaceId);
1087
1205
  }
1088
1206
  if (wasStopping)
1089
1207
  return false;
@@ -1107,17 +1225,11 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1107
1225
  // `reason` is authoritative (with the SDK engine `exitCode` is often null,
1108
1226
  // so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
1109
1227
  // `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
1110
- // 'watchdog' is a forced kill, never a success. For an auto-loop workspace,
1111
- // handleTransientAutoLoopFailure (called above, before onSessionEnded) runs
1112
- // synchronously up to its first await and writes status 'quota' as its very
1113
- // first statement — that write lands before `currentWorkspace` is read above
1114
- // (line ~1144), so `preserveQuotaBackoff` is already true and this function
1115
- // returns early at the `if (preserveQuotaBackoff) return true` branch above.
1116
- // This computation is therefore never reached at all for that path, not
1117
- // merely short-circuited afterwards — it only runs on session ends that
1118
- // don't go through the auto-loop transient-failure path.
1119
- const isErrorOutcome = !resumeFailed && (reason === 'error' || reason === 'watchdog' || (exitCode !== null && exitCode !== 0));
1120
- const targetStatus = isErrorOutcome ? 'error' : 'completed';
1228
+ // A watchdog closure leaves the workspace idle unless the engine actually
1229
+ // failed. Auto-loop watchdog recovery keeps its bounded quota backoff via
1230
+ // the preserveQuotaBackoff branch above.
1231
+ const isErrorOutcome = !resumeFailed && (reason === 'error' || (exitCode !== null && exitCode !== 0));
1232
+ const targetStatus = isErrorOutcome ? 'error' : reason === 'watchdog' ? 'idle' : 'completed';
1121
1233
  // Skip the transition when the workspace is already in a terminal state.
1122
1234
  // This happens when stopAgent (or an equivalent caller) synchronously
1123
1235
  // normalised the status before the engine's async stop emitted session:ended
@@ -1210,7 +1322,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1210
1322
  let agentSessionId;
1211
1323
  let resumeFromEngineSessionId;
1212
1324
  if (resume) {
1213
- const r = resolveSessionForResume(workspaceId, existingSessionId, model);
1325
+ const r = resolveSessionForResume(workspaceId, existingSessionId, model, engineId);
1214
1326
  agentSessionId = r.agentSessionId;
1215
1327
  resumeFromEngineSessionId = r.engineSessionId;
1216
1328
  // A native resume is only used by the currently selected workspace engine.
@@ -1337,6 +1449,7 @@ export function interruptAgent(workspaceId, options = {}) {
1337
1449
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1338
1450
  async function stopController(workspaceId, ctrl, cause = 'user') {
1339
1451
  ctrl.stopCause = cause;
1452
+ restoreReviewConfiguration(workspaceId, ctrl.agentSessionId);
1340
1453
  preCompactionStatus.delete(ctrl);
1341
1454
  // Normalize the state synchronously so callers (archive, delete, manual
1342
1455
  // stop) see a clean workspace immediately — without waiting for the async
@@ -1938,15 +2051,17 @@ function clearStaleEngineSessionId(workspaceId) {
1938
2051
  }
1939
2052
  }
1940
2053
  /** Retryable upstream failures for an auto-loop iteration (not coding errors). */
1941
- export const TRANSIENT_SERVER_ERROR_PATTERN = /\b(?:http\s*)?500\b|internal server error|service unavailable|temporarily unavailable|overloaded/i;
2054
+ export const TRANSIENT_SERVER_ERROR_PATTERN = /\b(?:http\s*)?(?:500|502|503|504)\b|internal server error|bad gateway|gateway timeout|service unavailable|temporarily unavailable|overloaded|\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EPIPE)\b|fetch failed|network (?:error|connection)|connection (?:reset|closed|terminated)|socket hang up|request timed out|stopped reporting activity/i;
1942
2055
  const QUOTA_SAFETY_MARGIN_MS = 30_000;
1943
- const QUOTA_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
2056
+ // Weekly and monthly provider windows are legitimate. The scheduler chunks
2057
+ // long delays so they never overflow Node's signed 32-bit timeout.
2058
+ const QUOTA_MAX_BACKOFF_MS = 366 * 24 * 60 * 60 * 1000;
1944
2059
  const QUOTA_SATURATION_THRESHOLD_PCT = 95;
1945
2060
  /**
1946
2061
  * Fallback backoff ladder (in minutes) used when the `rate_limit` info
1947
2062
  * isn't usable. Indexed by `retryCount`; anything past the last entry
1948
- * clamps to the final value (5 h) — long enough to cross a weekly
1949
- * bucket reset if the rate_limit info truly never arrives.
2063
+ * clamps to the final value (5 h). Genuine quota retries retain their intent
2064
+ * until recovery, including when no provider reset time is available.
1950
2065
  */
1951
2066
  const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
1952
2067
  /**
@@ -1981,9 +2096,11 @@ export async function computeQuotaBackoffMs(workspaceId, retryCount, preferQuota
1981
2096
  }
1982
2097
  // 1.5. Try the official usage API (Claude subscription). Best-effort; never throws.
1983
2098
  try {
1984
- const snap = await refreshNow('claude-code');
2099
+ const snap = (getWs(workspaceId)?.engine ?? 'claude-code') === 'claude-code' ? await refreshNow('claude-code') : null;
1985
2100
  if (snap) {
1986
- const fiveHour = snap.buckets.find((b) => b.id === 'five_hour');
2101
+ const fiveHour = snap.buckets
2102
+ .filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && b.resetsAt)
2103
+ .sort((a, b) => Date.parse(b.resetsAt) - Date.parse(a.resetsAt))[0];
1987
2104
  if (fiveHour &&
1988
2105
  typeof fiveHour.usedPct === 'number' &&
1989
2106
  fiveHour.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT &&
@@ -2025,23 +2142,26 @@ async function handleQuota(workspaceId, _agentSessionId) {
2025
2142
  // would otherwise kill Kōbō and every agent running under it.
2026
2143
  try {
2027
2144
  const retryCount = retryCounts.get(workspaceId) ?? 0;
2028
- const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
2029
- const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
2030
- if (autoLoopEnabled && retryCount >= maxRetries) {
2031
- autoLoopService.disable(workspaceId, 'error');
2032
- try {
2033
- updateWorkspaceStatus(workspaceId, 'error');
2034
- }
2035
- catch {
2036
- // The loop is disabled even if an already-terminal status rejects this transition.
2037
- }
2038
- return;
2039
- }
2145
+ const generation = Symbol('quota-lookup');
2146
+ quotaLookups.set(workspaceId, generation);
2147
+ retryCounts.set(workspaceId, retryCount + 1);
2148
+ retryReasons.set(workspaceId, 'quota');
2149
+ // Establish durable ownership BEFORE the usage provider can yield. A
2150
+ // restart or failed lookup retains this conservative fallback deadline.
2151
+ const fallbackIndex = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
2152
+ quotaBackoffService.arm(workspaceId, QUOTA_FALLBACK_LADDER_MINUTES[fallbackIndex] * 60_000, {
2153
+ resetsAt: null,
2154
+ source: 'fallback_ladder',
2155
+ reason: 'quota',
2156
+ retryCount: retryCount + 1,
2157
+ });
2040
2158
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
2041
- // Usage lookup can finish after the agent has already resumed.
2042
- if (getWs(workspaceId)?.status !== 'quota')
2159
+ // A stop, recovery, replacement or newer quota may supersede this lookup.
2160
+ if (quotaLookups.get(workspaceId) !== generation)
2161
+ return;
2162
+ quotaLookups.delete(workspaceId);
2163
+ if (getWs(workspaceId)?.status !== 'quota' || !quotaBackoffService.getPending(workspaceId))
2043
2164
  return;
2044
- retryCounts.set(workspaceId, retryCount + 1);
2045
2165
  // The quotaBackoffService owns the timer + the persistent row + the
2046
2166
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
2047
2167
  quotaBackoffService.arm(workspaceId, delayMs, {
@@ -2061,13 +2181,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
2061
2181
  }
2062
2182
  }
2063
2183
  /**
2064
- * Last resort when the retry machinery itself fails: stop the loop and move the
2065
- * workspace to a status the UI actually surfaces, so the user sees something to
2066
- * act on instead of a workspace that has quietly stopped moving.
2184
+ * Last resort when retry scheduling fails: retain loop intent in a visible
2185
+ * blocked state so an explicit human resume can recover it.
2067
2186
  */
2068
2187
  function failVisibly(workspaceId) {
2069
2188
  try {
2070
- autoLoopService.disable(workspaceId, 'error');
2189
+ autoLoopService.block(workspaceId, 'retry-unavailable');
2071
2190
  }
2072
2191
  catch (err) {
2073
2192
  console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
@@ -2083,6 +2202,18 @@ function failVisibly(workspaceId) {
2083
2202
  * quota ladder's 15-minute floor is tuned for real quota windows — a forced
2084
2203
  * kill or a server blip deserves a much faster first attempt. */
2085
2204
  const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
2205
+ /** Charge an incident once, until this controller demonstrably starts working again. */
2206
+ function scheduleTransientAutoLoopRecovery(workspaceId, controller) {
2207
+ if (controller && transientRecoveryControllers.has(controller))
2208
+ return;
2209
+ // Preserve whichever backoff already owns the next start, including a real
2210
+ // provider quota followed by its engine's drain-watchdog diagnostic.
2211
+ if (getWs(workspaceId)?.status === 'quota' && quotaBackoffService.getPending(workspaceId))
2212
+ return;
2213
+ if (controller)
2214
+ transientRecoveryControllers.add(controller);
2215
+ void handleTransientAutoLoopFailure(workspaceId);
2216
+ }
2086
2217
  /**
2087
2218
  * Use the same persisted retry path for temporary upstream failures (HTTP 500
2088
2219
  * and overloads) while deliberately ignoring quota-reset data: a server error
@@ -2098,10 +2229,10 @@ async function handleTransientAutoLoopFailure(workspaceId) {
2098
2229
  // Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
2099
2230
  // unhandled-rejection policy mean a failure here must stay contained.
2100
2231
  try {
2101
- const retryCount = retryCounts.get(workspaceId) ?? 0;
2232
+ const retryCount = retryReasons.get(workspaceId) === 'quota' ? 0 : (retryCounts.get(workspaceId) ?? 0);
2102
2233
  const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
2103
2234
  if (retryCount >= maxRetries) {
2104
- autoLoopService.disable(workspaceId, 'error');
2235
+ autoLoopService.block(workspaceId, 'retry-exhausted');
2105
2236
  try {
2106
2237
  updateWorkspaceStatus(workspaceId, 'error');
2107
2238
  }
@@ -2110,12 +2241,14 @@ async function handleTransientAutoLoopFailure(workspaceId) {
2110
2241
  }
2111
2242
  return;
2112
2243
  }
2113
- const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
2244
+ const idx = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
2245
+ const delayMs = QUOTA_FALLBACK_LADDER_MINUTES[idx] * 60_000;
2114
2246
  const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
2115
2247
  retryCounts.set(workspaceId, retryCount + 1);
2248
+ retryReasons.set(workspaceId, 'transient');
2116
2249
  quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
2117
- resetsAt: resetsAt ?? null,
2118
- source,
2250
+ resetsAt: null,
2251
+ source: 'fallback_ladder',
2119
2252
  reason: 'transient',
2120
2253
  retryCount: retryCount + 1,
2121
2254
  });
@@ -2138,8 +2271,18 @@ export const _handleTransientAutoLoopFailure = handleTransientAutoLoopFailure;
2138
2271
  export function restoreRetryCountsFromDb() {
2139
2272
  for (const pending of quotaBackoffService.listPending()) {
2140
2273
  retryCounts.set(pending.workspaceId, pending.retryCount);
2274
+ retryReasons.set(pending.workspaceId, pending.reason);
2141
2275
  }
2142
2276
  }
2277
+ /** An explicit human resume starts a new bounded transient-recovery attempt. */
2278
+ export function resetAutoLoopRetries(workspaceId) {
2279
+ retryCounts.delete(workspaceId);
2280
+ retryReasons.delete(workspaceId);
2281
+ quotaLookups.delete(workspaceId);
2282
+ const controller = controllers.get(workspaceId);
2283
+ if (controller)
2284
+ transientRecoveryControllers.delete(controller);
2285
+ }
2143
2286
  // One-time wire: when the persisted backoff timer fires (or a row is
2144
2287
  // restored at boot), hand the workspace off to auto-loop. The auto-loop
2145
2288
  // service decides whether to spawn the next iteration or fall back to a
@@ -2148,8 +2291,8 @@ export function restoreRetryCountsFromDb() {
2148
2291
  // IMPORTANT — behavioural contract: only auto-loop workspaces auto-resume
2149
2292
  // after a quota backoff. `onQuotaBackoffExpired` no-ops if `auto_loop !== 1`
2150
2293
  // (see auto-loop-service). Workspaces hit by quota WITHOUT auto-loop stay
2151
- // in `quota` status and require manual user action (resume / new message)
2152
- // to leave that state. This is intentional: without an auto-loop intent,
2294
+ // in `quota` status until manual user action (resume / new message) or an
2295
+ // explicitly scheduled wakeup/cron resumes them after expiry. Without either intent,
2153
2296
  // firing a fresh agent run in the user's absence would surprise them.
2154
2297
  quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
2155
2298
  autoLoopService.onQuotaBackoffExpired(workspaceId, pending);