@loicngr/kobo 1.11.36 → 1.11.38

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 (112) hide show
  1. package/AGENTS.md +18 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/index.js +20 -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 +161 -109
  7. package/dist/server/services/agent/engines/claude-code/engine.js +11 -0
  8. package/dist/server/services/agent/engines/codex/client.js +1 -0
  9. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  10. package/dist/server/services/agent/engines/codex/event-mapper.js +8 -2
  11. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  12. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  13. package/dist/server/services/agent/event-router.js +1 -1
  14. package/dist/server/services/agent/orchestrator.js +219 -78
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +60 -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/pr-watcher-service.js +5 -3
  24. package/dist/server/services/quota-backoff-service.js +25 -8
  25. package/dist/server/services/wakeup-service.js +23 -3
  26. package/dist/server/services/websocket-service.js +10 -2
  27. package/dist/server/services/workspace-service.js +8 -4
  28. package/dist/server/services/worktree-purge-service.js +3 -7
  29. package/dist/server/utils/agent-stop-result.js +12 -0
  30. package/dist/server/utils/git-ops.js +8 -1
  31. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  32. package/dist/server/utils/safe-path.js +21 -0
  33. package/dist/server/utils/script-runner.js +32 -9
  34. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  35. package/package.json +1 -1
  36. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +8 -0
  37. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-Chsw1Mh7.css} +1 -1
  38. package/src/client/dist/pwa/assets/{ChangelogPage-BFGYQ6eY.js → ChangelogPage-D8563008.js} +1 -1
  39. package/src/client/dist/pwa/assets/ComparisonPanel-Bd0X08sg.js +2 -0
  40. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +1 -0
  41. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  42. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +2 -0
  43. package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +1 -0
  44. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +1 -0
  45. package/src/client/dist/pwa/assets/{DiffViewer-DSlSAIiW.js → DiffViewer-BpsLNxnj.js} +3 -3
  46. package/src/client/dist/pwa/assets/{DocumentsPanel-C1eXTw_Q.js → DocumentsPanel-BJsJ0fNs.js} +1 -1
  47. package/src/client/dist/pwa/assets/{DrawerToggleButton-BxYpUb4y.js → DrawerToggleButton-BVLO5K5d.js} +1 -1
  48. package/src/client/dist/pwa/assets/{GitPanel-DnBWSdVp.js → GitPanel-Cal_wzC0.js} +3 -3
  49. package/src/client/dist/pwa/assets/{HealthPage-rX1TiaP9.js → HealthPage-RFZS7Lao.js} +1 -1
  50. package/src/client/dist/pwa/assets/{MainLayout-Ceuh5uJj.js → MainLayout-vvyiRcql.js} +2 -2
  51. package/src/client/dist/pwa/assets/{QBtnDropdown-K7syiyyH.js → QBtnDropdown-X8oNU5Mb.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QDrawer-DapzE8DH.js → QDrawer-Cij17skX.js} +1 -1
  53. package/src/client/dist/pwa/assets/{QSelect-Bodml89k.js → QSelect-xd-AlGo_.js} +1 -1
  54. package/src/client/dist/pwa/assets/{QTooltip-K-9OdDfq.js → QTooltip-CwcCGSWq.js} +1 -1
  55. package/src/client/dist/pwa/assets/{SchedulePanel-CfumTzAa.js → SchedulePanel-CXRUsz6G.js} +1 -1
  56. package/src/client/dist/pwa/assets/{SearchPage-AhZYS-dS.js → SearchPage-DopbgLxh.js} +1 -1
  57. package/src/client/dist/pwa/assets/{SettingsPage-BoHXWvKn.js → SettingsPage-Jx6TnOzR.js} +1 -1
  58. package/src/client/dist/pwa/assets/{SplitWorkspacePage-3wK11mcu.js → SplitWorkspacePage-D7lGFRKs.js} +1 -1
  59. package/src/client/dist/pwa/assets/{TerminalPanel-CKg0BeNK.js → TerminalPanel-DI8DHb9e.js} +1 -1
  60. package/src/client/dist/pwa/assets/{TouchPan-D6ctyPYf.js → TouchPan-B3nrkCY8.js} +1 -1
  61. package/src/client/dist/pwa/assets/{TourReplayButton-Dn4Dy_kH.js → TourReplayButton-DU1UShMY.js} +1 -1
  62. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DnoabsfK.js → WorkspaceAttentionLabels-C-yN5IX8.js} +1 -1
  63. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-BAC6u9OP.css} +1 -1
  64. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +4 -0
  65. package/src/client/dist/pwa/assets/{browser-DHpSUK8-.js → browser-f9MHQjfi.js} +1 -1
  66. package/src/client/dist/pwa/assets/{build-path-tree-DtEjsZlh.js → build-path-tree-BWBzP8os.js} +1 -1
  67. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-QkDLrcGq.js} +1 -1
  68. package/src/client/dist/pwa/assets/{documents-B2dg1wRf.js → documents-C2cLnEBQ.js} +1 -1
  69. package/src/client/dist/pwa/assets/{editor.api-Cd1D35oj.js → editor.api-sw146MWN.js} +1 -1
  70. package/src/client/dist/pwa/assets/{efforts-IB1vPjJQ.js → efforts-C5ZZgCH4.js} +1 -1
  71. package/src/client/dist/pwa/assets/{engineFeatures-DYSyDEJe.js → engineFeatures-CAVMDawq.js} +1 -1
  72. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-BgQFKlo8.js} +1 -1
  73. package/src/client/dist/pwa/assets/{expand-template-CNAHiesL.js → expand-template-B6xAlAaM.js} +1 -1
  74. package/src/client/dist/pwa/assets/{format-DidwgbbX.js → format-BwWP6Aay.js} +1 -1
  75. package/src/client/dist/pwa/assets/{formatters-BjzyUHlu.js → formatters-DyWtO-nR.js} +1 -1
  76. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-DAdLWjNM.js} +1 -1
  77. package/src/client/dist/pwa/assets/{freemarker2-jNGWawVw.js → freemarker2-DZ5Hm8UH.js} +1 -1
  78. package/src/client/dist/pwa/assets/{handlebars-DfSqClrW.js → handlebars-BRUGpTA7.js} +1 -1
  79. package/src/client/dist/pwa/assets/{html-ByLX-n8T.js → html-DNOayNNs.js} +1 -1
  80. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +1 -0
  81. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +20 -0
  82. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-Bumaewp1.js} +1 -1
  83. package/src/client/dist/pwa/assets/{javascript-Dja1fHfv.js → javascript-C0tDQU3z.js} +1 -1
  84. package/src/client/dist/pwa/assets/{liquid-D315hW6C.js → liquid-CN46O6OF.js} +1 -1
  85. package/src/client/dist/pwa/assets/{mdx-B5yUwpzv.js → mdx-DM-lrr8Q.js} +1 -1
  86. package/src/client/dist/pwa/assets/{monaco-contributions-NfxrU0r3.js → monaco-contributions-CkN7y-8c.js} +1 -1
  87. package/src/client/dist/pwa/assets/{monaco.contribution-D1hXPkDZ.js → monaco.contribution-2BcAOLjc.js} +2 -2
  88. package/src/client/dist/pwa/assets/{network-auth-CARVtbNe.js → network-auth-KYS4snQH.js} +1 -1
  89. package/src/client/dist/pwa/assets/{notify-theme-CKC3-EAZ.js → notify-theme-a91Tcr6o.js} +1 -1
  90. package/src/client/dist/pwa/assets/{pending-diff-open-BlLrQaW7.js → pending-diff-open-DOtELZfB.js} +1 -1
  91. package/src/client/dist/pwa/assets/{permissionModes-DMzFEQhr.js → permissionModes-C-9Crj-c.js} +1 -1
  92. package/src/client/dist/pwa/assets/{project-color-CFjdIj6W.js → project-color-BRe4oyNx.js} +1 -1
  93. package/src/client/dist/pwa/assets/{python-Dc6mnEip.js → python-BRpJG6sZ.js} +1 -1
  94. package/src/client/dist/pwa/assets/{razor-DArVFBGO.js → razor-CehCmvHl.js} +1 -1
  95. package/src/client/dist/pwa/assets/{render-chat-markdown-BTPAjU2g.js → render-chat-markdown-DlbQLZ02.js} +1 -1
  96. package/src/client/dist/pwa/assets/{typescript-COE1vIyB.js → typescript-Dt_8mVuj.js} +1 -1
  97. package/src/client/dist/pwa/assets/{use-tours-C8x2V1w5.js → use-tours-CSVM_54K.js} +1 -1
  98. package/src/client/dist/pwa/assets/{workspace-templates-DHyA_GEW.js → workspace-templates-GCObcjO6.js} +1 -1
  99. package/src/client/dist/pwa/assets/{xml-eYjr4cGR.js → xml-_gOwdzB4.js} +1 -1
  100. package/src/client/dist/pwa/assets/{yaml-UooOcssF.js → yaml-BxeBqpGf.js} +1 -1
  101. package/src/client/dist/pwa/index.html +1 -1
  102. package/src/client/dist/pwa/sw.js +1 -1
  103. package/src/client/dist/pwa/assets/ActivityFeed-BPx-xhFY.js +0 -8
  104. package/src/client/dist/pwa/assets/ComparisonPanel-C6DfjpZZ.js +0 -2
  105. package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +0 -1
  106. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  107. package/src/client/dist/pwa/assets/CreatePage-ypEzkAKv.js +0 -2
  108. package/src/client/dist/pwa/assets/DashboardPage-DsVRjzzy.js +0 -1
  109. package/src/client/dist/pwa/assets/DashboardPage-DsWn5cWl.css +0 -1
  110. package/src/client/dist/pwa/assets/WorkspacePage-BI7CW2yT.js +0 -4
  111. package/src/client/dist/pwa/assets/i18n-CnjXQjxy.js +0 -1
  112. package/src/client/dist/pwa/assets/index-DcwcFDrZ.js +0 -20
