@loicngr/kobo 1.11.36 → 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 (100) hide show
  1. package/AGENTS.md +15 -0
  2. package/CHANGELOG.md +4 -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/codex/client.js +1 -0
  8. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  9. package/dist/server/services/agent/engines/codex/event-mapper.js +5 -1
  10. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  11. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  12. package/dist/server/services/agent/orchestrator.js +146 -68
  13. package/dist/server/services/agent/session-controller.js +12 -2
  14. package/dist/server/services/auto-loop-service.js +57 -3
  15. package/dist/server/services/change-source-branch-service.js +36 -4
  16. package/dist/server/services/cron-service.js +19 -0
  17. package/dist/server/services/db-backup-service.js +30 -0
  18. package/dist/server/services/dev-server-service.js +167 -38
  19. package/dist/server/services/image-service.js +25 -28
  20. package/dist/server/services/pr-checkout-service.js +34 -5
  21. package/dist/server/services/quota-backoff-service.js +25 -8
  22. package/dist/server/services/wakeup-service.js +23 -3
  23. package/dist/server/services/worktree-purge-service.js +3 -7
  24. package/dist/server/utils/agent-stop-result.js +12 -0
  25. package/dist/server/utils/git-ops.js +8 -1
  26. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  27. package/dist/server/utils/safe-path.js +21 -0
  28. package/dist/server/utils/script-runner.js +32 -9
  29. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  30. package/package.json +1 -1
  31. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-BDyFBoxd.css} +1 -1
  32. package/src/client/dist/pwa/assets/ActivityFeed-C_-hxIo8.js +8 -0
  33. package/src/client/dist/pwa/assets/{ChangelogPage-BFGYQ6eY.js → ChangelogPage-CBlGJ4zT.js} +1 -1
  34. package/src/client/dist/pwa/assets/{ComparisonPanel-C6DfjpZZ.js → ComparisonPanel-DZAE2X9j.js} +1 -1
  35. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  36. package/src/client/dist/pwa/assets/CreatePage-ArmtcF4d.js +2 -0
  37. package/src/client/dist/pwa/assets/{DashboardPage-DsVRjzzy.js → DashboardPage-eQTCXcdz.js} +1 -1
  38. package/src/client/dist/pwa/assets/{DiffViewer-DSlSAIiW.js → DiffViewer-CaT7vuSr.js} +3 -3
  39. package/src/client/dist/pwa/assets/{DocumentsPanel-C1eXTw_Q.js → DocumentsPanel-A0isCTaI.js} +1 -1
  40. package/src/client/dist/pwa/assets/{DrawerToggleButton-BxYpUb4y.js → DrawerToggleButton-T70fh8dp.js} +1 -1
  41. package/src/client/dist/pwa/assets/{GitPanel-DnBWSdVp.js → GitPanel-CAvk0G7h.js} +3 -3
  42. package/src/client/dist/pwa/assets/{HealthPage-rX1TiaP9.js → HealthPage-D8Mr0V6w.js} +1 -1
  43. package/src/client/dist/pwa/assets/{MainLayout-Ceuh5uJj.js → MainLayout-x6Es4Da-.js} +2 -2
  44. package/src/client/dist/pwa/assets/{QBtnDropdown-K7syiyyH.js → QBtnDropdown-CQqzzlFI.js} +1 -1
  45. package/src/client/dist/pwa/assets/{QDrawer-DapzE8DH.js → QDrawer-Cwj2qh0r.js} +1 -1
  46. package/src/client/dist/pwa/assets/{QSelect-Bodml89k.js → QSelect-BSPtuXJg.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QTooltip-K-9OdDfq.js → QTooltip-DC_kDKku.js} +1 -1
  48. package/src/client/dist/pwa/assets/{SchedulePanel-CfumTzAa.js → SchedulePanel-9aJSoz6Y.js} +1 -1
  49. package/src/client/dist/pwa/assets/{SearchPage-AhZYS-dS.js → SearchPage-BjNvtE_U.js} +1 -1
  50. package/src/client/dist/pwa/assets/{SettingsPage-BoHXWvKn.js → SettingsPage-CvLnT73V.js} +1 -1
  51. package/src/client/dist/pwa/assets/{SplitWorkspacePage-3wK11mcu.js → SplitWorkspacePage-DTao0DHr.js} +1 -1
  52. package/src/client/dist/pwa/assets/{TerminalPanel-CKg0BeNK.js → TerminalPanel-BrKIW-bJ.js} +1 -1
  53. package/src/client/dist/pwa/assets/{TouchPan-D6ctyPYf.js → TouchPan-_n1AQgoo.js} +1 -1
  54. package/src/client/dist/pwa/assets/{TourReplayButton-Dn4Dy_kH.js → TourReplayButton-CJF3nVvn.js} +1 -1
  55. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DnoabsfK.js → WorkspaceAttentionLabels-CxesUiIm.js} +1 -1
  56. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-DoXtwpjV.css} +1 -1
  57. package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +4 -0
  58. package/src/client/dist/pwa/assets/{browser-DHpSUK8-.js → browser-DGWpkvN3.js} +1 -1
  59. package/src/client/dist/pwa/assets/{build-path-tree-DtEjsZlh.js → build-path-tree-Ba1dSLgi.js} +1 -1
  60. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-DVK21RB6.js} +1 -1
  61. package/src/client/dist/pwa/assets/{documents-B2dg1wRf.js → documents-BMHzjW_g.js} +1 -1
  62. package/src/client/dist/pwa/assets/{editor.api-Cd1D35oj.js → editor.api-CYTuNwqu.js} +1 -1
  63. package/src/client/dist/pwa/assets/{efforts-IB1vPjJQ.js → efforts-BPlRSHXp.js} +1 -1
  64. package/src/client/dist/pwa/assets/{engineFeatures-DYSyDEJe.js → engineFeatures-DVB2rklO.js} +1 -1
  65. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-C8cQwDZM.js} +1 -1
  66. package/src/client/dist/pwa/assets/{expand-template-CNAHiesL.js → expand-template-Cj82YE-O.js} +1 -1
  67. package/src/client/dist/pwa/assets/{format-DidwgbbX.js → format-Dk7O3LhA.js} +1 -1
  68. package/src/client/dist/pwa/assets/{formatters-BjzyUHlu.js → formatters-TGjvs7Ca.js} +1 -1
  69. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-BVOMZ2Bl.js} +1 -1
  70. package/src/client/dist/pwa/assets/{freemarker2-jNGWawVw.js → freemarker2-39lO_dWH.js} +1 -1
  71. package/src/client/dist/pwa/assets/{handlebars-DfSqClrW.js → handlebars-M_0V82cD.js} +1 -1
  72. package/src/client/dist/pwa/assets/{html-ByLX-n8T.js → html-yILqYDsl.js} +1 -1
  73. package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +1 -0
  74. package/src/client/dist/pwa/assets/{index-DcwcFDrZ.js → index-BJU5dnkF.js} +2 -2
  75. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-CxkAoOj4.js} +1 -1
  76. package/src/client/dist/pwa/assets/{javascript-Dja1fHfv.js → javascript-DrW-amDc.js} +1 -1
  77. package/src/client/dist/pwa/assets/{liquid-D315hW6C.js → liquid-D_mXmUoL.js} +1 -1
  78. package/src/client/dist/pwa/assets/{mdx-B5yUwpzv.js → mdx-E8O3lvR5.js} +1 -1
  79. package/src/client/dist/pwa/assets/{monaco-contributions-NfxrU0r3.js → monaco-contributions-C2K3sl_V.js} +1 -1
  80. package/src/client/dist/pwa/assets/{monaco.contribution-D1hXPkDZ.js → monaco.contribution-DLhHQIJn.js} +2 -2
  81. package/src/client/dist/pwa/assets/{network-auth-CARVtbNe.js → network-auth-BUwWtnui.js} +1 -1
  82. package/src/client/dist/pwa/assets/{notify-theme-CKC3-EAZ.js → notify-theme-1mOpY2vN.js} +1 -1
  83. package/src/client/dist/pwa/assets/{pending-diff-open-BlLrQaW7.js → pending-diff-open-BL7dKRqF.js} +1 -1
  84. package/src/client/dist/pwa/assets/{permissionModes-DMzFEQhr.js → permissionModes-BtC3_P-P.js} +1 -1
  85. package/src/client/dist/pwa/assets/{project-color-CFjdIj6W.js → project-color-BFBFZWeG.js} +1 -1
  86. package/src/client/dist/pwa/assets/{python-Dc6mnEip.js → python-DoZAYim6.js} +1 -1
  87. package/src/client/dist/pwa/assets/{razor-DArVFBGO.js → razor-9bZ30g2y.js} +1 -1
  88. package/src/client/dist/pwa/assets/{render-chat-markdown-BTPAjU2g.js → render-chat-markdown-7EtWNjG0.js} +1 -1
  89. package/src/client/dist/pwa/assets/{typescript-COE1vIyB.js → typescript-C1CBfL3L.js} +1 -1
  90. package/src/client/dist/pwa/assets/{use-tours-C8x2V1w5.js → use-tours-BJ81clv6.js} +1 -1
  91. package/src/client/dist/pwa/assets/{workspace-templates-DHyA_GEW.js → workspace-templates-r1-bipjM.js} +1 -1
  92. package/src/client/dist/pwa/assets/{xml-eYjr4cGR.js → xml-COuu5isK.js} +1 -1
  93. package/src/client/dist/pwa/assets/{yaml-UooOcssF.js → yaml-ChZ7WqaJ.js} +1 -1
  94. package/src/client/dist/pwa/index.html +1 -1
  95. package/src/client/dist/pwa/sw.js +1 -1
  96. package/src/client/dist/pwa/assets/ActivityFeed-BPx-xhFY.js +0 -8
  97. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  98. package/src/client/dist/pwa/assets/CreatePage-ypEzkAKv.js +0 -2
  99. package/src/client/dist/pwa/assets/WorkspacePage-BI7CW2yT.js +0 -4
  100. package/src/client/dist/pwa/assets/i18n-CnjXQjxy.js +0 -1
