@loicngr/kobo 1.11.16 → 1.11.17

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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +8 -7
  4. package/dist/server/db/migrations.js +57 -0
  5. package/dist/server/db/schema.js +10 -39
  6. package/dist/server/index.js +55 -7
  7. package/dist/server/routes/git.js +8 -3
  8. package/dist/server/routes/health.js +19 -2
  9. package/dist/server/routes/settings.js +24 -0
  10. package/dist/server/routes/workspaces.js +371 -72
  11. package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
  12. package/dist/server/services/agent/engines/codex/client.js +16 -4
  13. package/dist/server/services/agent/engines/codex/engine.js +23 -2
  14. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
  15. package/dist/server/services/agent/engines/types.js +13 -0
  16. package/dist/server/services/agent/orchestrator.js +354 -133
  17. package/dist/server/services/agent/session-controller.js +27 -0
  18. package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
  19. package/dist/server/services/change-source-branch-service.js +115 -11
  20. package/dist/server/services/db-backup-service.js +27 -16
  21. package/dist/server/services/pr-watcher-service.js +17 -2
  22. package/dist/server/services/settings-service.js +39 -0
  23. package/dist/server/services/websocket-service.js +114 -48
  24. package/dist/server/services/workspace-service.js +54 -2
  25. package/dist/server/services/worktree-purge-service.js +19 -5
  26. package/dist/server/services/worktree-service.js +38 -1
  27. package/dist/server/services/ws-events-retention-service.js +133 -0
  28. package/dist/server/utils/git-ops.js +209 -14
  29. package/dist/server/utils/git-repo-lock.js +45 -0
  30. package/dist/server/utils/logger.js +108 -0
  31. package/dist/server/utils/script-runner.js +11 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
  34. package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
  36. package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
  37. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
  39. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
  40. package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
  41. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
  42. package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
  43. package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
  44. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
  45. package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
  46. package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
  47. package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
  48. package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
  49. package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
  50. package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
  51. package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
  52. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
  53. package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
  54. package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
  55. package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
  56. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
  57. package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
  58. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
  59. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
  60. package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
  61. package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
  62. package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
  63. package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
  64. package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
  65. package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
  66. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
  67. package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
  68. package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
  69. package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
  70. package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
  71. package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
  72. package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
  73. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
  74. package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
  75. package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
  76. package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
  77. package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
  78. package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
  79. package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
  80. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
  81. package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
  82. package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
  83. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
  84. package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
  88. package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
  91. package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
  92. package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
  93. package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
  94. package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
  95. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
  96. package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
  97. package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
  98. package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
  99. package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
  100. package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
  101. package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
  102. package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
  103. package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
  106. package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
  107. package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
  108. package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
  111. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
  112. package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
  113. package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
  114. package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
  117. package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
  118. package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
  119. package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
  120. package/src/client/dist/pwa/index.html +7 -5
  121. package/src/client/dist/pwa/sw.js +1 -1
  122. package/src/mcp-server/kobo-tasks-server.ts +8 -7
  123. package/dist/server/utils/process-tracker.js +0 -49
  124. package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
  125. package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
  126. package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
  127. package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
  128. package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
  129. package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
  130. package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
  131. package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
  132. package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
  133. package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
  134. package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
  135. package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
  136. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
  137. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
  138. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
  139. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
  140. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
  141. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
  142. package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
  143. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
  144. package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
  145. package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
  146. package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
  147. package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
  148. package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
  149. package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
  150. package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
  151. package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
  152. package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
  153. package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
  154. package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
  155. package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
  156. package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
  157. package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
  158. package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
  159. package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
  160. package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
  161. package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
  162. package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
  163. package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
  164. package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
  165. package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
  166. package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
  167. package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
  168. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
  169. package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
  170. package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
  171. package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
  174. package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
  175. package/src/client/dist/pwa/assets/use-onboarding-BNo8mb65.js +0 -2
@@ -2,7 +2,6 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../../db/index.js';
4
4
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
- import { unregisterProcess } from '../../utils/process-tracker.js';
6
5
  import * as autoLoopService from '../auto-loop-service.js';
7
6
  import * as cleanupScriptService from '../cleanup-script-service.js';
8
7
  import * as cronService from '../cron-service.js';
@@ -15,6 +14,7 @@ import { emit, emitEphemeral } from '../websocket-service.js';
15
14
  import * as permissionPolicyService from '../workspace-permission-policy-service.js';
16
15
  import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } from '../workspace-service.js';
17
16
  import { resolveEngine } from './engines/registry.js';
17
+ import { AGENT_NO_LONGER_RUNNING_TEXT, } from './engines/types.js';
18
18
  import { routeEvent } from './event-router.js';
19
19
  import { SessionController } from './session-controller.js';
