@loicngr/kobo 1.11.43 → 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 (144) hide show
  1. package/AGENTS.md +36 -1
  2. package/CHANGELOG.md +6 -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 +1 -0
  13. package/dist/server/services/agent/engines/codex/engine.js +91 -31
  14. package/dist/server/services/agent/orchestrator.js +237 -88
  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-D1e-zV3O.js → ActivityFeed-D_y4aikp.js} +1 -1
  34. package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.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-B4ne55Xj.js → ComparisonPanel-DDxcsxyC.js} +1 -1
  37. package/src/client/dist/pwa/assets/{CreatePage-Bx_GpUni.js → CreatePage-DKVEknbL.js} +2 -2
  38. package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-Dg2s9nnv.js} +1 -1
  39. package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-BSzNScIr.js} +4 -4
  40. package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-DfEoI_f7.js} +1 -1
  41. package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.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-XrOuMoDJ.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-163oM6T_.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-BQWY7aeM.js → QTooltip-cDK5AOQ5.js} +1 -1
  61. package/src/client/dist/pwa/assets/{SchedulePanel-Bos94OyR.js → SchedulePanel-LvHiZ6ZH.js} +1 -1
  62. package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BO-J4aNc.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SettingsPage-DtGsd0XH.js → SettingsPage-Aw89fRSq.js} +2 -2
  64. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-BIEceNwH.js} +1 -1
  65. package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-DitOCad1.js} +1 -1
  66. package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-DvPAhZsa.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-CJYdH3Jf.js} +1 -1
  68. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.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-Bkx4fbxl.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-BU0dILBs.js → de-Mu7biceO.js} +2 -2
  74. package/src/client/dist/pwa/assets/documents-SCd3w8xV.js +1 -0
  75. package/src/client/dist/pwa/assets/{editor.api-DreEG0If.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-CNp0s9na.js → es-BY5IfNI2.js} +2 -2
  81. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +1 -0
  82. package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-BjQxf4pe.js} +3 -3
  83. package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-BejhmNTV.js} +1 -1
  84. package/src/client/dist/pwa/assets/{fr-RqJyWSdm.js → fr-BMXCzFCM.js} +2 -2
  85. package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-5d5GhyRp.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DaafEsDK.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-iZL3bbXq.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-DSxNuCwL.js → it-ByPZQK_K.js} +2 -2
  92. package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DSNIEEH5.js} +1 -1
  93. package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-Dma1UpA1.js} +1 -1
  94. package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-D8rqEAws.js} +1 -1
  95. package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-2E7O2zoi.js} +1 -1
  96. package/src/client/dist/pwa/assets/{monaco.contribution-D7NmJUJH.js → monaco.contribution-DqOgo72G.js} +2 -2
  97. package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-DNmhJTZn.js} +1 -1
  98. package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-B6Hiwvwb.js} +1 -1
  99. package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.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-CihwpDAA.js → project-color-BCp9SWoI.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-f-9NqRlI.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.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-VVzxoeyM.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-1ui80Kqo.js → workspace-templates-DUWDLMIX.js} +1 -1
  116. package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-Plt-Y7we.js} +1 -1
  117. package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.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-C9mz4GXF.js +0 -3
  123. package/src/client/dist/pwa/assets/MainLayout-BwV89R-l.css +0 -1
  124. package/src/client/dist/pwa/assets/MainLayout-CwWavQjV.js +0 -2
  125. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
  126. package/src/client/dist/pwa/assets/QDrawer-DNEj8wLZ.js +0 -1
  127. package/src/client/dist/pwa/assets/QSelect-BUlOQe5k.js +0 -1
  128. package/src/client/dist/pwa/assets/WorkspacePage-Clc4Fl3B.js +0 -4
  129. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
  130. package/src/client/dist/pwa/assets/build-path-tree-BpSY7N5O.js +0 -1
  131. package/src/client/dist/pwa/assets/documents-BIlWh3o-.js +0 -1
  132. package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
  133. package/src/client/dist/pwa/assets/efforts-D_yTzymv.js +0 -9
  134. package/src/client/dist/pwa/assets/engineFeatures-C33LTVkK.js +0 -1
  135. package/src/client/dist/pwa/assets/expand-template-wRhAXT-1.js +0 -1
  136. package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  137. package/src/client/dist/pwa/assets/i18n--PCet00a.js +0 -1
  138. package/src/client/dist/pwa/assets/index-CPfrY38K.js +0 -20
  139. package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
  140. package/src/client/dist/pwa/assets/permissionModes-DC51Pz84.js +0 -36
  141. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
  142. package/src/client/dist/pwa/assets/render-chat-markdown-BQ_saiy5.js +0 -67
  143. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
  144. package/src/client/dist/pwa/assets/use-tours-BKeYrJaQ.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;
@@ -1204,7 +1322,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1204
1322
  let agentSessionId;
1205
1323
  let resumeFromEngineSessionId;
