@loicngr/kobo 1.11.43 → 1.11.45
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 +74 -1
- package/CHANGELOG.md +11 -0
- package/README.md +13 -1
- package/dist/mcp-server/kobo-tasks-handlers.js +33 -75
- package/dist/mcp-server/kobo-tasks-server.js +82 -9
- package/dist/server/db/auto-loop-schema.js +44 -0
- package/dist/server/db/migrations.js +18 -0
- package/dist/server/db/review-return-schema.js +10 -0
- package/dist/server/db/schema.js +8 -1
- package/dist/server/db/session-handoff-schema.js +24 -0
- package/dist/server/index.js +12 -9
- package/dist/server/routes/auto-loop-messages.js +51 -0
- package/dist/server/routes/documents.js +1 -1
- package/dist/server/routes/workspaces.js +119 -145
- package/dist/server/services/agent/engines/claude-code/engine.js +16 -0
- package/dist/server/services/agent/engines/codex/engine.js +92 -31
- package/dist/server/services/agent/orchestrator.js +298 -96
- package/dist/server/services/agent/session-controller.js +42 -2
- package/dist/server/services/auto-loop-message-service.js +79 -0
- package/dist/server/services/auto-loop-service.js +225 -86
- package/dist/server/services/auto-loop-state-service.js +30 -0
- package/dist/server/services/cron-service.js +30 -16
- package/dist/server/services/engine-handoff-service.js +5 -5
- package/dist/server/services/quota-backoff-service.js +42 -10
- package/dist/server/services/review-return-service.js +64 -0
- package/dist/server/services/review-service.js +187 -0
- package/dist/server/services/session-activity-service.js +17 -0
- package/dist/server/services/session-handoff-runtime.js +26 -0
- package/dist/server/services/session-handoff-service.js +479 -0
- package/dist/server/services/settings-service.js +3 -3
- package/dist/server/services/task-mutations.js +186 -0
- package/dist/server/services/wakeup-service.js +24 -1
- package/dist/server/services/workspace-message-service.js +16 -2
- package/dist/server/services/workspace-service.js +28 -36
- package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
- package/dist/shared/auto-loop-prompts.js +22 -11
- package/dist/shared/auto-loop-types.js +1 -0
- package/dist/shared/document-roots.js +2 -0
- package/dist/shared/review.js +6 -0
- package/dist/shared/session-handoff.js +4 -0
- package/dist/shared/task-verification.js +11 -0
- package/package.json +12 -12
- package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.js → ChangelogPage-CnXTnFhe.js} +1 -1
- package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-B4ne55Xj.js → ComparisonPanel-CKylN45q.js} +2 -2
- package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-CZQLfoV1.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-Csf3WmLc.js} +4 -4
- package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-BeCttB-x.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.js → DrawerToggleButton-BJBBYeOc.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-XrOuMoDJ.js → HealthPage-l88TEk-k.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +2 -0
- package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
- package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
- package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-163oM6T_.js → QBtnDropdown-44Mj-y_1.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
- package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
- package/src/client/dist/pwa/assets/QDrawer-B4qHw8wt.js +1 -0
- package/src/client/dist/pwa/assets/QExpansionItem-Bok1m8Wi.js +1 -0
- package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
- package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
- package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
- package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +1 -0
- package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-BQWY7aeM.js → QTooltip-BDihn2aY.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +1 -0
- package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BwZ2TX1G.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-Bg6nKBcA.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-Cq-FR7A7.js} +9 -9
- package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-BQ1cvzRC.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-Dz1yEz_z.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.js → WorkspaceAttentionLabels-BCVOP8vp.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-BbUvR1Vf.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +4 -0
- package/src/client/dist/pwa/assets/{browser-Bkx4fbxl.js → browser-BmWgBx9M.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-6EjBmYYY.js +1 -0
- package/src/client/dist/pwa/assets/de-COObokpu.js +17 -0
- package/src/client/dist/pwa/assets/documents-BZ8bwcjr.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-DreEG0If.js → editor.api-CK7--ckY.js} +1 -1
- package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
- package/src/client/dist/pwa/assets/efforts-Bw0o2Bud.js +9 -0
- package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
- package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +1 -0
- package/src/client/dist/pwa/assets/es-By2Q9Tca.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-C8VyjASo.js +1 -0
- package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-LtTLLitJ.js} +3 -3
- package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-Bl9zA7Ai.js} +1 -1
- package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-DxezMaje.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DK_Bj6Rx.js} +1 -1
- package/src/client/dist/pwa/assets/{html-iZL3bbXq.js → html-DFfsfStD.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +1 -0
- package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
- package/src/client/dist/pwa/assets/index-BLd8DAzg.js +20 -0
- package/src/client/dist/pwa/assets/it-CeK5lyIA.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DuNbcaL7.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-CM3JKyhv.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-BG3zYwNF.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-DLscZGH-.js} +1 -1
- package/src/client/dist/pwa/assets/monaco.contribution-DnhZHwXT.js +2 -0
- package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-BJiPbO5l.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-D4p3qPkp.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.js → pending-diff-open-dYIFdS4r.js} +1 -1
- package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +36 -0
- package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
- package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-CihwpDAA.js → project-color-R_1je4vW.js} +1 -1
- package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-COs6pHk2.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.js → razor-Jl1VqMsk.js} +1 -1
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
- package/src/client/dist/pwa/assets/render-chat-markdown-AaOo0Tgq.js +67 -0
- package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-VVzxoeyM.js → typescript-BU_TUuAM.js} +1 -1
- package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
- package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
- package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
- package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
- package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
- package/src/client/dist/pwa/assets/use-tours-CVn874kD.js +2 -0
- package/src/client/dist/pwa/assets/{workspace-templates-1ui80Kqo.js → workspace-templates-BCKR8TXr.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-gFFsobxE.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.js → yaml-DaXaqaGD.js} +1 -1
- package/src/client/dist/pwa/index.html +10 -10
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +8 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +72 -105
- package/src/mcp-server/kobo-tasks-server.ts +94 -9
- package/src/client/dist/pwa/assets/ActivityFeed-D1e-zV3O.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-Bx_GpUni.js +0 -2
- package/src/client/dist/pwa/assets/GitPanel-C9mz4GXF.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-BwV89R-l.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-CwWavQjV.js +0 -2
- package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
- package/src/client/dist/pwa/assets/QDrawer-DNEj8wLZ.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-BUlOQe5k.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-Bos94OyR.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-DtGsd0XH.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-Clc4Fl3B.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
- package/src/client/dist/pwa/assets/build-path-tree-BpSY7N5O.js +0 -1
- package/src/client/dist/pwa/assets/de-BU0dILBs.js +0 -17
- package/src/client/dist/pwa/assets/documents-BIlWh3o-.js +0 -1
- package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
- package/src/client/dist/pwa/assets/efforts-D_yTzymv.js +0 -9
- package/src/client/dist/pwa/assets/engineFeatures-C33LTVkK.js +0 -1
- package/src/client/dist/pwa/assets/es-CNp0s9na.js +0 -17
- package/src/client/dist/pwa/assets/expand-template-wRhAXT-1.js +0 -1
- package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/pwa/assets/fr-RqJyWSdm.js +0 -17
- package/src/client/dist/pwa/assets/i18n--PCet00a.js +0 -1
- package/src/client/dist/pwa/assets/index-CPfrY38K.js +0 -20
- package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
- package/src/client/dist/pwa/assets/it-DSxNuCwL.js +0 -17
- package/src/client/dist/pwa/assets/monaco.contribution-D7NmJUJH.js +0 -2
- package/src/client/dist/pwa/assets/permissionModes-DC51Pz84.js +0 -36
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-BQ_saiy5.js +0 -67
- package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
- package/src/client/dist/pwa/assets/use-tours-BKeYrJaQ.js +0 -2
|
@@ -3,13 +3,17 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { getDb } from '../../db/index.js';
|
|
4
4
|
import { assertAgentStopped } from '../../utils/agent-stop-result.js';
|
|
5
5
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
|
|
6
|
-
import { assertWorkspaceLifecycleAvailable } from '../../utils/workspace-lifecycle-guard.js';
|
|
6
|
+
import { assertWorkspaceLifecycleAvailable, workspaceLifecycleReason } from '../../utils/workspace-lifecycle-guard.js';
|
|
7
|
+
import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
|
|
7
8
|
import * as autoLoopService from '../auto-loop-service.js';
|
|
8
9
|
import * as cleanupScriptService from '../cleanup-script-service.js';
|
|
9
10
|
import * as cronService from '../cron-service.js';
|
|
10
11
|
import { resolveForge } from '../forge/resolve.js';
|
|
11
12
|
import * as lifecycleHookService from '../lifecycle-hook-service.js';
|
|
12
13
|
import * as quotaBackoffService from '../quota-backoff-service.js';
|
|
14
|
+
import { buildReviewReturnPrompt, getReviewReturn, restoreReviewConfiguration } from '../review-return-service.js';
|
|
15
|
+
import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
|
|
16
|
+
import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
|
|
13
17
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
14
18
|
import { refreshNow } from '../usage/poller.js';
|
|
15
19
|
import * as wakeupService from '../wakeup-service.js';
|
|
@@ -41,6 +45,8 @@ export function getBackendPort() {
|
|
|
41
45
|
}
|
|
42
46
|
/** workspaceId -> SessionController */
|
|
43
47
|
const controllers = new Map();
|
|
48
|
+
const pendingTerminalControllers = new WeakSet();
|
|
49
|
+
const finalizedControllers = new WeakSet();
|
|
44
50
|
/** Replacements waiting for the previous controller to release its worktree. */
|
|
45
51
|
const pendingStarts = new Map();
|
|
46
52
|
let shuttingDown = false;
|
|
@@ -261,6 +267,10 @@ let availableSkills = (() => {
|
|
|
261
267
|
})();
|
|
262
268
|
/** workspaceId -> retry count (for quota backoff) */
|
|
263
269
|
const retryCounts = new Map();
|
|
270
|
+
const retryReasons = new Map();
|
|
271
|
+
const quotaLookups = new Map();
|
|
272
|
+
/** Multiple error surfaces and the terminal watchdog can describe one incident. */
|
|
273
|
+
const transientRecoveryControllers = new WeakSet();
|
|
264
274
|
/** Tracks agent sessions that failed due to a stale --resume session ID. */
|
|
265
275
|
const resumeFailedSessions = new Map();
|
|
266
276
|
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
@@ -285,7 +295,7 @@ function runWatchdog() {
|
|
|
285
295
|
// window) must not be raced here: doing so would report a false "Agent
|
|
286
296
|
// process died unexpectedly", force the workspace to `error`, and set an
|
|
287
297
|
// unread badge — all on a stop the user explicitly asked for.
|
|
288
|
-
if (ctrl.status === 'stopping')
|
|
298
|
+
if (ctrl.status === 'stopping' || pendingTerminalControllers.has(ctrl))
|
|
289
299
|
continue;
|
|
290
300
|
const ep = ctrl.engineProcess;
|
|
291
301
|
if (!ep) {
|
|
@@ -294,6 +304,17 @@ function runWatchdog() {
|
|
|
294
304
|
if (Date.now() - ctrl.startedAt < CONTROLLER_STARTUP_GRACE_MS)
|
|
295
305
|
continue;
|
|
296
306
|
console.error(`[watchdog] Controller for workspace '${workspaceId}' produced no engine process within ${CONTROLLER_STARTUP_GRACE_MS}ms — cleaning up`);
|
|
307
|
+
// The start may still complete and acquire a process. Keep ownership
|
|
308
|
+
// until its stop is confirmed instead of dispatching a second writer.
|
|
309
|
+
void stopAgentAndWait(workspaceId, STOP_AGENT_TIMEOUT_MS, 'replacement').then((outcome) => {
|
|
310
|
+
if (outcome === 'timeout' || outcome === 'failed') {
|
|
311
|
+
autoLoopService.block(workspaceId, 'engine-start-timeout');
|
|
312
|
+
}
|
|
313
|
+
else if (getWs(workspaceId)?.autoLoop && !shuttingDown) {
|
|
314
|
+
scheduleTransientAutoLoopRecovery(workspaceId, ctrl);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
297
318
|
}
|
|
298
319
|
else {
|
|
299
320
|
// D1 — the pid is never a liveness criterion. The Claude engine exposes
|
|
@@ -304,50 +325,23 @@ function runWatchdog() {
|
|
|
304
325
|
continue;
|
|
305
326
|
console.error(`[watchdog] Agent engine for workspace '${workspaceId}' reports dead — cleaning up`);
|
|
306
327
|
}
|
|
307
|
-
//
|
|
328
|
+
// Use the same terminal path as an engine watchdog: it preserves the
|
|
329
|
+
// loop's retry intent and releases capacity only after actual closure.
|
|
308
330
|
try {
|
|
309
|
-
|
|
331
|
+
handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
|
|
310
332
|
kind: 'error',
|
|
311
333
|
category: 'other',
|
|
312
334
|
message: 'Agent process died unexpectedly',
|
|
313
335
|
});
|
|
314
|
-
|
|
336
|
+
handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
|
|
315
337
|
kind: 'session:ended',
|
|
316
|
-
reason: '
|
|
338
|
+
reason: getWs(workspaceId)?.autoLoop ? 'watchdog' : 'error',
|
|
317
339
|
exitCode: null,
|
|
318
340
|
});
|
|
319
341
|
}
|
|
320
342
|
catch (err) {
|
|
321
|
-
console.warn('[watchdog] Failed to
|
|
322
|
-
|
|
323
|
-
if (controllers.get(workspaceId) === ctrl) {
|
|
324
|
-
controllers.delete(workspaceId);
|
|
325
|
-
notifyCapacityAvailable(workspaceId);
|
|
326
|
-
}
|
|
327
|
-
preCompactionStatus.delete(ctrl);
|
|
328
|
-
retryCounts.delete(workspaceId);
|
|
329
|
-
// This end never goes through handleEvent → onSessionEnded, so the user's
|
|
330
|
-
// hook would otherwise miss precisely the case it is most useful for.
|
|
331
|
-
fireSessionEndedHook(workspaceId, ctrl.agentSessionId, 'killed', null, ctrl);
|
|
332
|
-
try {
|
|
333
|
-
const db = getDb();
|
|
334
|
-
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
|
|
335
|
-
}
|
|
336
|
-
catch (err) {
|
|
337
|
-
console.error('[watchdog] Failed to update agent_sessions:', err);
|
|
338
|
-
}
|
|
339
|
-
try {
|
|
340
|
-
updateWorkspaceStatus(workspaceId, 'error');
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
console.warn('[watchdog] Failed to transition workspace to error (likely invalid transition):', err);
|
|
344
|
-
}
|
|
345
|
-
try {
|
|
346
|
-
markWorkspaceUnread(workspaceId);
|
|
347
|
-
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
348
|
-
}
|
|
349
|
-
catch (err) {
|
|
350
|
-
console.warn('[watchdog] Failed to mark workspace unread:', err);
|
|
343
|
+
console.warn('[watchdog] Failed to recover a dead agent:', err);
|
|
344
|
+
autoLoopService.block(workspaceId, 'engine-recovery-failed');
|
|
351
345
|
}
|
|
352
346
|
}
|
|
353
347
|
}
|
|
@@ -482,7 +476,7 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
482
476
|
};
|
|
483
477
|
}
|
|
484
478
|
}
|
|
485
|
-
function buildMcpServers(workspaceId) {
|
|
479
|
+
function buildMcpServers(workspaceId, extraEnv) {
|
|
486
480
|
const mcpServerCompiled = getCompiledMcpServerPath();
|
|
487
481
|
const mcpServerSource = getMcpServerSourcePath();
|
|
488
482
|
return [
|
|
@@ -492,6 +486,7 @@ function buildMcpServers(workspaceId) {
|
|
|
492
486
|
args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
|
|
493
487
|
env: {
|
|
494
488
|
KOBO_WORKSPACE_ID: workspaceId,
|
|
489
|
+
...extraEnv,
|
|
495
490
|
KOBO_DB_PATH: getDbPath(),
|
|
496
491
|
KOBO_SETTINGS_PATH: getSettingsPath(),
|
|
497
492
|
KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
|
|
@@ -531,7 +526,7 @@ function buildAgentPrompt(prompt, projectPath) {
|
|
|
531
526
|
prompt,
|
|
532
527
|
].join('\n');
|
|
533
528
|
}
|
|
534
|
-
function resolveSessionForResume(workspaceId, existingSessionId, model) {
|
|
529
|
+
function resolveSessionForResume(workspaceId, existingSessionId, model, engineId) {
|
|
535
530
|
const db = getDb();
|
|
536
531
|
let lastSession;
|
|
537
532
|
if (existingSessionId) {
|
|
@@ -542,13 +537,18 @@ function resolveSessionForResume(workspaceId, existingSessionId, model) {
|
|
|
542
537
|
throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
|
|
543
538
|
'session not found or has no associated engine conversation');
|
|
544
539
|
}
|
|
540
|
+
if ((lastSession.engine ?? 'claude-code') !== engineId) {
|
|
541
|
+
throw new Error(`Cannot resume session '${existingSessionId}' with engine '${engineId}': the conversation belongs to '${lastSession.engine}'`);
|
|
542
|
+
}
|
|
545
543
|
}
|
|
546
544
|
else {
|
|
547
545
|
lastSession = db
|
|
548
|
-
.prepare(
|
|
549
|
-
.get(workspaceId);
|
|
546
|
+
.prepare(`SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND COALESCE(engine, 'claude-code') = ? AND engine_session_id IS NOT NULL AND activation_order >= 0 ORDER BY ${SESSION_RECENCY_ORDER} LIMIT 1`)
|
|
547
|
+
.get(workspaceId, engineId);
|
|
550
548
|
}
|
|
551
|
-
|
|
549
|
+
// A workspace can now contain conversations from multiple engines. The
|
|
550
|
+
// unscoped in-memory ID may belong to the reviewer, so only trust its DB row.
|
|
551
|
+
const engineSessionId = lastSession?.engine_session_id;
|
|
552
552
|
if (engineSessionId) {
|
|
553
553
|
const existingId = lastSession?.id ??
|
|
554
554
|
db
|
|
@@ -615,6 +615,10 @@ function captureTaskProgressBaseline(workspaceId, agentSessionId) {
|
|
|
615
615
|
getDb()
|
|
616
616
|
.prepare('UPDATE agent_sessions SET task_progress_baseline = ? WHERE id = ?')
|
|
617
617
|
.run(JSON.stringify(snapshot), agentSessionId);
|
|
618
|
+
const baseline = JSON.parse(snapshot.stateSignature || '[]');
|
|
619
|
+
const insert = getDb().prepare('INSERT OR IGNORE INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)');
|
|
620
|
+
for (const task of baseline)
|
|
621
|
+
insert.run(workspaceId, task.id, taskMilestone(task.status));
|
|
618
622
|
}
|
|
619
623
|
catch (err) {
|
|
620
624
|
console.warn('[orchestrator] Failed to persist task progress baseline:', err);
|
|
@@ -687,7 +691,7 @@ function getTaskStateSignature(workspaceId) {
|
|
|
687
691
|
try {
|
|
688
692
|
const db = getDb();
|
|
689
693
|
const rows = db
|
|
690
|
-
.prepare('SELECT id, status
|
|
694
|
+
.prepare('SELECT id, status FROM tasks WHERE workspace_id = ? ORDER BY id')
|
|
691
695
|
.all(workspaceId);
|
|
692
696
|
return JSON.stringify(rows);
|
|
693
697
|
}
|
|
@@ -696,6 +700,42 @@ function getTaskStateSignature(workspaceId) {
|
|
|
696
700
|
return '';
|
|
697
701
|
}
|
|
698
702
|
}
|
|
703
|
+
function taskMilestone(status) {
|
|
704
|
+
return status === 'done' ? 2 : status === 'in_progress' ? 1 : 0;
|
|
705
|
+
}
|
|
706
|
+
/** Credit each forward task milestone only once during the current loop cycle. */
|
|
707
|
+
function consumeMonotoneTaskProgress(workspaceId, snapshot) {
|
|
708
|
+
if (!snapshot)
|
|
709
|
+
return 0;
|
|
710
|
+
try {
|
|
711
|
+
const before = new Map(JSON.parse(snapshot.stateSignature || '[]').map((task) => [
|
|
712
|
+
task.id,
|
|
713
|
+
taskMilestone(task.status),
|
|
714
|
+
]));
|
|
715
|
+
const db = getDb();
|
|
716
|
+
return db.transaction(() => {
|
|
717
|
+
const rows = db
|
|
718
|
+
.prepare(`SELECT tasks.id, tasks.status, COALESCE(auto_loop_progress.milestone, 0) AS milestone
|
|
719
|
+
FROM tasks LEFT JOIN auto_loop_progress ON auto_loop_progress.workspace_id = tasks.workspace_id
|
|
720
|
+
AND auto_loop_progress.task_id = tasks.id WHERE tasks.workspace_id = ?`)
|
|
721
|
+
.all(workspaceId);
|
|
722
|
+
const record = db.prepare(`INSERT INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)
|
|
723
|
+
ON CONFLICT(workspace_id, task_id) DO UPDATE SET milestone = MAX(milestone, excluded.milestone)`);
|
|
724
|
+
let progressed = false;
|
|
725
|
+
for (const task of rows) {
|
|
726
|
+
const milestone = taskMilestone(task.status);
|
|
727
|
+
if (milestone > (before.get(task.id) ?? 0) && milestone > task.milestone)
|
|
728
|
+
progressed = true;
|
|
729
|
+
record.run(workspaceId, task.id, milestone);
|
|
730
|
+
}
|
|
731
|
+
return progressed ? 1 : 0;
|
|
732
|
+
})();
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
console.warn('[orchestrator] Failed to record monotone task progress:', err);
|
|
736
|
+
return 0;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
699
739
|
/** Clear the in-memory done-count snapshot for a workspace (called on delete). */
|
|
700
740
|
export function forgetTasksDoneSnapshot(workspaceId) {
|
|
701
741
|
taskProgressSnapshots.delete(workspaceId);
|
|
@@ -732,7 +772,42 @@ export function forgetSessionId(workspaceId) {
|
|
|
732
772
|
sessionIds.delete(workspaceId);
|
|
733
773
|
sessionLifecycleOwners.delete(workspaceId);
|
|
734
774
|
}
|
|
735
|
-
function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
775
|
+
function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminalAfterClose = false) {
|
|
776
|
+
if (sourceController && !terminalAfterClose) {
|
|
777
|
+
if (ev.kind === 'session:ended') {
|
|
778
|
+
if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController))
|
|
779
|
+
return;
|
|
780
|
+
// Real engines may announce their logical result before their writer
|
|
781
|
+
// exits. Delay all terminal effects, including review return and quota
|
|
782
|
+
// retries, until SessionController confirms closure.
|
|
783
|
+
if (!sourceController.isClosed && (sourceController.engineProcess?.closed || !sourceController.engineProcess)) {
|
|
784
|
+
pendingTerminalControllers.add(sourceController);
|
|
785
|
+
const closeDeadline = setTimeout(() => {
|
|
786
|
+
if (controllers.get(workspaceId) === sourceController && !sourceController.isClosed) {
|
|
787
|
+
autoLoopService.block(workspaceId, 'engine-stop-unconfirmed');
|
|
788
|
+
}
|
|
789
|
+
}, STOP_AGENT_TIMEOUT_MS);
|
|
790
|
+
closeDeadline.unref?.();
|
|
791
|
+
void sourceController.closed
|
|
792
|
+
.then(() => {
|
|
793
|
+
clearTimeout(closeDeadline);
|
|
794
|
+
pendingTerminalControllers.delete(sourceController);
|
|
795
|
+
finalizedControllers.add(sourceController);
|
|
796
|
+
handleEvent(workspaceId, agentSessionId, sourceController, ev, true);
|
|
797
|
+
})
|
|
798
|
+
.catch((err) => {
|
|
799
|
+
clearTimeout(closeDeadline);
|
|
800
|
+
console.error('[orchestrator] Terminal recovery failed:', err);
|
|
801
|
+
autoLoopService.block(workspaceId, 'engine-recovery-failed');
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
finalizedControllers.add(sourceController);
|
|
806
|
+
}
|
|
807
|
+
else if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController)) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
736
811
|
const registeredController = controllers.get(workspaceId);
|
|
737
812
|
const hasReplacement = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
|
|
738
813
|
const sourceNoLongerOwnsWorkspace = sourceController !== undefined && registeredController !== sourceController;
|
|
@@ -769,6 +844,13 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
769
844
|
return;
|
|
770
845
|
const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
|
|
771
846
|
routeEvent(workspaceId, agentSessionId, routedEvent);
|
|
847
|
+
const launch = sourceController ? launchOptions.get(sourceController) : undefined;
|
|
848
|
+
if (ev.kind === 'error')
|
|
849
|
+
launch?.onError?.(ev.message);
|
|
850
|
+
// Summary turns can read and report, but must not drive loop/quota/scheduler side effects.
|
|
851
|
+
if (launch?.handoffGeneration &&
|
|
852
|
+
!['session:started', 'session:ended', 'session:user-input-requested'].includes(ev.kind))
|
|
853
|
+
return;
|
|
772
854
|
if (sourceController && !sourceControllerIsStopping) {
|
|
773
855
|
if (ev.kind === 'session:compacting' && ev.active) {
|
|
774
856
|
const status = getWs(workspaceId)?.status;
|
|
@@ -789,7 +871,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
789
871
|
if (ev.kind === 'tool:call' && !sourceControllerIsStopping && getWs(workspaceId)?.status === 'quota') {
|
|
790
872
|
updateWorkspaceStatus(workspaceId, 'executing');
|
|
791
873
|
quotaBackoffService.cancel(workspaceId, 'completed');
|
|
792
|
-
|
|
874
|
+
resetAutoLoopRetries(workspaceId);
|
|
793
875
|
emitEphemeral(workspaceId, 'agent:quota-recovered', {});
|
|
794
876
|
}
|
|
795
877
|
if (ev.kind === 'rate_limit') {
|
|
@@ -886,15 +968,21 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
886
968
|
console.error('[orchestrator] Failed to transition to executing:', err);
|
|
887
969
|
}
|
|
888
970
|
}
|
|
889
|
-
|
|
971
|
+
const transferring = workspaceLifecycleReason(workspaceId) === 'session-handoff';
|
|
972
|
+
const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
|
|
973
|
+
const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
|
|
974
|
+
if (quotaError && !sourceControllerIsStopping && !returningReview && !transferring) {
|
|
890
975
|
void handleQuota(workspaceId, agentSessionId);
|
|
891
976
|
}
|
|
892
977
|
if (ev.kind === 'error' &&
|
|
893
|
-
|
|
978
|
+
!quotaError &&
|
|
979
|
+
ev.category !== 'resume_failed' &&
|
|
894
980
|
TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
|
|
981
|
+
!returningReview &&
|
|
982
|
+
!transferring &&
|
|
895
983
|
getWs(workspaceId)?.autoLoop &&
|
|
896
984
|
!sourceControllerIsStopping) {
|
|
897
|
-
|
|
985
|
+
scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
|
|
898
986
|
}
|
|
899
987
|
if (ev.kind === 'error' && ev.category === 'resume_failed') {
|
|
900
988
|
rememberResumeFailed(workspaceId, agentSessionId);
|
|
@@ -908,17 +996,27 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
908
996
|
markSessionLifecycleOwnerEnded(workspaceId, agentSessionId, sourceController);
|
|
909
997
|
const isResumeFailed = consumeResumeFailed(workspaceId, agentSessionId);
|
|
910
998
|
const snapshot = consumeTaskProgressSnapshot(workspaceId, agentSessionId);
|
|
911
|
-
const before = snapshot?.doneCount ?? getDoneTaskCount(workspaceId);
|
|
912
|
-
const after = getDoneTaskCount(workspaceId);
|
|
913
|
-
const completedDelta = Math.max(0, after - before);
|
|
914
|
-
const taskStateBefore = snapshot?.stateSignature ?? getTaskStateSignature(workspaceId);
|
|
915
|
-
const taskStateAfter = getTaskStateSignature(workspaceId);
|
|
916
|
-
const progressDelta = completedDelta > 0 || taskStateBefore !== taskStateAfter ? 1 : 0;
|
|
917
999
|
clearPendingForSession(workspaceId, agentSessionId);
|
|
918
1000
|
// A completed/failed SDK session cannot resolve canUseTool anymore. Drop
|
|
919
1001
|
// its persisted prompts too, otherwise a reconnect resurrects a panel
|
|
920
1002
|
// whose backend callback has already been discarded.
|
|
921
1003
|
purgeAllPersistedUserInputRequests(workspaceId, agentSessionId);
|
|
1004
|
+
if (transferring) {
|
|
1005
|
+
onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
|
|
1006
|
+
launch?.onEnded?.(ev);
|
|
1007
|
+
if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
|
|
1008
|
+
if (!launch?.handoffGeneration) {
|
|
1009
|
+
settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed');
|
|
1010
|
+
if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown'))
|
|
1011
|
+
autoLoopService.block(workspaceId, 'message-delivery-unknown');
|
|
1012
|
+
}
|
|
1013
|
+
notifyCapacityAvailable(workspaceId);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
// A target can finish before its ready/start promise settles. Its callback
|
|
1017
|
+
// completed the transfer and released ownership: process this actual work
|
|
1018
|
+
// session through the ordinary terminal/auto-loop path below exactly once.
|
|
1019
|
+
}
|
|
922
1020
|
// A watchdog end means Kōbō forced the stream closed after the engine
|
|
923
1021
|
// failed to drain. It is neither a clean completion nor evidence of an
|
|
924
1022
|
// agent stall. Move auto-loop work onto the existing persisted, bounded
|
|
@@ -932,7 +1030,11 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
932
1030
|
// dead-engine branch evicts a controller from the map WITHOUT ever
|
|
933
1031
|
// calling `.stop()` on it, so an old controller can still be 'running'
|
|
934
1032
|
// by the time its own drain watchdog reports a late `session:ended`.
|
|
935
|
-
const watchdogRecovery = !
|
|
1033
|
+
const watchdogRecovery = !returningReview &&
|
|
1034
|
+
!hasReplacement &&
|
|
1035
|
+
ev.reason === 'watchdog' &&
|
|
1036
|
+
getWs(workspaceId)?.autoLoop === true &&
|
|
1037
|
+
autoLoopService.getStatus(workspaceId).state !== 'blocked';
|
|
936
1038
|
// A drain watchdog can fire mid-`stop()`: `stopController` already cancelled
|
|
937
1039
|
// any pending quota backoff (`quotaBackoffService.cancel(id, 'user')`), and
|
|
938
1040
|
// re-arming one here would resurrect a session the user just stopped — the
|
|
@@ -943,14 +1045,58 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
943
1045
|
// could matter.
|
|
944
1046
|
if (watchdogRecovery && !sourceControllerIsStopping) {
|
|
945
1047
|
console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
|
|
946
|
-
|
|
1048
|
+
scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
|
|
947
1049
|
}
|
|
948
1050
|
// Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
|
|
949
1051
|
// startAgent, otherwise startAgent throws "Agent already running" because
|
|
950
1052
|
// the just-ended controller is still in the map.
|
|
951
1053
|
const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
|
|
1054
|
+
// A technical stop deliberately skips normal loop continuation, but its
|
|
1055
|
+
// instructions still need a definitive delivery state after closure.
|
|
1056
|
+
// A superseded controller must never settle a replacement's instructions.
|
|
1057
|
+
let instructionIntake = false;
|
|
1058
|
+
if (!hasReplacement) {
|
|
1059
|
+
instructionIntake = settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed') > 0;
|
|
1060
|
+
if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown')) {
|
|
1061
|
+
restoreReviewConfiguration(workspaceId, agentSessionId);
|
|
1062
|
+
autoLoopService.block(workspaceId, 'message-delivery-unknown');
|
|
1063
|
+
if (ownsWorkspaceLifecycle)
|
|
1064
|
+
notifyCapacityAvailable(workspaceId);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
952
1068
|
if (!ownsWorkspaceLifecycle)
|
|
953
1069
|
return;
|
|
1070
|
+
const progressDelta = consumeMonotoneTaskProgress(workspaceId, snapshot);
|
|
1071
|
+
if (returningReview) {
|
|
1072
|
+
const pending = restoreReviewConfiguration(workspaceId, agentSessionId);
|
|
1073
|
+
const workspace = getWs(workspaceId);
|
|
1074
|
+
if (pending &&
|
|
1075
|
+
workspace &&
|
|
1076
|
+
!shuttingDown &&
|
|
1077
|
+
!workspace.archivedAt &&
|
|
1078
|
+
!workspace.worktreePurgedAt &&
|
|
1079
|
+
autoLoopService.getStatus(workspaceId).state !== 'blocked' &&
|
|
1080
|
+
(ev.reason === 'completed' || ev.reason === 'watchdog') &&
|
|
1081
|
+
(ev.exitCode === null || ev.exitCode === 0)) {
|
|
1082
|
+
try {
|
|
1083
|
+
const prompt = buildReviewReturnPrompt(workspaceId, agentSessionId);
|
|
1084
|
+
const original = pending.original;
|
|
1085
|
+
startAgent(workspaceId, workspace.worktreePath, prompt, original.sessionModel ?? original.model, true, original.agentPermissionMode, pending.originalSessionId, original.reasoningEffort);
|
|
1086
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
1087
|
+
emit(workspaceId, 'user:message', { content: prompt, sender: 'system-prompt' }, pending.originalSessionId);
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
routeEvent(workspaceId, agentSessionId, {
|
|
1091
|
+
kind: 'error',
|
|
1092
|
+
category: 'other',
|
|
1093
|
+
message: `Could not return to the original session: ${err instanceof Error ? err.message : String(err)}`,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
notifyCapacityAvailable(workspaceId);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
954
1100
|
if (watchdogRecovery) {
|
|
955
1101
|
notifyCapacityAvailable(workspaceId);
|
|
956
1102
|
return;
|
|
@@ -963,7 +1109,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
963
1109
|
// disable() clears it, and the cleanup hook needs to know whether this was
|
|
964
1110
|
// a mid-loop session (never cleans) or a standalone one.
|
|
965
1111
|
const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
|
|
966
|
-
autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
|
|
1112
|
+
autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta, instructionIntake);
|
|
967
1113
|
// A slot just freed. Any auto-loop workspace parked on the concurrency
|
|
968
1114
|
// limit has no session of its own to bring it back; this one does it.
|
|
969
1115
|
autoLoopService.resumeWaitingWorkspaces();
|
|
@@ -1072,6 +1218,11 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1072
1218
|
preCompactionStatus.delete(sourceController);
|
|
1073
1219
|
if (isSuperseded)
|
|
1074
1220
|
return false;
|
|
1221
|
+
if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
|
|
1222
|
+
if (registeredController === sourceController && sourceController?.status !== 'stopping')
|
|
1223
|
+
controllers.delete(workspaceId);
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1075
1226
|
// A superseded end (above) is a session that was replaced, not one that
|
|
1076
1227
|
// ended; everything past this point is a real end the user may want to act
|
|
1077
1228
|
// on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
|
|
@@ -1083,7 +1234,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1083
1234
|
controllers.delete(workspaceId);
|
|
1084
1235
|
}
|
|
1085
1236
|
if (!preserveQuotaBackoff) {
|
|
1086
|
-
|
|
1237
|
+
resetAutoLoopRetries(workspaceId);
|
|
1087
1238
|
}
|
|
1088
1239
|
if (wasStopping)
|
|
1089
1240
|
return false;
|
|
@@ -1136,6 +1287,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1136
1287
|
}
|
|
1137
1288
|
return true;
|
|
1138
1289
|
}
|
|
1290
|
+
const launchOptions = new WeakMap();
|
|
1139
1291
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
1140
1292
|
/**
|
|
1141
1293
|
* Spawn an agent (via the resolved engine) for a workspace. Returns
|
|
@@ -1143,10 +1295,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1143
1295
|
* after `engine.start` resolves — callers should subscribe to WS events or
|
|
1144
1296
|
* query the controller via `_getControllers()` for tests.
|
|
1145
1297
|
*/
|
|
1146
|
-
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch) {
|
|
1298
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
|
|
1147
1299
|
if (shuttingDown)
|
|
1148
1300
|
throw new Error('Cannot start an agent while the server is shutting down');
|
|
1149
|
-
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1301
|
+
assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
|
|
1150
1302
|
const workspace = getWs(workspaceId);
|
|
1151
1303
|
if (!workspace)
|
|
1152
1304
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -1204,7 +1356,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1204
1356
|
let agentSessionId;
|
|
1205
1357
|
let resumeFromEngineSessionId;
|
|
1206
1358
|
if (resume) {
|
|
1207
|
-
const r = resolveSessionForResume(workspaceId, existingSessionId, model);
|
|
1359
|
+
const r = resolveSessionForResume(workspaceId, existingSessionId, model, engineId);
|
|
1208
1360
|
agentSessionId = r.agentSessionId;
|
|
1209
1361
|
resumeFromEngineSessionId = r.engineSessionId;
|
|
1210
1362
|
// A native resume is only used by the currently selected workspace engine.
|
|
@@ -1216,6 +1368,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1216
1368
|
}
|
|
1217
1369
|
// Persist before the asynchronous engine can emit its first event. This
|
|
1218
1370
|
// survives a Kōbō restart and does not depend on SDK system:init delivery.
|
|
1371
|
+
activateSession(workspaceId, agentSessionId);
|
|
1219
1372
|
captureTaskProgressBaseline(workspaceId, agentSessionId);
|
|
1220
1373
|
const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
|
|
1221
1374
|
const options = {
|
|
@@ -1237,12 +1390,14 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1237
1390
|
}
|
|
1238
1391
|
})(),
|
|
1239
1392
|
settings,
|
|
1240
|
-
mcpServers: buildMcpServers(workspaceId),
|
|
1393
|
+
mcpServers: buildMcpServers(workspaceId, launch?.mcpEnv),
|
|
1241
1394
|
env: ws ? buildAgentEnv(ws.projectPath) : undefined,
|
|
1242
1395
|
};
|
|
1243
1396
|
beforeDispatch?.();
|
|
1244
1397
|
let controller;
|
|
1245
1398
|
controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
|
|
1399
|
+
if (launch)
|
|
1400
|
+
launchOptions.set(controller, launch);
|
|
1246
1401
|
registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
|
|
1247
1402
|
if (!existingCtrl)
|
|
1248
1403
|
controllers.set(workspaceId, controller);
|
|
@@ -1259,7 +1414,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1259
1414
|
return;
|
|
1260
1415
|
if (pendingStarts.get(workspaceId) === controller)
|
|
1261
1416
|
pendingStarts.delete(workspaceId);
|
|
1262
|
-
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1417
|
+
assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
|
|
1263
1418
|
const current = getWs(workspaceId);
|
|
1264
1419
|
if (!current || current.archivedAt || current.worktreePurgedAt)
|
|
1265
1420
|
throw new Error('Workspace is no longer available');
|
|
@@ -1269,7 +1424,11 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1269
1424
|
controllers.set(workspaceId, controller);
|
|
1270
1425
|
return controller.start(options);
|
|
1271
1426
|
})
|
|
1272
|
-
.then(() => {
|
|
1427
|
+
.then(async () => {
|
|
1428
|
+
if (launch?.onStarted)
|
|
1429
|
+
await controller.engineProcess?.ready;
|
|
1430
|
+
if (controller.status !== 'stopping')
|
|
1431
|
+
launch?.onStarted?.();
|
|
1273
1432
|
const pid = controller.pid;
|
|
1274
1433
|
if (pid !== undefined) {
|
|
1275
1434
|
try {
|
|
@@ -1331,6 +1490,7 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
1331
1490
|
export const STOP_AGENT_TIMEOUT_MS = 15_000;
|
|
1332
1491
|
async function stopController(workspaceId, ctrl, cause = 'user') {
|
|
1333
1492
|
ctrl.stopCause = cause;
|
|
1493
|
+
restoreReviewConfiguration(workspaceId, ctrl.agentSessionId);
|
|
1334
1494
|
preCompactionStatus.delete(ctrl);
|
|
1335
1495
|
// Normalize the state synchronously so callers (archive, delete, manual
|
|
1336
1496
|
// stop) see a clean workspace immediately — without waiting for the async
|
|
@@ -1405,6 +1565,9 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
|
|
|
1405
1565
|
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
|
|
1406
1566
|
/** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
|
|
1407
1567
|
cause = 'user') {
|
|
1568
|
+
const finishHandoffStop = ['user', 'delete', 'purge', 'archive'].includes(cause)
|
|
1569
|
+
? requestHandoffStop(workspaceId)
|
|
1570
|
+
: undefined;
|
|
1408
1571
|
const pending = pendingStarts.get(workspaceId);
|
|
1409
1572
|
let pendingStop = Promise.resolve();
|
|
1410
1573
|
if (pending) {
|
|
@@ -1425,7 +1588,8 @@ cause = 'user') {
|
|
|
1425
1588
|
// Shutdown only suspends timers: persisted intent belongs to the next boot.
|
|
1426
1589
|
const cancelSchedules = new Promise((resolve) => {
|
|
1427
1590
|
if (cause !== 'shutdown') {
|
|
1428
|
-
|
|
1591
|
+
if (cause !== 'handoff')
|
|
1592
|
+
wakeupService.cancel(workspaceId, 'stopped');
|
|
1429
1593
|
quotaBackoffService.cancel(workspaceId, 'user');
|
|
1430
1594
|
}
|
|
1431
1595
|
resolve();
|
|
@@ -1442,6 +1606,7 @@ cause = 'user') {
|
|
|
1442
1606
|
if (outcome === 'timeout') {
|
|
1443
1607
|
console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
|
|
1444
1608
|
}
|
|
1609
|
+
finishHandoffStop?.(outcome);
|
|
1445
1610
|
return outcome;
|
|
1446
1611
|
}
|
|
1447
1612
|
/**
|
|
@@ -1465,6 +1630,7 @@ export async function stopAllAgents(timeoutMs = 3_000) {
|
|
|
1465
1630
|
wakeupService.suspendForShutdown();
|
|
1466
1631
|
cronService.suspendForShutdown();
|
|
1467
1632
|
shuttingDown = true;
|
|
1633
|
+
suspendHandoffTransfers();
|
|
1468
1634
|
if (capacityTimer)
|
|
1469
1635
|
clearTimeout(capacityTimer);
|
|
1470
1636
|
capacityTimer = undefined;
|
|
@@ -1484,6 +1650,8 @@ export function isShuttingDown() {
|
|
|
1484
1650
|
}
|
|
1485
1651
|
/** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
|
|
1486
1652
|
export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
1653
|
+
if (workspaceLifecycleReason(workspaceId) === 'session-handoff')
|
|
1654
|
+
return false;
|
|
1487
1655
|
const ctrl = controllers.get(workspaceId);
|
|
1488
1656
|
if (ctrl?.status !== 'running')
|
|
1489
1657
|
return false;
|
|
@@ -1501,6 +1669,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
|
1501
1669
|
}
|
|
1502
1670
|
/** Write a user message to the running agent. */
|
|
1503
1671
|
export async function sendMessage(workspaceId, content, expectedSessionId, beforeDispatch) {
|
|
1672
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1504
1673
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1505
1674
|
const ctrl = controllers.get(workspaceId);
|
|
1506
1675
|
if (!ctrl) {
|
|
@@ -1514,6 +1683,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
|
|
|
1514
1683
|
await ctrl.sendMessage(content);
|
|
1515
1684
|
}
|
|
1516
1685
|
export async function sendMessageForFallback(workspaceId, content) {
|
|
1686
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1517
1687
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1518
1688
|
const capturedController = controllers.get(workspaceId);
|
|
1519
1689
|
wakeupService.cancel(workspaceId, 'user-message');
|
|
@@ -1524,6 +1694,7 @@ export async function sendMessageForFallback(workspaceId, content) {
|
|
|
1524
1694
|
return { status: 'sent', sessionId: capturedController.agentSessionId };
|
|
1525
1695
|
}
|
|
1526
1696
|
catch {
|
|
1697
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1527
1698
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1528
1699
|
const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
|
|
1529
1700
|
while (controllers.get(workspaceId) === capturedController) {
|
|
@@ -1538,6 +1709,7 @@ export async function sendMessageForFallback(workspaceId, content) {
|
|
|
1538
1709
|
const replacementController = controllers.get(workspaceId);
|
|
1539
1710
|
if (!replacementController)
|
|
1540
1711
|
return { status: 'stopped' };
|
|
1712
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1541
1713
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1542
1714
|
await replacementController.sendMessage(content);
|
|
1543
1715
|
return { status: 'sent', sessionId: replacementController.agentSessionId };
|
|
@@ -1932,15 +2104,17 @@ function clearStaleEngineSessionId(workspaceId) {
|
|
|
1932
2104
|
}
|
|
1933
2105
|
}
|
|
1934
2106
|
/** Retryable upstream failures for an auto-loop iteration (not coding errors). */
|
|
1935
|
-
export const TRANSIENT_SERVER_ERROR_PATTERN = /\b(?:http\s*)?500\b|internal server error|service unavailable|temporarily unavailable|overloaded/i;
|
|
2107
|
+
export const TRANSIENT_SERVER_ERROR_PATTERN = /\b(?:http\s*)?(?:500|502|503|504)\b|internal server error|bad gateway|gateway timeout|service unavailable|temporarily unavailable|overloaded|\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EPIPE)\b|fetch failed|network (?:error|connection)|connection (?:reset|closed|terminated)|socket hang up|request timed out|stopped reporting activity/i;
|
|
1936
2108
|
const QUOTA_SAFETY_MARGIN_MS = 30_000;
|
|
1937
|
-
|
|
2109
|
+
// Weekly and monthly provider windows are legitimate. The scheduler chunks
|
|
2110
|
+
// long delays so they never overflow Node's signed 32-bit timeout.
|
|
2111
|
+
const QUOTA_MAX_BACKOFF_MS = 366 * 24 * 60 * 60 * 1000;
|
|
1938
2112
|
const QUOTA_SATURATION_THRESHOLD_PCT = 95;
|
|
1939
2113
|
/**
|
|
1940
2114
|
* Fallback backoff ladder (in minutes) used when the `rate_limit` info
|
|
1941
2115
|
* isn't usable. Indexed by `retryCount`; anything past the last entry
|
|
1942
|
-
* clamps to the final value (5 h)
|
|
1943
|
-
*
|
|
2116
|
+
* clamps to the final value (5 h). Genuine quota retries retain their intent
|
|
2117
|
+
* until recovery, including when no provider reset time is available.
|
|
1944
2118
|
*/
|
|
1945
2119
|
const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
|
|
1946
2120
|
/**
|
|
@@ -1975,9 +2149,11 @@ export async function computeQuotaBackoffMs(workspaceId, retryCount, preferQuota
|
|
|
1975
2149
|
}
|
|
1976
2150
|
// 1.5. Try the official usage API (Claude subscription). Best-effort; never throws.
|
|
1977
2151
|
try {
|
|
1978
|
-
const snap = await refreshNow('claude-code');
|
|
2152
|
+
const snap = (getWs(workspaceId)?.engine ?? 'claude-code') === 'claude-code' ? await refreshNow('claude-code') : null;
|
|
1979
2153
|
if (snap) {
|
|
1980
|
-
const fiveHour = snap.buckets
|
|
2154
|
+
const fiveHour = snap.buckets
|
|
2155
|
+
.filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && b.resetsAt)
|
|
2156
|
+
.sort((a, b) => Date.parse(b.resetsAt) - Date.parse(a.resetsAt))[0];
|
|
1981
2157
|
if (fiveHour &&
|
|
1982
2158
|
typeof fiveHour.usedPct === 'number' &&
|
|
1983
2159
|
fiveHour.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT &&
|
|
@@ -2019,23 +2195,26 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
2019
2195
|
// would otherwise kill Kōbō and every agent running under it.
|
|
2020
2196
|
try {
|
|
2021
2197
|
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
2022
|
-
const
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2198
|
+
const generation = Symbol('quota-lookup');
|
|
2199
|
+
quotaLookups.set(workspaceId, generation);
|
|
2200
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
2201
|
+
retryReasons.set(workspaceId, 'quota');
|
|
2202
|
+
// Establish durable ownership BEFORE the usage provider can yield. A
|
|
2203
|
+
// restart or failed lookup retains this conservative fallback deadline.
|
|
2204
|
+
const fallbackIndex = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
|
|
2205
|
+
quotaBackoffService.arm(workspaceId, QUOTA_FALLBACK_LADDER_MINUTES[fallbackIndex] * 60_000, {
|
|
2206
|
+
resetsAt: null,
|
|
2207
|
+
source: 'fallback_ladder',
|
|
2208
|
+
reason: 'quota',
|
|
2209
|
+
retryCount: retryCount + 1,
|
|
2210
|
+
});
|
|
2034
2211
|
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
2035
|
-
//
|
|
2036
|
-
if (
|
|
2212
|
+
// A stop, recovery, replacement or newer quota may supersede this lookup.
|
|
2213
|
+
if (quotaLookups.get(workspaceId) !== generation)
|
|
2214
|
+
return;
|
|
2215
|
+
quotaLookups.delete(workspaceId);
|
|
2216
|
+
if (getWs(workspaceId)?.status !== 'quota' || !quotaBackoffService.getPending(workspaceId))
|
|
2037
2217
|
return;
|
|
2038
|
-
retryCounts.set(workspaceId, retryCount + 1);
|
|
2039
2218
|
// The quotaBackoffService owns the timer + the persistent row + the
|
|
2040
2219
|
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
2041
2220
|
quotaBackoffService.arm(workspaceId, delayMs, {
|
|
@@ -2055,13 +2234,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
2055
2234
|
}
|
|
2056
2235
|
}
|
|
2057
2236
|
/**
|
|
2058
|
-
* Last resort when
|
|
2059
|
-
*
|
|
2060
|
-
* act on instead of a workspace that has quietly stopped moving.
|
|
2237
|
+
* Last resort when retry scheduling fails: retain loop intent in a visible
|
|
2238
|
+
* blocked state so an explicit human resume can recover it.
|
|
2061
2239
|
*/
|
|
2062
2240
|
function failVisibly(workspaceId) {
|
|
2063
2241
|
try {
|
|
2064
|
-
autoLoopService.
|
|
2242
|
+
autoLoopService.block(workspaceId, 'retry-unavailable');
|
|
2065
2243
|
}
|
|
2066
2244
|
catch (err) {
|
|
2067
2245
|
console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
|
|
@@ -2077,6 +2255,18 @@ function failVisibly(workspaceId) {
|
|
|
2077
2255
|
* quota ladder's 15-minute floor is tuned for real quota windows — a forced
|
|
2078
2256
|
* kill or a server blip deserves a much faster first attempt. */
|
|
2079
2257
|
const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
|
|
2258
|
+
/** Charge an incident once, until this controller demonstrably starts working again. */
|
|
2259
|
+
function scheduleTransientAutoLoopRecovery(workspaceId, controller) {
|
|
2260
|
+
if (controller && transientRecoveryControllers.has(controller))
|
|
2261
|
+
return;
|
|
2262
|
+
// Preserve whichever backoff already owns the next start, including a real
|
|
2263
|
+
// provider quota followed by its engine's drain-watchdog diagnostic.
|
|
2264
|
+
if (getWs(workspaceId)?.status === 'quota' && quotaBackoffService.getPending(workspaceId))
|
|
2265
|
+
return;
|
|
2266
|
+
if (controller)
|
|
2267
|
+
transientRecoveryControllers.add(controller);
|
|
2268
|
+
void handleTransientAutoLoopFailure(workspaceId);
|
|
2269
|
+
}
|
|
2080
2270
|
/**
|
|
2081
2271
|
* Use the same persisted retry path for temporary upstream failures (HTTP 500
|
|
2082
2272
|
* and overloads) while deliberately ignoring quota-reset data: a server error
|
|
@@ -2092,10 +2282,10 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
2092
2282
|
// Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
|
|
2093
2283
|
// unhandled-rejection policy mean a failure here must stay contained.
|
|
2094
2284
|
try {
|
|
2095
|
-
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
2285
|
+
const retryCount = retryReasons.get(workspaceId) === 'quota' ? 0 : (retryCounts.get(workspaceId) ?? 0);
|
|
2096
2286
|
const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
|
|
2097
2287
|
if (retryCount >= maxRetries) {
|
|
2098
|
-
autoLoopService.
|
|
2288
|
+
autoLoopService.block(workspaceId, 'retry-exhausted');
|
|
2099
2289
|
try {
|
|
2100
2290
|
updateWorkspaceStatus(workspaceId, 'error');
|
|
2101
2291
|
}
|
|
@@ -2104,12 +2294,14 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
2104
2294
|
}
|
|
2105
2295
|
return;
|
|
2106
2296
|
}
|
|
2107
|
-
const
|
|
2297
|
+
const idx = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
|
|
2298
|
+
const delayMs = QUOTA_FALLBACK_LADDER_MINUTES[idx] * 60_000;
|
|
2108
2299
|
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
2109
2300
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
2301
|
+
retryReasons.set(workspaceId, 'transient');
|
|
2110
2302
|
quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
|
|
2111
|
-
resetsAt:
|
|
2112
|
-
source,
|
|
2303
|
+
resetsAt: null,
|
|
2304
|
+
source: 'fallback_ladder',
|
|
2113
2305
|
reason: 'transient',
|
|
2114
2306
|
retryCount: retryCount + 1,
|
|
2115
2307
|
});
|
|
@@ -2132,8 +2324,18 @@ export const _handleTransientAutoLoopFailure = handleTransientAutoLoopFailure;
|
|
|
2132
2324
|
export function restoreRetryCountsFromDb() {
|
|
2133
2325
|
for (const pending of quotaBackoffService.listPending()) {
|
|
2134
2326
|
retryCounts.set(pending.workspaceId, pending.retryCount);
|
|
2327
|
+
retryReasons.set(pending.workspaceId, pending.reason);
|
|
2135
2328
|
}
|
|
2136
2329
|
}
|
|
2330
|
+
/** An explicit human resume starts a new bounded transient-recovery attempt. */
|
|
2331
|
+
export function resetAutoLoopRetries(workspaceId) {
|
|
2332
|
+
retryCounts.delete(workspaceId);
|
|
2333
|
+
retryReasons.delete(workspaceId);
|
|
2334
|
+
quotaLookups.delete(workspaceId);
|
|
2335
|
+
const controller = controllers.get(workspaceId);
|
|
2336
|
+
if (controller)
|
|
2337
|
+
transientRecoveryControllers.delete(controller);
|
|
2338
|
+
}
|
|
2137
2339
|
// One-time wire: when the persisted backoff timer fires (or a row is
|
|
2138
2340
|
// restored at boot), hand the workspace off to auto-loop. The auto-loop
|
|
2139
2341
|
// service decides whether to spawn the next iteration or fall back to a
|
|
@@ -2142,8 +2344,8 @@ export function restoreRetryCountsFromDb() {
|
|
|
2142
2344
|
// IMPORTANT — behavioural contract: only auto-loop workspaces auto-resume
|
|
2143
2345
|
// after a quota backoff. `onQuotaBackoffExpired` no-ops if `auto_loop !== 1`
|
|
2144
2346
|
// (see auto-loop-service). Workspaces hit by quota WITHOUT auto-loop stay
|
|
2145
|
-
// in `quota` status
|
|
2146
|
-
//
|
|
2347
|
+
// in `quota` status until manual user action (resume / new message) or an
|
|
2348
|
+
// explicitly scheduled wakeup/cron resumes them after expiry. Without either intent,
|
|
2147
2349
|
// firing a fresh agent run in the user's absence would surprise them.
|
|
2148
2350
|
quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
|
|
2149
2351
|
autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
|