@loicngr/kobo 1.11.35 → 1.11.37

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 (102) hide show
  1. package/AGENTS.md +15 -0
  2. package/CHANGELOG.md +9 -0
  3. package/dist/server/index.js +11 -24
  4. package/dist/server/routes/dev-server.js +2 -1
  5. package/dist/server/routes/pull-requests.js +5 -4
  6. package/dist/server/routes/workspaces.js +122 -91
  7. package/dist/server/services/agent/engines/claude-code/engine.js +26 -0
  8. package/dist/server/services/agent/engines/claude-code/event-mapper.js +10 -10
  9. package/dist/server/services/agent/engines/codex/client.js +1 -0
  10. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  11. package/dist/server/services/agent/engines/codex/event-mapper.js +5 -1
  12. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  13. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  14. package/dist/server/services/agent/orchestrator.js +175 -68
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +71 -6
  17. package/dist/server/services/change-source-branch-service.js +36 -4
  18. package/dist/server/services/cron-service.js +19 -0
  19. package/dist/server/services/db-backup-service.js +30 -0
  20. package/dist/server/services/dev-server-service.js +167 -38
  21. package/dist/server/services/image-service.js +25 -28
  22. package/dist/server/services/pr-checkout-service.js +34 -5
  23. package/dist/server/services/quota-backoff-service.js +25 -8
  24. package/dist/server/services/wakeup-service.js +37 -3
  25. package/dist/server/services/worktree-purge-service.js +3 -7
  26. package/dist/server/utils/agent-stop-result.js +12 -0
  27. package/dist/server/utils/git-ops.js +8 -1
  28. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  29. package/dist/server/utils/safe-path.js +21 -0
  30. package/dist/server/utils/script-runner.js +32 -9
  31. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-BDyFBoxd.css} +1 -1
  34. package/src/client/dist/pwa/assets/ActivityFeed-C_-hxIo8.js +8 -0
  35. package/src/client/dist/pwa/assets/{ChangelogPage-DVLWWvvz.js → ChangelogPage-CBlGJ4zT.js} +1 -1
  36. package/src/client/dist/pwa/assets/{ComparisonPanel-LZrvk6a6.js → ComparisonPanel-DZAE2X9j.js} +1 -1
  37. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-ArmtcF4d.js +2 -0
  39. package/src/client/dist/pwa/assets/{DashboardPage-0odIhseb.js → DashboardPage-eQTCXcdz.js} +1 -1
  40. package/src/client/dist/pwa/assets/{DiffViewer-NWB2uQkz.js → DiffViewer-CaT7vuSr.js} +3 -3
  41. package/src/client/dist/pwa/assets/{DocumentsPanel-3yApHgbl.js → DocumentsPanel-A0isCTaI.js} +1 -1
  42. package/src/client/dist/pwa/assets/{DrawerToggleButton-C_bNtZsE.js → DrawerToggleButton-T70fh8dp.js} +1 -1
  43. package/src/client/dist/pwa/assets/{GitPanel-BMjWwiAX.js → GitPanel-CAvk0G7h.js} +3 -3
  44. package/src/client/dist/pwa/assets/{HealthPage-Vyr1YG2o.js → HealthPage-D8Mr0V6w.js} +1 -1
  45. package/src/client/dist/pwa/assets/{MainLayout-s8_9FPE2.js → MainLayout-x6Es4Da-.js} +2 -2
  46. package/src/client/dist/pwa/assets/{QBtnDropdown-BxuXEBp5.js → QBtnDropdown-CQqzzlFI.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QDrawer-BdlDiU6a.js → QDrawer-Cwj2qh0r.js} +1 -1
  48. package/src/client/dist/pwa/assets/{QSelect-BrSemS3o.js → QSelect-BSPtuXJg.js} +1 -1
  49. package/src/client/dist/pwa/assets/{QTooltip-CLCFHW2D.js → QTooltip-DC_kDKku.js} +1 -1
  50. package/src/client/dist/pwa/assets/{SchedulePanel-F9bdAq6K.js → SchedulePanel-9aJSoz6Y.js} +1 -1
  51. package/src/client/dist/pwa/assets/{SearchPage-CBCefYqc.js → SearchPage-BjNvtE_U.js} +1 -1
  52. package/src/client/dist/pwa/assets/{SettingsPage-BPgTHmKj.js → SettingsPage-CvLnT73V.js} +1 -1
  53. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DDs2nuoT.js → SplitWorkspacePage-DTao0DHr.js} +1 -1
  54. package/src/client/dist/pwa/assets/{TerminalPanel-CulE0wTU.js → TerminalPanel-BrKIW-bJ.js} +1 -1
  55. package/src/client/dist/pwa/assets/{TouchPan-DUgqs2cW.js → TouchPan-_n1AQgoo.js} +1 -1
  56. package/src/client/dist/pwa/assets/{TourReplayButton-CdHpGBdr.js → TourReplayButton-CJF3nVvn.js} +1 -1
  57. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-W5SvqzXO.js → WorkspaceAttentionLabels-CxesUiIm.js} +1 -1
  58. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-DoXtwpjV.css} +1 -1
  59. package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +4 -0
  60. package/src/client/dist/pwa/assets/{browser-CnuqDTyc.js → browser-DGWpkvN3.js} +1 -1
  61. package/src/client/dist/pwa/assets/{build-path-tree-JodIO7-I.js → build-path-tree-Ba1dSLgi.js} +1 -1
  62. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-DVK21RB6.js} +1 -1
  63. package/src/client/dist/pwa/assets/{documents-3ZBK1pXE.js → documents-BMHzjW_g.js} +1 -1
  64. package/src/client/dist/pwa/assets/{editor.api-DZ9WO_J7.js → editor.api-CYTuNwqu.js} +1 -1
  65. package/src/client/dist/pwa/assets/{efforts-LrtJPx-j.js → efforts-BPlRSHXp.js} +1 -1
  66. package/src/client/dist/pwa/assets/{engineFeatures-DmnHj_uD.js → engineFeatures-DVB2rklO.js} +1 -1
  67. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-C8cQwDZM.js} +1 -1
  68. package/src/client/dist/pwa/assets/{expand-template-Dv4JC2zU.js → expand-template-Cj82YE-O.js} +1 -1
  69. package/src/client/dist/pwa/assets/{format-CHjfjuhr.js → format-Dk7O3LhA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{formatters-BsgH8CoH.js → formatters-TGjvs7Ca.js} +1 -1
  71. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-BVOMZ2Bl.js} +1 -1
  72. package/src/client/dist/pwa/assets/{freemarker2-Dla55SIR.js → freemarker2-39lO_dWH.js} +1 -1
  73. package/src/client/dist/pwa/assets/{handlebars-DtuuZy7D.js → handlebars-M_0V82cD.js} +1 -1
  74. package/src/client/dist/pwa/assets/{html-CKYOCR8W.js → html-yILqYDsl.js} +1 -1
  75. package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +1 -0
  76. package/src/client/dist/pwa/assets/{index-CIP08MpU.js → index-BJU5dnkF.js} +2 -2
  77. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-CxkAoOj4.js} +1 -1
  78. package/src/client/dist/pwa/assets/{javascript-BxzTnsIQ.js → javascript-DrW-amDc.js} +1 -1
  79. package/src/client/dist/pwa/assets/{liquid-C8BfPFUy.js → liquid-D_mXmUoL.js} +1 -1
  80. package/src/client/dist/pwa/assets/{mdx-DZm_YXkC.js → mdx-E8O3lvR5.js} +1 -1
  81. package/src/client/dist/pwa/assets/{monaco-contributions-HDbRHiNR.js → monaco-contributions-C2K3sl_V.js} +1 -1
  82. package/src/client/dist/pwa/assets/{monaco.contribution-DRW-851R.js → monaco.contribution-DLhHQIJn.js} +2 -2
  83. package/src/client/dist/pwa/assets/{network-auth-Ce9hsryo.js → network-auth-BUwWtnui.js} +1 -1
  84. package/src/client/dist/pwa/assets/{notify-theme-OBpMEBvP.js → notify-theme-1mOpY2vN.js} +1 -1
  85. package/src/client/dist/pwa/assets/{pending-diff-open-CVQxWyzP.js → pending-diff-open-BL7dKRqF.js} +1 -1
  86. package/src/client/dist/pwa/assets/{permissionModes-CVFHS2ec.js → permissionModes-BtC3_P-P.js} +1 -1
  87. package/src/client/dist/pwa/assets/{project-color-mcpQp2o6.js → project-color-BFBFZWeG.js} +1 -1
  88. package/src/client/dist/pwa/assets/{python-CO5iQN74.js → python-DoZAYim6.js} +1 -1
  89. package/src/client/dist/pwa/assets/{razor-BC7D8LMf.js → razor-9bZ30g2y.js} +1 -1
  90. package/src/client/dist/pwa/assets/{render-chat-markdown-Cb15kvox.js → render-chat-markdown-7EtWNjG0.js} +1 -1
  91. package/src/client/dist/pwa/assets/{typescript-BCHCSNpD.js → typescript-C1CBfL3L.js} +1 -1
  92. package/src/client/dist/pwa/assets/{use-tours-DhLhglwr.js → use-tours-BJ81clv6.js} +1 -1
  93. package/src/client/dist/pwa/assets/{workspace-templates-CoWrOEac.js → workspace-templates-r1-bipjM.js} +1 -1
  94. package/src/client/dist/pwa/assets/{xml-y9M3NAIZ.js → xml-COuu5isK.js} +1 -1
  95. package/src/client/dist/pwa/assets/{yaml-CthlToPo.js → yaml-ChZ7WqaJ.js} +1 -1
  96. package/src/client/dist/pwa/index.html +1 -1
  97. package/src/client/dist/pwa/sw.js +1 -1
  98. package/src/client/dist/pwa/assets/ActivityFeed-YRbIRutK.js +0 -8
  99. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  100. package/src/client/dist/pwa/assets/CreatePage-gFrn4xl9.js +0 -2
  101. package/src/client/dist/pwa/assets/WorkspacePage-B6O7j57u.js +0 -4
  102. package/src/client/dist/pwa/assets/i18n-DTfKQUMf.js +0 -1