@@ -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
@@ -102,6 +131,25 @@ const pendingQueue = new Map();
102
131
  * returns to that status instead of being yanked to `executing`.
103
132
  */
104
133
  const preAwaitStatus = new Map();
134
+ /** The lifecycle phase hidden by this controller's temporary compaction. */
135
+ const preCompactionStatus = new Map();
136
+ function finishCompaction(workspaceId, controller) {
137
+ const previous = preCompactionStatus.get(controller);
138
+ preCompactionStatus.delete(controller);
139
+ if (getWs(workspaceId)?.status !== 'compacting')
140
+ return;
141
+ const restored = peekPending(workspaceId)
142
+ ? 'awaiting-user'
143
+ : previous === 'awaiting-user'
144
+ ? consumePreAwaitStatus(workspaceId)
145
+ : (previous ?? 'executing');
146
+ updateWorkspaceStatus(workspaceId, restored);
147
+ }
148
+ export function assertWorkspaceNotCompacting(workspaceId) {
149
+ if (getWs(workspaceId)?.status === 'compacting') {
150
+ throw new Error('Workspace is compacting its context; wait until compaction finishes before sending a message');
151
+ }
152
+ }
105
153
  function enqueuePending(workspaceId, item) {
106
154
  const arr = pendingQueue.get(workspaceId) ?? [];
107
155
  arr.push(item);
@@ -169,6 +217,13 @@ function rememberPreAwaitStatus(workspaceId) {
169
217
  return;
170
218
  if (ws.status === 'awaiting-user')
171
219
  return;
220
+ if (ws.status === 'compacting') {
221
+ const controller = controllers.get(workspaceId);
222
+ const previous = controller && preCompactionStatus.get(controller);
223
+ if (previous && previous !== 'awaiting-user')
224
+ preAwaitStatus.set(workspaceId, previous);
225
+ return;
226
+ }
172
227
  preAwaitStatus.set(workspaceId, ws.status);
173
228
  }
174
229
  /**
@@ -222,8 +277,7 @@ function runWatchdog() {
222
277
  for (const [workspaceId, ctrl] of controllers) {
223
278
  // D1 — a controller already in `stopping` state is being torn down by
224
279
  // `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 —
280
+ // (STOP_AGENT_TIMEOUT_MS). A deadline alone never evicts the controller. Before D1 this branch could never see a stopping controller —
227
281
  // the entry was removed from `controllers` synchronously, before
228
282
  // `ctrl.stop()` was even awaited. Now that the entry survives the whole
229
283
  // stop, a slow-but-honest voluntary stop (the engine's iterator already
@@ -266,8 +320,11 @@ function runWatchdog() {
266
320
  catch (err) {
267
321
  console.warn('[watchdog] Failed to route death notification events:', err);
268
322
  }
269
- if (controllers.get(workspaceId) === ctrl)
323
+ if (controllers.get(workspaceId) === ctrl) {
270
324
  controllers.delete(workspaceId);
325
+ notifyCapacityAvailable(workspaceId);
326
+ }
327
+ preCompactionStatus.delete(ctrl);
271
328
  retryCounts.delete(workspaceId);
272
329
  // This end never goes through handleEvent → onSessionEnded, so the user's
273
330
  // hook would otherwise miss precisely the case it is most useful for.
@@ -333,7 +390,7 @@ export function reconcileOrphanSessions() {
333
390
  const db = getDb();
334
391
  const result = db
335
392
  .prepare(`UPDATE workspaces SET status = 'idle', updated_at = ?
336
- WHERE status IN ('executing', 'brainstorming', 'extracting', 'awaiting-user')`)
393
+ WHERE status IN ('executing', 'brainstorming', 'extracting', 'awaiting-user', 'compacting')`)
337
394
  .run(now);
338
395
  if (result.changes > 0) {
339
396
  console.log(`[orchestrator] Reconciled ${result.changes} orphan workspace status(es) at boot.`);
@@ -693,8 +750,24 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
693
750
  // `awaiting-user`/`executing`. Every branch that does one of those must
694
751
  // check this guard.
695
752
  const sourceControllerIsStopping = sourceController?.status === 'stopping';
753
+ if (sourceControllerIsStopping && ev.kind === 'session:compacting')
754
+ return;
696
755
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
697
756
  routeEvent(workspaceId, agentSessionId, routedEvent);
757
+ if (sourceController && !sourceControllerIsStopping) {
758
+ if (ev.kind === 'session:compacting' && ev.active) {
759
+ const status = getWs(workspaceId)?.status;
760
+ if (status && ['extracting', 'brainstorming', 'executing', 'awaiting-user'].includes(status)) {
761
+ preCompactionStatus.set(sourceController, status);
762
+ updateWorkspaceStatus(workspaceId, 'compacting');
763
+ }
764
+ }
765
+ else if ((ev.kind === 'session:compacting' && !ev.active) ||
766
+ ev.kind === 'session:compacted' ||
767
+ ev.kind === 'error') {
768
+ finishCompaction(workspaceId, sourceController);
769
+ }
770
+ }
698
771
  // A fresh tool invocation proves this controller resumed working (for
699
772
  // example after an account change). Tool results can still drain after a
700
773
  // rejection, so they must never count as recovery.
@@ -785,7 +858,9 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
785
858
  // nothing but this status transition (no separate bookkeeping to preserve),
786
859
  // so it is entirely behind the guard; the event itself is still
787
860
  // persisted/broadcast unconditionally via `routeEvent` above.
788
- if (ev.kind === 'session:brainstorm-complete' && !sourceControllerIsStopping) {
861
+ if (ev.kind === 'session:brainstorm-complete' &&
862
+ !sourceControllerIsStopping &&
863
+ getWs(workspaceId)?.status !== 'compacting') {
789
864
  try {
790
865
  const ws = getWs(workspaceId);
791
866
  if (ws && ws.status !== 'executing') {
@@ -861,8 +936,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
861
936
  const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
862
937
  if (!ownsWorkspaceLifecycle)
863
938
  return;
864
- if (watchdogRecovery)
939
+ if (watchdogRecovery) {
940
+ notifyCapacityAvailable(workspaceId);
865
941
  return;
942
+ }
866
943
  // resume_failed exits with an error but the workspace is fine (stale id
867
944
  // cleared, next iteration will start fresh) — report 'completed' to
868
945
  // auto-loop so it continues.
@@ -898,7 +975,8 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
898
975
  }
899
976
  rememberPreAwaitStatus(workspaceId);
900
977
  try {
901
- updateWorkspaceStatus(workspaceId, 'awaiting-user');
978
+ if (getWs(workspaceId)?.status !== 'compacting')
979
+ updateWorkspaceStatus(workspaceId, 'awaiting-user');
902
980
  }
903
981
  catch (err) {
904
982
  console.warn('[orchestrator] Failed to transition to awaiting-user:', err);
@@ -975,6 +1053,8 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
975
1053
  }
976
1054
  const registeredController = controllers.get(workspaceId);
977
1055
  const isSuperseded = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
1056
+ if (sourceController)
1057
+ preCompactionStatus.delete(sourceController);
978
1058
  if (isSuperseded)
979
1059
  return false;
980
1060
  // A superseded end (above) is a session that was replaced, not one that
@@ -984,7 +1064,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
984
1064
  const currentWorkspace = getWs(workspaceId);
985
1065
  const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
986
1066
  const wasStopping = sourceController?.status === 'stopping';
987
- if (registeredController === sourceController) {
1067
+ if (!wasStopping && registeredController === sourceController) {
988
1068
  controllers.delete(workspaceId);
989
1069
  }
990
1070
  if (!preserveQuotaBackoff) {
@@ -1055,9 +1135,13 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1055
1135
  * query the controller via `_getControllers()` for tests.
1056
1136
  */
1057
1137
  export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
1138
+ if (shuttingDown)
1139
+ throw new Error('Cannot start an agent while the server is shutting down');
1140
+ assertWorkspaceLifecycleAvailable(workspaceId);
1058
1141
  const workspace = getWs(workspaceId);
1059
1142
  if (!workspace)
1060
1143
  throw new Error(`Workspace '${workspaceId}' not found`);
1144
+ assertWorkspaceNotCompacting(workspaceId);
1061
1145
  if (workspace.archivedAt)
1062
1146
  throw new Error(`Workspace '${workspaceId}' is archived`);
1063
1147
  if (workspace.worktreePurgedAt)
@@ -1068,26 +1152,22 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1068
1152
  // callback can leave its controller in the map after the workspace is
1069
1153
  // logically idle. Evict it instead of refusing the new session.
1070
1154
  //
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.
1155
+ // A controller already stopping keeps ownership until its stop completes;
1156
+ // callers replacing it must await stopAgentAndWait before starting again.
1074
1157
  let zombieEviction = Promise.resolve();
1075
1158
  const existingCtrl = controllers.get(workspaceId);
1076
1159
  if (existingCtrl) {
1160
+ if (existingCtrl.status === 'stopping')
1161
+ throw new Error('Agent is still stopping; retry after its stop completes');
1077
1162
  const wsForCheck = getWs(workspaceId);
1078
1163
  const status = wsForCheck?.status;
1079
1164
  const isLogicallyDone = status === 'idle' || status === 'completed' || status === 'error' || status === 'quota';
1080
- if (isLogicallyDone || existingCtrl.status === 'stopping') {
1165
+ if (isLogicallyDone) {
1081
1166
  console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}, controller=${existingCtrl.status}) before starting fresh session`);
1082
1167
  // The new engine must not touch the worktree while the zombie may still
1083
1168
  // 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
- });
1169
+ // case this path exists for. An unconfirmed stop fails the replacement.
1170
+ zombieEviction = stopAgentAndWait(workspaceId, undefined, 'replacement').then(assertAgentStopped);
1091
1171
  // Drop any queued pending items + persisted user-input-requested events
1092
1172
  // tied to the zombie's agentSessionId so the new session doesn't inherit
1093
1173
  // a stale queue and so a future sync replay doesn't resurrect them.
@@ -1104,7 +1184,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1104
1184
  catch (err) {
1105
1185
  console.warn('[orchestrator] Failed to purge zombie pending state:', err);
1106
1186
  }
1107
- controllers.delete(workspaceId);
1108
1187
  }
1109
1188
  else {
1110
1189
  throw new Error(`Agent already running for workspace '${workspaceId}'`);
@@ -1155,12 +1234,31 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1155
1234
  let controller;
1156
1235
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1157
1236
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1158
- controllers.set(workspaceId, controller);
1237
+ if (!existingCtrl)
1238
+ controllers.set(workspaceId, controller);
1239
+ else
1240
+ pendingStarts.set(workspaceId, controller);
1159
1241
  // Kick off engine.start asynchronously, BEHIND the zombie eviction so the
1160
1242
  // new engine never runs concurrently with the one it replaces. Errors
1161
1243
  // surface as error events.
1162
1244
  void zombieEviction
1163
- .then(() => controller.start(options))
1245
+ .then(() => {
1246
+ // An explicit stop can cancel this replacement while its predecessor
1247
+ // still owns the worktree. Its session row was already finalized there.
1248
+ if (existingCtrl && controller.status === 'stopping')
1249
+ return;
1250
+ if (pendingStarts.get(workspaceId) === controller)
1251
+ pendingStarts.delete(workspaceId);
1252
+ assertWorkspaceLifecycleAvailable(workspaceId);
1253
+ const current = getWs(workspaceId);
1254
+ if (!current || current.archivedAt || current.worktreePurgedAt)
1255
+ throw new Error('Workspace is no longer available');
1256
+ const registered = controllers.get(workspaceId);
1257
+ if (registered && registered !== controller)
1258
+ throw new Error('Agent is still stopping');
1259
+ controllers.set(workspaceId, controller);
1260
+ return controller.start(options);
1261
+ })
1164
1262
  .then(() => {
1165
1263
  const pid = controller.pid;
1166
1264
  if (pid !== undefined) {
@@ -1174,6 +1272,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1174
1272
  }
1175
1273
  })
1176
1274
  .catch((err) => {
1275
+ if (existingCtrl && controller.status === 'stopping')
1276
+ return;
1177
1277
  console.error('[orchestrator] engine.start failed:', err);
1178
1278
  const message = err instanceof Error ? err.message : String(err);
1179
1279
  handleEvent(workspaceId, agentSessionId, controller, {
@@ -1186,6 +1286,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1186
1286
  reason: 'error',
1187
1287
  exitCode: null,
1188
1288
  });
1289
+ })
1290
+ .finally(() => {
1291
+ if (pendingStarts.get(workspaceId) === controller)
1292
+ pendingStarts.delete(workspaceId);
1189
1293
  });
1190
1294
  return { agentSessionId, pid: undefined };
1191
1295
  }
@@ -1213,21 +1317,11 @@ export function interruptAgent(workspaceId, options = {}) {
1213
1317
  autoLoopService.disable(workspaceId, 'user-action');
1214
1318
  }
1215
1319
  }
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
- */
1320
+ /** Bound the caller's wait, retaining ownership until actual engine shutdown. */
1227
1321
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1228
1322
  async function stopController(workspaceId, ctrl, cause = 'user') {
1229
1323
  ctrl.stopCause = cause;
1230
- wakeupService.cancel(workspaceId, 'stopped');
1324
+ preCompactionStatus.delete(ctrl);
1231
1325
  // Normalize the state synchronously so callers (archive, delete, manual
1232
1326
  // stop) see a clean workspace immediately — without waiting for the async
1233
1327
  // controller.stop() → session:ended round-trip, which onSessionEnded skips
@@ -1244,9 +1338,9 @@ async function stopController(workspaceId, ctrl, cause = 'user') {
1244
1338
  // Steps 1-2 are `awaiting-user`-specific: they only make sense when the
1245
1339
  // session actually left a pending question/permission behind.
1246
1340
  const wsBefore = getWs(workspaceId);
1247
- const activeStatuses = ['executing', 'brainstorming', 'extracting', 'awaiting-user'];
1341
+ const activeStatuses = ['executing', 'brainstorming', 'extracting', 'awaiting-user', 'compacting'];
1248
1342
  if (wsBefore && activeStatuses.includes(wsBefore.status)) {
1249
- if (wsBefore.status === 'awaiting-user') {
1343
+ if (wsBefore.status === 'awaiting-user' || wsBefore.status === 'compacting') {
1250
1344
  clearPendingForSession(workspaceId, ctrl.agentSessionId);
1251
1345
  purgeAllPersistedUserInputRequests(workspaceId, ctrl.agentSessionId);
1252
1346
  }
@@ -1257,25 +1351,21 @@ async function stopController(workspaceId, ctrl, cause = 'user') {
1257
1351
  console.warn(`[orchestrator] Failed to normalize ${wsBefore.status} → idle on stop:`, err);
1258
1352
  }
1259
1353
  }
1260
- // Manual stop should also drop any pending quota auto-resume.
1261
- quotaBackoffService.cancel(workspaceId, 'user');
1262
1354
  // D1 — the controller stays REGISTERED for the whole stop, in `stopping`
1263
1355
  // state. Removing it up-front (as we used to) made the only "is an agent
1264
1356
  // running?" signal of the system lie for the entire teardown window: cron
1265
1357
  // and auto-loop could start a second agent on the same worktree, and delete
1266
1358
  // 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);
1359
+ await ctrl.stop();
1360
+ if (controllers.get(workspaceId) === ctrl) {
1361
+ controllers.delete(workspaceId);
1362
+ notifyCapacityAvailable(workspaceId);
1273
1363
  }
1274
1364
  }
1275
1365
  /**
1276
1366
  * Race an arbitrary promise against a bounded deadline. Resolves to the
1277
1367
  * winner's value; if the deadline wins, `onTimeout()` supplies the value
1278
- * instead. Never throws — the timer is always cleared. Single source of
1368
+ * instead. The timer is always cleared. Single source of
1279
1369
  * truth for the "stop, but not forever" pattern: both `stopWithTimeout` and
1280
1370
  * `stopAgentAndWait` used to hand-roll their own `Promise.race` + timer, two
1281
1371
  * copies of the same logic that could silently drift apart.
@@ -1296,16 +1386,6 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1296
1386
  clearTimeout(timer);
1297
1387
  }
1298
1388
  }
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
1389
  /**
1310
1390
  * Stop the agent and wait for it to actually die. Every caller that mutates
1311
1391
  * the worktree afterwards — delete, archive, purge, setup script, engine
@@ -1313,21 +1393,46 @@ async function stopWithTimeout(ctrl, timeoutMs) {
1313
1393
  * engine may still be writing files.
1314
1394
  */
1315
1395
  export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1316
- /** Why. `delete` and `purge` tell the session-ended hook to stand down. */
1396
+ /** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
1317
1397
  cause = 'user') {
1398
+ const pending = pendingStarts.get(workspaceId);
1399
+ let pendingStop = Promise.resolve();
1400
+ if (pending) {
1401
+ pendingStarts.delete(workspaceId);
1402
+ pending.stopCause = cause;
1403
+ pendingStop = pending.stop().then(() => {
1404
+ handleEvent(workspaceId, pending.agentSessionId, pending, {
1405
+ kind: 'session:ended',
1406
+ reason: 'killed',
1407
+ exitCode: null,
1408
+ });
1409
+ });
1410
+ }
1318
1411
  const ctrl = controllers.get(workspaceId);
1319
- if (!ctrl)
1320
- return 'not-running';
1321
- const finished = await raceWithTimeout(stopController(workspaceId, ctrl, cause).then(() => true, (err) => {
1412
+ const controllerStop = ctrl ? stopController(workspaceId, ctrl, cause) : Promise.resolve();
1413
+ // Cancel synchronously, including schedules whose controller already exited.
1414
+ // A Promise executor contains DB failures within the bounded stop outcome.
1415
+ // Shutdown only suspends timers: persisted intent belongs to the next boot.
1416
+ const cancelSchedules = new Promise((resolve) => {
1417
+ if (cause !== 'shutdown') {
1418
+ wakeupService.cancel(workspaceId, 'stopped');
1419
+ quotaBackoffService.cancel(workspaceId, 'user');
1420
+ }
1421
+ resolve();
1422
+ });
1423
+ // Mark every controller as stopping before the disable hook can run. Persist
1424
+ // the user's intent even for a loop waiting for capacity with no controller.
1425
+ const disableLoop = cause === 'user'
1426
+ ? Promise.resolve().then(() => autoLoopService.disable(workspaceId, 'user-action'))
1427
+ : Promise.resolve();
1428
+ const outcome = await raceWithTimeout(Promise.all([pendingStop, controllerStop, cancelSchedules, disableLoop]).then(() => (ctrl || pending ? 'stopped' : 'not-running'), (err) => {
1322
1429
  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';
1430
+ return 'failed';
1431
+ }), timeoutMs, () => 'timeout');
1432
+ if (outcome === 'timeout') {
1433
+ console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
1434
+ }
1435
+ return outcome;
1331
1436
  }
1332
1437
  /**
1333
1438
  * Fire-and-forget stop, for the few callers that cannot await (WebSocket
@@ -1346,13 +1451,26 @@ export function stopAgent(workspaceId) {
1346
1451
  }
1347
1452
  /** Stop every live engine before process shutdown, with a bounded wait per engine. */
1348
1453
  export async function stopAllAgents(timeoutMs = 3_000) {
1454
+ quotaBackoffService.suspendForShutdown();
1455
+ wakeupService.suspendForShutdown();
1456
+ cronService.suspendForShutdown();
1457
+ shuttingDown = true;
1458
+ if (capacityTimer)
1459
+ clearTimeout(capacityTimer);
1460
+ capacityTimer = undefined;
1461
+ stoppedCapacityOwners.clear();
1349
1462
  // No per-workspace try/catch here: unlike the pre-D1 shutdown path,
1350
1463
  // `stopAgentAndWait` never rejects — every internal failure (a rejecting
1351
1464
  // `controller.stop()`, or the bounded deadline firing) is already converted
1352
- // into a logged, returned `StopAgentOutcome` ('stopped' | 'timeout')
1465
+ // into a logged, returned `StopAgentOutcome`
1353
1466
  // instead of a thrown error. A wrapping catch here would be unreachable
1354
1467
  // dead code pretending to guard against a failure mode that can't occur.
1355
- await Promise.all([...controllers.keys()].map((workspaceId) => stopAgentAndWait(workspaceId, timeoutMs)));
1468
+ const workspaceIds = new Set([...controllers.keys(), ...pendingStarts.keys()]);
1469
+ await Promise.all([...workspaceIds].map((workspaceId) => stopAgentAndWait(workspaceId, timeoutMs, 'shutdown')));
1470
+ }
1471
+ /** Automatic continuations must preserve their durable intent during teardown. */
1472
+ export function isShuttingDown() {
1473
+ return shuttingDown;
1356
1474
  }
1357
1475
  /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1358
1476
  export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
@@ -1364,7 +1482,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1364
1482
  const workspace = getWs(workspaceId);
1365
1483
  if (!workspace || workspace.archivedAt || workspace.worktreePurgedAt)
1366
1484
  return false;
1367
- if (workspace.status === 'awaiting-user' || workspace.status === 'quota')
1485
+ if (workspace.status === 'awaiting-user' || workspace.status === 'quota' || workspace.status === 'compacting')
1368
1486
  return false;
1369
1487
  if (!ctrl.engineProcess?.sendWakeupIfWaiting?.(content))
1370
1488
  return false;
@@ -1373,6 +1491,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1373
1491
  }
1374
1492
  /** Write a user message to the running agent. */
1375
1493
  export async function sendMessage(workspaceId, content, expectedSessionId) {
1494
+ assertWorkspaceNotCompacting(workspaceId);
1376
1495
  const ctrl = controllers.get(workspaceId);
1377
1496
  if (!ctrl) {
1378
1497
  throw new Error(`No agent running for workspace '${workspaceId}'`);
@@ -1384,15 +1503,17 @@ export async function sendMessage(workspaceId, content, expectedSessionId) {
1384
1503
  await ctrl.sendMessage(content);
1385
1504
  }
1386
1505
  export async function sendMessageForFallback(workspaceId, content) {
1506
+ assertWorkspaceNotCompacting(workspaceId);
1387
1507
  const capturedController = controllers.get(workspaceId);
1508
+ wakeupService.cancel(workspaceId, 'user-message');
1388
1509
  if (!capturedController)
1389
1510
  return { status: 'stopped' };
1390
- wakeupService.cancel(workspaceId, 'user-message');
1391
1511
  try {
1392
1512
  await capturedController.sendMessage(content);
1393
1513
  return { status: 'sent', sessionId: capturedController.agentSessionId };
1394
1514
  }
1395
1515
  catch {
1516
+ assertWorkspaceNotCompacting(workspaceId);
1396
1517
  const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
1397
1518
  while (controllers.get(workspaceId) === capturedController) {
1398
1519
  const remaining = deadline - Date.now();
@@ -1406,6 +1527,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1406
1527
  const replacementController = controllers.get(workspaceId);
1407
1528
  if (!replacementController)
1408
1529
  return { status: 'stopped' };
1530
+ assertWorkspaceNotCompacting(workspaceId);
1409
1531
  await replacementController.sendMessage(content);
1410
1532
  return { status: 'sent', sessionId: replacementController.agentSessionId };
1411
1533
  }
@@ -1525,7 +1647,10 @@ export async function answerPendingQuestion(workspaceId, answers, expectedToolCa
1525
1647
  purgePersistedUserInputRequest(workspaceId, head.toolCallId);
1526
1648
  const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
1527
1649
  try {
1528
- updateWorkspaceStatus(workspaceId, restoreTo);
1650
+ if (getWs(workspaceId)?.status === 'compacting')
1651
+ preCompactionStatus.set(ctrl, restoreTo);
1652
+ else
1653
+ updateWorkspaceStatus(workspaceId, restoreTo);
1529
1654
  }
1530
1655
  catch (err) {
1531
1656
  console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
@@ -1613,7 +1738,10 @@ export async function answerPendingPermission(workspaceId, decision) {
1613
1738
  purgePersistedUserInputRequest(workspaceId, head.toolCallId);
1614
1739
  const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
1615
1740
  try {
1616
- updateWorkspaceStatus(workspaceId, restoreTo);
1741
+ if (getWs(workspaceId)?.status === 'compacting')
1742
+ preCompactionStatus.set(ctrl, restoreTo);
1743
+ else
1744
+ updateWorkspaceStatus(workspaceId, restoreTo);
1617
1745
  }
1618
1746
  catch (err) {
1619
1747
  console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
@@ -1671,7 +1799,10 @@ export async function cancelPendingQuestion(workspaceId, reason, expectedToolCal
1671
1799
  purgePersistedUserInputRequest(workspaceId, head.toolCallId);
1672
1800
  const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
1673
1801
  try {
1674
- updateWorkspaceStatus(workspaceId, restoreTo);
1802
+ if (getWs(workspaceId)?.status === 'compacting')
1803
+ preCompactionStatus.set(ctrl, restoreTo);
1804
+ else
1805
+ updateWorkspaceStatus(workspaceId, restoreTo);
1675
1806
  }
1676
1807
  catch (err) {
1677
1808
  console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
@@ -1896,7 +2027,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
1896
2027
  retryCounts.set(workspaceId, retryCount + 1);
1897
2028
  // The quotaBackoffService owns the timer + the persistent row + the
1898
2029
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
1899
- quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
2030
+ quotaBackoffService.arm(workspaceId, delayMs, {
2031
+ resetsAt: resetsAt ?? null,
2032
+ source,
2033
+ reason: 'quota',
2034
+ retryCount: retryCount + 1,
2035
+ });
1900
2036
  }
1901
2037
  catch (err) {
1902
2038
  console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
@@ -1960,7 +2096,12 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1960
2096
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1961
2097
  const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
1962
2098
  retryCounts.set(workspaceId, retryCount + 1);
1963
- quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
2099
+ quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
2100
+ resetsAt: resetsAt ?? null,
2101
+ source,
2102
+ reason: 'transient',
2103
+ retryCount: retryCount + 1,
2104
+ });
1964
2105
  }
1965
2106
  catch (err) {
1966
2107
  console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
@@ -1993,8 +2134,8 @@ export function restoreRetryCountsFromDb() {
1993
2134
  // in `quota` status and require manual user action (resume / new message)
1994
2135
  // to leave that state. This is intentional: without an auto-loop intent,
1995
2136
  // firing a fresh agent run in the user's absence would surprise them.
1996
- quotaBackoffService.setOnFireCallback((workspaceId) => {
1997
- autoLoopService.onQuotaBackoffExpired(workspaceId);
2137
+ quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
2138
+ autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
1998
2139
  });
1999
2140
  // ── Testing utilities ─────────────────────────────────────────────────────────
2000
2141
  /** @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