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