@loicngr/kobo 1.11.16 → 1.11.18
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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/dist/mcp-server/kobo-tasks-server.js +8 -7
- package/dist/server/db/migrations.js +57 -0
- package/dist/server/db/schema.js +10 -39
- package/dist/server/index.js +55 -7
- package/dist/server/routes/git.js +8 -3
- package/dist/server/routes/health.js +19 -2
- package/dist/server/routes/settings.js +24 -0
- package/dist/server/routes/workspaces.js +379 -72
- package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
- package/dist/server/services/agent/engines/codex/client.js +16 -4
- package/dist/server/services/agent/engines/codex/engine.js +23 -2
- package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
- package/dist/server/services/agent/engines/types.js +13 -0
- package/dist/server/services/agent/orchestrator.js +354 -133
- package/dist/server/services/agent/session-controller.js +27 -0
- package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
- package/dist/server/services/change-source-branch-service.js +115 -11
- package/dist/server/services/db-backup-service.js +27 -16
- package/dist/server/services/pr-watcher-service.js +17 -2
- package/dist/server/services/settings-service.js +39 -0
- package/dist/server/services/websocket-service.js +114 -48
- package/dist/server/services/workspace-service.js +54 -2
- package/dist/server/services/worktree-purge-service.js +19 -5
- package/dist/server/services/worktree-service.js +38 -1
- package/dist/server/services/ws-events-retention-service.js +133 -0
- package/dist/server/utils/git-ops.js +209 -14
- package/dist/server/utils/git-repo-lock.js +45 -0
- package/dist/server/utils/logger.js +108 -0
- package/dist/server/utils/script-runner.js +11 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
- package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
- package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
- package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
- package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
- package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
- package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
- package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
- package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
- package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
- package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
- package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
- package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
- package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
- package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
- package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
- package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
- package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
- package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
- package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
- package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
- package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
- package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
- package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
- package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
- package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
- package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
- package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
- package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
- package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
- package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
- package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
- package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
- package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
- package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
- package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
- package/src/client/dist/pwa/index.html +7 -5
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-server.ts +8 -7
- package/dist/server/utils/process-tracker.js +0 -49
- package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
- package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
- package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
- package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
- package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
- package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
- package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
- package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
- package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
- package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
- package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
- package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
- package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
- package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
- package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
- package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
- package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
- package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
- package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
- package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
- package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
- package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
- 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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
//
|
|
307
|
-
// controller to
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// transition
|
|
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(
|
|
317
|
-
|
|
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}
|
|
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
|
|
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
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
// depend on transient
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
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
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|