@@ -15,6 +15,7 @@ export function createAppServerClient(opts) {
15
15
  onNotification: opts.onNotification ?? (() => { }),
16
16
  onServerRequest: opts.onServerRequest ?? (() => { }),
17
17
  onError: opts.onError,
18
+ onDisconnect: opts.onDisconnect,
18
19
  });
19
20
  return {
20
21
  peer,
@@ -11,6 +11,7 @@ import { buildResponseForResolve, handleServerRequest } from './server-requests.
11
11
  import { spawnAppServer } from './spawn.js';
12
12
  /** Long enough for normal tool work, short enough to recover a lost turn event. */
13
13
  export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
14
+ export const CODEX_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
14
15
  export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
15
16
  /** Grace given to a SIGTERM before the child is killed outright. */
16
17
  const CODEX_FORCE_KILL_TIMEOUT_MS = 3_000;
@@ -24,6 +25,35 @@ export const CODEX_SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
24
25
  function wait(ms) {
25
26
  return new Promise((resolve) => setTimeout(resolve, ms));
26
27
  }
28
+ /** A sent signal is only a request; resolve true exclusively on confirmed exit. */
29
+ function signalAndWaitForExit(child, signal, timeoutMs) {
30
+ if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined)
31
+ return Promise.resolve(true);
32
+ return new Promise((resolve, reject) => {
33
+ const cleanup = () => {
34
+ clearTimeout(timer);
35
+ child.removeListener('exit', onExit);
36
+ };
37
+ const onExit = () => {
38
+ cleanup();
39
+ resolve(true);
40
+ };
41
+ const timer = setTimeout(() => {
42
+ cleanup();
43
+ resolve(false);
44
+ }, timeoutMs);
45
+ timer.unref?.();
46
+ // Install before signalling so an immediate exit cannot be missed.
47
+ child.once('exit', onExit);
48
+ try {
49
+ child.kill(signal);
50
+ }
51
+ catch (err) {
52
+ cleanup();
53
+ reject(err);
54
+ }
55
+ });
56
+ }
27
57
  class CodexTurnTimeoutError extends Error {
28
58
  constructor() {
29
59
  super('Codex stopped reporting activity for this turn');
@@ -77,7 +107,17 @@ export function createCodexEngine() {
77
107
  }
78
108
  };
79
109
  const streamingBatcher = createStreamingBatcher(emitDirect);
80
- const safeEmit = (ev) => streamingBatcher.push(ev);
110
+ const pendingToolCalls = new Set();
111
+ const safeEmit = (ev) => {
112
+ if (ev.kind === 'tool:call')
113
+ pendingToolCalls.add(ev.toolCallId);
114
+ if (ev.kind === 'tool:result')
115
+ pendingToolCalls.delete(ev.toolCallId);
116
+ if (ev.kind === 'tool:call' || ev.kind === 'tool:result') {
117
+ turnLiveness.setTimeoutMs(pendingToolCalls.size ? CODEX_TOOL_IDLE_TIMEOUT_MS : CODEX_TURN_IDLE_TIMEOUT_MS);
118
+ }
119
+ streamingBatcher.push(ev);
120
+ };
81
121
  let rejectChildFailure;
82
122
  const childFailurePromise = new Promise((_resolve, reject) => {
83
123
  rejectChildFailure = reject;
@@ -324,6 +364,7 @@ export function createCodexEngine() {
324
364
  autoApprove: (toolName, payload) => isWorkspacePermissionAllowed(options.workspaceId, { engine: 'codex', toolName, payload }),
325
365
  });
326
366
  },
367
+ onDisconnect: rejectChildFailure,
327
368
  onError(err) {
328
369
  console.error('[codex] JSON-RPC transport error:', err);
329
370
  rejectTurnDone(err);
@@ -428,6 +469,7 @@ export function createCodexEngine() {
428
469
  }
429
470
  }
430
471
  pendingByCallId.clear();
472
+ pendingToolCalls.clear();
431
473
  client.close();
432
474
  try {
433
475
  child.kill('SIGTERM');
@@ -515,15 +557,10 @@ export function createCodexEngine() {
515
557
  // stop, the archive, the delete and Kōbō's own shutdown, holding the
516
558
  // worktree and its share of the model quota, with nothing left in
517
559
  // this process tracking it.
518
- if (child.exitCode === null && child.signalCode === null) {
519
- child.kill('SIGTERM');
520
- const exited = await Promise.race([
521
- new Promise((resolve) => child.once('exit', () => resolve(true))),
522
- wait(CODEX_FORCE_KILL_TIMEOUT_MS).then(() => false),
523
- ]);
524
- if (!exited) {
525
- console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
526
- child.kill('SIGKILL');
560
+ if (!(await signalAndWaitForExit(child, 'SIGTERM', CODEX_FORCE_KILL_TIMEOUT_MS))) {
561
+ console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
562
+ if (!(await signalAndWaitForExit(child, 'SIGKILL', CODEX_FORCE_KILL_TIMEOUT_MS))) {
563
+ throw new Error('Codex app-server did not exit after SIGKILL');
527
564
  }
528
565
  }
529
566
  },
@@ -302,13 +302,17 @@ export function handleItemCompleted(item, state) {
302
302
  }
303
303
  if (item.type === 'plan') {
304
304
  // Codex's `plan` is a markdown blob, not a structured list — parse bullets.
305
+ const toolCallId = scopedId(state, item.id);
305
306
  events.push({
306
307
  kind: 'tool:call',
307
308
  messageId: '',
308
- toolCallId: scopedId(state, item.id),
309
+ toolCallId,
309
310
  name: 'TodoWrite',
310
311
  input: { todos: parseCodexPlanText(item.text) },
311
312
  });
313
+ // The plan has already completed. Close the synthetic update so both the
314
+ // tool card and the engine's idle watchdog see no outstanding operation.
315
+ events.push({ kind: 'tool:result', toolCallId, output: null, isError: false });
312
316
  return events;
313
317
  }
314
318
  if (item.type === 'error') {
@@ -19,6 +19,7 @@ export class JsonRpcTimeoutError extends Error {
19
19
  }
20
20
  export function createJsonRpcPeer(opts) {
21
21
  let nextId = 1;
22
+ let closedError;
22
23
  const pending = new Map();
23
24
  /** Remove a pending slot and clear its deadline in one place. */
24
25
  const takePending = (id) => {
@@ -30,10 +31,21 @@ export function createJsonRpcPeer(opts) {
30
31
  pending.delete(id);
31
32
  return slot;
32
33
  };
34
+ const rejectPending = (err) => {
35
+ if (closedError)
36
+ return;
37
+ closedError = err;
38
+ for (const id of pending.keys())
39
+ takePending(id)?.reject(err);
40
+ };
33
41
  const transport = createJsonRpcTransport({
34
42
  stdin: opts.stdin,
35
43
  stdout: opts.stdout,
36
44
  onError: opts.onError ?? (() => { }),
45
+ onDisconnect(err) {
46
+ rejectPending(err);
47
+ opts.onDisconnect?.(err);
48
+ },
37
49
  onMessage(msg) {
38
50
  // Response to one of our requests
39
51
  if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
@@ -61,6 +73,8 @@ export function createJsonRpcPeer(opts) {
61
73
  });
62
74
  return {
63
75
  request(method, params, timeoutMs) {
76
+ if (closedError)
77
+ return Promise.reject(closedError);
64
78
  const id = nextId++;
65
79
  const deadlineMs = timeoutMs ?? opts.defaultRequestTimeoutMs ?? DEFAULT_JSONRPC_REQUEST_TIMEOUT_MS;
66
80
  return new Promise((resolve, reject) => {
@@ -73,7 +87,12 @@ export function createJsonRpcPeer(opts) {
73
87
  slot.timer.unref?.();
74
88
  }
75
89
  pending.set(id, slot);
76
- transport.send({ jsonrpc: '2.0', id, method, params });
90
+ try {
91
+ transport.send({ jsonrpc: '2.0', id, method, params });
92
+ }
93
+ catch (err) {
94
+ takePending(id)?.reject(err instanceof Error ? err : new Error(String(err)));
95
+ }
77
96
  });
78
97
  },
79
98
  notify(method, params) {
@@ -86,14 +105,8 @@ export function createJsonRpcPeer(opts) {
86
105
  transport.send({ jsonrpc: '2.0', id, error: { code, message } });
87
106
  },
88
107
  close() {
108
+ rejectPending(new Error('peer closed'));
89
109
  transport.close();
90
- const err = new Error('peer closed');
91
- for (const slot of pending.values()) {
92
- if (slot.timer)
93
- clearTimeout(slot.timer);
94
- slot.reject(err);
95
- }
96
- pending.clear();
97
110
  },
98
111
  };
99
112
  }
@@ -1,5 +1,17 @@
1
1
  export function createJsonRpcTransport(opts) {
2
2
  let buffer = '';
3
+ let closed = false;
4
+ const fail = (err) => {
5
+ if (closed)
6
+ return;
7
+ closed = true;
8
+ opts.onDisconnect?.(err);
9
+ opts.onError(err);
10
+ };
11
+ // Keep listeners installed through teardown: late pipe errors must stay handled.
12
+ opts.stdin.on('error', fail);
13
+ opts.stdout.on('error', fail);
14
+ opts.stdout.on('end', () => fail(new Error('JSON-RPC transport closed')));
3
15
  opts.stdout.setEncoding?.('utf-8');
4
16
  opts.stdout.on('data', (chunk) => {
5
17
  buffer += chunk;
@@ -19,13 +31,28 @@ export function createJsonRpcTransport(opts) {
19
31
  }
20
32
  }
21
33
  });
22
- opts.stdout.on('error', opts.onError);
23
34
  return {
24
35
  send(msg) {
25
- opts.stdin.write(`${JSON.stringify(msg)}\n`);
36
+ if (closed)
37
+ throw new Error('JSON-RPC transport closed');
38
+ try {
39
+ opts.stdin.write(`${JSON.stringify(msg)}\n`);
40
+ }
41
+ catch (err) {
42
+ fail(err instanceof Error ? err : new Error(String(err)));
43
+ throw err;
44
+ }
26
45
  },
27
46
  close() {
28
- opts.stdin.end();
47
+ if (closed)
48
+ return;
49
+ closed = true;
50
+ try {
51
+ opts.stdin.end();
52
+ }
53
+ catch {
54
+ /* Already disconnected. */
55
+ }
29
56
  },
30
57
  };
31
58
  }
@@ -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.
@@ -861,8 +891,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
861
891
  const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
862
892
  if (!ownsWorkspaceLifecycle)
863
893
  return;
864
- if (watchdogRecovery)
894
+ if (watchdogRecovery) {
895
+ notifyCapacityAvailable(workspaceId);
865
896
  return;
897
+ }
866
898
  // resume_failed exits with an error but the workspace is fine (stale id
867
899
  // cleared, next iteration will start fresh) — report 'completed' to
868
900
  // auto-loop so it continues.
@@ -984,7 +1016,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
984
1016
  const currentWorkspace = getWs(workspaceId);
985
1017
  const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
986
1018
  const wasStopping = sourceController?.status === 'stopping';
987
- if (registeredController === sourceController) {
1019
+ if (!wasStopping && registeredController === sourceController) {
988
1020
  controllers.delete(workspaceId);
989
1021
  }
990
1022
  if (!preserveQuotaBackoff) {
@@ -1055,6 +1087,9 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1055
1087
  * query the controller via `_getControllers()` for tests.
1056
1088
  */
1057
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);
1058
1093
  const workspace = getWs(workspaceId);
1059
1094
  if (!workspace)
1060
1095
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -1068,26 +1103,22 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1068
1103
  // callback can leave its controller in the map after the workspace is
1069
1104
  // logically idle. Evict it instead of refusing the new session.
1070
1105
  //
1071
- // A controller already in `stopping` state is evictable too: it stays
1072
- // registered for the whole teardown now (D1), and refusing here would break
1073
- // 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.
1074
1108
  let zombieEviction = Promise.resolve();
1075
1109
  const existingCtrl = controllers.get(workspaceId);
1076
1110
  if (existingCtrl) {
1111
+ if (existingCtrl.status === 'stopping')
1112
+ throw new Error('Agent is still stopping; retry after its stop completes');
1077
1113
  const wsForCheck = getWs(workspaceId);
1078
1114
  const status = wsForCheck?.status;
1079
1115
  const isLogicallyDone = status === 'idle' || status === 'completed' || status === 'error' || status === 'quota';
1080
- if (isLogicallyDone || existingCtrl.status === 'stopping') {
1116
+ if (isLogicallyDone) {
1081
1117
  console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}, controller=${existingCtrl.status}) before starting fresh session`);
1082
1118
  // The new engine must not touch the worktree while the zombie may still
1083
1119
  // be writing to it — a zombie that ignores its own stop is exactly the
1084
- // case this path exists for. Bounded: it cannot hold the new session
1085
- // hostage for more than STOP_AGENT_TIMEOUT_MS.
1086
- zombieEviction = stopWithTimeout(existingCtrl, STOP_AGENT_TIMEOUT_MS).then((stopped) => {
1087
- if (!stopped) {
1088
- console.error(`[orchestrator] Zombie controller for workspace '${workspaceId}' did not stop within ${STOP_AGENT_TIMEOUT_MS}ms — starting the new session anyway`);
1089
- }
1090
- });
1120
+ // case this path exists for. An unconfirmed stop fails the replacement.
1121
+ zombieEviction = stopAgentAndWait(workspaceId, undefined, 'replacement').then(assertAgentStopped);
1091
1122
  // Drop any queued pending items + persisted user-input-requested events
1092
1123
  // tied to the zombie's agentSessionId so the new session doesn't inherit
1093
1124
  // a stale queue and so a future sync replay doesn't resurrect them.
@@ -1104,7 +1135,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1104
1135
  catch (err) {
1105
1136
  console.warn('[orchestrator] Failed to purge zombie pending state:', err);
1106
1137
  }
1107
- controllers.delete(workspaceId);
1108
1138
  }
1109
1139
  else {
1110
1140
  throw new Error(`Agent already running for workspace '${workspaceId}'`);
@@ -1155,12 +1185,31 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1155
1185
  let controller;
1156
1186
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1157
1187
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1158
- controllers.set(workspaceId, controller);
1188
+ if (!existingCtrl)
1189
+ controllers.set(workspaceId, controller);
1190
+ else
1191
+ pendingStarts.set(workspaceId, controller);
1159
1192
  // Kick off engine.start asynchronously, BEHIND the zombie eviction so the
1160
1193
  // new engine never runs concurrently with the one it replaces. Errors
1161
1194
  // surface as error events.
1162
1195
  void zombieEviction
1163
- .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
+ })
1164
1213
  .then(() => {
1165
1214
  const pid = controller.pid;
1166
1215
  if (pid !== undefined) {
@@ -1174,6 +1223,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1174
1223
  }
1175
1224
  })
1176
1225
  .catch((err) => {
1226
+ if (existingCtrl && controller.status === 'stopping')
1227
+ return;
1177
1228
  console.error('[orchestrator] engine.start failed:', err);
1178
1229
  const message = err instanceof Error ? err.message : String(err);
1179
1230
  handleEvent(workspaceId, agentSessionId, controller, {
@@ -1186,6 +1237,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1186
1237
  reason: 'error',
1187
1238
  exitCode: null,
1188
1239
  });
1240
+ })
1241
+ .finally(() => {
1242
+ if (pendingStarts.get(workspaceId) === controller)
1243
+ pendingStarts.delete(workspaceId);
1189
1244
  });
1190
1245
  return { agentSessionId, pid: undefined };
1191
1246
  }
@@ -1213,21 +1268,10 @@ export function interruptAgent(workspaceId, options = {}) {
1213
1268
  autoLoopService.disable(workspaceId, 'user-action');
1214
1269
  }
1215
1270
  }
1216
- /**
1217
- * Upper bound on a stop. Beyond this the engine is considered deaf to its own
1218
- * stop path: we evict the controller so the workspace becomes usable again.
1219
- *
1220
- * This is an honest abandonment, not an escalation to something else that
1221
- * watches over the process: Kōbō has no graduated SIGTERM/SIGKILL path of its
1222
- * own, the `process-tracker` module that used to exist was removed, and the
1223
- * session watchdog only walks the `controllers` map — the very map this
1224
- * eviction just removed the entry from. Whatever survives past this timeout
1225
- * is untracked by anyone in this process; only the OS or the user can end it.
1226
- */
1271
+ /** Bound the caller's wait, retaining ownership until actual engine shutdown. */
1227
1272
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1228
1273
  async function stopController(workspaceId, ctrl, cause = 'user') {
1229
1274
  ctrl.stopCause = cause;
1230
- wakeupService.cancel(workspaceId, 'stopped');
1231
1275
  // Normalize the state synchronously so callers (archive, delete, manual
1232
1276
  // stop) see a clean workspace immediately — without waiting for the async
1233
1277
  // controller.stop() → session:ended round-trip, which onSessionEnded skips
@@ -1257,25 +1301,21 @@ async function stopController(workspaceId, ctrl, cause = 'user') {
1257
1301
  console.warn(`[orchestrator] Failed to normalize ${wsBefore.status} → idle on stop:`, err);
1258
1302
  }
1259
1303
  }
1260
- // Manual stop should also drop any pending quota auto-resume.
1261
- quotaBackoffService.cancel(workspaceId, 'user');
1262
1304
  // D1 — the controller stays REGISTERED for the whole stop, in `stopping`
1263
1305
  // state. Removing it up-front (as we used to) made the only "is an agent
1264
1306
  // running?" signal of the system lie for the entire teardown window: cron
1265
1307
  // and auto-loop could start a second agent on the same worktree, and delete
1266
1308
  // could pull the worktree from under a process still writing to it.
1267
- try {
1268
- await ctrl.stop();
1269
- }
1270
- finally {
1271
- if (controllers.get(workspaceId) === ctrl)
1272
- controllers.delete(workspaceId);
1309
+ await ctrl.stop();
1310
+ if (controllers.get(workspaceId) === ctrl) {
1311
+ controllers.delete(workspaceId);
1312
+ notifyCapacityAvailable(workspaceId);
1273
1313
  }
1274
1314
  }
1275
1315
  /**
1276
1316
  * Race an arbitrary promise against a bounded deadline. Resolves to the
1277
1317
  * winner's value; if the deadline wins, `onTimeout()` supplies the value
1278
- * instead. Never throws — the timer is always cleared. Single source of
1318
+ * instead. The timer is always cleared. Single source of
1279
1319
  * truth for the "stop, but not forever" pattern: both `stopWithTimeout` and
1280
1320
  * `stopAgentAndWait` used to hand-roll their own `Promise.race` + timer, two
1281
1321
  * copies of the same logic that could silently drift apart.
@@ -1296,16 +1336,6 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1296
1336
  clearTimeout(timer);
1297
1337
  }
1298
1338
  }
1299
- /** Race a controller stop against a bounded deadline. Never throws. */
1300
- async function stopWithTimeout(ctrl, timeoutMs) {
1301
- return raceWithTimeout(ctrl
1302
- .stop()
1303
- .then(() => true)
1304
- .catch((err) => {
1305
- console.error('[orchestrator] controller.stop rejected:', err);
1306
- return true;
1307
- }), timeoutMs, () => false);
1308
- }
1309
1339
  /**
1310
1340
  * Stop the agent and wait for it to actually die. Every caller that mutates
1311
1341
  * the worktree afterwards — delete, archive, purge, setup script, engine
@@ -1313,21 +1343,46 @@ async function stopWithTimeout(ctrl, timeoutMs) {
1313
1343
  * engine may still be writing files.
1314
1344
  */
1315
1345
  export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1316
- /** Why. `delete` and `purge` tell the session-ended hook to stand down. */
1346
+ /** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
1317
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
+ }
1318
1361
  const ctrl = controllers.get(workspaceId);
1319
- if (!ctrl)
1320
- return 'not-running';
1321
- 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) => {
1322
1379
  console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
1323
- return true;
1324
- }), timeoutMs, () => false);
1325
- if (finished)
1326
- return 'stopped';
1327
- console.error(`[orchestrator] Agent for workspace '${workspaceId}' did not stop within ${timeoutMs}ms — evicting its controller`);
1328
- if (controllers.get(workspaceId) === ctrl)
1329
- controllers.delete(workspaceId);
1330
- 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;
1331
1386
  }
1332
1387
  /**
1333
1388
  * Fire-and-forget stop, for the few callers that cannot await (WebSocket
@@ -1346,13 +1401,26 @@ export function stopAgent(workspaceId) {
1346
1401
  }
1347
1402
  /** Stop every live engine before process shutdown, with a bounded wait per engine. */
1348
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();
1349
1412
  // No per-workspace try/catch here: unlike the pre-D1 shutdown path,
1350
1413
  // `stopAgentAndWait` never rejects — every internal failure (a rejecting
1351
1414
  // `controller.stop()`, or the bounded deadline firing) is already converted
1352
- // into a logged, returned `StopAgentOutcome` ('stopped' | 'timeout')
1415
+ // into a logged, returned `StopAgentOutcome`
1353
1416
  // instead of a thrown error. A wrapping catch here would be unreachable
1354
1417
  // dead code pretending to guard against a failure mode that can't occur.
1355
- 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;
1356
1424
  }
1357
1425
  /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1358
1426
  export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
@@ -1896,7 +1964,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
1896
1964
  retryCounts.set(workspaceId, retryCount + 1);
1897
1965
  // The quotaBackoffService owns the timer + the persistent row + the
1898
1966
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
1899
- 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
+ });
1900
1973
  }
1901
1974
  catch (err) {
1902
1975
  console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
@@ -1960,7 +2033,12 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1960
2033
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1961
2034
  const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
1962
2035
  retryCounts.set(workspaceId, retryCount + 1);
1963
- 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
+ });
1964
2042
  }
1965
2043
  catch (err) {
1966
2044
  console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
@@ -1993,8 +2071,8 @@ export function restoreRetryCountsFromDb() {
1993
2071
  // in `quota` status and require manual user action (resume / new message)
1994
2072
  // to leave that state. This is intentional: without an auto-loop intent,
1995
2073
  // firing a fresh agent run in the user's absence would surprise them.
1996
- quotaBackoffService.setOnFireCallback((workspaceId) => {
1997
- autoLoopService.onQuotaBackoffExpired(workspaceId);
2074
+ quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
2075
+ autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
1998
2076
  });
1999
2077
  // ── Testing utilities ─────────────────────────────────────────────────────────
2000
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