@loicngr/kobo 1.11.43 → 1.11.44
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 +36 -1
- package/CHANGELOG.md +6 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +31 -74
- package/dist/mcp-server/kobo-tasks-server.js +51 -4
- package/dist/server/db/auto-loop-schema.js +44 -0
- package/dist/server/db/migrations.js +4 -0
- package/dist/server/db/review-return-schema.js +10 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/index.js +10 -9
- package/dist/server/routes/auto-loop-messages.js +51 -0
- package/dist/server/routes/workspaces.js +46 -136
- package/dist/server/services/agent/engines/claude-code/engine.js +1 -0
- package/dist/server/services/agent/engines/codex/engine.js +91 -31
- package/dist/server/services/agent/orchestrator.js +237 -88
- 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/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/settings-service.js +3 -3
- package/dist/server/services/task-mutations.js +186 -0
- package/dist/server/services/wakeup-service.js +16 -1
- package/dist/server/services/workspace-message-service.js +16 -2
- package/dist/server/services/workspace-service.js +25 -36
- package/dist/shared/auto-loop-prompts.js +22 -11
- package/dist/shared/auto-loop-types.js +1 -0
- package/dist/shared/review.js +6 -0
- package/dist/shared/task-verification.js +11 -0
- package/package.json +12 -12
- package/src/client/dist/pwa/assets/{ActivityFeed-D1e-zV3O.js → ActivityFeed-D_y4aikp.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.js → ChangelogPage-DSKCvpr2.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-DDxcsxyC.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-Bx_GpUni.js → CreatePage-DKVEknbL.js} +2 -2
- package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-Dg2s9nnv.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-BSzNScIr.js} +4 -4
- package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-DfEoI_f7.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.js → DrawerToggleButton-D85PwhKb.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-XrOuMoDJ.js → HealthPage-CaLCpejR.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +1 -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-qJWGcZpC.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-DX3PhMqC.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-CY4TWW_j.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-cDK5AOQ5.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-Bos94OyR.js → SchedulePanel-LvHiZ6ZH.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BO-J4aNc.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-DtGsd0XH.js → SettingsPage-Aw89fRSq.js} +2 -2
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-BIEceNwH.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-DitOCad1.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-DvPAhZsa.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-CJYdH3Jf.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.js → WorkspaceAttentionLabels-CY-9z9nw.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DAVo-E4p.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +4 -0
- package/src/client/dist/pwa/assets/{browser-Bkx4fbxl.js → browser-BsB6cFv4.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +1 -0
- package/src/client/dist/pwa/assets/{de-BU0dILBs.js → de-Mu7biceO.js} +2 -2
- package/src/client/dist/pwa/assets/documents-SCd3w8xV.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-DreEG0If.js → editor.api-BUdM7I6y.js} +1 -1
- package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
- package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +9 -0
- package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
- package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +1 -0
- package/src/client/dist/pwa/assets/{es-CNp0s9na.js → es-BY5IfNI2.js} +2 -2
- package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +1 -0
- package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-BjQxf4pe.js} +3 -3
- package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-BejhmNTV.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-RqJyWSdm.js → fr-BMXCzFCM.js} +2 -2
- package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-5d5GhyRp.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DaafEsDK.js} +1 -1
- package/src/client/dist/pwa/assets/{html-iZL3bbXq.js → html-CwHBDd2A.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +1 -0
- package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
- package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +20 -0
- package/src/client/dist/pwa/assets/{it-DSxNuCwL.js → it-ByPZQK_K.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DSNIEEH5.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-Dma1UpA1.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-D8rqEAws.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-2E7O2zoi.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-D7NmJUJH.js → monaco.contribution-DqOgo72G.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-DNmhJTZn.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-B6Hiwvwb.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.js → pending-diff-open-CeiVIFZn.js} +1 -1
- package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.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-BCp9SWoI.js} +1 -1
- package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-f-9NqRlI.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.js → razor-DsCmoX0h.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-DK5BD9mU.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-cEHg4L10.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-tours-BmTbosmB.js +2 -0
- package/src/client/dist/pwa/assets/{workspace-templates-1ui80Kqo.js → workspace-templates-DUWDLMIX.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-Plt-Y7we.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.js → yaml-ScEmaAeN.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/kobo-tasks-handlers.ts +68 -104
- package/src/mcp-server/kobo-tasks-server.ts +56 -4
- 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/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/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/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/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/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
|
@@ -4,12 +4,14 @@ 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
6
|
import { assertWorkspaceLifecycleAvailable } 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';
|
|
13
15
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
14
16
|
import { refreshNow } from '../usage/poller.js';
|
|
15
17
|
import * as wakeupService from '../wakeup-service.js';
|
|
@@ -41,6 +43,8 @@ export function getBackendPort() {
|
|
|
41
43
|
}
|
|
42
44
|
/** workspaceId -> SessionController */
|
|
43
45
|
const controllers = new Map();
|
|
46
|
+
const pendingTerminalControllers = new WeakSet();
|
|
47
|
+
const finalizedControllers = new WeakSet();
|
|
44
48
|
/** Replacements waiting for the previous controller to release its worktree. */
|
|
45
49
|
const pendingStarts = new Map();
|
|
46
50
|
let shuttingDown = false;
|
|
@@ -261,6 +265,10 @@ let availableSkills = (() => {
|
|
|
261
265
|
})();
|
|
262
266
|
/** workspaceId -> retry count (for quota backoff) */
|
|
263
267
|
const retryCounts = new Map();
|
|
268
|
+
const retryReasons = new Map();
|
|
269
|
+
const quotaLookups = new Map();
|
|
270
|
+
/** Multiple error surfaces and the terminal watchdog can describe one incident. */
|
|
271
|
+
const transientRecoveryControllers = new WeakSet();
|
|
264
272
|
/** Tracks agent sessions that failed due to a stale --resume session ID. */
|
|
265
273
|
const resumeFailedSessions = new Map();
|
|
266
274
|
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
@@ -285,7 +293,7 @@ function runWatchdog() {
|
|
|
285
293
|
// window) must not be raced here: doing so would report a false "Agent
|
|
286
294
|
// process died unexpectedly", force the workspace to `error`, and set an
|
|
287
295
|
// unread badge — all on a stop the user explicitly asked for.
|
|
288
|
-
if (ctrl.status === 'stopping')
|
|
296
|
+
if (ctrl.status === 'stopping' || pendingTerminalControllers.has(ctrl))
|
|
289
297
|
continue;
|
|
290
298
|
const ep = ctrl.engineProcess;
|
|
291
299
|
if (!ep) {
|
|
@@ -294,6 +302,17 @@ function runWatchdog() {
|
|
|
294
302
|
if (Date.now() - ctrl.startedAt < CONTROLLER_STARTUP_GRACE_MS)
|
|
295
303
|
continue;
|
|
296
304
|
console.error(`[watchdog] Controller for workspace '${workspaceId}' produced no engine process within ${CONTROLLER_STARTUP_GRACE_MS}ms — cleaning up`);
|
|
305
|
+
// The start may still complete and acquire a process. Keep ownership
|
|
306
|
+
// until its stop is confirmed instead of dispatching a second writer.
|
|
307
|
+
void stopAgentAndWait(workspaceId, STOP_AGENT_TIMEOUT_MS, 'replacement').then((outcome) => {
|
|
308
|
+
if (outcome === 'timeout' || outcome === 'failed') {
|
|
309
|
+
autoLoopService.block(workspaceId, 'engine-start-timeout');
|
|
310
|
+
}
|
|
311
|
+
else if (getWs(workspaceId)?.autoLoop && !shuttingDown) {
|
|
312
|
+
scheduleTransientAutoLoopRecovery(workspaceId, ctrl);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
297
316
|
}
|
|
298
317
|
else {
|
|
299
318
|
// D1 — the pid is never a liveness criterion. The Claude engine exposes
|
|
@@ -304,50 +323,23 @@ function runWatchdog() {
|
|
|
304
323
|
continue;
|
|
305
324
|
console.error(`[watchdog] Agent engine for workspace '${workspaceId}' reports dead — cleaning up`);
|
|
306
325
|
}
|
|
307
|
-
//
|
|
326
|
+
// Use the same terminal path as an engine watchdog: it preserves the
|
|
327
|
+
// loop's retry intent and releases capacity only after actual closure.
|
|
308
328
|
try {
|
|
309
|
-
|
|
329
|
+
handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
|
|
310
330
|
kind: 'error',
|
|
311
331
|
category: 'other',
|
|
312
332
|
message: 'Agent process died unexpectedly',
|
|
313
333
|
});
|
|
314
|
-
|
|
334
|
+
handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
|
|
315
335
|
kind: 'session:ended',
|
|
316
|
-
reason: '
|
|
336
|
+
reason: getWs(workspaceId)?.autoLoop ? 'watchdog' : 'error',
|
|
317
337
|
exitCode: null,
|
|
318
338
|
});
|
|
319
339
|
}
|
|
320
340
|
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);
|
|
341
|
+
console.warn('[watchdog] Failed to recover a dead agent:', err);
|
|
342
|
+
autoLoopService.block(workspaceId, 'engine-recovery-failed');
|
|
351
343
|
}
|
|
352
344
|
}
|
|
353
345
|
}
|
|
@@ -531,7 +523,7 @@ function buildAgentPrompt(prompt, projectPath) {
|
|
|
531
523
|
prompt,
|
|
532
524
|
].join('\n');
|
|
533
525
|
}
|
|
534
|
-
function resolveSessionForResume(workspaceId, existingSessionId, model) {
|
|
526
|
+
function resolveSessionForResume(workspaceId, existingSessionId, model, engineId) {
|
|
535
527
|
const db = getDb();
|
|
536
528
|
let lastSession;
|
|
537
529
|
if (existingSessionId) {
|
|
@@ -542,13 +534,18 @@ function resolveSessionForResume(workspaceId, existingSessionId, model) {
|
|
|
542
534
|
throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
|
|
543
535
|
'session not found or has no associated engine conversation');
|
|
544
536
|
}
|
|
537
|
+
if ((lastSession.engine ?? 'claude-code') !== engineId) {
|
|
538
|
+
throw new Error(`Cannot resume session '${existingSessionId}' with engine '${engineId}': the conversation belongs to '${lastSession.engine}'`);
|
|
539
|
+
}
|
|
545
540
|
}
|
|
546
541
|
else {
|
|
547
542
|
lastSession = db
|
|
548
|
-
.prepare(
|
|
549
|
-
.get(workspaceId);
|
|
543
|
+
.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 ORDER BY started_at DESC LIMIT 1")
|
|
544
|
+
.get(workspaceId, engineId);
|
|
550
545
|
}
|
|
551
|
-
|
|
546
|
+
// A workspace can now contain conversations from multiple engines. The
|
|
547
|
+
// unscoped in-memory ID may belong to the reviewer, so only trust its DB row.
|
|
548
|
+
const engineSessionId = lastSession?.engine_session_id;
|
|
552
549
|
if (engineSessionId) {
|
|
553
550
|
const existingId = lastSession?.id ??
|
|
554
551
|
db
|
|
@@ -615,6 +612,10 @@ function captureTaskProgressBaseline(workspaceId, agentSessionId) {
|
|
|
615
612
|
getDb()
|
|
616
613
|
.prepare('UPDATE agent_sessions SET task_progress_baseline = ? WHERE id = ?')
|
|
617
614
|
.run(JSON.stringify(snapshot), agentSessionId);
|
|
615
|
+
const baseline = JSON.parse(snapshot.stateSignature || '[]');
|
|
616
|
+
const insert = getDb().prepare('INSERT OR IGNORE INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)');
|
|
617
|
+
for (const task of baseline)
|
|
618
|
+
insert.run(workspaceId, task.id, taskMilestone(task.status));
|
|
618
619
|
}
|
|
619
620
|
catch (err) {
|
|
620
621
|
console.warn('[orchestrator] Failed to persist task progress baseline:', err);
|
|
@@ -687,7 +688,7 @@ function getTaskStateSignature(workspaceId) {
|
|
|
687
688
|
try {
|
|
688
689
|
const db = getDb();
|
|
689
690
|
const rows = db
|
|
690
|
-
.prepare('SELECT id, status
|
|
691
|
+
.prepare('SELECT id, status FROM tasks WHERE workspace_id = ? ORDER BY id')
|
|
691
692
|
.all(workspaceId);
|
|
692
693
|
return JSON.stringify(rows);
|
|
693
694
|
}
|
|
@@ -696,6 +697,42 @@ function getTaskStateSignature(workspaceId) {
|
|
|
696
697
|
return '';
|
|
697
698
|
}
|
|
698
699
|
}
|
|
700
|
+
function taskMilestone(status) {
|
|
701
|
+
return status === 'done' ? 2 : status === 'in_progress' ? 1 : 0;
|
|
702
|
+
}
|
|
703
|
+
/** Credit each forward task milestone only once during the current loop cycle. */
|
|
704
|
+
function consumeMonotoneTaskProgress(workspaceId, snapshot) {
|
|
705
|
+
if (!snapshot)
|
|
706
|
+
return 0;
|
|
707
|
+
try {
|
|
708
|
+
const before = new Map(JSON.parse(snapshot.stateSignature || '[]').map((task) => [
|
|
709
|
+
task.id,
|
|
710
|
+
taskMilestone(task.status),
|
|
711
|
+
]));
|
|
712
|
+
const db = getDb();
|
|
713
|
+
return db.transaction(() => {
|
|
714
|
+
const rows = db
|
|
715
|
+
.prepare(`SELECT tasks.id, tasks.status, COALESCE(auto_loop_progress.milestone, 0) AS milestone
|
|
716
|
+
FROM tasks LEFT JOIN auto_loop_progress ON auto_loop_progress.workspace_id = tasks.workspace_id
|
|
717
|
+
AND auto_loop_progress.task_id = tasks.id WHERE tasks.workspace_id = ?`)
|
|
718
|
+
.all(workspaceId);
|
|
719
|
+
const record = db.prepare(`INSERT INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)
|
|
720
|
+
ON CONFLICT(workspace_id, task_id) DO UPDATE SET milestone = MAX(milestone, excluded.milestone)`);
|
|
721
|
+
let progressed = false;
|
|
722
|
+
for (const task of rows) {
|
|
723
|
+
const milestone = taskMilestone(task.status);
|
|
724
|
+
if (milestone > (before.get(task.id) ?? 0) && milestone > task.milestone)
|
|
725
|
+
progressed = true;
|
|
726
|
+
record.run(workspaceId, task.id, milestone);
|
|
727
|
+
}
|
|
728
|
+
return progressed ? 1 : 0;
|
|
729
|
+
})();
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
console.warn('[orchestrator] Failed to record monotone task progress:', err);
|
|
733
|
+
return 0;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
699
736
|
/** Clear the in-memory done-count snapshot for a workspace (called on delete). */
|
|
700
737
|
export function forgetTasksDoneSnapshot(workspaceId) {
|
|
701
738
|
taskProgressSnapshots.delete(workspaceId);
|
|
@@ -732,7 +769,42 @@ export function forgetSessionId(workspaceId) {
|
|
|
732
769
|
sessionIds.delete(workspaceId);
|
|
733
770
|
sessionLifecycleOwners.delete(workspaceId);
|
|
734
771
|
}
|
|
735
|
-
function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
772
|
+
function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminalAfterClose = false) {
|
|
773
|
+
if (sourceController && !terminalAfterClose) {
|
|
774
|
+
if (ev.kind === 'session:ended') {
|
|
775
|
+
if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController))
|
|
776
|
+
return;
|
|
777
|
+
// Real engines may announce their logical result before their writer
|
|
778
|
+
// exits. Delay all terminal effects, including review return and quota
|
|
779
|
+
// retries, until SessionController confirms closure.
|
|
780
|
+
if (!sourceController.isClosed && (sourceController.engineProcess?.closed || !sourceController.engineProcess)) {
|
|
781
|
+
pendingTerminalControllers.add(sourceController);
|
|
782
|
+
const closeDeadline = setTimeout(() => {
|
|
783
|
+
if (controllers.get(workspaceId) === sourceController && !sourceController.isClosed) {
|
|
784
|
+
autoLoopService.block(workspaceId, 'engine-stop-unconfirmed');
|
|
785
|
+
}
|
|
786
|
+
}, STOP_AGENT_TIMEOUT_MS);
|
|
787
|
+
closeDeadline.unref?.();
|
|
788
|
+
void sourceController.closed
|
|
789
|
+
.then(() => {
|
|
790
|
+
clearTimeout(closeDeadline);
|
|
791
|
+
pendingTerminalControllers.delete(sourceController);
|
|
792
|
+
finalizedControllers.add(sourceController);
|
|
793
|
+
handleEvent(workspaceId, agentSessionId, sourceController, ev, true);
|
|
794
|
+
})
|
|
795
|
+
.catch((err) => {
|
|
796
|
+
clearTimeout(closeDeadline);
|
|
797
|
+
console.error('[orchestrator] Terminal recovery failed:', err);
|
|
798
|
+
autoLoopService.block(workspaceId, 'engine-recovery-failed');
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
finalizedControllers.add(sourceController);
|
|
803
|
+
}
|
|
804
|
+
else if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController)) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
736
808
|
const registeredController = controllers.get(workspaceId);
|
|
737
809
|
const hasReplacement = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
|
|
738
810
|
const sourceNoLongerOwnsWorkspace = sourceController !== undefined && registeredController !== sourceController;
|
|
@@ -789,7 +861,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
789
861
|
if (ev.kind === 'tool:call' && !sourceControllerIsStopping && getWs(workspaceId)?.status === 'quota') {
|
|
790
862
|
updateWorkspaceStatus(workspaceId, 'executing');
|
|
791
863
|
quotaBackoffService.cancel(workspaceId, 'completed');
|
|
792
|
-
|
|
864
|
+
resetAutoLoopRetries(workspaceId);
|
|
793
865
|
emitEphemeral(workspaceId, 'agent:quota-recovered', {});
|
|
794
866
|
}
|
|
795
867
|
if (ev.kind === 'rate_limit') {
|
|
@@ -886,15 +958,19 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
886
958
|
console.error('[orchestrator] Failed to transition to executing:', err);
|
|
887
959
|
}
|
|
888
960
|
}
|
|
889
|
-
|
|
961
|
+
const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
|
|
962
|
+
const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
|
|
963
|
+
if (quotaError && !sourceControllerIsStopping && !returningReview) {
|
|
890
964
|
void handleQuota(workspaceId, agentSessionId);
|
|
891
965
|
}
|
|
892
966
|
if (ev.kind === 'error' &&
|
|
893
|
-
|
|
967
|
+
!quotaError &&
|
|
968
|
+
ev.category !== 'resume_failed' &&
|
|
894
969
|
TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
|
|
970
|
+
!returningReview &&
|
|
895
971
|
getWs(workspaceId)?.autoLoop &&
|
|
896
972
|
!sourceControllerIsStopping) {
|
|
897
|
-
|
|
973
|
+
scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
|
|
898
974
|
}
|
|
899
975
|
if (ev.kind === 'error' && ev.category === 'resume_failed') {
|
|
900
976
|
rememberResumeFailed(workspaceId, agentSessionId);
|
|
@@ -908,12 +984,6 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
908
984
|
markSessionLifecycleOwnerEnded(workspaceId, agentSessionId, sourceController);
|
|
909
985
|
const isResumeFailed = consumeResumeFailed(workspaceId, agentSessionId);
|
|
910
986
|
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
987
|
clearPendingForSession(workspaceId, agentSessionId);
|
|
918
988
|
// A completed/failed SDK session cannot resolve canUseTool anymore. Drop
|
|
919
989
|
// its persisted prompts too, otherwise a reconnect resurrects a panel
|
|
@@ -932,7 +1002,11 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
932
1002
|
// dead-engine branch evicts a controller from the map WITHOUT ever
|
|
933
1003
|
// calling `.stop()` on it, so an old controller can still be 'running'
|
|
934
1004
|
// by the time its own drain watchdog reports a late `session:ended`.
|
|
935
|
-
const watchdogRecovery = !
|
|
1005
|
+
const watchdogRecovery = !returningReview &&
|
|
1006
|
+
!hasReplacement &&
|
|
1007
|
+
ev.reason === 'watchdog' &&
|
|
1008
|
+
getWs(workspaceId)?.autoLoop === true &&
|
|
1009
|
+
autoLoopService.getStatus(workspaceId).state !== 'blocked';
|
|
936
1010
|
// A drain watchdog can fire mid-`stop()`: `stopController` already cancelled
|
|
937
1011
|
// any pending quota backoff (`quotaBackoffService.cancel(id, 'user')`), and
|
|
938
1012
|
// re-arming one here would resurrect a session the user just stopped — the
|
|
@@ -943,14 +1017,58 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
943
1017
|
// could matter.
|
|
944
1018
|
if (watchdogRecovery && !sourceControllerIsStopping) {
|
|
945
1019
|
console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
|
|
946
|
-
|
|
1020
|
+
scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
|
|
947
1021
|
}
|
|
948
1022
|
// Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
|
|
949
1023
|
// startAgent, otherwise startAgent throws "Agent already running" because
|
|
950
1024
|
// the just-ended controller is still in the map.
|
|
951
1025
|
const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
|
|
1026
|
+
// A technical stop deliberately skips normal loop continuation, but its
|
|
1027
|
+
// instructions still need a definitive delivery state after closure.
|
|
1028
|
+
// A superseded controller must never settle a replacement's instructions.
|
|
1029
|
+
let instructionIntake = false;
|
|
1030
|
+
if (!hasReplacement) {
|
|
1031
|
+
instructionIntake = settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed') > 0;
|
|
1032
|
+
if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown')) {
|
|
1033
|
+
restoreReviewConfiguration(workspaceId, agentSessionId);
|
|
1034
|
+
autoLoopService.block(workspaceId, 'message-delivery-unknown');
|
|
1035
|
+
if (ownsWorkspaceLifecycle)
|
|
1036
|
+
notifyCapacityAvailable(workspaceId);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
952
1040
|
if (!ownsWorkspaceLifecycle)
|
|
953
1041
|
return;
|
|
1042
|
+
const progressDelta = consumeMonotoneTaskProgress(workspaceId, snapshot);
|
|
1043
|
+
if (returningReview) {
|
|
1044
|
+
const pending = restoreReviewConfiguration(workspaceId, agentSessionId);
|
|
1045
|
+
const workspace = getWs(workspaceId);
|
|
1046
|
+
if (pending &&
|
|
1047
|
+
workspace &&
|
|
1048
|
+
!shuttingDown &&
|
|
1049
|
+
!workspace.archivedAt &&
|
|
1050
|
+
!workspace.worktreePurgedAt &&
|
|
1051
|
+
autoLoopService.getStatus(workspaceId).state !== 'blocked' &&
|
|
1052
|
+
(ev.reason === 'completed' || ev.reason === 'watchdog') &&
|
|
1053
|
+
(ev.exitCode === null || ev.exitCode === 0)) {
|
|
1054
|
+
try {
|
|
1055
|
+
const prompt = buildReviewReturnPrompt(workspaceId, agentSessionId);
|
|
1056
|
+
const original = pending.original;
|
|
1057
|
+
startAgent(workspaceId, workspace.worktreePath, prompt, original.sessionModel ?? original.model, true, original.agentPermissionMode, pending.originalSessionId, original.reasoningEffort);
|
|
1058
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
1059
|
+
emit(workspaceId, 'user:message', { content: prompt, sender: 'system-prompt' }, pending.originalSessionId);
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
routeEvent(workspaceId, agentSessionId, {
|
|
1063
|
+
kind: 'error',
|
|
1064
|
+
category: 'other',
|
|
1065
|
+
message: `Could not return to the original session: ${err instanceof Error ? err.message : String(err)}`,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
notifyCapacityAvailable(workspaceId);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
954
1072
|
if (watchdogRecovery) {
|
|
955
1073
|
notifyCapacityAvailable(workspaceId);
|
|
956
1074
|
return;
|
|
@@ -963,7 +1081,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
963
1081
|
// disable() clears it, and the cleanup hook needs to know whether this was
|
|
964
1082
|
// a mid-loop session (never cleans) or a standalone one.
|
|
965
1083
|
const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
|
|
966
|
-
autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
|
|
1084
|
+
autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta, instructionIntake);
|
|
967
1085
|
// A slot just freed. Any auto-loop workspace parked on the concurrency
|
|
968
1086
|
// limit has no session of its own to bring it back; this one does it.
|
|
969
1087
|
autoLoopService.resumeWaitingWorkspaces();
|
|
@@ -1083,7 +1201,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1083
1201
|
controllers.delete(workspaceId);
|
|
1084
1202
|
}
|
|
1085
1203
|
if (!preserveQuotaBackoff) {
|
|
1086
|
-
|
|
1204
|
+
resetAutoLoopRetries(workspaceId);
|
|
1087
1205
|
}
|
|
1088
1206
|
if (wasStopping)
|
|
1089
1207
|
return false;
|
|
@@ -1204,7 +1322,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1204
1322
|
let agentSessionId;
|
|
1205
1323
|
let resumeFromEngineSessionId;
|
|
1206
1324
|
if (resume) {
|
|
1207
|
-
const r = resolveSessionForResume(workspaceId, existingSessionId, model);
|
|
1325
|
+
const r = resolveSessionForResume(workspaceId, existingSessionId, model, engineId);
|
|
1208
1326
|
agentSessionId = r.agentSessionId;
|
|
1209
1327
|
resumeFromEngineSessionId = r.engineSessionId;
|
|
1210
1328
|
// A native resume is only used by the currently selected workspace engine.
|
|
@@ -1331,6 +1449,7 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
1331
1449
|
export const STOP_AGENT_TIMEOUT_MS = 15_000;
|
|
1332
1450
|
async function stopController(workspaceId, ctrl, cause = 'user') {
|
|
1333
1451
|
ctrl.stopCause = cause;
|
|
1452
|
+
restoreReviewConfiguration(workspaceId, ctrl.agentSessionId);
|
|
1334
1453
|
preCompactionStatus.delete(ctrl);
|
|
1335
1454
|
// Normalize the state synchronously so callers (archive, delete, manual
|
|
1336
1455
|
// stop) see a clean workspace immediately — without waiting for the async
|
|
@@ -1932,15 +2051,17 @@ function clearStaleEngineSessionId(workspaceId) {
|
|
|
1932
2051
|
}
|
|
1933
2052
|
}
|
|
1934
2053
|
/** 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;
|
|
2054
|
+
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
2055
|
const QUOTA_SAFETY_MARGIN_MS = 30_000;
|
|
1937
|
-
|
|
2056
|
+
// Weekly and monthly provider windows are legitimate. The scheduler chunks
|
|
2057
|
+
// long delays so they never overflow Node's signed 32-bit timeout.
|
|
2058
|
+
const QUOTA_MAX_BACKOFF_MS = 366 * 24 * 60 * 60 * 1000;
|
|
1938
2059
|
const QUOTA_SATURATION_THRESHOLD_PCT = 95;
|
|
1939
2060
|
/**
|
|
1940
2061
|
* Fallback backoff ladder (in minutes) used when the `rate_limit` info
|
|
1941
2062
|
* isn't usable. Indexed by `retryCount`; anything past the last entry
|
|
1942
|
-
* clamps to the final value (5 h)
|
|
1943
|
-
*
|
|
2063
|
+
* clamps to the final value (5 h). Genuine quota retries retain their intent
|
|
2064
|
+
* until recovery, including when no provider reset time is available.
|
|
1944
2065
|
*/
|
|
1945
2066
|
const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
|
|
1946
2067
|
/**
|
|
@@ -1975,9 +2096,11 @@ export async function computeQuotaBackoffMs(workspaceId, retryCount, preferQuota
|
|
|
1975
2096
|
}
|
|
1976
2097
|
// 1.5. Try the official usage API (Claude subscription). Best-effort; never throws.
|
|
1977
2098
|
try {
|
|
1978
|
-
const snap = await refreshNow('claude-code');
|
|
2099
|
+
const snap = (getWs(workspaceId)?.engine ?? 'claude-code') === 'claude-code' ? await refreshNow('claude-code') : null;
|
|
1979
2100
|
if (snap) {
|
|
1980
|
-
const fiveHour = snap.buckets
|
|
2101
|
+
const fiveHour = snap.buckets
|
|
2102
|
+
.filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && b.resetsAt)
|
|
2103
|
+
.sort((a, b) => Date.parse(b.resetsAt) - Date.parse(a.resetsAt))[0];
|
|
1981
2104
|
if (fiveHour &&
|
|
1982
2105
|
typeof fiveHour.usedPct === 'number' &&
|
|
1983
2106
|
fiveHour.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT &&
|
|
@@ -2019,23 +2142,26 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
2019
2142
|
// would otherwise kill Kōbō and every agent running under it.
|
|
2020
2143
|
try {
|
|
2021
2144
|
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
2022
|
-
const
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2145
|
+
const generation = Symbol('quota-lookup');
|
|
2146
|
+
quotaLookups.set(workspaceId, generation);
|
|
2147
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
2148
|
+
retryReasons.set(workspaceId, 'quota');
|
|
2149
|
+
// Establish durable ownership BEFORE the usage provider can yield. A
|
|
2150
|
+
// restart or failed lookup retains this conservative fallback deadline.
|
|
2151
|
+
const fallbackIndex = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
|
|
2152
|
+
quotaBackoffService.arm(workspaceId, QUOTA_FALLBACK_LADDER_MINUTES[fallbackIndex] * 60_000, {
|
|
2153
|
+
resetsAt: null,
|
|
2154
|
+
source: 'fallback_ladder',
|
|
2155
|
+
reason: 'quota',
|
|
2156
|
+
retryCount: retryCount + 1,
|
|
2157
|
+
});
|
|
2034
2158
|
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
2035
|
-
//
|
|
2036
|
-
if (
|
|
2159
|
+
// A stop, recovery, replacement or newer quota may supersede this lookup.
|
|
2160
|
+
if (quotaLookups.get(workspaceId) !== generation)
|
|
2161
|
+
return;
|
|
2162
|
+
quotaLookups.delete(workspaceId);
|
|
2163
|
+
if (getWs(workspaceId)?.status !== 'quota' || !quotaBackoffService.getPending(workspaceId))
|
|
2037
2164
|
return;
|
|
2038
|
-
retryCounts.set(workspaceId, retryCount + 1);
|
|
2039
2165
|
// The quotaBackoffService owns the timer + the persistent row + the
|
|
2040
2166
|
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
2041
2167
|
quotaBackoffService.arm(workspaceId, delayMs, {
|
|
@@ -2055,13 +2181,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
2055
2181
|
}
|
|
2056
2182
|
}
|
|
2057
2183
|
/**
|
|
2058
|
-
* Last resort when
|
|
2059
|
-
*
|
|
2060
|
-
* act on instead of a workspace that has quietly stopped moving.
|
|
2184
|
+
* Last resort when retry scheduling fails: retain loop intent in a visible
|
|
2185
|
+
* blocked state so an explicit human resume can recover it.
|
|
2061
2186
|
*/
|
|
2062
2187
|
function failVisibly(workspaceId) {
|
|
2063
2188
|
try {
|
|
2064
|
-
autoLoopService.
|
|
2189
|
+
autoLoopService.block(workspaceId, 'retry-unavailable');
|
|
2065
2190
|
}
|
|
2066
2191
|
catch (err) {
|
|
2067
2192
|
console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
|
|
@@ -2077,6 +2202,18 @@ function failVisibly(workspaceId) {
|
|
|
2077
2202
|
* quota ladder's 15-minute floor is tuned for real quota windows — a forced
|
|
2078
2203
|
* kill or a server blip deserves a much faster first attempt. */
|
|
2079
2204
|
const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
|
|
2205
|
+
/** Charge an incident once, until this controller demonstrably starts working again. */
|
|
2206
|
+
function scheduleTransientAutoLoopRecovery(workspaceId, controller) {
|
|
2207
|
+
if (controller && transientRecoveryControllers.has(controller))
|
|
2208
|
+
return;
|
|
2209
|
+
// Preserve whichever backoff already owns the next start, including a real
|
|
2210
|
+
// provider quota followed by its engine's drain-watchdog diagnostic.
|
|
2211
|
+
if (getWs(workspaceId)?.status === 'quota' && quotaBackoffService.getPending(workspaceId))
|
|
2212
|
+
return;
|
|
2213
|
+
if (controller)
|
|
2214
|
+
transientRecoveryControllers.add(controller);
|
|
2215
|
+
void handleTransientAutoLoopFailure(workspaceId);
|
|
2216
|
+
}
|
|
2080
2217
|
/**
|
|
2081
2218
|
* Use the same persisted retry path for temporary upstream failures (HTTP 500
|
|
2082
2219
|
* and overloads) while deliberately ignoring quota-reset data: a server error
|
|
@@ -2092,10 +2229,10 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
2092
2229
|
// Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
|
|
2093
2230
|
// unhandled-rejection policy mean a failure here must stay contained.
|
|
2094
2231
|
try {
|
|
2095
|
-
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
2232
|
+
const retryCount = retryReasons.get(workspaceId) === 'quota' ? 0 : (retryCounts.get(workspaceId) ?? 0);
|
|
2096
2233
|
const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
|
|
2097
2234
|
if (retryCount >= maxRetries) {
|
|
2098
|
-
autoLoopService.
|
|
2235
|
+
autoLoopService.block(workspaceId, 'retry-exhausted');
|
|
2099
2236
|
try {
|
|
2100
2237
|
updateWorkspaceStatus(workspaceId, 'error');
|
|
2101
2238
|
}
|
|
@@ -2104,12 +2241,14 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
2104
2241
|
}
|
|
2105
2242
|
return;
|
|
2106
2243
|
}
|
|
2107
|
-
const
|
|
2244
|
+
const idx = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
|
|
2245
|
+
const delayMs = QUOTA_FALLBACK_LADDER_MINUTES[idx] * 60_000;
|
|
2108
2246
|
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
2109
2247
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
2248
|
+
retryReasons.set(workspaceId, 'transient');
|
|
2110
2249
|
quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
|
|
2111
|
-
resetsAt:
|
|
2112
|
-
source,
|
|
2250
|
+
resetsAt: null,
|
|
2251
|
+
source: 'fallback_ladder',
|
|
2113
2252
|
reason: 'transient',
|
|
2114
2253
|
retryCount: retryCount + 1,
|
|
2115
2254
|
});
|
|
@@ -2132,8 +2271,18 @@ export const _handleTransientAutoLoopFailure = handleTransientAutoLoopFailure;
|
|
|
2132
2271
|
export function restoreRetryCountsFromDb() {
|
|
2133
2272
|
for (const pending of quotaBackoffService.listPending()) {
|
|
2134
2273
|
retryCounts.set(pending.workspaceId, pending.retryCount);
|
|
2274
|
+
retryReasons.set(pending.workspaceId, pending.reason);
|
|
2135
2275
|
}
|
|
2136
2276
|
}
|
|
2277
|
+
/** An explicit human resume starts a new bounded transient-recovery attempt. */
|
|
2278
|
+
export function resetAutoLoopRetries(workspaceId) {
|
|
2279
|
+
retryCounts.delete(workspaceId);
|
|
2280
|
+
retryReasons.delete(workspaceId);
|
|
2281
|
+
quotaLookups.delete(workspaceId);
|
|
2282
|
+
const controller = controllers.get(workspaceId);
|
|
2283
|
+
if (controller)
|
|
2284
|
+
transientRecoveryControllers.delete(controller);
|
|
2285
|
+
}
|
|
2137
2286
|
// One-time wire: when the persisted backoff timer fires (or a row is
|
|
2138
2287
|
// restored at boot), hand the workspace off to auto-loop. The auto-loop
|
|
2139
2288
|
// service decides whether to spawn the next iteration or fall back to a
|
|
@@ -2142,8 +2291,8 @@ export function restoreRetryCountsFromDb() {
|
|
|
2142
2291
|
// IMPORTANT — behavioural contract: only auto-loop workspaces auto-resume
|
|
2143
2292
|
// after a quota backoff. `onQuotaBackoffExpired` no-ops if `auto_loop !== 1`
|
|
2144
2293
|
// (see auto-loop-service). Workspaces hit by quota WITHOUT auto-loop stay
|
|
2145
|
-
// in `quota` status
|
|
2146
|
-
//
|
|
2294
|
+
// in `quota` status until manual user action (resume / new message) or an
|
|
2295
|
+
// explicitly scheduled wakeup/cron resumes them after expiry. Without either intent,
|
|
2147
2296
|
// firing a fresh agent run in the user's absence would surprise them.
|
|
2148
2297
|
quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
|
|
2149
2298
|
autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
|
|
@@ -19,6 +19,19 @@ export class SessionController {
|
|
|
19
19
|
* is awaiting the engine.
|
|
20
20
|
*/
|
|
21
21
|
_stopRequested = false;
|
|
22
|
+
_sessionEnded = false;
|
|
23
|
+
_isClosed = false;
|
|
24
|
+
resolveClosed;
|
|
25
|
+
closed = new Promise((resolve) => {
|
|
26
|
+
this.resolveClosed = resolve;
|
|
27
|
+
});
|
|
28
|
+
get isClosed() {
|
|
29
|
+
return this._isClosed;
|
|
30
|
+
}
|
|
31
|
+
confirmClosed() {
|
|
32
|
+
this._isClosed = true;
|
|
33
|
+
this.resolveClosed();
|
|
34
|
+
}
|
|
22
35
|
/**
|
|
23
36
|
* Wall-clock creation time. The watchdog needs it: `engineProcess` stays
|
|
24
37
|
* undefined until `engine.start` resolves, and without a grace window any
|
|
@@ -63,13 +76,31 @@ export class SessionController {
|
|
|
63
76
|
// until the next boot — blocking deleteSession and tagging the next
|
|
64
77
|
// messages onto a ghost session.
|
|
65
78
|
if (this._stopRequested) {
|
|
79
|
+
this.confirmClosed();
|
|
66
80
|
this.handle({ kind: 'session:ended', reason: 'killed', exitCode: null });
|
|
67
81
|
return;
|
|
68
82
|
}
|
|
69
|
-
|
|
83
|
+
let process;
|
|
84
|
+
try {
|
|
85
|
+
process = await this.engine.start(options, (ev) => this.handle(ev));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
this.confirmClosed();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
70
91
|
this._engineProcess = process;
|
|
92
|
+
if (process.closed) {
|
|
93
|
+
// A failed closure is never evidence that the runtime is gone.
|
|
94
|
+
void process.closed.then(() => this.confirmClosed()).catch(() => { });
|
|
95
|
+
}
|
|
96
|
+
else if (this._sessionEnded) {
|
|
97
|
+
this.confirmClosed();
|
|
98
|
+
}
|
|
71
99
|
if (this._status === 'stopping') {
|
|
72
100
|
await process.stop();
|
|
101
|
+
if (process.closed)
|
|
102
|
+
await process.closed;
|
|
103
|
+
this.confirmClosed();
|
|
73
104
|
this._engineProcess = undefined;
|
|
74
105
|
return;
|
|
75
106
|
}
|
|
@@ -112,8 +143,12 @@ export class SessionController {
|
|
|
112
143
|
// the stop, hence the swallowed rejection.
|
|
113
144
|
if (this._startPromise)
|
|
114
145
|
await this._startPromise.catch(() => { });
|
|
115
|
-
if (this._engineProcess)
|
|
146
|
+
if (this._engineProcess) {
|
|
116
147
|
await this._engineProcess.stop();
|
|
148
|
+
if (this._engineProcess.closed)
|
|
149
|
+
await this._engineProcess.closed;
|
|
150
|
+
}
|
|
151
|
+
this.confirmClosed();
|
|
117
152
|
}
|
|
118
153
|
get status() {
|
|
119
154
|
return this._status;
|
|
@@ -126,6 +161,11 @@ export class SessionController {
|
|
|
126
161
|
}
|
|
127
162
|
handle(ev) {
|
|
128
163
|
this._lastEventAt = Date.now();
|
|
164
|
+
if (ev.kind === 'session:ended') {
|
|
165
|
+
this._sessionEnded = true;
|
|
166
|
+
if (this._engineProcess && !this._engineProcess.closed)
|
|
167
|
+
this.confirmClosed();
|
|
168
|
+
}
|
|
129
169
|
this.onEvent(ev);
|
|
130
170
|
}
|
|
131
171
|
}
|