@loicngr/kobo 1.11.26 → 1.11.28
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 +19 -1
- package/CHANGELOG.md +10 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
- package/dist/server/db/migrations.js +34 -0
- package/dist/server/db/schema.js +3 -1
- package/dist/server/index.js +80 -23
- package/dist/server/middleware/host-check-middleware.js +80 -0
- package/dist/server/routes/changelog.js +51 -3
- package/dist/server/routes/notion.js +1 -1
- package/dist/server/routes/pull-requests.js +6 -2
- package/dist/server/routes/sentry.js +1 -1
- package/dist/server/routes/settings.js +23 -8
- package/dist/server/routes/usage.js +12 -0
- package/dist/server/routes/workspaces.js +240 -91
- package/dist/server/services/agent/engines/claude-code/engine.js +112 -15
- package/dist/server/services/agent/engines/codex/engine.js +18 -0
- package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
- package/dist/server/services/agent/orchestrator.js +143 -34
- package/dist/server/services/agent/session-controller.js +28 -0
- package/dist/server/services/agent/streaming-batcher.js +1 -0
- package/dist/server/services/agent/turn-liveness.js +12 -1
- package/dist/server/services/auto-loop-service.js +65 -0
- package/dist/server/services/awaiting-user-reminder-service.js +115 -0
- package/dist/server/services/change-source-branch-service.js +1 -1
- package/dist/server/services/file-editor-service.js +2 -46
- package/dist/server/services/forge/bitbucket/provider.js +9 -8
- package/dist/server/services/forge/github/provider.js +4 -3
- package/dist/server/services/forge/gitlab/provider.js +4 -3
- package/dist/server/services/forge/parse-cli-json.js +14 -0
- package/dist/server/services/forge/resolve.js +29 -1
- package/dist/server/services/lifecycle-hook-service.js +114 -0
- package/dist/server/services/network-access-service.js +187 -6
- package/dist/server/services/pr-watcher-service.js +27 -1
- package/dist/server/services/settings-service.js +147 -4
- package/dist/server/services/usage/reliability.js +81 -0
- package/dist/server/services/wakeup-service.js +23 -0
- package/dist/server/services/websocket-service.js +31 -3
- package/dist/server/services/workspace-service.js +36 -4
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +159 -1
- package/dist/server/utils/paths.js +19 -0
- package/dist/server/utils/safe-path.js +21 -1
- package/dist/server/utils/script-runner.js +2 -1
- package/dist/server/utils/workspace-name.js +37 -0
- package/dist/shared/consts.js +22 -0
- package/dist/shared/models.js +4 -4
- package/package.json +3 -2
- package/src/client/dist/pwa/assets/{ActivityFeed-DUjY6lCm.css → ActivityFeed-C6XSxdJ0.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
- package/src/client/dist/pwa/assets/DiffViewer-DnR79ycb.css +1 -0
- package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
- package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
- package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
- package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
- package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
- package/src/client/dist/pwa/assets/TerminalPanel-CSPk8QtK.js +18 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
- package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
- package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
- package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
- package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
- package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
- package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
- package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Db4w0alg.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
- package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
- package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
- package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
- package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
- package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
- package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-KxB1BtrL.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-7TByd_Zc.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-BoVZ_gre.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
- package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
- package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
- package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BLL8vPQy.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-DzcT-R6k.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-Bguo999w.js} +1 -1
- package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
- package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
- package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-C7mNmzKx.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
- package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-54JZHVy2.js} +1 -1
- package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-D5RQNiPF.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-CVwoDJol.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-NAmPVIQp.js} +1 -1
- package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-qdgwClKe.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
- package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DNSfQSlk.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-6gnX2NnP.js} +1 -1
- package/src/client/dist/pwa/index.html +6 -5
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
- package/src/client/dist/pwa/assets/ActivityFeed-CGMEOMn5.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-rvISZfJb.js +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BptAgVvu.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
- package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
- package/src/client/dist/pwa/assets/DocumentsPanel-DuWbZiID.js +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-BiIXQPfL.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-DTrmhh3X.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
- package/src/client/dist/pwa/assets/MainLayout-Vq1iUPjx.css +0 -1
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-Ckvd3IVR.js +0 -1
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DJWMsAoh.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-BI8w9utc.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
- package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
- package/src/client/dist/pwa/assets/TouchPan-pqksSni0.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-CQ4zZqo0.js +0 -1
- package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
- package/src/client/dist/pwa/assets/cssMode-MGWKgqj7.js +0 -1
- package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +0 -1
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
- package/src/client/dist/pwa/assets/editor.api-Dq41Mm99.js +0 -847
- package/src/client/dist/pwa/assets/engineFeatures-Dd_dxoOS.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
- package/src/client/dist/pwa/assets/expand-template-BfZqON3q.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
- package/src/client/dist/pwa/assets/htmlMode-CFxFF9iH.js +0 -1
- package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
- package/src/client/dist/pwa/assets/index-xTf0VfFG.js +0 -82
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
- package/src/client/dist/pwa/assets/jsonMode-JhLTE1-C.js +0 -7
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-BIDd3ml0.js +0 -4
- package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BdA8vrDx.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
- package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
- package/src/client/dist/pwa/assets/tsMode-Db77HzVu.js +0 -11
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-BafrD9nc.js +0 -2
- package/src/client/dist/pwa/assets/vs-Bb18eQC8.js +0 -56
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
- package/src/client/dist/pwa/assets/workers-CImko7IX.js +0 -1
|
@@ -17,7 +17,7 @@ import { buildStopHookOutput } from './stop-hook.js';
|
|
|
17
17
|
* post-result drain watchdog force-emits `session:ended` so the orchestrator
|
|
18
18
|
* and auto-loop are not frozen forever.
|
|
19
19
|
*/
|
|
20
|
-
const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
20
|
+
export const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
21
21
|
/**
|
|
22
22
|
* Deadline for a stream that stops reporting ANY activity. The former
|
|
23
23
|
* text-only guard was re-armed solely on a message with text and no tool call
|
|
@@ -26,12 +26,23 @@ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
|
26
26
|
* forever with zero trace. Exported so tests never hard-code the value.
|
|
27
27
|
*/
|
|
28
28
|
export const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 120_000;
|
|
29
|
+
/**
|
|
30
|
+
* Idle ceiling while a FOREGROUND tool call is in flight (tool:call seen, no
|
|
31
|
+
* tool:result yet). A long Bash run — a test suite, a CI-poll sleep — emits no
|
|
32
|
+
* SDK message until its tool_result, so the short deadline would kill a
|
|
33
|
+
* healthy session mid-tool (the "random session stops" bug). A genuinely dead
|
|
34
|
+
* stream is still reaped once this longer ceiling elapses.
|
|
35
|
+
*/
|
|
36
|
+
export const CLAUDE_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
|
37
|
+
/** Ceiling on a context compaction: past this, assume it wedged and let the
|
|
38
|
+
* liveness deadline take over instead of pausing forever. */
|
|
39
|
+
export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
29
40
|
// Safety net for a subagent whose terminal `task_notification` carries a
|
|
30
41
|
// status outside the mapper's known-terminal set (SDK schema drift) — it
|
|
31
42
|
// never clears `activeSubagentToolCallIds`, which otherwise blocks
|
|
32
43
|
// `inputStream.close()`/the result-drain watchdog forever. Generous window:
|
|
33
44
|
// legitimate subagents can run for several minutes.
|
|
34
|
-
const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
45
|
+
export const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
35
46
|
const MAX_PENDING_USER_MESSAGES = 20;
|
|
36
47
|
function toMcpServersMap(specs) {
|
|
37
48
|
if (!specs || specs.length === 0)
|
|
@@ -106,6 +117,9 @@ export function createClaudeCodeEngine() {
|
|
|
106
117
|
// UI/tool ids can differ from the SDK task id. Key by the stable UI id
|
|
107
118
|
// so a terminal notification that omits task_id still clears the task.
|
|
108
119
|
const activeSubagentTaskIds = new Map();
|
|
120
|
+
// Foreground tool calls currently awaiting their tool_result. Cleared on
|
|
121
|
+
// every `result` message: a turn cannot end with a tool still in flight.
|
|
122
|
+
const pendingToolCallIds = new Set();
|
|
109
123
|
// Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
|
|
110
124
|
const pendingResolvers = new Map();
|
|
111
125
|
const isInteractive = options.agentPermissionMode === 'interactive';
|
|
@@ -141,6 +155,7 @@ export function createClaudeCodeEngine() {
|
|
|
141
155
|
return;
|
|
142
156
|
}
|
|
143
157
|
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
158
|
+
resolver.cleanup = () => ctx.signal.removeEventListener('abort', onAbort);
|
|
144
159
|
onEvent({
|
|
145
160
|
kind: 'session:user-input-requested',
|
|
146
161
|
requestKind,
|
|
@@ -283,16 +298,74 @@ export function createClaudeCodeEngine() {
|
|
|
283
298
|
const turnLiveness = createTurnLiveness({
|
|
284
299
|
timeoutMs: CLAUDE_STREAM_IDLE_TIMEOUT_MS,
|
|
285
300
|
onTimeout() {
|
|
286
|
-
|
|
301
|
+
// The armed deadline tracks whether a foreground tool call is in
|
|
302
|
+
// flight (see `turnLiveness.setTimeoutMs` below) — log the duration
|
|
303
|
+
// that actually elapsed, not always the shorter baseline, so the
|
|
304
|
+
// watchdog's own diagnostic message stays trustworthy.
|
|
305
|
+
const timeoutMs = pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS;
|
|
306
|
+
console.warn(`[claude-engine] SDK stream reported no activity for ${timeoutMs}ms — forcing session:ended`);
|
|
287
307
|
if (userInterrupted)
|
|
288
308
|
emitSessionEnded('killed', null);
|
|
289
309
|
else if (mapperState.sawErrorResult)
|
|
290
310
|
emitSessionEnded('error', null);
|
|
291
|
-
else
|
|
311
|
+
else {
|
|
312
|
+
safeEmit({
|
|
313
|
+
kind: 'error',
|
|
314
|
+
category: 'other',
|
|
315
|
+
message: 'Session force-ended by the liveness watchdog: no SDK activity within the deadline. If the agent was legitimately busy, this is a bug worth reporting.',
|
|
316
|
+
});
|
|
292
317
|
emitSessionEnded('watchdog', null);
|
|
318
|
+
}
|
|
293
319
|
abortController.abort();
|
|
294
320
|
},
|
|
295
321
|
});
|
|
322
|
+
// The single source of truth for "should the idle deadline be paused
|
|
323
|
+
// right now" — a background subagent, a pending permission/question
|
|
324
|
+
// card, or an in-progress compaction can each legitimately keep the
|
|
325
|
+
// stream quiet for minutes; their own dedicated watchdogs own those
|
|
326
|
+
// windows. Every call site that changes one of these three conditions
|
|
327
|
+
// must re-evaluate through this helper, not duplicate the check.
|
|
328
|
+
const reevaluateLivenessPause = () => {
|
|
329
|
+
if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
|
|
330
|
+
turnLiveness.pause();
|
|
331
|
+
else
|
|
332
|
+
turnLiveness.resume();
|
|
333
|
+
};
|
|
334
|
+
// Backstop for a compaction that never reports completion (stuck
|
|
335
|
+
// `session:compacting` with `active: true` forever): `isCompacting`
|
|
336
|
+
// pauses `turnLiveness` indefinitely, and a wedged-but-alive generator
|
|
337
|
+
// never trips `isAlive()`'s own sweep either. Past this ceiling, assume
|
|
338
|
+
// it wedged and resume the liveness deadline so the existing tool-aware
|
|
339
|
+
// / short deadline can reap it, mirroring `armSubagentStallWatchdog`.
|
|
340
|
+
let compactionStallTimer;
|
|
341
|
+
const clearCompactionStallTimer = () => {
|
|
342
|
+
if (!compactionStallTimer)
|
|
343
|
+
return;
|
|
344
|
+
clearTimeout(compactionStallTimer);
|
|
345
|
+
compactionStallTimer = undefined;
|
|
346
|
+
};
|
|
347
|
+
const armCompactionStallTimer = () => {
|
|
348
|
+
clearCompactionStallTimer();
|
|
349
|
+
compactionStallTimer = setTimeout(() => {
|
|
350
|
+
compactionStallTimer = undefined;
|
|
351
|
+
if (!isCompacting)
|
|
352
|
+
return;
|
|
353
|
+
console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
|
|
354
|
+
isCompacting = false;
|
|
355
|
+
reevaluateLivenessPause();
|
|
356
|
+
if (activeSubagentTaskIds.size > 0 && !subagentStallTimer) {
|
|
357
|
+
// Resume declined because a subagent is still tracked — but the
|
|
358
|
+
// subagent stall net is only armed from the `result` branch, which
|
|
359
|
+
// a fully wedged generator may never reach. Arm it here as a
|
|
360
|
+
// second stage so this combination can't hang forever. The
|
|
361
|
+
// `!subagentStallTimer` guard matters: arming re-sets the
|
|
362
|
+
// deadline, and a net already armed by a prior `result` must not
|
|
363
|
+
// be extended by this backstop (it is not new activity).
|
|
364
|
+
armSubagentStallWatchdog();
|
|
365
|
+
}
|
|
366
|
+
}, COMPACTION_STALL_TIMEOUT_MS);
|
|
367
|
+
compactionStallTimer.unref?.();
|
|
368
|
+
};
|
|
296
369
|
const armResultDrainWatchdog = () => {
|
|
297
370
|
if (resultDrainTimer)
|
|
298
371
|
return;
|
|
@@ -302,8 +375,14 @@ export function createClaudeCodeEngine() {
|
|
|
302
375
|
emitSessionEnded('killed', null);
|
|
303
376
|
else if (mapperState.sawErrorResult)
|
|
304
377
|
emitSessionEnded('error', null);
|
|
305
|
-
else
|
|
378
|
+
else {
|
|
379
|
+
safeEmit({
|
|
380
|
+
kind: 'error',
|
|
381
|
+
category: 'other',
|
|
382
|
+
message: 'Session force-ended: the SDK generator stayed open after its final result (drain watchdog).',
|
|
383
|
+
});
|
|
306
384
|
emitSessionEnded('watchdog', null);
|
|
385
|
+
}
|
|
307
386
|
abortController.abort();
|
|
308
387
|
}, RESULT_DRAIN_TIMEOUT_MS);
|
|
309
388
|
resultDrainTimer.unref?.();
|
|
@@ -369,12 +448,25 @@ export function createClaudeCodeEngine() {
|
|
|
369
448
|
activeSubagentTaskIds.delete(ev.toolCallId);
|
|
370
449
|
}
|
|
371
450
|
for (const ev of events) {
|
|
372
|
-
if (ev.kind === 'session:compacting')
|
|
451
|
+
if (ev.kind === 'session:compacting') {
|
|
373
452
|
isCompacting = ev.active;
|
|
453
|
+
if (ev.active)
|
|
454
|
+
armCompactionStallTimer();
|
|
455
|
+
else
|
|
456
|
+
clearCompactionStallTimer();
|
|
457
|
+
}
|
|
374
458
|
// Older SDKs can emit only compact_boundary, without a trailing
|
|
375
459
|
// status update. This boundary marks compaction as complete.
|
|
376
|
-
else if (ev.kind === 'session:compacted')
|
|
460
|
+
else if (ev.kind === 'session:compacted') {
|
|
377
461
|
isCompacting = false;
|
|
462
|
+
clearCompactionStallTimer();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const ev of events) {
|
|
466
|
+
if (ev.kind === 'tool:call')
|
|
467
|
+
pendingToolCallIds.add(ev.toolCallId);
|
|
468
|
+
else if (ev.kind === 'tool:result')
|
|
469
|
+
pendingToolCallIds.delete(ev.toolCallId);
|
|
378
470
|
}
|
|
379
471
|
// The stall watchdog is only armed while we're waiting purely on
|
|
380
472
|
// background subagents (no further SDK turn expected unless one
|
|
@@ -398,11 +490,12 @@ export function createClaudeCodeEngine() {
|
|
|
398
490
|
// A background subagent or a pending permission card can stay
|
|
399
491
|
// legitimately quiet for minutes; their own dedicated watchdogs
|
|
400
492
|
// own those windows, so suspend the turn deadline meanwhile.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
493
|
+
// A FOREGROUND tool call in flight keeps the deadline armed but
|
|
494
|
+
// stretched to the tool-aware ceiling.
|
|
495
|
+
turnLiveness.setTimeoutMs(pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS);
|
|
496
|
+
reevaluateLivenessPause();
|
|
405
497
|
if (msg.type === 'result') {
|
|
498
|
+
pendingToolCallIds.clear();
|
|
406
499
|
completedResponses++;
|
|
407
500
|
emitTurnCompletedIfSettled();
|
|
408
501
|
// A queued forced message starts the next response on this same SDK stream.
|
|
@@ -457,11 +550,13 @@ export function createClaudeCodeEngine() {
|
|
|
457
550
|
clearResultDrainWatchdog();
|
|
458
551
|
turnLiveness.stop();
|
|
459
552
|
clearSubagentStallWatchdog();
|
|
553
|
+
clearCompactionStallTimer();
|
|
460
554
|
// Drain any callback still pending (SDK terminated while awaiting an
|
|
461
555
|
// answer). canUseTool's abort path covers signalled stops; this
|
|
462
556
|
// covers natural iterator completion.
|
|
463
557
|
for (const resolver of pendingResolvers.values()) {
|
|
464
558
|
try {
|
|
559
|
+
resolver.cleanup?.();
|
|
465
560
|
resolver.resolve({ behavior: 'deny', message: 'session ended', interrupt: false });
|
|
466
561
|
}
|
|
467
562
|
catch {
|
|
@@ -541,10 +636,12 @@ export function createClaudeCodeEngine() {
|
|
|
541
636
|
if (!resolver)
|
|
542
637
|
return false;
|
|
543
638
|
pendingResolvers.delete(toolCallId);
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
639
|
+
resolver.cleanup?.();
|
|
640
|
+
// Re-evaluate rather than unconditionally resuming: a sibling
|
|
641
|
+
// request, a still-active subagent, or an in-progress compaction
|
|
642
|
+
// may each still have their own legitimate reason to keep the
|
|
643
|
+
// deadline paused.
|
|
644
|
+
reevaluateLivenessPause();
|
|
548
645
|
if (response.kind === 'question') {
|
|
549
646
|
// Echo the original questions array + answers so the SDK
|
|
550
647
|
// reconstructs the AskUserQuestion tool input.
|
|
@@ -12,6 +12,8 @@ import { spawnAppServer } from './spawn.js';
|
|
|
12
12
|
/** Long enough for normal tool work, short enough to recover a lost turn event. */
|
|
13
13
|
export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
|
|
14
14
|
export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
|
|
15
|
+
/** Grace given to a SIGTERM before the child is killed outright. */
|
|
16
|
+
const CODEX_FORCE_KILL_TIMEOUT_MS = 3_000;
|
|
15
17
|
// Safety net while `turnLiveness` is paused for background subagents: that
|
|
16
18
|
// pause is deliberately unbounded (a legitimate subagent can run long), but
|
|
17
19
|
// if a thread never reports a terminal status (dropped notification, or the
|
|
@@ -508,6 +510,22 @@ export function createCodexEngine() {
|
|
|
508
510
|
catch {
|
|
509
511
|
// swallow
|
|
510
512
|
}
|
|
513
|
+
// Escalate. The SIGTERM in the `finally` below is a request, not a
|
|
514
|
+
// guarantee: a wedged `codex app-server` that ignores it outlives the
|
|
515
|
+
// stop, the archive, the delete and Kōbō's own shutdown, holding the
|
|
516
|
+
// worktree and its share of the model quota, with nothing left in
|
|
517
|
+
// this process tracking it.
|
|
518
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
519
|
+
child.kill('SIGTERM');
|
|
520
|
+
const exited = await Promise.race([
|
|
521
|
+
new Promise((resolve) => child.once('exit', () => resolve(true))),
|
|
522
|
+
wait(CODEX_FORCE_KILL_TIMEOUT_MS).then(() => false),
|
|
523
|
+
]);
|
|
524
|
+
if (!exited) {
|
|
525
|
+
console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
|
|
526
|
+
child.kill('SIGKILL');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
511
529
|
},
|
|
512
530
|
resolvePendingUserInput(callId, response) {
|
|
513
531
|
const pending = pendingByCallId.get(callId);
|
|
@@ -67,14 +67,11 @@ export function handleServerRequest(args) {
|
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
69
69
|
if (method === 'item/permissions/requestApproval') {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
toolName: 'Permissions',
|
|
76
|
-
payload: p,
|
|
77
|
-
});
|
|
70
|
+
// Kōbō doesn't model granular permission grants: the only answer we can
|
|
71
|
+
// send is the empty grant (Codex then falls back to the turn policy), so
|
|
72
|
+
// an Allow/Deny card would be a lie — "Allow" and "Deny" produced the
|
|
73
|
+
// same decline. Answer immediately and keep the session moving.
|
|
74
|
+
respond?.(requestId, { permissions: {}, scope: 'turn' });
|
|
78
75
|
return true;
|
|
79
76
|
}
|
|
80
77
|
return false; // unknown method
|
|
@@ -87,12 +84,6 @@ export function handleServerRequest(args) {
|
|
|
87
84
|
* (and the matching `FileChangeApprovalDecision`): `'accept' | 'acceptForSession' | 'decline' | 'cancel'`.
|
|
88
85
|
* NOT `'approve' / 'reject'` — those would be silently rejected as unknown
|
|
89
86
|
* variants, which breaks the strict and interactive permission modes.
|
|
90
|
-
*
|
|
91
|
-
* `PermissionsRequestApprovalResponse` has a completely different shape:
|
|
92
|
-
* `{ permissions, scope, strictAutoReview? }` — no `decision` field. Since
|
|
93
|
-
* Kōbō doesn't yet model permission grants, we deny the request by sending
|
|
94
|
-
* an empty permissions response. A future iteration could add a UI for
|
|
95
|
-
* granular permission grants.
|
|
96
87
|
*/
|
|
97
88
|
export function buildResponseForResolve(pending, response) {
|
|
98
89
|
if (pending.kind === 'command' || pending.kind === 'file_change') {
|
|
@@ -100,12 +91,6 @@ export function buildResponseForResolve(pending, response) {
|
|
|
100
91
|
return { decision: 'accept' };
|
|
101
92
|
return { decision: 'decline' };
|
|
102
93
|
}
|
|
103
|
-
if (pending.kind === 'permissions') {
|
|
104
|
-
// Codex's PermissionsRequestApprovalResponse shape — not { decision }.
|
|
105
|
-
// We don't yet model granular permission grants, so deny by returning an
|
|
106
|
-
// empty permissions object; Codex falls back to the existing turn policy.
|
|
107
|
-
return { permissions: {}, scope: 'turn' };
|
|
108
|
-
}
|
|
109
94
|
if (pending.kind === 'user_input') {
|
|
110
95
|
if (response.kind === 'question') {
|
|
111
96
|
const answers = {};
|
|
@@ -6,6 +6,7 @@ import * as autoLoopService from '../auto-loop-service.js';
|
|
|
6
6
|
import * as cleanupScriptService from '../cleanup-script-service.js';
|
|
7
7
|
import * as cronService from '../cron-service.js';
|
|
8
8
|
import { resolveForge } from '../forge/resolve.js';
|
|
9
|
+
import * as lifecycleHookService from '../lifecycle-hook-service.js';
|
|
9
10
|
import * as quotaBackoffService from '../quota-backoff-service.js';
|
|
10
11
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
11
12
|
import { refreshNow } from '../usage/poller.js';
|
|
@@ -268,6 +269,9 @@ function runWatchdog() {
|
|
|
268
269
|
if (controllers.get(workspaceId) === ctrl)
|
|
269
270
|
controllers.delete(workspaceId);
|
|
270
271
|
retryCounts.delete(workspaceId);
|
|
272
|
+
// This end never goes through handleEvent → onSessionEnded, so the user's
|
|
273
|
+
// hook would otherwise miss precisely the case it is most useful for.
|
|
274
|
+
fireSessionEndedHook(workspaceId, ctrl.agentSessionId, 'killed', null, ctrl);
|
|
271
275
|
try {
|
|
272
276
|
const db = getDb();
|
|
273
277
|
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
|
|
@@ -413,6 +417,9 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
413
417
|
cleanupScriptOnlyOnChanges: false,
|
|
414
418
|
archiveScript: '',
|
|
415
419
|
changeSourceBranchScript: '',
|
|
420
|
+
sessionEndedScript: '',
|
|
421
|
+
prMergedScript: '',
|
|
422
|
+
autoLoopDisabledScript: '',
|
|
416
423
|
notionStatusProperty: '',
|
|
417
424
|
notionInProgressStatus: '',
|
|
418
425
|
};
|
|
@@ -856,6 +863,9 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
856
863
|
// a mid-loop session (never cleans) or a standalone one.
|
|
857
864
|
const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
|
|
858
865
|
autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
|
|
866
|
+
// A slot just freed. Any auto-loop workspace parked on the concurrency
|
|
867
|
+
// limit has no session of its own to bring it back; this one does it.
|
|
868
|
+
autoLoopService.resumeWaitingWorkspaces();
|
|
859
869
|
cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
|
|
860
870
|
}
|
|
861
871
|
if (ev.kind === 'session:user-input-requested' && !sourceControllerIsStopping) {
|
|
@@ -913,11 +923,43 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
913
923
|
}
|
|
914
924
|
}
|
|
915
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* Sessions whose session-ended hook already ran. Two paths can report the
|
|
928
|
+
* same end — the watchdog's dead-engine sweep, then the engine's own late
|
|
929
|
+
* `session:ended` — and the user's script must run once per session, not
|
|
930
|
+
* once per report. Bounded: cleared past a size no single process reaches.
|
|
931
|
+
*/
|
|
932
|
+
const sessionEndedHookFired = new Set();
|
|
933
|
+
function fireSessionEndedHook(workspaceId, agentSessionId, reason, exitCode, controller) {
|
|
934
|
+
if (sessionEndedHookFired.has(agentSessionId))
|
|
935
|
+
return;
|
|
936
|
+
if (sessionEndedHookFired.size > 10_000)
|
|
937
|
+
sessionEndedHookFired.clear();
|
|
938
|
+
sessionEndedHookFired.add(agentSessionId);
|
|
939
|
+
// The worktree is about to be removed by the very operation that stopped
|
|
940
|
+
// this session; a script spawned in it would lose its cwd mid-run.
|
|
941
|
+
const cause = controller?.stopCause;
|
|
942
|
+
if (cause === 'delete' || cause === 'purge')
|
|
943
|
+
return;
|
|
944
|
+
void lifecycleHookService.onSessionEnded(workspaceId, {
|
|
945
|
+
sessionId: agentSessionId,
|
|
946
|
+
reason,
|
|
947
|
+
exitCode,
|
|
948
|
+
stopCause: controller?.status === 'stopping' ? (cause ?? 'user') : '',
|
|
949
|
+
// Read BEFORE auto-loop reacts to this end (which may disable it): the
|
|
950
|
+
// hook wants to know whether the next iteration is about to start.
|
|
951
|
+
autoLoopActive: getWs(workspaceId)?.autoLoop === true,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
916
954
|
function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode, reason, resumeFailed = false) {
|
|
917
955
|
// Update the agent_sessions row
|
|
918
956
|
try {
|
|
919
957
|
const db = getDb();
|
|
920
|
-
|
|
958
|
+
// `status` only distinguishes completed from error, by exit code. Keep the
|
|
959
|
+
// reason too: "the watchdog force-ended it", "the user stopped it" and "it
|
|
960
|
+
// finished on its own" all landed on the same two values, so after the fact
|
|
961
|
+
// nothing could say why a session stopped.
|
|
962
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ?, end_reason = ? WHERE id = ?').run(exitCode === 0 ? 'completed' : 'error', new Date().toISOString(), reason, agentSessionId);
|
|
921
963
|
}
|
|
922
964
|
catch (err) {
|
|
923
965
|
console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
|
|
@@ -926,6 +968,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
926
968
|
const isSuperseded = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
|
|
927
969
|
if (isSuperseded)
|
|
928
970
|
return false;
|
|
971
|
+
// A superseded end (above) is a session that was replaced, not one that
|
|
972
|
+
// ended; everything past this point is a real end the user may want to act
|
|
973
|
+
// on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
|
|
974
|
+
fireSessionEndedHook(workspaceId, agentSessionId, reason, exitCode, sourceController);
|
|
929
975
|
const currentWorkspace = getWs(workspaceId);
|
|
930
976
|
const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
|
|
931
977
|
const wasStopping = sourceController?.status === 'stopping';
|
|
@@ -957,7 +1003,16 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
957
1003
|
// `reason` is authoritative (with the SDK engine `exitCode` is often null,
|
|
958
1004
|
// so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
|
|
959
1005
|
// `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
|
|
960
|
-
|
|
1006
|
+
// 'watchdog' is a forced kill, never a success. For an auto-loop workspace,
|
|
1007
|
+
// handleTransientAutoLoopFailure (called above, before onSessionEnded) runs
|
|
1008
|
+
// synchronously up to its first await and writes status 'quota' as its very
|
|
1009
|
+
// first statement — that write lands before `currentWorkspace` is read above
|
|
1010
|
+
// (line ~1144), so `preserveQuotaBackoff` is already true and this function
|
|
1011
|
+
// returns early at the `if (preserveQuotaBackoff) return true` branch above.
|
|
1012
|
+
// This computation is therefore never reached at all for that path, not
|
|
1013
|
+
// merely short-circuited afterwards — it only runs on session ends that
|
|
1014
|
+
// don't go through the auto-loop transient-failure path.
|
|
1015
|
+
const isErrorOutcome = !resumeFailed && (reason === 'error' || reason === 'watchdog' || (exitCode !== null && exitCode !== 0));
|
|
961
1016
|
const targetStatus = isErrorOutcome ? 'error' : 'completed';
|
|
962
1017
|
// Skip the transition when the workspace is already in a terminal state.
|
|
963
1018
|
// This happens when stopAgent (or an equivalent caller) synchronously
|
|
@@ -1161,7 +1216,8 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
1161
1216
|
* is untracked by anyone in this process; only the OS or the user can end it.
|
|
1162
1217
|
*/
|
|
1163
1218
|
export const STOP_AGENT_TIMEOUT_MS = 15_000;
|
|
1164
|
-
async function stopController(workspaceId, ctrl) {
|
|
1219
|
+
async function stopController(workspaceId, ctrl, cause = 'user') {
|
|
1220
|
+
ctrl.stopCause = cause;
|
|
1165
1221
|
wakeupService.cancel(workspaceId, 'stopped');
|
|
1166
1222
|
// Normalize the state synchronously so callers (archive, delete, manual
|
|
1167
1223
|
// stop) see a clean workspace immediately — without waiting for the async
|
|
@@ -1247,11 +1303,13 @@ async function stopWithTimeout(ctrl, timeoutMs) {
|
|
|
1247
1303
|
* switch — MUST use this: the fire-and-forget `stopAgent` returns while the
|
|
1248
1304
|
* engine may still be writing files.
|
|
1249
1305
|
*/
|
|
1250
|
-
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS
|
|
1306
|
+
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
|
|
1307
|
+
/** Why. `delete` and `purge` tell the session-ended hook to stand down. */
|
|
1308
|
+
cause = 'user') {
|
|
1251
1309
|
const ctrl = controllers.get(workspaceId);
|
|
1252
1310
|
if (!ctrl)
|
|
1253
1311
|
return 'not-running';
|
|
1254
|
-
const finished = await raceWithTimeout(stopController(workspaceId, ctrl).then(() => true, (err) => {
|
|
1312
|
+
const finished = await raceWithTimeout(stopController(workspaceId, ctrl, cause).then(() => true, (err) => {
|
|
1255
1313
|
console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
|
|
1256
1314
|
return true;
|
|
1257
1315
|
}), timeoutMs, () => false);
|
|
@@ -1638,6 +1696,10 @@ export function getAgentStatus(workspaceId) {
|
|
|
1638
1696
|
export function hasController(workspaceId) {
|
|
1639
1697
|
return controllers.has(workspaceId);
|
|
1640
1698
|
}
|
|
1699
|
+
/** How many agent sessions are live right now, across every workspace. */
|
|
1700
|
+
export function runningAgentCount() {
|
|
1701
|
+
return controllers.size;
|
|
1702
|
+
}
|
|
1641
1703
|
/** The agent_session_id of the active controller for the workspace, if any. */
|
|
1642
1704
|
export function getActiveSessionId(workspaceId) {
|
|
1643
1705
|
return controllers.get(workspaceId)?.agentSessionId;
|
|
@@ -1782,25 +1844,63 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
1782
1844
|
// Never silent: a refused transition here loses the backoff two lines down.
|
|
1783
1845
|
console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota:`, err);
|
|
1784
1846
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1847
|
+
// Everything below reads the settings file, the workspace row and the
|
|
1848
|
+
// backoff table. Both call sites are fire-and-forget (`void handleQuota(…)`)
|
|
1849
|
+
// and the process treats an unhandled rejection as fatal, so a transient
|
|
1850
|
+
// failure here must not escape: hitting a quota while the database is busy
|
|
1851
|
+
// would otherwise kill Kōbō and every agent running under it.
|
|
1852
|
+
try {
|
|
1853
|
+
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
1854
|
+
const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
|
|
1855
|
+
const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
|
|
1856
|
+
if (autoLoopEnabled && retryCount >= maxRetries) {
|
|
1857
|
+
autoLoopService.disable(workspaceId, 'error');
|
|
1858
|
+
try {
|
|
1859
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
// The loop is disabled even if an already-terminal status rejects this transition.
|
|
1863
|
+
}
|
|
1864
|
+
return;
|
|
1795
1865
|
}
|
|
1796
|
-
|
|
1866
|
+
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
1867
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
1868
|
+
// The quotaBackoffService owns the timer + the persistent row + the
|
|
1869
|
+
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
1870
|
+
quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
|
|
1871
|
+
}
|
|
1872
|
+
catch (err) {
|
|
1873
|
+
console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
|
|
1874
|
+
// The status was already moved to 'quota' above, and the banner offering
|
|
1875
|
+
// "resume now" only renders once a backoff row exists. Leaving it there
|
|
1876
|
+
// would strand the workspace waiting on a timer nobody armed, with the
|
|
1877
|
+
// console line as its only trace. 'error' has a banner of its own.
|
|
1878
|
+
failVisibly(workspaceId);
|
|
1797
1879
|
}
|
|
1798
|
-
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
1799
|
-
retryCounts.set(workspaceId, retryCount + 1);
|
|
1800
|
-
// The quotaBackoffService owns the timer + the persistent row + the
|
|
1801
|
-
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
1802
|
-
quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
|
|
1803
1880
|
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Last resort when the retry machinery itself fails: stop the loop and move the
|
|
1883
|
+
* workspace to a status the UI actually surfaces, so the user sees something to
|
|
1884
|
+
* act on instead of a workspace that has quietly stopped moving.
|
|
1885
|
+
*/
|
|
1886
|
+
function failVisibly(workspaceId) {
|
|
1887
|
+
try {
|
|
1888
|
+
autoLoopService.disable(workspaceId, 'error');
|
|
1889
|
+
}
|
|
1890
|
+
catch (err) {
|
|
1891
|
+
console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
1895
|
+
}
|
|
1896
|
+
catch {
|
|
1897
|
+
// An already-terminal status refusing this transition is fine.
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
/** First retry after a transient failure (watchdog recovery, HTTP 500). The
|
|
1901
|
+
* quota ladder's 15-minute floor is tuned for real quota windows — a forced
|
|
1902
|
+
* kill or a server blip deserves a much faster first attempt. */
|
|
1903
|
+
const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
|
|
1804
1904
|
/**
|
|
1805
1905
|
* Use the same persisted retry path for temporary upstream failures (HTTP 500
|
|
1806
1906
|
* and overloads) while deliberately ignoring quota-reset data: a server error
|
|
@@ -1813,21 +1913,30 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
1813
1913
|
catch (err) {
|
|
1814
1914
|
console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota for a transient failure:`, err);
|
|
1815
1915
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1916
|
+
// Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
|
|
1917
|
+
// unhandled-rejection policy mean a failure here must stay contained.
|
|
1918
|
+
try {
|
|
1919
|
+
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
1920
|
+
const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
|
|
1921
|
+
if (retryCount >= maxRetries) {
|
|
1922
|
+
autoLoopService.disable(workspaceId, 'error');
|
|
1923
|
+
try {
|
|
1924
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
1925
|
+
}
|
|
1926
|
+
catch {
|
|
1927
|
+
// The loop is disabled even if an already-terminal status rejects this transition.
|
|
1928
|
+
}
|
|
1929
|
+
return;
|
|
1825
1930
|
}
|
|
1826
|
-
|
|
1931
|
+
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
|
|
1932
|
+
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
1933
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
1934
|
+
quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
|
|
1935
|
+
}
|
|
1936
|
+
catch (err) {
|
|
1937
|
+
console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
|
|
1938
|
+
failVisibly(workspaceId);
|
|
1827
1939
|
}
|
|
1828
|
-
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
|
|
1829
|
-
retryCounts.set(workspaceId, retryCount + 1);
|
|
1830
|
-
quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
|
|
1831
1940
|
}
|
|
1832
1941
|
/** @internal test-only — re-export of `handleQuota` for direct testing. */
|
|
1833
1942
|
export const _handleQuota = handleQuota;
|
|
@@ -3,9 +3,21 @@ export class SessionController {
|
|
|
3
3
|
agentSessionId;
|
|
4
4
|
engine;
|
|
5
5
|
onEvent;
|
|
6
|
+
/**
|
|
7
|
+
* Set by the orchestrator when it stops this controller on purpose. A
|
|
8
|
+
* delete or purge is about to remove the worktree, and a hook spawned in it
|
|
9
|
+
* would run in a directory being deleted — the hook path checks this.
|
|
10
|
+
*/
|
|
11
|
+
stopCause;
|
|
6
12
|
_engineProcess;
|
|
7
13
|
_startPromise;
|
|
8
14
|
_status = 'running';
|
|
15
|
+
/**
|
|
16
|
+
* Set by `stop()`. Distinct from `_status` so the check in `startEngine` is
|
|
17
|
+
* not narrowed away by the compiler: `stop()` can flip it while that method
|
|
18
|
+
* is awaiting the engine.
|
|
19
|
+
*/
|
|
20
|
+
_stopRequested = false;
|
|
9
21
|
/**
|
|
10
22
|
* Wall-clock creation time. The watchdog needs it: `engineProcess` stays
|
|
11
23
|
* undefined until `engine.start` resolves, and without a grace window any
|
|
@@ -38,6 +50,21 @@ export class SessionController {
|
|
|
38
50
|
await this._startPromise;
|
|
39
51
|
}
|
|
40
52
|
async startEngine(options) {
|
|
53
|
+
// Already stopped before we got here. The orchestrator chains `start`
|
|
54
|
+
// behind a zombie eviction that can take up to STOP_AGENT_TIMEOUT_MS, and a
|
|
55
|
+
// stop landing in that window resolves immediately and reports "stopped" —
|
|
56
|
+
// so the caller (delete, purge-worktree) is already removing the worktree.
|
|
57
|
+
// Spawning into it and stopping afterwards, as the check below does, is too
|
|
58
|
+
// late; the process must never exist.
|
|
59
|
+
//
|
|
60
|
+
// Still report an end: the orchestrator already created the session row,
|
|
61
|
+
// and only session:ended closes it. Without this the row stayed `running`
|
|
62
|
+
// until the next boot — blocking deleteSession and tagging the next
|
|
63
|
+
// messages onto a ghost session.
|
|
64
|
+
if (this._stopRequested) {
|
|
65
|
+
this.handle({ kind: 'session:ended', reason: 'killed', exitCode: null });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
41
68
|
const process = await this.engine.start(options, (ev) => this.handle(ev));
|
|
42
69
|
this._engineProcess = process;
|
|
43
70
|
if (this._status === 'stopping') {
|
|
@@ -63,6 +90,7 @@ export class SessionController {
|
|
|
63
90
|
this._engineProcess.interrupt();
|
|
64
91
|
}
|
|
65
92
|
async stop() {
|
|
93
|
+
this._stopRequested = true;
|
|
66
94
|
this._status = 'stopping';
|
|
67
95
|
// `startEngine` may still be in flight: `_engineProcess` stays undefined
|
|
68
96
|
// until `engine.start` resolves, so without this wait `stop()` would
|