@@ -1,7 +1,9 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../../db/index.js';
4
+ import { assertAgentStopped } from '../../utils/agent-stop-result.js';
4
5
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
6
+ import { assertWorkspaceLifecycleAvailable } from '../../utils/workspace-lifecycle-guard.js';
5
7
  import * as autoLoopService from '../auto-loop-service.js';
6
8
  import * as cleanupScriptService from '../cleanup-script-service.js';
7
9
  import * as cronService from '../cron-service.js';
@@ -39,6 +41,33 @@ export function getBackendPort() {
39
41
  }
40
42
  /** workspaceId -> SessionController */
41
43
  const controllers = new Map();
44
+ /** Replacements waiting for the previous controller to release its worktree. */
45
+ const pendingStarts = new Map();
46
+ let shuttingDown = false;
47
+ let capacityTimer;
48
+ const stoppedCapacityOwners = new Set();
49
+ function notifyCapacityAvailable(stoppedWorkspaceId) {
50
+ if (shuttingDown)
51
+ return;
52
+ stoppedCapacityOwners.add(stoppedWorkspaceId);
53
+ if (capacityTimer)
54
+ return;
55
+ // Let the stopping caller finish disabling/archiving before scheduling other work.
56
+ capacityTimer = setTimeout(() => {
57
+ capacityTimer = undefined;
58
+ const excluded = new Set(stoppedCapacityOwners);
59
+ stoppedCapacityOwners.clear();
60
+ if (shuttingDown)
61
+ return;
62
+ try {
63
+ autoLoopService.resumeWaitingWorkspaces(excluded);
64
+ }
65
+ catch (err) {
66
+ console.error('[orchestrator] capacity resume failed:', err);
67
+ }
68
+ }, 0);
69
+ capacityTimer.unref?.();
70
+ }
42
71
  /**
43
72
  * Latest controller generation per persisted session row. A resumed run can
44
73
  * reuse an agentSessionId while its predecessor is still draining events, so
@@ -222,8 +251,7 @@ function runWatchdog() {
222
251
  for (const [workspaceId, ctrl] of controllers) {
223
252
  // D1 — a controller already in `stopping` state is being torn down by
224
253
  // `stopController`/`stopAgentAndWait`, which has its own bounded deadline
225
- // (STOP_AGENT_TIMEOUT_MS) and its own eviction path once that deadline
226
- // passes. Before D1 this branch could never see a stopping controller —
254
+ // (STOP_AGENT_TIMEOUT_MS). A deadline alone never evicts the controller. Before D1 this branch could never see a stopping controller —
227
255
  // the entry was removed from `controllers` synchronously, before
228
256
  // `ctrl.stop()` was even awaited. Now that the entry survives the whole
229
257
  // stop, a slow-but-honest voluntary stop (the engine's iterator already
@@ -266,8 +294,10 @@ function runWatchdog() {
266
294
  catch (err) {
267
295
  console.warn('[watchdog] Failed to route death notification events:', err);
268
296
  }
269
- if (controllers.get(workspaceId) === ctrl)
297
+ if (controllers.get(workspaceId) === ctrl) {
270
298
  controllers.delete(workspaceId);
299
+ notifyCapacityAvailable(workspaceId);
300
+ }
271
301
  retryCounts.delete(workspaceId);
272
302
  // This end never goes through handleEvent → onSessionEnded, so the user's
273
303
  // hook would otherwise miss precisely the case it is most useful for.
@@ -695,6 +725,15 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
695
725
  const sourceControllerIsStopping = sourceController?.status === 'stopping';
696
726
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
697
727
  routeEvent(workspaceId, agentSessionId, routedEvent);
728
+ // A fresh tool invocation proves this controller resumed working (for
729
+ // example after an account change). Tool results can still drain after a
730
+ // rejection, so they must never count as recovery.
731
+ if (ev.kind === 'tool:call' && !sourceControllerIsStopping && getWs(workspaceId)?.status === 'quota') {
732
+ updateWorkspaceStatus(workspaceId, 'executing');
733
+ quotaBackoffService.cancel(workspaceId, 'completed');
734
+ retryCounts.delete(workspaceId);
735
+ emitEphemeral(workspaceId, 'agent:quota-recovered', {});
736
+ }
698
737
  if (ev.kind === 'rate_limit') {
699
738
  latestRateLimitInfo.set(workspaceId, ev.info);
700
739
  }
@@ -852,8 +891,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
852
891
  const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
853
892
  if (!ownsWorkspaceLifecycle)
854
893
  return;
855
- if (watchdogRecovery)
894
+ if (watchdogRecovery) {
895
+ notifyCapacityAvailable(workspaceId);
856
896
  return;
897
+ }
857
898
  // resume_failed exits with an error but the workspace is fine (stale id
858
899
  // cleared, next iteration will start fresh) — report 'completed' to
859
900
  // auto-loop so it continues.
@@ -975,7 +1016,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
975
1016
  const currentWorkspace = getWs(workspaceId);
976
1017
  const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
977
1018
  const wasStopping = sourceController?.status === 'stopping';
978
- if (registeredController === sourceController) {
1019
+ if (!wasStopping && registeredController === sourceController) {
979
1020
  controllers.delete(workspaceId);
980
1021
  }
981
1022
  if (!preserveQuotaBackoff) {
@@ -1046,6 +1087,9 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1046
1087
  * query the controller via `_getControllers()` for tests.
1047
1088
  */