20
20
  export class InterruptAgentError extends Error {
@@ -210,26 +210,45 @@ const resumeFailedSessions = new Map();
210
210
  // ── Watchdog ──────────────────────────────────────────────────────────────────
211
211
  const WATCHDOG_INTERVAL_MS = 30_000;
212
212
  let watchdogTimer = null;
213
- function isProcessAlive(pid) {
214
- try {
215
- process.kill(pid, 0);
216
- return true;
217
- }
218
- catch {
219
- return false;
220
- }
221
- }
213
+ /**
214
+ * Grace window for a controller whose `engine.start` has not resolved yet.
215
+ * `engineProcess` is undefined during that window, so there is no probe to
216
+ * call — and concluding "dead" from a missing pid is exactly the dormant bug
217
+ * that would kill every new session the day the start path becomes blocking.
218
+ */
219
+ const CONTROLLER_STARTUP_GRACE_MS = 60_000;
222
220
  function runWatchdog() {
223
221
  for (const [workspaceId, ctrl] of controllers) {
224
- const pid = ctrl.pid;
225
- // SDK-backed engines have no pid — query the engine's optional `isAlive`
226
- // probe instead so the watchdog isn't blind on those engines. Legacy
227
- // engines without `isAlive` fall back to the pid-based check.
228
- const ep = ctrl.engineProcess;
229
- const alive = ep && typeof ep.isAlive === 'function' ? ep.isAlive() : pid !== undefined && isProcessAlive(pid);
230
- if (alive)
222
+ // D1 — a controller already in `stopping` state is being torn down by
223
+ // `stopController`/`stopAgentAndWait`, which has its own bounded deadline
224
+ // (STOP_AGENT_TIMEOUT_MS) and its own eviction path once that deadline
225
+ // passes. Before D1 this branch could never see a stopping controller —
226
+ // the entry was removed from `controllers` synchronously, before
227
+ // `ctrl.stop()` was even awaited. Now that the entry survives the whole
228
+ // stop, a slow-but-honest voluntary stop (the engine's iterator already
229
+ // closed, `isAlive()` already reporting `false`, well within the 15s
230
+ // window) must not be raced here: doing so would report a false "Agent
231
+ // process died unexpectedly", force the workspace to `error`, and set an
232
+ // unread badge — all on a stop the user explicitly asked for.
233
+ if (ctrl.status === 'stopping')
231
234
  continue;
232
- console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
235
+ const ep = ctrl.engineProcess;
236
+ if (!ep) {
237
+ // engine.start is still in flight — stay silent until the grace window
238
+ // closes, then treat a controller that never produced a process as dead.
239
+ if (Date.now() - ctrl.startedAt < CONTROLLER_STARTUP_GRACE_MS)
240
+ continue;
241
+ console.error(`[watchdog] Controller for workspace '${workspaceId}' produced no engine process within ${CONTROLLER_STARTUP_GRACE_MS}ms — cleaning up`);
242
+ }
243
+ else {
244
+ // D1 — the pid is never a liveness criterion. The Claude engine exposes
245
+ // none, and a pid recycled after a machine restart reads as alive. An
246
+ // engine that cannot answer is presumed alive: only an explicit `false`
247
+ // triggers the cleanup below.
248
+ if (ep.isAlive?.() !== false)
249
+ continue;
250
+ console.error(`[watchdog] Agent engine for workspace '${workspaceId}' reports dead — cleaning up`);
251
+ }
233
252
  // Emit an error + session:ended AgentEvent pair so clients can react uniformly
234
253
  try {
235
254
  routeEvent(workspaceId, ctrl.agentSessionId, {
@@ -246,7 +265,6 @@ function runWatchdog() {
246
265
  catch (err) {
247
266
  console.warn('[watchdog] Failed to route death notification events:', err);
248
267
  }
249
- unregisterProcess(workspaceId);
250
268
  if (controllers.get(workspaceId) === ctrl)
251
269
  controllers.delete(workspaceId);
252
270
  retryCounts.delete(workspaceId);
@@ -273,54 +291,68 @@ function runWatchdog() {
273
291
  }
274
292
  }
275
293
  /**
276
- * On server start, any `agent_sessions` row still in `running` status is
277
- * necessarily orphaned — the process that owned it died with the previous
278
- * server run. Mark those rows as `error` (or `completed` if the PID is
279
- * somehow still alive and reachable) so the health check stops complaining
280
- * and the UI doesn't show ghost agents.
294
+ * Boot-time reconciliation. D1 — the `controllers` map is THE liveness source
295
+ * of truth, and it is empty by definition at this point: every row implying a
296
+ * live agent is therefore orphaned, unconditionally, with no probe to run.
297
+ *
298
+ * The former `isProcessAlive(row.pid)` guard was wrong in both directions. The
299
+ * Claude engine never exposes a pid, so the column stayed NULL and the probe
300
+ * never ran for the default engine. And after a machine restart, a pid
301
+ * recycled by an unrelated program read as "still alive" — pinning the session
302
+ * as running forever, with no controller and no watchdog to ever clean it up.
281
303
  *
282
304
  * Called once at boot, BEFORE `startWatchdog`.
283
305
  */
284
306
  export function reconcileOrphanSessions() {
307
+ const now = new Date().toISOString();
285
308
  try {
286
309
  const db = getDb();
287
- const rows = db.prepare("SELECT id, pid FROM agent_sessions WHERE status = 'running'").all();
288
- if (rows.length > 0) {
289
- const now = new Date().toISOString();
290
- const update = db.prepare("UPDATE agent_sessions SET status = 'error', ended_at = ? WHERE id = ?");
291
- let fixed = 0;
292
- for (const row of rows) {
293
- if (row.pid && isProcessAlive(row.pid))
294
- continue; // genuine leftover from a graceful restart — skip
295
- update.run(now, row.id);
296
- fixed++;
297
- }
298
- if (fixed > 0) {
299
- console.log(`[orchestrator] Reconciled ${fixed} orphan agent_sessions row(s) at boot.`);
300
- }
310
+ const result = db
311
+ .prepare("UPDATE agent_sessions SET status = 'error', ended_at = ? WHERE status = 'running'")
312
+ .run(now);
313
+ if (result.changes > 0) {
314
+ console.log(`[orchestrator] Reconciled ${result.changes} orphan agent_sessions row(s) at boot.`);
301
315
  }
302
316
  }
303
317
  catch (err) {
304
318
  console.error('[orchestrator] Failed to reconcile orphan agent_sessions at boot:', err);
305
319
  }
306
- // Workspaces stuck in `awaiting-user` after a server kill have no live
307
- // controller to resolve canUseTool, so the chat input is disabled forever.
308
- // Drop them back to `idle` so the user can interact (start fresh, etc).
309
- // We bypass `updateWorkspaceStatus` here because the orchestrator/workspace
310
- // module pair is circular and the reference may not be initialised at boot
311
- // time when this runs; a raw SQL update is safe — the awaiting-user → idle
312
- // transition is allowed by VALID_TRANSITIONS.
320
+ // Same reasoning for the workspaces themselves: a status that implies a live
321
+ // agent has no controller left to drive it, so the UI would show "the agent
322
+ // is busy" forever with the chat input disabled.
323
+ //
324
+ // Raw SQL on purpose: `updateWorkspaceStatus` lives in a module that forms a
325
+ // cycle with this one and may not be initialised this early. Every
326
+ // transition performed here (executing / brainstorming / extracting /
327
+ // awaiting-user → idle) is allowed by VALID_TRANSITIONS anyway.
313
328
  try {
314
329
  const db = getDb();
315
330
  const result = db
316
- .prepare("UPDATE workspaces SET status = 'idle', updated_at = ? WHERE status = 'awaiting-user' AND archived_at IS NULL")
317
- .run(new Date().toISOString());
331
+ .prepare(`UPDATE workspaces SET status = 'idle', updated_at = ?
332
+ WHERE status IN ('executing', 'brainstorming', 'extracting', 'awaiting-user')`)
333
+ .run(now);
318
334
  if (result.changes > 0) {
319
- console.log(`[orchestrator] Reconciled ${result.changes} awaiting-user workspace(s) at boot.`);
335
+ console.log(`[orchestrator] Reconciled ${result.changes} orphan workspace status(es) at boot.`);
320
336
  }
321
337
  }
322
338
  catch (err) {
323
- console.error('[orchestrator] Failed to reconcile awaiting-user workspaces at boot:', err);
339
+ console.error('[orchestrator] Failed to reconcile orphan workspace statuses at boot:', err);
340
+ }
341
+ // The dev-server children died with the previous server run too, so a
342
+ // `running` / `starting` column is exactly as false as an orphan agent
343
+ // status — and the health page counts it.
344
+ try {
345
+ const db = getDb();
346
+ const result = db
347
+ .prepare(`UPDATE workspaces SET dev_server_status = 'stopped', updated_at = ?
348
+ WHERE dev_server_status IN ('running', 'starting')`)
349
+ .run(now);
350
+ if (result.changes > 0) {
351
+ console.log(`[orchestrator] Reconciled ${result.changes} orphan dev_server_status value(s) at boot.`);
352
+ }
353
+ }
354
+ catch (err) {
355
+ console.error('[orchestrator] Failed to reconcile dev_server_status at boot:', err);
324
356
  }
325
357
  }
326
358
  /** Start the watchdog (called once from server bootstrap). */
@@ -630,15 +662,30 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
630
662
  lifecycleOwnership !== undefined &&
631
663
  lifecycleOwnership.owner !== sourceController &&
632
664
  lifecycleOwnership.supersededControllers.has(sourceController);
633
- // A stopped controller can continue draining buffered engine events after it
634
- // has lost ownership. Drop every stale non-terminal event before it reaches
635
- // persistence/live delivery, including the gap where no replacement is
636
- // registered yet. A terminal event still records session-local history; it
637
- // carries a durable marker only when a different controller now owns the
638
- // workspace, or when the latest owner already ended, so replay does not
639
- // depend on transient browser state.
665
+ // An EVICTED controller (replaced, or timed out past STOP_AGENT_TIMEOUT_MS)
666
+ // can continue draining buffered engine events after it has lost ownership.
667
+ // Drop every stale non-terminal event before it reaches persistence/live
668
+ // delivery, including the gap where no replacement is registered yet. A
669
+ // terminal event still records session-local history; it carries a durable
670
+ // marker only when a different controller now owns the workspace, or when
671
+ // the latest owner already ended, so replay does not depend on transient
672
+ // browser state.
673
+ //
674
+ // This does NOT cover the `stopping` window (D1): a controller mid-stop
675
+ // stays registered as its own workspace's owner, so its events are real and
676
+ // reach persistence/delivery below unconditionally. What must NOT happen is
677
+ // any side effect that could resurrect the session the user just told to
678
+ // stop — see `sourceControllerIsStopping` further down.
640
679
  if (sourceNoLongerOwnsWorkspace && ev.kind !== 'session:ended')
641
680
  return;
681
+ // A controller already told to stop (D1) keeps emitting real events until it
682
+ // actually dies — they are persisted and broadcast like any other (see
683
+ // `routeEvent` below, unconditional). But a handful of side effects here
684
+ // would re-arm or re-open a session the user just stopped: a new wakeup, a
685
+ // new cron, a quota/auto-loop backoff, or a status bounce back into
686
+ // `awaiting-user`/`executing`. Every branch that does one of those must
687
+ // check this guard.
688
+ const sourceControllerIsStopping = sourceController?.status === 'stopping';
642
689
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
643
690
  routeEvent(workspaceId, agentSessionId, routedEvent);
644
691
  if (ev.kind === 'rate_limit') {
@@ -659,7 +706,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
659
706
  const delay = typeof input?.delaySeconds === 'number' ? input.delaySeconds : 0;
660
707
  const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
661
708
  const reason = typeof input?.reason === 'string' ? input.reason : undefined;
662
- if (delay > 0 && prompt) {
709
+ if (delay > 0 && prompt && !sourceControllerIsStopping) {
663
710
  console.warn(`[orchestrator] Legacy ScheduleWakeup intercepted for workspace '${workspaceId}' — agent should use kobo__schedule_wakeup instead.`);
664
711
  wakeupService.schedule(workspaceId, delay, prompt, reason, agentSessionId);
665
712
  }
@@ -680,17 +727,19 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
680
727
  (typeof input?.expression === 'string' && input.expression) ||
681
728
  '';
682
729
  if (prompt && expression) {
683
- console.warn(`[orchestrator] Native CronCreate intercepted for workspace '${workspaceId}' — armed equivalent kobo cron. Prefer kobo__cron_create.`);
684
- try {
685
- cronService.arm(workspaceId, {
686
- expression,
687
- prompt,
688
- label: 'from-native-CronCreate',
689
- agentSessionId,
690
- });
691
- }
692
- catch (err) {
693
- console.error('[orchestrator] Failed to mirror native CronCreate as kobo cron:', err);
730
+ if (!sourceControllerIsStopping) {
731
+ console.warn(`[orchestrator] Native CronCreate intercepted for workspace '${workspaceId}' — armed equivalent kobo cron. Prefer kobo__cron_create.`);
732
+ try {
733
+ cronService.arm(workspaceId, {
734
+ expression,
735
+ prompt,
736
+ label: 'from-native-CronCreate',
737
+ agentSessionId,
738
+ });
739
+ }
740
+ catch (err) {
741
+ console.error('[orchestrator] Failed to mirror native CronCreate as kobo cron:', err);
742
+ }
694
743
  }
695
744
  }
696
745
  else if (prompt || input) {
@@ -714,7 +763,13 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
714
763
  console.error('[orchestrator] Failed to persist skills:', err);
715
764
  }
716
765
  }
717
- if (ev.kind === 'session:brainstorm-complete') {
766
+ // The `[BRAINSTORM_COMPLETE]` marker is produced by both engine mappers from
767
+ // assistant text, so it can arrive during `stop()`'s own drain — same class
768
+ // of bug as `session:started`'s executing-bounce (D1). The whole block is
769
+ // nothing but this status transition (no separate bookkeeping to preserve),
770
+ // so it is entirely behind the guard; the event itself is still
771
+ // persisted/broadcast unconditionally via `routeEvent` above.
772
+ if (ev.kind === 'session:brainstorm-complete' && !sourceControllerIsStopping) {
718
773
  try {
719
774
  const ws = getWs(workspaceId);
720
775
  if (ws && ws.status !== 'executing') {
@@ -725,13 +780,14 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
725
780
  console.error('[orchestrator] Failed to transition to executing:', err);
726
781
  }
727
782
  }
728
- if (ev.kind === 'error' && ev.category === 'quota') {
783
+ if (ev.kind === 'error' && ev.category === 'quota' && !sourceControllerIsStopping) {
729
784
  void handleQuota(workspaceId, agentSessionId);
730
785
  }
731
786
  if (ev.kind === 'error' &&
732
787
  ev.category === 'other' &&
733
788
  TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
734
- getWs(workspaceId)?.autoLoop) {
789
+ getWs(workspaceId)?.autoLoop &&
790
+ !sourceControllerIsStopping) {
735
791
  void handleTransientAutoLoopFailure(workspaceId);
736
792
  }
737
793
  if (ev.kind === 'error' && ev.category === 'resume_failed') {
@@ -761,8 +817,25 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
761
817
  // failed to drain. It is neither a clean completion nor evidence of an
762
818
  // agent stall. Move auto-loop work onto the existing persisted, bounded
763
819
  // transient-retry path before lifecycle cleanup observes the status.
764
- const watchdogRecovery = ev.reason === 'watchdog' && getWs(workspaceId)?.autoLoop === true;
765
- if (watchdogRecovery) {
820
+ //
821
+ // `!hasReplacement` is load-bearing: this recovery is workspace-scoped,
822
+ // not session-scoped, and it runs BEFORE onSessionEnded's own superseded
823
+ // guard. Without it, an old session timing out would flip the workspace
824
+ // that a NEW session is already running into `quota` and arm a backoff.
825
+ // This is not covered by `sourceControllerIsStopping`: `runWatchdog`'s
826
+ // dead-engine branch evicts a controller from the map WITHOUT ever
827
+ // calling `.stop()` on it, so an old controller can still be 'running'
828
+ // by the time its own drain watchdog reports a late `session:ended`.
829
+ const watchdogRecovery = !hasReplacement && ev.reason === 'watchdog' && getWs(workspaceId)?.autoLoop === true;
830
+ // A drain watchdog can fire mid-`stop()`: `stopController` already cancelled
831
+ // any pending quota backoff (`quotaBackoffService.cancel(id, 'user')`), and
832
+ // re-arming one here would resurrect a session the user just stopped — the
833
+ // same `sourceControllerIsStopping` guard as every other revive side effect
834
+ // in this function. `watchdogRecovery` itself stays computed unguarded: the
835
+ // `onSessionEnded` call below independently bails out via its own
836
+ // `wasStopping` check before its later `if (watchdogRecovery) return` use
837
+ // could matter.
838
+ if (watchdogRecovery && !sourceControllerIsStopping) {
766
839
  console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
767
840
  void handleTransientAutoLoopFailure(workspaceId);
768
841
  }
@@ -785,7 +858,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
785
858
  autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
786
859
  cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
787
860
  }
788
- if (ev.kind === 'session:user-input-requested') {
861
+ if (ev.kind === 'session:user-input-requested' && !sourceControllerIsStopping) {
789
862
  if (ev.requestKind === 'question') {
790
863
  enqueuePending(workspaceId, {
791
864
  kind: 'question',
@@ -823,16 +896,20 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
823
896
  }
824
897
  // Transition terminal states (completed/idle/error/quota) → executing so
825
898
  // the frontend's `sessionActive` flips and streaming messages get the
826
- // typing spinner.
827
- try {
828
- const ws = getWs(workspaceId);
829
- if (ws && (ws.status === 'completed' || ws.status === 'idle' || ws.status === 'error' || ws.status === 'quota')) {
830
- updateWorkspaceStatus(workspaceId, 'executing');
899
+ // typing spinner. Skip while the source controller is stopping — this
900
+ // would bounce a workspace the user just stopped back into `executing`.
901
+ if (!sourceControllerIsStopping) {
902
+ try {
903
+ const ws = getWs(workspaceId);
904
+ if (ws &&
905
+ (ws.status === 'completed' || ws.status === 'idle' || ws.status === 'error' || ws.status === 'quota')) {
906
+ updateWorkspaceStatus(workspaceId, 'executing');
907
+ }
908
+ }
909
+ catch (err) {
910
+ // Transition may be invalid for some edge states — best-effort.
911
+ console.warn('[orchestrator] Could not transition workspace to executing on session:started:', err);
831
912
  }
832
- }
833
- catch (err) {
834
- // Transition may be invalid for some edge states — best-effort.
835
- console.warn('[orchestrator] Could not transition workspace to executing on session:started:', err);
836
913
  }
837
914
  }
838
915
  }
@@ -855,7 +932,6 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
855
932
  if (registeredController === sourceController) {
856
933
  controllers.delete(workspaceId);
857
934
  }
858
- unregisterProcess(workspaceId);
859
935
  if (!preserveQuotaBackoff) {
860
936
  retryCounts.delete(workspaceId);
861
937
  }
@@ -927,14 +1003,27 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
927
1003
  // Zombie detection: an SDK iterator hung on a never-resolved canUseTool
928
1004
  // callback can leave its controller in the map after the workspace is
929
1005
  // logically idle. Evict it instead of refusing the new session.
1006
+ //
1007
+ // A controller already in `stopping` state is evictable too: it stays
1008
+ // registered for the whole teardown now (D1), and refusing here would break
1009
+ // every legitimate stop-then-start sequence.
1010
+ let zombieEviction = Promise.resolve();
930
1011
  const existingCtrl = controllers.get(workspaceId);
931
1012
  if (existingCtrl) {
932
1013
  const wsForCheck = getWs(workspaceId);
933
1014
  const status = wsForCheck?.status;
934
1015
  const isLogicallyDone = status === 'idle' || status === 'completed' || status === 'error' || status === 'quota';
935
- if (isLogicallyDone) {
936
- console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}) before starting fresh session`);
937
- void existingCtrl.stop().catch(() => { });
1016
+ if (isLogicallyDone || existingCtrl.status === 'stopping') {
1017
+ console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}, controller=${existingCtrl.status}) before starting fresh session`);
1018
+ // The new engine must not touch the worktree while the zombie may still
1019
+ // be writing to it — a zombie that ignores its own stop is exactly the
1020
+ // case this path exists for. Bounded: it cannot hold the new session
1021
+ // hostage for more than STOP_AGENT_TIMEOUT_MS.
1022
+ zombieEviction = stopWithTimeout(existingCtrl, STOP_AGENT_TIMEOUT_MS).then((stopped) => {
1023
+ if (!stopped) {
1024
+ console.error(`[orchestrator] Zombie controller for workspace '${workspaceId}' did not stop within ${STOP_AGENT_TIMEOUT_MS}ms — starting the new session anyway`);
1025
+ }
1026
+ });
938
1027
  // Drop any queued pending items + persisted user-input-requested events
939
1028
  // tied to the zombie's agentSessionId so the new session doesn't inherit
940
1029
  // a stale queue and so a future sync replay doesn't resurrect them.
@@ -1003,9 +1092,11 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1003
1092
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1004
1093
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1005
1094
  controllers.set(workspaceId, controller);
1006
- // Kick off engine.start asynchronously. Errors surface as error events.
1007
- void controller
1008
- .start(options)
1095
+ // Kick off engine.start asynchronously, BEHIND the zombie eviction so the
1096
+ // new engine never runs concurrently with the one it replaces. Errors
1097
+ // surface as error events.
1098
+ void zombieEviction
1099
+ .then(() => controller.start(options))
1009
1100
  .then(() => {
1010
1101
  const pid = controller.pid;
1011
1102
  if (pid !== undefined) {
@@ -1058,69 +1149,143 @@ export function interruptAgent(workspaceId, options = {}) {
1058
1149
  autoLoopService.disable(workspaceId, 'user-action');
1059
1150
  }
1060
1151
  }
1152
+ /**
1153
+ * Upper bound on a stop. Beyond this the engine is considered deaf to its own
1154
+ * stop path: we evict the controller so the workspace becomes usable again.
1155
+ *
1156
+ * This is an honest abandonment, not an escalation to something else that
1157
+ * watches over the process: Kōbō has no graduated SIGTERM/SIGKILL path of its
1158
+ * own, the `process-tracker` module that used to exist was removed, and the
1159
+ * session watchdog only walks the `controllers` map — the very map this
1160
+ * eviction just removed the entry from. Whatever survives past this timeout
1161
+ * is untracked by anyone in this process; only the OS or the user can end it.
1162
+ */
1163
+ export const STOP_AGENT_TIMEOUT_MS = 15_000;
1061
1164
  async function stopController(workspaceId, ctrl) {
1062
1165
  wakeupService.cancel(workspaceId, 'stopped');
1063
- // If the session was waiting on a question/permission, normalize the state
1064
- // synchronously so callers (archive, delete, manual stop) see a clean
1065
- // workspace immediately — without waiting for the async controller.stop()
1066
- // → session:ended round-trip. We:
1166
+ // Normalize the state synchronously so callers (archive, delete, manual
1167
+ // stop) see a clean workspace immediately — without waiting for the async
1168
+ // controller.stop() → session:ended round-trip, which onSessionEnded skips
1169
+ // entirely for a controller already in `stopping` state (see `wasStopping`
1170
+ // there). Every status that implies a live agent must be covered here —
1171
+ // the same four covered by boot-time reconciliation — or a manual stop
1172
+ // from `executing`/`brainstorming`/`extracting` leaves the workspace
1173
+ // stuck showing "agent busy" with nothing left to ever clear it.
1067
1174
  // 1. drop queued pending items for this session,
1068
1175
  // 2. purge persisted `session:user-input-requested` events so a F5 can't
1069
1176
  // resurrect zombie panels,
1070
- // 3. transition the workspace out of `awaiting-user` (→ idle) so badges
1071
- // and unarchive don't leave a stuck status.
1177
+ // 3. transition the workspace out of the active status (→ idle) so
1178
+ // badges and unarchive don't leave a stuck status.
1179
+ // Steps 1-2 are `awaiting-user`-specific: they only make sense when the
1180
+ // session actually left a pending question/permission behind.
1072
1181
  const wsBefore = getWs(workspaceId);
1073
- if (wsBefore?.status === 'awaiting-user') {
1074
- clearPendingForSession(workspaceId, ctrl.agentSessionId);
1075
- purgeAllPersistedUserInputRequests(workspaceId, ctrl.agentSessionId);
1182
+ const activeStatuses = ['executing', 'brainstorming', 'extracting', 'awaiting-user'];
1183
+ if (wsBefore && activeStatuses.includes(wsBefore.status)) {
1184
+ if (wsBefore.status === 'awaiting-user') {
1185
+ clearPendingForSession(workspaceId, ctrl.agentSessionId);
1186
+ purgeAllPersistedUserInputRequests(workspaceId, ctrl.agentSessionId);
1187
+ }
1076
1188
  try {
1077
1189
  updateWorkspaceStatus(workspaceId, 'idle');
1078
1190
  }
1079
1191
  catch (err) {
1080
- console.warn('[orchestrator] Failed to normalize awaiting-user → idle on stop:', err);
1192
+ console.warn(`[orchestrator] Failed to normalize ${wsBefore.status} → idle on stop:`, err);
1081
1193
  }
1082
1194
  }
1083
- // Remove from the map immediately so a fresh startAgent can proceed. The
1084
- // session:ended handler checks identity before removing, so a new controller
1085
- // started in the meantime is preserved.
1086
- controllers.delete(workspaceId);
1087
1195
  // Manual stop should also drop any pending quota auto-resume.
1088
1196
  quotaBackoffService.cancel(workspaceId, 'user');
1089
- await ctrl.stop();
1197
+ // D1 — the controller stays REGISTERED for the whole stop, in `stopping`
1198
+ // state. Removing it up-front (as we used to) made the only "is an agent
1199
+ // running?" signal of the system lie for the entire teardown window: cron
1200
+ // and auto-loop could start a second agent on the same worktree, and delete
1201
+ // could pull the worktree from under a process still writing to it.
1202
+ try {
1203
+ await ctrl.stop();
1204
+ }
1205
+ finally {
1206
+ if (controllers.get(workspaceId) === ctrl)
1207
+ controllers.delete(workspaceId);
1208
+ }
1209
+ }
1210
+ /**
1211
+ * Race an arbitrary promise against a bounded deadline. Resolves to the
1212
+ * winner's value; if the deadline wins, `onTimeout()` supplies the value
1213
+ * instead. Never throws — the timer is always cleared. Single source of
1214
+ * truth for the "stop, but not forever" pattern: both `stopWithTimeout` and
1215
+ * `stopAgentAndWait` used to hand-roll their own `Promise.race` + timer, two
1216
+ * copies of the same logic that could silently drift apart.
1217
+ */
1218
+ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1219
+ let timer;
1220
+ try {
1221
+ return await Promise.race([
1222
+ promise,
1223
+ new Promise((resolve) => {
1224
+ timer = setTimeout(() => resolve(onTimeout()), timeoutMs);
1225
+ timer.unref?.();
1226
+ }),
1227
+ ]);
1228
+ }
1229
+ finally {
1230
+ if (timer)
1231
+ clearTimeout(timer);
1232
+ }
1233
+ }
1234
+ /** Race a controller stop against a bounded deadline. Never throws. */
1235
+ async function stopWithTimeout(ctrl, timeoutMs) {
1236
+ return raceWithTimeout(ctrl
1237
+ .stop()
1238
+ .then(() => true)
1239
+ .catch((err) => {
1240
+ console.error('[orchestrator] controller.stop rejected:', err);
1241
+ return true;
1242
+ }), timeoutMs, () => false);
1090
1243
  }
1091
- /** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
1244
+ /**
1245
+ * Stop the agent and wait for it to actually die. Every caller that mutates
1246
+ * the worktree afterwards — delete, archive, purge, setup script, engine
1247
+ * switch — MUST use this: the fire-and-forget `stopAgent` returns while the
1248
+ * engine may still be writing files.
1249
+ */
1250
+ export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS) {
1251
+ const ctrl = controllers.get(workspaceId);
1252
+ if (!ctrl)
1253
+ return 'not-running';
1254
+ const finished = await raceWithTimeout(stopController(workspaceId, ctrl).then(() => true, (err) => {
1255
+ console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
1256
+ return true;
1257
+ }), timeoutMs, () => false);
1258
+ if (finished)
1259
+ return 'stopped';
1260
+ console.error(`[orchestrator] Agent for workspace '${workspaceId}' did not stop within ${timeoutMs}ms — evicting its controller`);
1261
+ if (controllers.get(workspaceId) === ctrl)
1262
+ controllers.delete(workspaceId);
1263
+ return 'timeout';
1264
+ }
1265
+ /**
1266
+ * Fire-and-forget stop, for the few callers that cannot await (WebSocket
1267
+ * handlers). Anything that touches the worktree afterwards must call
1268
+ * `stopAgentAndWait` instead. Escalation is the engine's business — Kōbō has
1269
+ * no graduated SIGTERM/SIGKILL path of its own.
1270
+ */
1092
1271
  export function stopAgent(workspaceId) {
1093
1272
  const ctrl = controllers.get(workspaceId);
1094
1273
  if (!ctrl) {
1095
1274
  throw new Error(`No agent running for workspace '${workspaceId}'`);
1096
1275
  }
1097
- // Fire-and-forget: callers such as archive/delete must remain synchronous.
1098
- void stopController(workspaceId, ctrl).catch((err) => {
1099
- console.error('[orchestrator] controller.stop failed:', err);
1276
+ void stopAgentAndWait(workspaceId).catch((err) => {
1277
+ console.error('[orchestrator] stopAgent failed:', err);
1100
1278
  });
1101
1279
  }
1102
1280
  /** Stop every live engine before process shutdown, with a bounded wait per engine. */
1103
1281
  export async function stopAllAgents(timeoutMs = 3_000) {
1104
- const stops = [...controllers.entries()].map(async ([workspaceId, ctrl]) => {
1105
- let timeout;
1106
- try {
1107
- await Promise.race([
1108
- stopController(workspaceId, ctrl),
1109
- new Promise((resolve) => {
1110
- timeout = setTimeout(resolve, timeoutMs);
1111
- timeout.unref?.();
1112
- }),
1113
- ]);
1114
- }
1115
- catch (err) {
1116
- console.error(`[orchestrator] Failed to stop '${workspaceId}' during shutdown:`, err);
1117
- }
1118
- finally {
1119
- if (timeout)
1120
- clearTimeout(timeout);
1121
- }
1122
- });
1123
- await Promise.all(stops);
1282
+ // No per-workspace try/catch here: unlike the pre-D1 shutdown path,
1283
+ // `stopAgentAndWait` never rejects — every internal failure (a rejecting
1284
+ // `controller.stop()`, or the bounded deadline firing) is already converted
1285
+ // into a logged, returned `StopAgentOutcome` ('stopped' | 'timeout')
1286
+ // instead of a thrown error. A wrapping catch here would be unreachable
1287
+ // dead code pretending to guard against a failure mode that can't occur.
1288
+ await Promise.all([...controllers.keys()].map((workspaceId) => stopAgentAndWait(workspaceId, timeoutMs)));
1124
1289
  }
1125
1290
  /** Write a user message to the running agent. */
1126
1291
  export async function sendMessage(workspaceId, content, expectedSessionId) {
@@ -1161,6 +1326,39 @@ export async function sendMessageForFallback(workspaceId, content) {
1161
1326
  return { status: 'sent', sessionId: replacementController.agentSessionId };
1162
1327
  }
1163
1328
  }
1329
+ /**
1330
+ * Error shapes that all mean the same thing to a chat sender: this workspace
1331
+ * has no agent able to receive a message right now, so the correct reaction is
1332
+ * to resume a session — never to reject and drop what the user typed.
1333
+ *
1334
+ * `Claude input stream is closed` / `Claude agent is no longer running` are
1335
+ * exactly what the Claude engine throws after a one-prompt-one-turn session
1336
+ * closed its stdin. The former literal `includes('No agent running')` test
1337
+ * missed both, and the message vanished with no feedback at all.
1338
+ *
1339
+ * The third pattern is built from `AGENT_NO_LONGER_RUNNING_TEXT`
1340
+ * (`engines/types.ts`) rather than hardcoded again here: the Codex engine's
1341
+ * `sendMessage` throws `Codex ${AGENT_NO_LONGER_RUNNING_TEXT}` once its
1342
+ * session has fully ended (JSON-RPC peer closed, child killed) and a message
1343
+ * arrives afterward — the same "resume, don't reject" situation Claude hits,
1344
+ * reusing this one already-recognized shape instead of adding a fourth,
1345
+ * engine-specific string to maintain here.
1346
+ *
1347
+ * Deliberately EXCLUDED: `SessionController is stopping` and
1348
+ * `SessionController not started` (Claude), and Codex's own
1349
+ * `Codex session is not ready to receive a message` (thrown before its first
1350
+ * turn is established). Resuming any of those would spawn a second agent on
1351
+ * a worktree that is mid-teardown or mid-startup, or mask a genuine startup
1352
+ * failure behind a resume loop. Those stay visible rejections.
1353
+ */
1354
+ const AGENT_UNAVAILABLE_PATTERNS = [
1355
+ /no agent running/i,
1356
+ /input stream is closed/i,
1357
+ new RegExp(AGENT_NO_LONGER_RUNNING_TEXT, 'i'),
1358
+ ];
1359
+ export function isAgentUnavailableError(message) {
1360
+ return AGENT_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(message));
1361
+ }
1164
1362
  /**
1165
1363
  * Render the user's answer to an AskUserQuestion as a markdown chat
1166
1364
  * message. Each question becomes a bullet line `**<question>** → <answer>`.
@@ -1448,6 +1646,28 @@ export function getActiveSessionId(workspaceId) {
1448
1646
  export function getRunningCount() {
1449
1647
  return controllers.size;
1450
1648
  }
1649
+ /** Liveness for one workspace, or `null` when no controller owns it. */
1650
+ export function getAgentLiveness(workspaceId) {
1651
+ const ctrl = controllers.get(workspaceId);
1652
+ if (!ctrl)
1653
+ return null;
1654
+ return {
1655
+ status: ctrl.status,
1656
+ agentSessionId: ctrl.agentSessionId,
1657
+ startedAt: new Date(ctrl.startedAt).toISOString(),
1658
+ lastEventAt: new Date(ctrl.lastEventAt).toISOString(),
1659
+ };
1660
+ }
1661
+ /** Liveness for every live controller, keyed by workspace id. */
1662
+ export function getAllAgentLiveness() {
1663
+ const out = {};
1664
+ for (const workspaceId of controllers.keys()) {
1665
+ const liveness = getAgentLiveness(workspaceId);
1666
+ if (liveness)
1667
+ out[workspaceId] = liveness;
1668
+ }
1669
+ return out;
1670
+ }
1451
1671
  /** Kobo built-in slash commands injected into the skill list (without leading /). */
1452
1672
  const KOBO_COMMANDS = ['kobo-check-progress', 'kobo-prep-autoloop'];
1453
1673
  /** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
@@ -1558,8 +1778,9 @@ async function handleQuota(workspaceId, _agentSessionId) {
1558
1778
  try {
1559
1779
  updateWorkspaceStatus(workspaceId, 'quota');
1560
1780
  }
1561
- catch {
1562
- // May fail if transition is not valid
1781
+ catch (err) {
1782
+ // Never silent: a refused transition here loses the backoff two lines down.
1783
+ console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota:`, err);
1563
1784
  }
1564
1785
  const retryCount = retryCounts.get(workspaceId) ?? 0;
1565
1786
  const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
@@ -1589,8 +1810,8 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1589
1810
  try {
1590
1811
  updateWorkspaceStatus(workspaceId, 'quota');
1591
1812
  }
1592
- catch {
1593
- // May fail if transition is not valid.
1813
+ catch (err) {
1814
+ console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota for a transient failure:`, err);
1594
1815
  }
1595
1816
  const retryCount = retryCounts.get(workspaceId) ?? 0;
1596
1817
  const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;