1206
1324
  if (resume) {
1207
- const r = resolveSessionForResume(workspaceId, existingSessionId, model);
1325
+ const r = resolveSessionForResume(workspaceId, existingSessionId, model, engineId);
1208
1326
  agentSessionId = r.agentSessionId;
1209
1327
  resumeFromEngineSessionId = r.engineSessionId;
1210
1328
  // A native resume is only used by the currently selected workspace engine.
@@ -1331,6 +1449,7 @@ export function interruptAgent(workspaceId, options = {}) {
1331
1449
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1332
1450
  async function stopController(workspaceId, ctrl, cause = 'user') {
1333
1451
  ctrl.stopCause = cause;
1452
+ restoreReviewConfiguration(workspaceId, ctrl.agentSessionId);
1334
1453
  preCompactionStatus.delete(ctrl);
1335
1454
  // Normalize the state synchronously so callers (archive, delete, manual
1336
1455
  // stop) see a clean workspace immediately — without waiting for the async
@@ -1932,15 +2051,17 @@ function clearStaleEngineSessionId(workspaceId) {
1932
2051
  }
1933
2052
  }
1934
2053
  /** Retryable upstream failures for an auto-loop iteration (not coding errors). */
1935
- 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;
1936
2055
  const QUOTA_SAFETY_MARGIN_MS = 30_000;
1937
- 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;
1938
2059
  const QUOTA_SATURATION_THRESHOLD_PCT = 95;
1939
2060
  /**
1940
2061
  * Fallback backoff ladder (in minutes) used when the `rate_limit` info
1941
2062
  * isn't usable. Indexed by `retryCount`; anything past the last entry
1942
- * clamps to the final value (5 h) — long enough to cross a weekly
1943
- * 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.
1944
2065
  */
1945
2066
  const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
1946
2067
  /**
@@ -1975,9 +2096,11 @@ export async function computeQuotaBackoffMs(workspaceId, retryCount, preferQuota
1975
2096
  }
1976
2097
  // 1.5. Try the official usage API (Claude subscription). Best-effort; never throws.
1977
2098
  try {
1978
- const snap = await refreshNow('claude-code');
2099
+ const snap = (getWs(workspaceId)?.engine ?? 'claude-code') === 'claude-code' ? await refreshNow('claude-code') : null;
1979
2100
  if (snap) {
1980
- 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];
1981
2104
  if (fiveHour &&
1982
2105
  typeof fiveHour.usedPct === 'number' &&
1983
2106
  fiveHour.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT &&
@@ -2019,23 +2142,26 @@ async function handleQuota(workspaceId, _agentSessionId) {
2019
2142
  // would otherwise kill Kōbō and every agent running under it.
2020
2143
  try {
2021
2144
  const retryCount = retryCounts.get(workspaceId) ?? 0;
2022
- const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
2023
- const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
2024
- if (autoLoopEnabled && retryCount >= maxRetries) {
2025
- autoLoopService.disable(workspaceId, 'error');
2026
- try {
2027
- updateWorkspaceStatus(workspaceId, 'error');
2028
- }
2029
- catch {
2030
- // The loop is disabled even if an already-terminal status rejects this transition.
2031
- }
2032
- return;
2033
- }
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
+ });
2034
2158
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
2035
- // Usage lookup can finish after the agent has already resumed.
2036
- 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))
2037
2164
  return;
2038
- retryCounts.set(workspaceId, retryCount + 1);
2039
2165
  // The quotaBackoffService owns the timer + the persistent row + the
2040
2166
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
2041
2167
  quotaBackoffService.arm(workspaceId, delayMs, {
@@ -2055,13 +2181,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
2055
2181
  }
2056
2182
  }
2057
2183
  /**
2058
- * Last resort when the retry machinery itself fails: stop the loop and move the
2059
- * workspace to a status the UI actually surfaces, so the user sees something to
2060
- * 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.
2061
2186
  */
2062
2187
  function failVisibly(workspaceId) {
2063
2188
  try {
2064
- autoLoopService.disable(workspaceId, 'error');
2189
+ autoLoopService.block(workspaceId, 'retry-unavailable');
2065
2190
  }
2066
2191
  catch (err) {
2067
2192
  console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
@@ -2077,6 +2202,18 @@ function failVisibly(workspaceId) {
2077
2202
  * quota ladder's 15-minute floor is tuned for real quota windows — a forced
2078
2203
  * kill or a server blip deserves a much faster first attempt. */
2079
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
+ }
2080
2217
  /**
2081
2218
  * Use the same persisted retry path for temporary upstream failures (HTTP 500
2082
2219
  * and overloads) while deliberately ignoring quota-reset data: a server error
@@ -2092,10 +2229,10 @@ async function handleTransientAutoLoopFailure(workspaceId) {
2092
2229
  // Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
2093
2230
  // unhandled-rejection policy mean a failure here must stay contained.
2094
2231
  try {
2095
- const retryCount = retryCounts.get(workspaceId) ?? 0;
2232
+ const retryCount = retryReasons.get(workspaceId) === 'quota' ? 0 : (retryCounts.get(workspaceId) ?? 0);
2096
2233
  const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
2097
2234
  if (retryCount >= maxRetries) {
2098
- autoLoopService.disable(workspaceId, 'error');
2235
+ autoLoopService.block(workspaceId, 'retry-exhausted');
2099
2236
  try {
2100
2237
  updateWorkspaceStatus(workspaceId, 'error');
2101
2238
  }
@@ -2104,12 +2241,14 @@ async function handleTransientAutoLoopFailure(workspaceId) {
2104
2241
  }
2105
2242
  return;
2106
2243
  }
2107
- 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;
2108
2246
  const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
2109
2247
  retryCounts.set(workspaceId, retryCount + 1);
2248
+ retryReasons.set(workspaceId, 'transient');
2110
2249
  quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
2111
- resetsAt: resetsAt ?? null,
2112
- source,
2250
+ resetsAt: null,
2251
+ source: 'fallback_ladder',
2113
2252
  reason: 'transient',
2114
2253
  retryCount: retryCount + 1,
2115
2254
  });
@@ -2132,8 +2271,18 @@ export const _handleTransientAutoLoopFailure = handleTransientAutoLoopFailure;
2132
2271
  export function restoreRetryCountsFromDb() {
2133
2272
  for (const pending of quotaBackoffService.listPending()) {
2134
2273
  retryCounts.set(pending.workspaceId, pending.retryCount);
2274
+ retryReasons.set(pending.workspaceId, pending.reason);
2135
2275
  }
2136
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
+ }
2137
2286
  // One-time wire: when the persisted backoff timer fires (or a row is
2138
2287
  // restored at boot), hand the workspace off to auto-loop. The auto-loop
2139
2288
  // service decides whether to spawn the next iteration or fall back to a
@@ -2142,8 +2291,8 @@ export function restoreRetryCountsFromDb() {
2142
2291
  // IMPORTANT — behavioural contract: only auto-loop workspaces auto-resume
2143
2292
  // after a quota backoff. `onQuotaBackoffExpired` no-ops if `auto_loop !== 1`
2144
2293
  // (see auto-loop-service). Workspaces hit by quota WITHOUT auto-loop stay
2145
- // in `quota` status and require manual user action (resume / new message)
2146
- // 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,
2147
2296
  // firing a fresh agent run in the user's absence would surprise them.
2148
2297
  quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
2149
2298
  autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
@@ -19,6 +19,19 @@ export class SessionController {
19
19
  * is awaiting the engine.
20
20
  */
21
21
  _stopRequested = false;
22
+ _sessionEnded = false;
23
+ _isClosed = false;
24
+ resolveClosed;
25
+ closed = new Promise((resolve) => {
26
+ this.resolveClosed = resolve;
27
+ });
28
+ get isClosed() {
29
+ return this._isClosed;
30
+ }
31
+ confirmClosed() {
32
+ this._isClosed = true;
33
+ this.resolveClosed();
34
+ }
22
35
  /**
23
36
  * Wall-clock creation time. The watchdog needs it: `engineProcess` stays
24
37
  * undefined until `engine.start` resolves, and without a grace window any
@@ -63,13 +76,31 @@ export class SessionController {
63
76
  // until the next boot — blocking deleteSession and tagging the next
64
77
  // messages onto a ghost session.
65
78
  if (this._stopRequested) {
79
+ this.confirmClosed();
66
80
  this.handle({ kind: 'session:ended', reason: 'killed', exitCode: null });
67
81
  return;
68
82
  }
69
- const process = await this.engine.start(options, (ev) => this.handle(ev));
83
+ let process;
84
+ try {
85
+ process = await this.engine.start(options, (ev) => this.handle(ev));
86
+ }
87
+ catch (error) {
88
+ this.confirmClosed();
89
+ throw error;
90
+ }
70
91
  this._engineProcess = process;
92
+ if (process.closed) {
93
+ // A failed closure is never evidence that the runtime is gone.
94
+ void process.closed.then(() => this.confirmClosed()).catch(() => { });
95
+ }
96
+ else if (this._sessionEnded) {
97
+ this.confirmClosed();
98
+ }
71
99
  if (this._status === 'stopping') {
72
100
  await process.stop();
101
+ if (process.closed)
102
+ await process.closed;
103
+ this.confirmClosed();
73
104
  this._engineProcess = undefined;
74
105
  return;
75
106
  }
@@ -112,8 +143,12 @@ export class SessionController {
112
143
  // the stop, hence the swallowed rejection.
113
144
  if (this._startPromise)
114
145
  await this._startPromise.catch(() => { });
115
- if (this._engineProcess)
146
+ if (this._engineProcess) {
116
147
  await this._engineProcess.stop();
148
+ if (this._engineProcess.closed)
149
+ await this._engineProcess.closed;
150
+ }
151
+ this.confirmClosed();
117
152
  }
118
153
  get status() {
119
154
  return this._status;
@@ -126,6 +161,11 @@ export class SessionController {
126
161
  }
127
162
  handle(ev) {
128
163
  this._lastEventAt = Date.now();
164
+ if (ev.kind === 'session:ended') {
165
+ this._sessionEnded = true;
166
+ if (this._engineProcess && !this._engineProcess.closed)
167
+ this.confirmClosed();
168
+ }
129
169
  this.onEvent(ev);
130
170
  }
131
171
  }