1048
1089
  export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
1090
+ if (shuttingDown)
1091
+ throw new Error('Cannot start an agent while the server is shutting down');
1092
+ assertWorkspaceLifecycleAvailable(workspaceId);
1049
1093
  const workspace = getWs(workspaceId);
1050
1094
  if (!workspace)
1051
1095
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -1059,26 +1103,22 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1059
1103
  // callback can leave its controller in the map after the workspace is
1060
1104
  // logically idle. Evict it instead of refusing the new session.
1061
1105
  //
1062
- // A controller already in `stopping` state is evictable too: it stays
1063
- // registered for the whole teardown now (D1), and refusing here would break
1064
- // every legitimate stop-then-start sequence.
1106
+ // A controller already stopping keeps ownership until its stop completes;
1107
+ // callers replacing it must await stopAgentAndWait before starting again.
1065
1108
  let zombieEviction = Promise.resolve();
1066
1109
  const existingCtrl = controllers.get(workspaceId);
1067
1110
  if (existingCtrl) {
1111
+ if (existingCtrl.status === 'stopping')
1112
+ throw new Error('Agent is still stopping; retry after its stop completes');
1068
1113
  const wsForCheck = getWs(workspaceId);
1069
1114
  const status = wsForCheck?.status;
1070
1115
  const isLogicallyDone = status === 'idle' || status === 'completed' || status === 'error' || status === 'quota';
1071
- if (isLogicallyDone || existingCtrl.status === 'stopping') {
1116
+ if (isLogicallyDone) {
1072
1117
  console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}, controller=${existingCtrl.status}) before starting fresh session`);
1073
1118
  // The new engine must not touch the worktree while the zombie may still
1074
1119
  // be writing to it — a zombie that ignores its own stop is exactly the
1075
- // case this path exists for. Bounded: it cannot hold the new session
1076
- // hostage for more than STOP_AGENT_TIMEOUT_MS.
1077
- zombieEviction = stopWithTimeout(existingCtrl, STOP_AGENT_TIMEOUT_MS).then((stopped) => {
1078
- if (!stopped) {
1079
- console.error(`[orchestrator] Zombie controller for workspace '${workspaceId}' did not stop within ${STOP_AGENT_TIMEOUT_MS}ms — starting the new session anyway`);
1080
- }
1081
- });
1120
+ // case this path exists for. An unconfirmed stop fails the replacement.
1121
+ zombieEviction = stopAgentAndWait(workspaceId, undefined, 'replacement').then(assertAgentStopped);
1082
1122
  // Drop any queued pending items + persisted user-input-requested events
1083
1123
  // tied to the zombie's agentSessionId so the new session doesn't inherit
1084
1124
  // a stale queue and so a future sync replay doesn't resurrect them.
@@ -1095,7 +1135,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1095
1135
  catch (err) {
1096
1136
  console.warn('[orchestrator] Failed to purge zombie pending state:', err);
1097
1137
  }
1098
- controllers.delete(workspaceId);
1099
1138
  }
1100
1139
  else {
1101
1140
  throw new Error(`Agent already running for workspace '${workspaceId}'`);
@@ -1146,12 +1185,31 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1146
1185
  let controller;
1147
1186
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1148
1187
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1149
- controllers.set(workspaceId, controller);
1188
+ if (!existingCtrl)
1189
+ controllers.set(workspaceId, controller);
1190
+ else
1191
+ pendingStarts.set(workspaceId, controller);
1150
1192
  // Kick off engine.start asynchronously, BEHIND the zombie eviction so the
1151
1193
  // new engine never runs concurrently with the one it replaces. Errors
1152
1194
  // surface as error events.
1153
1195
  void zombieEviction
1154
- .then(() => controller.start(options))
1196
+ .then(() => {
1197
+ // An explicit stop can cancel this replacement while its predecessor
1198
+ // still owns the worktree. Its session row was already finalized there.
1199
+ if (existingCtrl && controller.status === 'stopping')
1200
+ return;
1201
+ if (pendingStarts.get(workspaceId) === controller)
1202
+ pendingStarts.delete(workspaceId);
1203
+ assertWorkspaceLifecycleAvailable(workspaceId);
1204
+ const current = getWs(workspaceId);
1205
+ if (!current || current.archivedAt || current.worktreePurgedAt)
1206
+ throw new Error('Workspace is no longer available');
1207
+ const registered = controllers.get(workspaceId);
1208
+ if (registered && registered !== controller)
1209
+ throw new Error('Agent is still stopping');
1210
+ controllers.set(workspaceId, controller);
1211
+ return controller.start(options);
1212
+ })
1155
1213
  .then(() => {
1156
1214
  const pid = controller.pid;
1157
1215
  if (pid !== undefined) {
@@ -1165,6 +1223,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1165
1223
  }
1166
1224
  })
1167
1225
  .catch((err) => {
1226
+ if (existingCtrl && controller.status === 'stopping')
1227
+ return;
1168
1228
  console.error('[orchestrator] engine.start failed:', err);
1169
1229
  const message = err instanceof Error ? err.message : String(err);
1170
1230
  handleEvent(workspaceId, agentSessionId, controller, {
@@ -1177,6 +1237,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1177
1237
  reason: 'error',
1178
1238
  exitCode: null,
1179
1239
  });
1240
+ })
1241
+ .finally(() => {
1242
+ if (pendingStarts.get(workspaceId) === controller)
1243
+ pendingStarts.delete(workspaceId);
1180
1244
  });
1181
1245
  return { agentSessionId, pid: undefined };
1182
1246
  }
@@ -1204,21 +1268,10 @@ export function interruptAgent(workspaceId, options = {}) {
1204
1268
  autoLoopService.disable(workspaceId, 'user-action');
1205
1269
  }
1206
1270
  }
1207
- /**
1208
- * Upper bound on a stop. Beyond this the engine is considered deaf to its own
1209
- * stop path: we evict the controller so the workspace becomes usable again.
1210
- *
1211
- * This is an honest abandonment, not an escalation to something else that
1212
- * watches over the process: Kōbō has no graduated SIGTERM/SIGKILL path of its
1213
- * own, the `process-tracker` module that used to exist was removed, and the
1214
- * session watchdog only walks the `controllers` map — the very map this
1215
- * eviction just removed the entry from. Whatever survives past this timeout
1216
- * is untracked by anyone in this process; only the OS or the user can end it.
1217
- */
1271
+ /** Bound the caller's wait, retaining ownership until actual engine shutdown. */
1218
1272
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1219
1273
  async function stopController(workspaceId, ctrl, cause = 'user') {
1220
1274
  ctrl.stopCause = cause;
1221
- wakeupService.cancel(workspaceId, 'stopped');
1222
1275
  // Normalize the state synchronously so callers (archive, delete, manual
1223
1276
  // stop) see a clean workspace immediately — without waiting for the async
1224
1277
  // controller.stop() → session:ended round-trip, which onSessionEnded skips
@@ -1248,25 +1301,21 @@ async function stopController(workspaceId, ctrl, cause = 'user') {
1248
1301
  console.warn(`[orchestrator] Failed to normalize ${wsBefore.status} → idle on stop:`, err);
1249
1302
  }
1250
1303
  }
1251
- // Manual stop should also drop any pending quota auto-resume.
1252
- quotaBackoffService.cancel(workspaceId, 'user');
1253
1304
  // D1 — the controller stays REGISTERED for the whole stop, in `stopping`
1254
1305
  // state. Removing it up-front (as we used to) made the only "is an agent
1255
1306
  // running?" signal of the system lie for the entire teardown window: cron
1256
1307
  // and auto-loop could start a second agent on the same worktree, and delete
1257
1308
  // could pull the worktree from under a process still writing to it.
1258
- try {
1259
- await ctrl.stop();
1260
- }
1261
- finally {
1262
- if (controllers.get(workspaceId) === ctrl)
1263
- controllers.delete(workspaceId);
1309
+ await ctrl.stop();
1310
+ if (controllers.get(workspaceId) === ctrl) {
1311
+ controllers.delete(workspaceId);
1312
+ notifyCapacityAvailable(workspaceId);
1264
1313
  }
1265
1314
  }
1266
1315
  /**
1267
1316
  * Race an arbitrary promise against a bounded deadline. Resolves to the
1268
1317
  * winner's value; if the deadline wins, `onTimeout()` supplies the value
1269
- * instead. Never throws — the timer is always cleared. Single source of
1318
+ * instead. The timer is always cleared. Single source of
1270
1319
  * truth for the "stop, but not forever" pattern: both `stopWithTimeout` and
1271
1320
  * `stopAgentAndWait` used to hand-roll their own `Promise.race` + timer, two
1272
1321
  * copies of the same logic that could silently drift apart.
@@ -1287,16 +1336,6 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1287
1336
  clearTimeout(timer);
1288
1337
  }
1289
1338
  }
1290
- /** Race a controller stop against a bounded deadline. Never throws. */
1291
- async function stopWithTimeout(ctrl, timeoutMs) {
1292
- return raceWithTimeout(ctrl
1293
- .stop()
1294
- .then(() => true)
1295
- .catch((err) => {
1296
- console.error('[orchestrator] controller.stop rejected:', err);
1297
- return true;
1298
- }), timeoutMs, () => false);
1299
- }
1300
1339
  /**
1301
1340
  * Stop the agent and wait for it to actually die. Every caller that mutates
1302
1341
  * the worktree afterwards — delete, archive, purge, setup script, engine
@@ -1304,21 +1343,46 @@ async function stopWithTimeout(ctrl, timeoutMs) {
1304
1343
  * engine may still be writing files.
1305
1344
  */
1306
1345
  export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1307
- /** Why. `delete` and `purge` tell the session-ended hook to stand down. */
1346
+ /** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
1308
1347
  cause = 'user') {
1348
+ const pending = pendingStarts.get(workspaceId);
1349
+ let pendingStop = Promise.resolve();
1350
+ if (pending) {
1351
+ pendingStarts.delete(workspaceId);
1352
+ pending.stopCause = cause;
1353
+ pendingStop = pending.stop().then(() => {
1354
+ handleEvent(workspaceId, pending.agentSessionId, pending, {
1355
+ kind: 'session:ended',
1356
+ reason: 'killed',
1357
+ exitCode: null,
1358
+ });
1359
+ });
1360
+ }
1309
1361
  const ctrl = controllers.get(workspaceId);
1310
- if (!ctrl)
1311
- return 'not-running';
1312
- const finished = await raceWithTimeout(stopController(workspaceId, ctrl, cause).then(() => true, (err) => {
1362
+ const controllerStop = ctrl ? stopController(workspaceId, ctrl, cause) : Promise.resolve();
1363
+ // Cancel synchronously, including schedules whose controller already exited.
1364
+ // A Promise executor contains DB failures within the bounded stop outcome.
1365
+ // Shutdown only suspends timers: persisted intent belongs to the next boot.
1366
+ const cancelSchedules = new Promise((resolve) => {
1367
+ if (cause !== 'shutdown') {
1368
+ wakeupService.cancel(workspaceId, 'stopped');
1369
+ quotaBackoffService.cancel(workspaceId, 'user');
1370
+ }
1371
+ resolve();
1372
+ });
1373
+ // Mark every controller as stopping before the disable hook can run. Persist
1374
+ // the user's intent even for a loop waiting for capacity with no controller.
1375
+ const disableLoop = cause === 'user'
1376
+ ? Promise.resolve().then(() => autoLoopService.disable(workspaceId, 'user-action'))
1377
+ : Promise.resolve();
1378
+ const outcome = await raceWithTimeout(Promise.all([pendingStop, controllerStop, cancelSchedules, disableLoop]).then(() => (ctrl || pending ? 'stopped' : 'not-running'), (err) => {
1313
1379
  console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
1314
- return true;
1315
- }), timeoutMs, () => false);
1316
- if (finished)
1317
- return 'stopped';
1318
- console.error(`[orchestrator] Agent for workspace '${workspaceId}' did not stop within ${timeoutMs}ms — evicting its controller`);
1319
- if (controllers.get(workspaceId) === ctrl)
1320
- controllers.delete(workspaceId);
1321
- return 'timeout';
1380
+ return 'failed';
1381
+ }), timeoutMs, () => 'timeout');
1382
+ if (outcome === 'timeout') {
1383
+ console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
1384
+ }
1385
+ return outcome;
1322
1386
  }
1323
1387
  /**
1324
1388
  * Fire-and-forget stop, for the few callers that cannot await (WebSocket
@@ -1337,13 +1401,43 @@ export function stopAgent(workspaceId) {
1337
1401
  }
1338
1402
  /** Stop every live engine before process shutdown, with a bounded wait per engine. */
1339
1403
  export async function stopAllAgents(timeoutMs = 3_000) {
1404
+ quotaBackoffService.suspendForShutdown();
1405
+ wakeupService.suspendForShutdown();
1406
+ cronService.suspendForShutdown();
1407
+ shuttingDown = true;
1408
+ if (capacityTimer)
1409
+ clearTimeout(capacityTimer);
1410
+ capacityTimer = undefined;
1411
+ stoppedCapacityOwners.clear();
1340
1412
  // No per-workspace try/catch here: unlike the pre-D1 shutdown path,
1341
1413
  // `stopAgentAndWait` never rejects — every internal failure (a rejecting
1342
1414
  // `controller.stop()`, or the bounded deadline firing) is already converted
1343
- // into a logged, returned `StopAgentOutcome` ('stopped' | 'timeout')
1415
+ // into a logged, returned `StopAgentOutcome`
1344
1416
  // instead of a thrown error. A wrapping catch here would be unreachable
1345
1417
  // dead code pretending to guard against a failure mode that can't occur.
1346
- await Promise.all([...controllers.keys()].map((workspaceId) => stopAgentAndWait(workspaceId, timeoutMs)));
1418
+ const workspaceIds = new Set([...controllers.keys(), ...pendingStarts.keys()]);
1419
+ await Promise.all([...workspaceIds].map((workspaceId) => stopAgentAndWait(workspaceId, timeoutMs, 'shutdown')));
1420
+ }
1421
+ /** Automatic continuations must preserve their durable intent during teardown. */
1422
+ export function isShuttingDown() {
1423
+ return shuttingDown;
1424
+ }
1425
+ /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1426
+ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1427
+ const ctrl = controllers.get(workspaceId);
1428
+ if (ctrl?.status !== 'running')
1429
+ return false;
1430
+ if (expectedSessionId && ctrl.agentSessionId !== expectedSessionId)
1431
+ return false;
1432
+ const workspace = getWs(workspaceId);
1433
+ if (!workspace || workspace.archivedAt || workspace.worktreePurgedAt)
1434
+ return false;
1435
+ if (workspace.status === 'awaiting-user' || workspace.status === 'quota')
1436
+ return false;
1437
+ if (!ctrl.engineProcess?.sendWakeupIfWaiting?.(content))
1438
+ return false;
1439
+ emit(workspaceId, 'user:message', { content, sender: 'system-prompt' }, ctrl.agentSessionId);
1440
+ return true;
1347
1441
  }
1348
1442
  /** Write a user message to the running agent. */
1349
1443
  export async function sendMessage(workspaceId, content, expectedSessionId) {
@@ -1864,10 +1958,18 @@ async function handleQuota(workspaceId, _agentSessionId) {
1864
1958
  return;
1865
1959
  }
1866
1960
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
1961
+ // Usage lookup can finish after the agent has already resumed.
1962
+ if (getWs(workspaceId)?.status !== 'quota')
1963
+ return;
1867
1964
  retryCounts.set(workspaceId, retryCount + 1);
1868
1965
  // The quotaBackoffService owns the timer + the persistent row + the
1869
1966
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
1870
- quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
1967
+ quotaBackoffService.arm(workspaceId, delayMs, {
1968
+ resetsAt: resetsAt ?? null,
1969
+ source,
1970
+ reason: 'quota',
1971
+ retryCount: retryCount + 1,
1972
+ });
1871
1973
  }
1872
1974
  catch (err) {
1873
1975
  console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
@@ -1931,7 +2033,12 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1931
2033
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1932
2034
  const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
1933
2035
  retryCounts.set(workspaceId, retryCount + 1);
1934
- quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
2036
+ quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
2037
+ resetsAt: resetsAt ?? null,
2038
+ source,
2039
+ reason: 'transient',
2040
+ retryCount: retryCount + 1,
2041
+ });
1935
2042
  }
1936
2043
  catch (err) {
1937
2044
  console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
@@ -1964,8 +2071,8 @@ export function restoreRetryCountsFromDb() {
1964
2071
  // in `quota` status and require manual user action (resume / new message)
1965
2072
  // to leave that state. This is intentional: without an auto-loop intent,
1966
2073
  // firing a fresh agent run in the user's absence would surprise them.
1967
- quotaBackoffService.setOnFireCallback((workspaceId) => {
1968
- autoLoopService.onQuotaBackoffExpired(workspaceId);
2074
+ quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
2075
+ autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
1969
2076
  });
1970
2077
  // ── Testing utilities ─────────────────────────────────────────────────────────
1971
2078
  /** @internal test-only */
@@ -11,6 +11,7 @@ export class SessionController {
11
11
  stopCause;
12
12
  _engineProcess;
13
13
  _startPromise;
14
+ _stopPromise;
14
15
  _status = 'running';
15
16
  /**
16
17
  * Set by `stop()`. Distinct from `_status` so the check in `startEngine` is
@@ -68,8 +69,8 @@ export class SessionController {
68
69
  const process = await this.engine.start(options, (ev) => this.handle(ev));
69
70
  this._engineProcess = process;
70
71
  if (this._status === 'stopping') {
71
- this._engineProcess = undefined;
72
72
  await process.stop();
73
+ this._engineProcess = undefined;
73
74
  return;
74
75
  }
75
76
  this._status = 'running';
@@ -89,7 +90,16 @@ export class SessionController {
89
90
  throw new Error('SessionController not started');
90
91
  this._engineProcess.interrupt();
91
92
  }
92
- async stop() {
93
+ stop() {
94
+ if (!this._stopPromise) {
95
+ this._stopPromise = this.stopEngine().catch((err) => {
96
+ this._stopPromise = undefined;
97
+ throw err;
98
+ });
99
+ }
100
+ return this._stopPromise;
101
+ }
102
+ async stopEngine() {
93
103
  this._stopRequested = true;
94
104
  this._status = 'stopping';
95
105
  // `startEngine` may still be in flight: `_engineProcess` stays undefined
@@ -2,14 +2,16 @@ import fs from 'node:fs';
2
2
  import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { slugifyProjectName } from '../utils/project-slug.js';
5
+ import { deferUntilWorkspaceAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
5
6
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
7
  import * as orchestrator from './agent/orchestrator.js';
7
8
  import * as cleanupScriptService from './cleanup-script-service.js';
8
9
  import * as lifecycleHookService from './lifecycle-hook-service.js';
10
+ import * as quotaBackoffService from './quota-backoff-service.js';
9
11
  import * as settingsService from './settings-service.js';
10
12
  import { getSuitePrompts } from './skill-suite-prompts.js';
11
13
  import { emit, emitEphemeral } from './websocket-service.js';
12
- import { listTasks } from './workspace-service.js';
14
+ import { listTasks, updateWorkspaceStatus } from './workspace-service.js';
13
15
  const NO_PROGRESS_STALL_THRESHOLD = 3;
14
16
  function getRow(workspaceId) {
15
17
  const db = getDb();
@@ -186,6 +188,10 @@ export function rehydrate() {
186
188
  const row = getRow(id);
187
189
  if (row?.auto_loop_ready !== 1)
188
190
  continue;
191
+ // Persisted quota timers own the restart of these workspaces. Boot
192
+ // restores them after this pass; starting here bypasses their delay.
193
+ if (row.status === 'quota')
194
+ continue;
189
195
  if (countPendingTasks(id) === 0) {
190
196
  disable(id, 'completed');
191
197
  continue;
@@ -291,7 +297,9 @@ function hasFreeAgentSlot() {
291
297
  * free, exactly one starts and the other two keep waiting rather than each
292
298
  * re-announcing that they wait.
293
299
  */
294
- export function resumeWaitingWorkspaces() {
300
+ export function resumeWaitingWorkspaces(excluded = new Set()) {
301
+ if (orchestrator.isShuttingDown())
302
+ return;
295
303
  const db = getDb();
296
304
  const rows = db
297
305
  .prepare(`SELECT id, status FROM workspaces
@@ -299,6 +307,8 @@ export function resumeWaitingWorkspaces() {
299
307
  ORDER BY updated_at ASC`)
300
308
  .all();
301
309
  for (const row of rows) {
310
+ if (excluded.has(row.id))
311
+ continue;
302
312
  if (!hasFreeAgentSlot())
303
313
  return;
304
314
  if (orchestrator.hasController(row.id))
@@ -316,8 +326,15 @@ export function resumeWaitingWorkspaces() {
316
326
  }
317
327
  }
318
328
  function spawnNextIteration(workspaceId, opts = {}) {
329
+ // Lifecycle/ready callbacks can outlive timer suspension; preserve their persisted loop intent.
330
+ if (orchestrator.isShuttingDown())
331
+ return;
319
332
  const row = getRow(workspaceId);
320
- if (!row)
333
+ if (!row || row.archived_at || !row.auto_loop)
334
+ return;
335
+ if (deferUntilWorkspaceAvailable(workspaceId, resumeWaitingWorkspaces))
336
+ return;
337
+ if (orchestrator.hasController(workspaceId))
321
338
  return;
322
339
  // Same guard as onSessionEnded — never race a deferred-resume start.
323
340
  if (row.status === 'awaiting-user')
@@ -427,22 +444,70 @@ function spawnNextIteration(workspaceId, opts = {}) {
427
444
  * Spawns the next auto-loop iteration if the workspace is still in quota status
428
445
  * with auto_loop active; no-ops otherwise (race-safe).
429
446
  *
430
- * No-op cases — these all leave the workspace in `quota` status awaiting
431
- * manual user action:
447
+ * No-op cases — leave the workspace unchanged:
432
448
  * - workspace was deleted between arm and fire
449
+ * - workspace was archived during the backoff window
433
450
  * - `auto_loop !== 1` (workspace was never an auto-loop target, OR the user
434
451
  * toggled the loop off during the backoff window)
435
452
  * - `status !== 'quota'` (user already manually resumed, or another path
436
453
  * transitioned the workspace)
437
454
  */
438
- export function onQuotaBackoffExpired(workspaceId) {
455
+ const pendingQuotaStops = new Set();
456
+ const QUOTA_STOP_RETRY_MS = 15_000;
457
+ function deferQuotaRetry(workspaceId, pending) {
458
+ const attempt = pending ?? quotaBackoffService.getPending(workspaceId);
459
+ quotaBackoffService.arm(workspaceId, QUOTA_STOP_RETRY_MS, {
460
+ resetsAt: attempt?.resetsAt ?? null,
461
+ source: attempt?.source ?? 'fallback_ladder',
462
+ reason: attempt?.reason ?? 'quota',
463
+ retryCount: attempt?.retryCount ?? 1,
464
+ });
465
+ }
466
+ export function onQuotaBackoffExpired(workspaceId, pending) {
439
467
  const row = getRow(workspaceId);
440
468
  if (!row)
441
469
  return;
470
+ if (row.archived_at !== null)
471
+ return;
442
472
  if (row.auto_loop !== 1)
443
473
  return;
444
474
  if (row.status !== 'quota')
445
475
  return;
476
+ if (pendingQuotaStops.has(workspaceId) || orchestrator.isShuttingDown() || isWorkspaceLifecycleBusy(workspaceId)) {
477
+ deferQuotaRetry(workspaceId, pending);
478
+ return;
479
+ }
480
+ if (orchestrator.hasController(workspaceId)) {
481
+ pendingQuotaStops.add(workspaceId);
482
+ const stopped = orchestrator.stopAgentAndWait(workspaceId, undefined, 'replacement');
483
+ // The timer already consumed its row. Persist recovery immediately, after the
484
+ // technical stop's synchronous cancellation, so shutdown/crash during the await loses no intent.
485
+ deferQuotaRetry(workspaceId, pending);
486
+ void stopped
487
+ .then((outcome) => {
488
+ // Stop, archive, or a manual replacement may have superseded this retry while we awaited the engine.
489
+ const current = getRow(workspaceId);
490
+ if (!current || current.archived_at || !current.auto_loop || current.status !== 'quota')
491
+ return;
492
+ if (outcome === 'timeout' || outcome === 'failed' || orchestrator.isShuttingDown()) {
493
+ deferQuotaRetry(workspaceId, pending);
494
+ return;
495
+ }
496
+ updateWorkspaceStatus(workspaceId, 'idle');
497
+ quotaBackoffService.cancel(workspaceId, 'completed');
498
+ spawnNextIteration(workspaceId);
499
+ })
500
+ .catch((err) => {
501
+ console.error(`[auto-loop] quota controller stop failed for '${workspaceId}':`, err);
502
+ })
503
+ .finally(() => pendingQuotaStops.delete(workspaceId));
504
+ return;
505
+ }
506
+ // The timer has consumed its persisted row. Release quota ownership before
507
+ // checking capacity so resumeWaitingWorkspaces can pick this workspace up
508
+ // when a slot frees, even if no agent can start at this instant.
509
+ updateWorkspaceStatus(workspaceId, 'idle');
510
+ emitEphemeral(workspaceId, 'agent:quota-backoff-cancelled', { reason: 'completed' });
446
511
  spawnNextIteration(workspaceId);
447
512
  }
448
513
  /**