@loicngr/kobo 1.11.35 → 1.11.37
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 +15 -0
- package/CHANGELOG.md +9 -0
- package/dist/server/index.js +11 -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 +122 -91
- package/dist/server/services/agent/engines/claude-code/engine.js +26 -0
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +10 -10
- 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 +5 -1
- 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/orchestrator.js +175 -68
- package/dist/server/services/agent/session-controller.js +12 -2
- package/dist/server/services/auto-loop-service.js +71 -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/quota-backoff-service.js +25 -8
- package/dist/server/services/wakeup-service.js +37 -3
- 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-Bakgvay6.css → ActivityFeed-BDyFBoxd.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-C_-hxIo8.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-DVLWWvvz.js → ChangelogPage-CBlGJ4zT.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-LZrvk6a6.js → ComparisonPanel-DZAE2X9j.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-ArmtcF4d.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-0odIhseb.js → DashboardPage-eQTCXcdz.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-NWB2uQkz.js → DiffViewer-CaT7vuSr.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-3yApHgbl.js → DocumentsPanel-A0isCTaI.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-C_bNtZsE.js → DrawerToggleButton-T70fh8dp.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-BMjWwiAX.js → GitPanel-CAvk0G7h.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-Vyr1YG2o.js → HealthPage-D8Mr0V6w.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-s8_9FPE2.js → MainLayout-x6Es4Da-.js} +2 -2
- package/src/client/dist/pwa/assets/{QBtnDropdown-BxuXEBp5.js → QBtnDropdown-CQqzzlFI.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-BdlDiU6a.js → QDrawer-Cwj2qh0r.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-BrSemS3o.js → QSelect-BSPtuXJg.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-CLCFHW2D.js → QTooltip-DC_kDKku.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-F9bdAq6K.js → SchedulePanel-9aJSoz6Y.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-CBCefYqc.js → SearchPage-BjNvtE_U.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-BPgTHmKj.js → SettingsPage-CvLnT73V.js} +1 -1
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-DDs2nuoT.js → SplitWorkspacePage-DTao0DHr.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CulE0wTU.js → TerminalPanel-BrKIW-bJ.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-DUgqs2cW.js → TouchPan-_n1AQgoo.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-CdHpGBdr.js → TourReplayButton-CJF3nVvn.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-W5SvqzXO.js → WorkspaceAttentionLabels-CxesUiIm.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-DoXtwpjV.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +4 -0
- package/src/client/dist/pwa/assets/{browser-CnuqDTyc.js → browser-DGWpkvN3.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-JodIO7-I.js → build-path-tree-Ba1dSLgi.js} +1 -1
- package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-DVK21RB6.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-3ZBK1pXE.js → documents-BMHzjW_g.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-DZ9WO_J7.js → editor.api-CYTuNwqu.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-LrtJPx-j.js → efforts-BPlRSHXp.js} +1 -1
- package/src/client/dist/pwa/assets/{engineFeatures-DmnHj_uD.js → engineFeatures-DVB2rklO.js} +1 -1
- package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-C8cQwDZM.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-Dv4JC2zU.js → expand-template-Cj82YE-O.js} +1 -1
- package/src/client/dist/pwa/assets/{format-CHjfjuhr.js → format-Dk7O3LhA.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BsgH8CoH.js → formatters-TGjvs7Ca.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-BVOMZ2Bl.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-Dla55SIR.js → freemarker2-39lO_dWH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DtuuZy7D.js → handlebars-M_0V82cD.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CKYOCR8W.js → html-yILqYDsl.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +1 -0
- package/src/client/dist/pwa/assets/{index-CIP08MpU.js → index-BJU5dnkF.js} +2 -2
- package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-CxkAoOj4.js} +1 -1
- package/src/client/dist/pwa/assets/{javascript-BxzTnsIQ.js → javascript-DrW-amDc.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-C8BfPFUy.js → liquid-D_mXmUoL.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DZm_YXkC.js → mdx-E8O3lvR5.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-HDbRHiNR.js → monaco-contributions-C2K3sl_V.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-DRW-851R.js → monaco.contribution-DLhHQIJn.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-Ce9hsryo.js → network-auth-BUwWtnui.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-OBpMEBvP.js → notify-theme-1mOpY2vN.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CVQxWyzP.js → pending-diff-open-BL7dKRqF.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-CVFHS2ec.js → permissionModes-BtC3_P-P.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-mcpQp2o6.js → project-color-BFBFZWeG.js} +1 -1
- package/src/client/dist/pwa/assets/{python-CO5iQN74.js → python-DoZAYim6.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BC7D8LMf.js → razor-9bZ30g2y.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-Cb15kvox.js → render-chat-markdown-7EtWNjG0.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-BCHCSNpD.js → typescript-C1CBfL3L.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-DhLhglwr.js → use-tours-BJ81clv6.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-CoWrOEac.js → workspace-templates-r1-bipjM.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-y9M3NAIZ.js → xml-COuu5isK.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-CthlToPo.js → yaml-ChZ7WqaJ.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-YRbIRutK.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-gFrn4xl9.js +0 -2
- package/src/client/dist/pwa/assets/WorkspacePage-B6O7j57u.js +0 -4
- package/src/client/dist/pwa/assets/i18n-DTfKQUMf.js +0 -1
|
@@ -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
|
|
@@ -222,8 +251,7 @@ function runWatchdog() {
|
|
|
222
251
|
for (const [workspaceId, ctrl] of controllers) {
|
|
223
252
|
// D1 — a controller already in `stopping` state is being torn down by
|
|
224
253
|
// `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 —
|
|
254
|
+
// (STOP_AGENT_TIMEOUT_MS). A deadline alone never evicts the controller. Before D1 this branch could never see a stopping controller —
|
|
227
255
|
// the entry was removed from `controllers` synchronously, before
|
|
228
256
|
// `ctrl.stop()` was even awaited. Now that the entry survives the whole
|
|
229
257
|
// stop, a slow-but-honest voluntary stop (the engine's iterator already
|
|
@@ -266,8 +294,10 @@ function runWatchdog() {
|
|
|
266
294
|
catch (err) {
|
|
267
295
|
console.warn('[watchdog] Failed to route death notification events:', err);
|
|
268
296
|
}
|
|
269
|
-
if (controllers.get(workspaceId) === ctrl)
|
|
297
|
+
if (controllers.get(workspaceId) === ctrl) {
|
|
270
298
|
controllers.delete(workspaceId);
|
|
299
|
+
notifyCapacityAvailable(workspaceId);
|
|
300
|
+
}
|
|
271
301
|
retryCounts.delete(workspaceId);
|
|
272
302
|
// This end never goes through handleEvent → onSessionEnded, so the user's
|
|
273
303
|
// hook would otherwise miss precisely the case it is most useful for.
|
|
@@ -695,6 +725,15 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
695
725
|
const sourceControllerIsStopping = sourceController?.status === 'stopping';
|
|
696
726
|
const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
|
|
697
727
|
routeEvent(workspaceId, agentSessionId, routedEvent);
|
|
728
|
+
// A fresh tool invocation proves this controller resumed working (for
|
|
729
|
+
// example after an account change). Tool results can still drain after a
|
|
730
|
+
// rejection, so they must never count as recovery.
|
|
731
|
+
if (ev.kind === 'tool:call' && !sourceControllerIsStopping && getWs(workspaceId)?.status === 'quota') {
|
|
732
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
733
|
+
quotaBackoffService.cancel(workspaceId, 'completed');
|
|
734
|
+
retryCounts.delete(workspaceId);
|
|
735
|
+
emitEphemeral(workspaceId, 'agent:quota-recovered', {});
|
|
736
|
+
}
|
|
698
737
|
if (ev.kind === 'rate_limit') {
|
|
699
738
|
latestRateLimitInfo.set(workspaceId, ev.info);
|
|
700
739
|
}
|
|
@@ -852,8 +891,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
852
891
|
const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
|
|
853
892
|
if (!ownsWorkspaceLifecycle)
|
|
854
893
|
return;
|
|
855
|
-
if (watchdogRecovery)
|
|
894
|
+
if (watchdogRecovery) {
|
|
895
|
+
notifyCapacityAvailable(workspaceId);
|
|
856
896
|
return;
|
|
897
|
+
}
|
|
857
898
|
// resume_failed exits with an error but the workspace is fine (stale id
|
|
858
899
|
// cleared, next iteration will start fresh) — report 'completed' to
|
|
859
900
|
// auto-loop so it continues.
|
|
@@ -975,7 +1016,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
975
1016
|
const currentWorkspace = getWs(workspaceId);
|
|
976
1017
|
const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
|
|
977
1018
|
const wasStopping = sourceController?.status === 'stopping';
|
|
978
|
-
if (registeredController === sourceController) {
|
|
1019
|
+
if (!wasStopping && registeredController === sourceController) {
|
|
979
1020
|
controllers.delete(workspaceId);
|
|
980
1021
|
}
|
|
981
1022
|
if (!preserveQuotaBackoff) {
|
|
@@ -1046,6 +1087,9 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1046
1087
|
* query the controller via `_getControllers()` for tests.
|
|
1047
1088
|
*/
|
|
1048
1089
|
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
|
|
1090
|
+
if (shuttingDown)
|
|
1091
|
+
throw new Error('Cannot start an agent while the server is shutting down');
|
|
1092
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1049
1093
|
const workspace = getWs(workspaceId);
|
|
1050
1094
|
if (!workspace)
|
|
1051
1095
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -1059,26 +1103,22 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1059
1103
|
// callback can leave its controller in the map after the workspace is
|
|
1060
1104
|
// logically idle. Evict it instead of refusing the new session.
|
|
1061
1105
|
//
|
|
1062
|
-
// A controller already
|
|
1063
|
-
//
|
|
1064
|
-
// every legitimate stop-then-start sequence.
|
|
1106
|
+
// A controller already stopping keeps ownership until its stop completes;
|
|
1107
|
+
// callers replacing it must await stopAgentAndWait before starting again.
|
|
1065
1108
|
let zombieEviction = Promise.resolve();
|
|
1066
1109
|
const existingCtrl = controllers.get(workspaceId);
|
|
1067
1110
|
if (existingCtrl) {
|
|
1111
|
+
if (existingCtrl.status === 'stopping')
|
|
1112
|
+
throw new Error('Agent is still stopping; retry after its stop completes');
|
|
1068
1113
|
const wsForCheck = getWs(workspaceId);
|
|
1069
1114
|
const status = wsForCheck?.status;
|
|
1070
1115
|
const isLogicallyDone = status === 'idle' || status === 'completed' || status === 'error' || status === 'quota';
|
|
1071
|
-
if (isLogicallyDone
|
|
1116
|
+
if (isLogicallyDone) {
|
|
1072
1117
|
console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}, controller=${existingCtrl.status}) before starting fresh session`);
|
|
1073
1118
|
// The new engine must not touch the worktree while the zombie may still
|
|
1074
1119
|
// be writing to it — a zombie that ignores its own stop is exactly the
|
|
1075
|
-
// case this path exists for.
|
|
1076
|
-
|
|
1077
|
-
zombieEviction = stopWithTimeout(existingCtrl, STOP_AGENT_TIMEOUT_MS).then((stopped) => {
|
|
1078
|
-
if (!stopped) {
|
|
1079
|
-
console.error(`[orchestrator] Zombie controller for workspace '${workspaceId}' did not stop within ${STOP_AGENT_TIMEOUT_MS}ms — starting the new session anyway`);
|
|
1080
|
-
}
|
|
1081
|
-
});
|
|
1120
|
+
// case this path exists for. An unconfirmed stop fails the replacement.
|
|
1121
|
+
zombieEviction = stopAgentAndWait(workspaceId, undefined, 'replacement').then(assertAgentStopped);
|
|
1082
1122
|
// Drop any queued pending items + persisted user-input-requested events
|
|
1083
1123
|
// tied to the zombie's agentSessionId so the new session doesn't inherit
|
|
1084
1124
|
// a stale queue and so a future sync replay doesn't resurrect them.
|
|
@@ -1095,7 +1135,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1095
1135
|
catch (err) {
|
|
1096
1136
|
console.warn('[orchestrator] Failed to purge zombie pending state:', err);
|
|
1097
1137
|
}
|
|
1098
|
-
controllers.delete(workspaceId);
|
|
1099
1138
|
}
|
|
1100
1139
|
else {
|
|
1101
1140
|
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
@@ -1146,12 +1185,31 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1146
1185
|
let controller;
|
|
1147
1186
|
controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
|
|
1148
1187
|
registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
|
|
1149
|
-
|
|
1188
|
+
if (!existingCtrl)
|
|
1189
|
+
controllers.set(workspaceId, controller);
|
|
1190
|
+
else
|
|
1191
|
+
pendingStarts.set(workspaceId, controller);
|
|
1150
1192
|
// Kick off engine.start asynchronously, BEHIND the zombie eviction so the
|
|
1151
1193
|
// new engine never runs concurrently with the one it replaces. Errors
|
|
1152
1194
|
// surface as error events.
|
|
1153
1195
|
void zombieEviction
|
|
1154
|
-
.then(() =>
|
|
1196
|
+
.then(() => {
|
|
1197
|
+
// An explicit stop can cancel this replacement while its predecessor
|
|
1198
|
+
// still owns the worktree. Its session row was already finalized there.
|
|
1199
|
+
if (existingCtrl && controller.status === 'stopping')
|
|
1200
|
+
return;
|
|
1201
|
+
if (pendingStarts.get(workspaceId) === controller)
|
|
1202
|
+
pendingStarts.delete(workspaceId);
|
|
1203
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1204
|
+
const current = getWs(workspaceId);
|
|
1205
|
+
if (!current || current.archivedAt || current.worktreePurgedAt)
|
|
1206
|
+
throw new Error('Workspace is no longer available');
|
|
1207
|
+
const registered = controllers.get(workspaceId);
|
|
1208
|
+
if (registered && registered !== controller)
|
|
1209
|
+
throw new Error('Agent is still stopping');
|
|
1210
|
+
controllers.set(workspaceId, controller);
|
|
1211
|
+
return controller.start(options);
|
|
1212
|
+
})
|
|
1155
1213
|
.then(() => {
|
|
1156
1214
|
const pid = controller.pid;
|
|
1157
1215
|
if (pid !== undefined) {
|
|
@@ -1165,6 +1223,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1165
1223
|
}
|
|
1166
1224
|
})
|
|
1167
1225
|
.catch((err) => {
|
|
1226
|
+
if (existingCtrl && controller.status === 'stopping')
|
|
1227
|
+
return;
|
|
1168
1228
|
console.error('[orchestrator] engine.start failed:', err);
|
|
1169
1229
|
const message = err instanceof Error ? err.message : String(err);
|
|
1170
1230
|
handleEvent(workspaceId, agentSessionId, controller, {
|
|
@@ -1177,6 +1237,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1177
1237
|
reason: 'error',
|
|
1178
1238
|
exitCode: null,
|
|
1179
1239
|
});
|
|
1240
|
+
})
|
|
1241
|
+
.finally(() => {
|
|
1242
|
+
if (pendingStarts.get(workspaceId) === controller)
|
|
1243
|
+
pendingStarts.delete(workspaceId);
|
|
1180
1244
|
});
|
|
1181
1245
|
return { agentSessionId, pid: undefined };
|
|
1182
1246
|
}
|
|
@@ -1204,21 +1268,10 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
1204
1268
|
autoLoopService.disable(workspaceId, 'user-action');
|
|
1205
1269
|
}
|
|
1206
1270
|
}
|
|
1207
|
-
/**
|
|
1208
|
-
* Upper bound on a stop. Beyond this the engine is considered deaf to its own
|
|
1209
|
-
* stop path: we evict the controller so the workspace becomes usable again.
|
|
1210
|
-
*
|
|
1211
|
-
* This is an honest abandonment, not an escalation to something else that
|
|
1212
|
-
* watches over the process: Kōbō has no graduated SIGTERM/SIGKILL path of its
|
|
1213
|
-
* own, the `process-tracker` module that used to exist was removed, and the
|
|
1214
|
-
* session watchdog only walks the `controllers` map — the very map this
|
|
1215
|
-
* eviction just removed the entry from. Whatever survives past this timeout
|
|
1216
|
-
* is untracked by anyone in this process; only the OS or the user can end it.
|
|
1217
|
-
*/
|
|
1271
|
+
/** Bound the caller's wait, retaining ownership until actual engine shutdown. */
|
|
1218
1272
|
export const STOP_AGENT_TIMEOUT_MS = 15_000;
|
|
1219
1273
|
async function stopController(workspaceId, ctrl, cause = 'user') {
|
|
1220
1274
|
ctrl.stopCause = cause;
|
|
1221
|
-
wakeupService.cancel(workspaceId, 'stopped');
|
|
1222
1275
|
// Normalize the state synchronously so callers (archive, delete, manual
|
|
1223
1276
|
// stop) see a clean workspace immediately — without waiting for the async
|
|
1224
1277
|
// controller.stop() → session:ended round-trip, which onSessionEnded skips
|
|
@@ -1248,25 +1301,21 @@ async function stopController(workspaceId, ctrl, cause = 'user') {
|
|
|
1248
1301
|
console.warn(`[orchestrator] Failed to normalize ${wsBefore.status} → idle on stop:`, err);
|
|
1249
1302
|
}
|
|
1250
1303
|
}
|
|
1251
|
-
// Manual stop should also drop any pending quota auto-resume.
|
|
1252
|
-
quotaBackoffService.cancel(workspaceId, 'user');
|
|
1253
1304
|
// D1 — the controller stays REGISTERED for the whole stop, in `stopping`
|
|
1254
1305
|
// state. Removing it up-front (as we used to) made the only "is an agent
|
|
1255
1306
|
// running?" signal of the system lie for the entire teardown window: cron
|
|
1256
1307
|
// and auto-loop could start a second agent on the same worktree, and delete
|
|
1257
1308
|
// could pull the worktree from under a process still writing to it.
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
if (controllers.get(workspaceId) === ctrl)
|
|
1263
|
-
controllers.delete(workspaceId);
|
|
1309
|
+
await ctrl.stop();
|
|
1310
|
+
if (controllers.get(workspaceId) === ctrl) {
|
|
1311
|
+
controllers.delete(workspaceId);
|
|
1312
|
+
notifyCapacityAvailable(workspaceId);
|
|
1264
1313
|
}
|
|
1265
1314
|
}
|
|
1266
1315
|
/**
|
|
1267
1316
|
* Race an arbitrary promise against a bounded deadline. Resolves to the
|
|
1268
1317
|
* winner's value; if the deadline wins, `onTimeout()` supplies the value
|
|
1269
|
-
* instead.
|
|
1318
|
+
* instead. The timer is always cleared. Single source of
|
|
1270
1319
|
* truth for the "stop, but not forever" pattern: both `stopWithTimeout` and
|
|
1271
1320
|
* `stopAgentAndWait` used to hand-roll their own `Promise.race` + timer, two
|
|
1272
1321
|
* copies of the same logic that could silently drift apart.
|
|
@@ -1287,16 +1336,6 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
|
|
|
1287
1336
|
clearTimeout(timer);
|
|
1288
1337
|
}
|
|
1289
1338
|
}
|
|
1290
|
-
/** Race a controller stop against a bounded deadline. Never throws. */
|
|
1291
|
-
async function stopWithTimeout(ctrl, timeoutMs) {
|
|
1292
|
-
return raceWithTimeout(ctrl
|
|
1293
|
-
.stop()
|
|
1294
|
-
.then(() => true)
|
|
1295
|
-
.catch((err) => {
|
|
1296
|
-
console.error('[orchestrator] controller.stop rejected:', err);
|
|
1297
|
-
return true;
|
|
1298
|
-
}), timeoutMs, () => false);
|
|
1299
|
-
}
|
|
1300
1339
|
/**
|
|
1301
1340
|
* Stop the agent and wait for it to actually die. Every caller that mutates
|
|
1302
1341
|
* the worktree afterwards — delete, archive, purge, setup script, engine
|
|
@@ -1304,21 +1343,46 @@ async function stopWithTimeout(ctrl, timeoutMs) {
|
|
|
1304
1343
|
* engine may still be writing files.
|
|
1305
1344
|
*/
|
|
1306
1345
|
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
|
|
1307
|
-
/**
|
|
1346
|
+
/** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
|
|
1308
1347
|
cause = 'user') {
|
|
1348
|
+
const pending = pendingStarts.get(workspaceId);
|
|
1349
|
+
let pendingStop = Promise.resolve();
|
|
1350
|
+
if (pending) {
|
|
1351
|
+
pendingStarts.delete(workspaceId);
|
|
1352
|
+
pending.stopCause = cause;
|
|
1353
|
+
pendingStop = pending.stop().then(() => {
|
|
1354
|
+
handleEvent(workspaceId, pending.agentSessionId, pending, {
|
|
1355
|
+
kind: 'session:ended',
|
|
1356
|
+
reason: 'killed',
|
|
1357
|
+
exitCode: null,
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1309
1361
|
const ctrl = controllers.get(workspaceId);
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1362
|
+
const controllerStop = ctrl ? stopController(workspaceId, ctrl, cause) : Promise.resolve();
|
|
1363
|
+
// Cancel synchronously, including schedules whose controller already exited.
|
|
1364
|
+
// A Promise executor contains DB failures within the bounded stop outcome.
|
|
1365
|
+
// Shutdown only suspends timers: persisted intent belongs to the next boot.
|
|
1366
|
+
const cancelSchedules = new Promise((resolve) => {
|
|
1367
|
+
if (cause !== 'shutdown') {
|
|
1368
|
+
wakeupService.cancel(workspaceId, 'stopped');
|
|
1369
|
+
quotaBackoffService.cancel(workspaceId, 'user');
|
|
1370
|
+
}
|
|
1371
|
+
resolve();
|
|
1372
|
+
});
|
|
1373
|
+
// Mark every controller as stopping before the disable hook can run. Persist
|
|
1374
|
+
// the user's intent even for a loop waiting for capacity with no controller.
|
|
1375
|
+
const disableLoop = cause === 'user'
|
|
1376
|
+
? Promise.resolve().then(() => autoLoopService.disable(workspaceId, 'user-action'))
|
|
1377
|
+
: Promise.resolve();
|
|
1378
|
+
const outcome = await raceWithTimeout(Promise.all([pendingStop, controllerStop, cancelSchedules, disableLoop]).then(() => (ctrl || pending ? 'stopped' : 'not-running'), (err) => {
|
|
1313
1379
|
console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
|
|
1314
|
-
return
|
|
1315
|
-
}), timeoutMs, () =>
|
|
1316
|
-
if (
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
controllers.delete(workspaceId);
|
|
1321
|
-
return 'timeout';
|
|
1380
|
+
return 'failed';
|
|
1381
|
+
}), timeoutMs, () => 'timeout');
|
|
1382
|
+
if (outcome === 'timeout') {
|
|
1383
|
+
console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
|
|
1384
|
+
}
|
|
1385
|
+
return outcome;
|
|
1322
1386
|
}
|
|
1323
1387
|
/**
|
|
1324
1388
|
* Fire-and-forget stop, for the few callers that cannot await (WebSocket
|
|
@@ -1337,13 +1401,43 @@ export function stopAgent(workspaceId) {
|
|
|
1337
1401
|
}
|
|
1338
1402
|
/** Stop every live engine before process shutdown, with a bounded wait per engine. */
|
|
1339
1403
|
export async function stopAllAgents(timeoutMs = 3_000) {
|
|
1404
|
+
quotaBackoffService.suspendForShutdown();
|
|
1405
|
+
wakeupService.suspendForShutdown();
|
|
1406
|
+
cronService.suspendForShutdown();
|
|
1407
|
+
shuttingDown = true;
|
|
1408
|
+
if (capacityTimer)
|
|
1409
|
+
clearTimeout(capacityTimer);
|
|
1410
|
+
capacityTimer = undefined;
|
|
1411
|
+
stoppedCapacityOwners.clear();
|
|
1340
1412
|
// No per-workspace try/catch here: unlike the pre-D1 shutdown path,
|
|
1341
1413
|
// `stopAgentAndWait` never rejects — every internal failure (a rejecting
|
|
1342
1414
|
// `controller.stop()`, or the bounded deadline firing) is already converted
|
|
1343
|
-
// into a logged, returned `StopAgentOutcome`
|
|
1415
|
+
// into a logged, returned `StopAgentOutcome`
|
|
1344
1416
|
// instead of a thrown error. A wrapping catch here would be unreachable
|
|
1345
1417
|
// dead code pretending to guard against a failure mode that can't occur.
|
|
1346
|
-
|
|
1418
|
+
const workspaceIds = new Set([...controllers.keys(), ...pendingStarts.keys()]);
|
|
1419
|
+
await Promise.all([...workspaceIds].map((workspaceId) => stopAgentAndWait(workspaceId, timeoutMs, 'shutdown')));
|
|
1420
|
+
}
|
|
1421
|
+
/** Automatic continuations must preserve their durable intent during teardown. */
|
|
1422
|
+
export function isShuttingDown() {
|
|
1423
|
+
return shuttingDown;
|
|
1424
|
+
}
|
|
1425
|
+
/** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
|
|
1426
|
+
export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
1427
|
+
const ctrl = controllers.get(workspaceId);
|
|
1428
|
+
if (ctrl?.status !== 'running')
|
|
1429
|
+
return false;
|
|
1430
|
+
if (expectedSessionId && ctrl.agentSessionId !== expectedSessionId)
|
|
1431
|
+
return false;
|
|
1432
|
+
const workspace = getWs(workspaceId);
|
|
1433
|
+
if (!workspace || workspace.archivedAt || workspace.worktreePurgedAt)
|
|
1434
|
+
return false;
|
|
1435
|
+
if (workspace.status === 'awaiting-user' || workspace.status === 'quota')
|
|
1436
|
+
return false;
|
|
1437
|
+
if (!ctrl.engineProcess?.sendWakeupIfWaiting?.(content))
|
|
1438
|
+
return false;
|
|
1439
|
+
emit(workspaceId, 'user:message', { content, sender: 'system-prompt' }, ctrl.agentSessionId);
|
|
1440
|
+
return true;
|
|
1347
1441
|
}
|
|
1348
1442
|
/** Write a user message to the running agent. */
|
|
1349
1443
|
export async function sendMessage(workspaceId, content, expectedSessionId) {
|
|
@@ -1864,10 +1958,18 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
1864
1958
|
return;
|
|
1865
1959
|
}
|
|
1866
1960
|
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
1961
|
+
// Usage lookup can finish after the agent has already resumed.
|
|
1962
|
+
if (getWs(workspaceId)?.status !== 'quota')
|
|
1963
|
+
return;
|
|
1867
1964
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
1868
1965
|
// The quotaBackoffService owns the timer + the persistent row + the
|
|
1869
1966
|
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
1870
|
-
quotaBackoffService.arm(workspaceId, delayMs, {
|
|
1967
|
+
quotaBackoffService.arm(workspaceId, delayMs, {
|
|
1968
|
+
resetsAt: resetsAt ?? null,
|
|
1969
|
+
source,
|
|
1970
|
+
reason: 'quota',
|
|
1971
|
+
retryCount: retryCount + 1,
|
|
1972
|
+
});
|
|
1871
1973
|
}
|
|
1872
1974
|
catch (err) {
|
|
1873
1975
|
console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
|
|
@@ -1931,7 +2033,12 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
1931
2033
|
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
|
|
1932
2034
|
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
1933
2035
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
1934
|
-
quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
|
|
2036
|
+
quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
|
|
2037
|
+
resetsAt: resetsAt ?? null,
|
|
2038
|
+
source,
|
|
2039
|
+
reason: 'transient',
|
|
2040
|
+
retryCount: retryCount + 1,
|
|
2041
|
+
});
|
|
1935
2042
|
}
|
|
1936
2043
|
catch (err) {
|
|
1937
2044
|
console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
|
|
@@ -1964,8 +2071,8 @@ export function restoreRetryCountsFromDb() {
|
|
|
1964
2071
|
// in `quota` status and require manual user action (resume / new message)
|
|
1965
2072
|
// to leave that state. This is intentional: without an auto-loop intent,
|
|
1966
2073
|
// firing a fresh agent run in the user's absence would surprise them.
|
|
1967
|
-
quotaBackoffService.setOnFireCallback((workspaceId) => {
|
|
1968
|
-
autoLoopService.onQuotaBackoffExpired(workspaceId);
|
|
2074
|
+
quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
|
|
2075
|
+
autoLoopService.onQuotaBackoffExpired(workspaceId, pending);
|
|
1969
2076
|
});
|
|
1970
2077
|
// ── Testing utilities ─────────────────────────────────────────────────────────
|
|
1971
2078
|
/** @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
|
|
@@ -2,14 +2,16 @@ import fs from 'node:fs';
|
|
|
2
2
|
import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
|
|
3
3
|
import { getDb } from '../db/index.js';
|
|
4
4
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
5
|
+
import { deferUntilWorkspaceAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
|
|
5
6
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
7
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
8
|
import * as cleanupScriptService from './cleanup-script-service.js';
|
|
8
9
|
import * as lifecycleHookService from './lifecycle-hook-service.js';
|
|
10
|
+
import * as quotaBackoffService from './quota-backoff-service.js';
|
|
9
11
|
import * as settingsService from './settings-service.js';
|
|
10
12
|
import { getSuitePrompts } from './skill-suite-prompts.js';
|
|
11
13
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
12
|
-
import { listTasks } from './workspace-service.js';
|
|
14
|
+
import { listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
13
15
|
const NO_PROGRESS_STALL_THRESHOLD = 3;
|
|
14
16
|
function getRow(workspaceId) {
|
|
15
17
|
const db = getDb();
|
|
@@ -186,6 +188,10 @@ export function rehydrate() {
|
|
|
186
188
|
const row = getRow(id);
|
|
187
189
|
if (row?.auto_loop_ready !== 1)
|
|
188
190
|
continue;
|
|
191
|
+
// Persisted quota timers own the restart of these workspaces. Boot
|
|
192
|
+
// restores them after this pass; starting here bypasses their delay.
|
|
193
|
+
if (row.status === 'quota')
|
|
194
|
+
continue;
|
|
189
195
|
if (countPendingTasks(id) === 0) {
|
|
190
196
|
disable(id, 'completed');
|
|
191
197
|
continue;
|
|
@@ -291,7 +297,9 @@ function hasFreeAgentSlot() {
|
|
|
291
297
|
* free, exactly one starts and the other two keep waiting rather than each
|
|
292
298
|
* re-announcing that they wait.
|
|
293
299
|
*/
|
|
294
|
-
export function resumeWaitingWorkspaces() {
|
|
300
|
+
export function resumeWaitingWorkspaces(excluded = new Set()) {
|
|
301
|
+
if (orchestrator.isShuttingDown())
|
|
302
|
+
return;
|
|
295
303
|
const db = getDb();
|
|
296
304
|
const rows = db
|
|
297
305
|
.prepare(`SELECT id, status FROM workspaces
|
|
@@ -299,6 +307,8 @@ export function resumeWaitingWorkspaces() {
|
|
|
299
307
|
ORDER BY updated_at ASC`)
|
|
300
308
|
.all();
|
|
301
309
|
for (const row of rows) {
|
|
310
|
+
if (excluded.has(row.id))
|
|
311
|
+
continue;
|
|
302
312
|
if (!hasFreeAgentSlot())
|
|
303
313
|
return;
|
|
304
314
|
if (orchestrator.hasController(row.id))
|
|
@@ -316,8 +326,15 @@ export function resumeWaitingWorkspaces() {
|
|
|
316
326
|
}
|
|
317
327
|
}
|
|
318
328
|
function spawnNextIteration(workspaceId, opts = {}) {
|
|
329
|
+
// Lifecycle/ready callbacks can outlive timer suspension; preserve their persisted loop intent.
|
|
330
|
+
if (orchestrator.isShuttingDown())
|
|
331
|
+
return;
|
|
319
332
|
const row = getRow(workspaceId);
|
|
320
|
-
if (!row)
|
|
333
|
+
if (!row || row.archived_at || !row.auto_loop)
|
|
334
|
+
return;
|
|
335
|
+
if (deferUntilWorkspaceAvailable(workspaceId, resumeWaitingWorkspaces))
|
|
336
|
+
return;
|
|
337
|
+
if (orchestrator.hasController(workspaceId))
|
|
321
338
|
return;
|
|
322
339
|
// Same guard as onSessionEnded — never race a deferred-resume start.
|
|
323
340
|
if (row.status === 'awaiting-user')
|
|
@@ -427,22 +444,70 @@ function spawnNextIteration(workspaceId, opts = {}) {
|
|
|
427
444
|
* Spawns the next auto-loop iteration if the workspace is still in quota status
|
|
428
445
|
* with auto_loop active; no-ops otherwise (race-safe).
|
|
429
446
|
*
|
|
430
|
-
* No-op cases —
|
|
431
|
-
* manual user action:
|
|
447
|
+
* No-op cases — leave the workspace unchanged:
|
|
432
448
|
* - workspace was deleted between arm and fire
|
|
449
|
+
* - workspace was archived during the backoff window
|
|
433
450
|
* - `auto_loop !== 1` (workspace was never an auto-loop target, OR the user
|
|
434
451
|
* toggled the loop off during the backoff window)
|
|
435
452
|
* - `status !== 'quota'` (user already manually resumed, or another path
|
|
436
453
|
* transitioned the workspace)
|
|
437
454
|
*/
|
|
438
|
-
|
|
455
|
+
const pendingQuotaStops = new Set();
|
|
456
|
+
const QUOTA_STOP_RETRY_MS = 15_000;
|
|
457
|
+
function deferQuotaRetry(workspaceId, pending) {
|
|
458
|
+
const attempt = pending ?? quotaBackoffService.getPending(workspaceId);
|
|
459
|
+
quotaBackoffService.arm(workspaceId, QUOTA_STOP_RETRY_MS, {
|
|
460
|
+
resetsAt: attempt?.resetsAt ?? null,
|
|
461
|
+
source: attempt?.source ?? 'fallback_ladder',
|
|
462
|
+
reason: attempt?.reason ?? 'quota',
|
|
463
|
+
retryCount: attempt?.retryCount ?? 1,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
export function onQuotaBackoffExpired(workspaceId, pending) {
|
|
439
467
|
const row = getRow(workspaceId);
|
|
440
468
|
if (!row)
|
|
441
469
|
return;
|
|
470
|
+
if (row.archived_at !== null)
|
|
471
|
+
return;
|
|
442
472
|
if (row.auto_loop !== 1)
|
|
443
473
|
return;
|
|
444
474
|
if (row.status !== 'quota')
|
|
445
475
|
return;
|
|
476
|
+
if (pendingQuotaStops.has(workspaceId) || orchestrator.isShuttingDown() || isWorkspaceLifecycleBusy(workspaceId)) {
|
|
477
|
+
deferQuotaRetry(workspaceId, pending);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (orchestrator.hasController(workspaceId)) {
|
|
481
|
+
pendingQuotaStops.add(workspaceId);
|
|
482
|
+
const stopped = orchestrator.stopAgentAndWait(workspaceId, undefined, 'replacement');
|
|
483
|
+
// The timer already consumed its row. Persist recovery immediately, after the
|
|
484
|
+
// technical stop's synchronous cancellation, so shutdown/crash during the await loses no intent.
|
|
485
|
+
deferQuotaRetry(workspaceId, pending);
|
|
486
|
+
void stopped
|
|
487
|
+
.then((outcome) => {
|
|
488
|
+
// Stop, archive, or a manual replacement may have superseded this retry while we awaited the engine.
|
|
489
|
+
const current = getRow(workspaceId);
|
|
490
|
+
if (!current || current.archived_at || !current.auto_loop || current.status !== 'quota')
|
|
491
|
+
return;
|
|
492
|
+
if (outcome === 'timeout' || outcome === 'failed' || orchestrator.isShuttingDown()) {
|
|
493
|
+
deferQuotaRetry(workspaceId, pending);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
updateWorkspaceStatus(workspaceId, 'idle');
|
|
497
|
+
quotaBackoffService.cancel(workspaceId, 'completed');
|
|
498
|
+
spawnNextIteration(workspaceId);
|
|
499
|
+
})
|
|
500
|
+
.catch((err) => {
|
|
501
|
+
console.error(`[auto-loop] quota controller stop failed for '${workspaceId}':`, err);
|
|
502
|
+
})
|
|
503
|
+
.finally(() => pendingQuotaStops.delete(workspaceId));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
// The timer has consumed its persisted row. Release quota ownership before
|
|
507
|
+
// checking capacity so resumeWaitingWorkspaces can pick this workspace up
|
|
508
|
+
// when a slot frees, even if no agent can start at this instant.
|
|
509
|
+
updateWorkspaceStatus(workspaceId, 'idle');
|
|
510
|
+
emitEphemeral(workspaceId, 'agent:quota-backoff-cancelled', { reason: 'completed' });
|
|
446
511
|
spawnNextIteration(workspaceId);
|
|
447
512
|
}
|
|
448
513
|
/**
|