stageflow 0.1.0
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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/agent/activity.d.ts +76 -0
- package/dist/agent/activity.js +89 -0
- package/dist/agent/activityObserver.d.ts +17 -0
- package/dist/agent/activityObserver.js +66 -0
- package/dist/agent/cursorExtension.d.ts +12 -0
- package/dist/agent/cursorExtension.js +88 -0
- package/dist/agent/cursorProvider.d.ts +14 -0
- package/dist/agent/cursorProvider.js +139 -0
- package/dist/agent/fakeAgent.d.ts +29 -0
- package/dist/agent/fakeAgent.js +295 -0
- package/dist/agent/oauthSessionManager.d.ts +33 -0
- package/dist/agent/oauthSessionManager.js +214 -0
- package/dist/agent/piAdapter.d.ts +128 -0
- package/dist/agent/piAdapter.js +967 -0
- package/dist/agent/port.d.ts +63 -0
- package/dist/agent/port.js +46 -0
- package/dist/agent/providerAuth.d.ts +101 -0
- package/dist/agent/providerAuth.js +282 -0
- package/dist/agent/providerSupport.d.ts +28 -0
- package/dist/agent/providerSupport.js +19 -0
- package/dist/cli/providersCommand.d.ts +11 -0
- package/dist/cli/providersCommand.js +275 -0
- package/dist/cli/terminalAuthInteraction.d.ts +8 -0
- package/dist/cli/terminalAuthInteraction.js +65 -0
- package/dist/cli/terminalSecret.d.ts +2 -0
- package/dist/cli/terminalSecret.js +67 -0
- package/dist/cli/validateCommand.d.ts +11 -0
- package/dist/cli/validateCommand.js +81 -0
- package/dist/cli/validateOutput.d.ts +8 -0
- package/dist/cli/validateOutput.js +75 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +273 -0
- package/dist/config/createPipeline.d.ts +31 -0
- package/dist/config/createPipeline.js +255 -0
- package/dist/config/createStage.d.ts +25 -0
- package/dist/config/createStage.js +181 -0
- package/dist/config/listConfig.d.ts +33 -0
- package/dist/config/listConfig.js +195 -0
- package/dist/config/listExtensions.d.ts +26 -0
- package/dist/config/listExtensions.js +50 -0
- package/dist/config/listSkills.d.ts +29 -0
- package/dist/config/listSkills.js +55 -0
- package/dist/config/loadOutcome.d.ts +27 -0
- package/dist/config/loadOutcome.js +6 -0
- package/dist/config/loadPipeline.d.ts +25 -0
- package/dist/config/loadPipeline.js +196 -0
- package/dist/config/loadStage.d.ts +4 -0
- package/dist/config/loadStage.js +127 -0
- package/dist/config/loadTask.d.ts +7 -0
- package/dist/config/loadTask.js +69 -0
- package/dist/config/readYamlObject.d.ts +1 -0
- package/dist/config/readYamlObject.js +5 -0
- package/dist/config/resolvePipelineDag.d.ts +12 -0
- package/dist/config/resolvePipelineDag.js +282 -0
- package/dist/config/validateCatalog.d.ts +36 -0
- package/dist/config/validateCatalog.js +322 -0
- package/dist/envelope/check.d.ts +3 -0
- package/dist/envelope/check.js +52 -0
- package/dist/envelope/payloadSchema.d.ts +10 -0
- package/dist/envelope/payloadSchema.js +91 -0
- package/dist/hitl/qaTrail.d.ts +31 -0
- package/dist/hitl/qaTrail.js +65 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +7 -0
- package/dist/mcp/projectRun.d.ts +37 -0
- package/dist/mcp/projectRun.js +57 -0
- package/dist/mcp/readArtifact.d.ts +2 -0
- package/dist/mcp/readArtifact.js +46 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +26 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +96 -0
- package/dist/package-meta.d.ts +1 -0
- package/dist/package-meta.js +1 -0
- package/dist/prompt/priorEnvelope.d.ts +2 -0
- package/dist/prompt/priorEnvelope.js +5 -0
- package/dist/runstore/catalog.d.ts +8 -0
- package/dist/runstore/catalog.js +16 -0
- package/dist/runstore/createStore.d.ts +15 -0
- package/dist/runstore/createStore.js +23 -0
- package/dist/runstore/disk/DiskRunStore.d.ts +30 -0
- package/dist/runstore/disk/DiskRunStore.js +238 -0
- package/dist/runstore/disk/catalogHelpers.d.ts +11 -0
- package/dist/runstore/disk/catalogHelpers.js +102 -0
- package/dist/runstore/layout.d.ts +22 -0
- package/dist/runstore/layout.js +58 -0
- package/dist/runstore/paths.d.ts +7 -0
- package/dist/runstore/paths.js +60 -0
- package/dist/runstore/pipelineDagSnapshot.d.ts +5 -0
- package/dist/runstore/pipelineDagSnapshot.js +43 -0
- package/dist/runstore/port.d.ts +146 -0
- package/dist/runstore/port.js +28 -0
- package/dist/runstore/runProjection.d.ts +6 -0
- package/dist/runstore/runProjection.js +102 -0
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +48 -0
- package/dist/runstore/sqlite/SqliteRunStore.js +460 -0
- package/dist/runstore/sqlite/migrateFromDisk.d.ts +5 -0
- package/dist/runstore/sqlite/migrateFromDisk.js +108 -0
- package/dist/runstore/sqlite/schema.d.ts +1 -0
- package/dist/runstore/sqlite/schema.js +59 -0
- package/dist/runstore/stageExecution.d.ts +13 -0
- package/dist/runstore/stageExecution.js +22 -0
- package/dist/runstore/stageSnapshot.d.ts +2 -0
- package/dist/runstore/stageSnapshot.js +46 -0
- package/dist/runstore/syntheticStageSnapshot.d.ts +2 -0
- package/dist/runstore/syntheticStageSnapshot.js +10 -0
- package/dist/runstore/trackProjection.d.ts +7 -0
- package/dist/runstore/trackProjection.js +94 -0
- package/dist/runstore/workspaceLayout.d.ts +31 -0
- package/dist/runstore/workspaceLayout.js +172 -0
- package/dist/runtime/answerResume.d.ts +16 -0
- package/dist/runtime/answerResume.js +22 -0
- package/dist/runtime/credentialBinding.d.ts +16 -0
- package/dist/runtime/credentialBinding.js +80 -0
- package/dist/runtime/dagTraversal.d.ts +3 -0
- package/dist/runtime/dagTraversal.js +26 -0
- package/dist/runtime/envelopeRouting.d.ts +20 -0
- package/dist/runtime/envelopeRouting.js +59 -0
- package/dist/runtime/hitlSeams.d.ts +38 -0
- package/dist/runtime/hitlSeams.js +2 -0
- package/dist/runtime/pipelineRunner.d.ts +77 -0
- package/dist/runtime/pipelineRunner.js +141 -0
- package/dist/runtime/pipelineScheduler.d.ts +90 -0
- package/dist/runtime/pipelineScheduler.js +494 -0
- package/dist/runtime/pipelineValidationError.d.ts +6 -0
- package/dist/runtime/pipelineValidationError.js +8 -0
- package/dist/runtime/resumeReconstruct.d.ts +33 -0
- package/dist/runtime/resumeReconstruct.js +144 -0
- package/dist/runtime/runManager.d.ts +129 -0
- package/dist/runtime/runManager.js +818 -0
- package/dist/runtime/runRetryCoordinator.d.ts +85 -0
- package/dist/runtime/runRetryCoordinator.js +372 -0
- package/dist/runtime/settingsFile.d.ts +17 -0
- package/dist/runtime/settingsFile.js +88 -0
- package/dist/runtime/stageAttemptBootstrap.d.ts +40 -0
- package/dist/runtime/stageAttemptBootstrap.js +82 -0
- package/dist/runtime/stageAttemptContext.d.ts +11 -0
- package/dist/runtime/stageAttemptContext.js +26 -0
- package/dist/runtime/stageConcurrency.d.ts +10 -0
- package/dist/runtime/stageConcurrency.js +51 -0
- package/dist/runtime/stageHitl.d.ts +95 -0
- package/dist/runtime/stageHitl.js +198 -0
- package/dist/runtime/stageProcessLauncher.d.ts +45 -0
- package/dist/runtime/stageProcessLauncher.js +199 -0
- package/dist/runtime/stageRecovery.d.ts +10 -0
- package/dist/runtime/stageRecovery.js +43 -0
- package/dist/runtime/stageRoots.d.ts +22 -0
- package/dist/runtime/stageRoots.js +93 -0
- package/dist/runtime/stageRunner.d.ts +59 -0
- package/dist/runtime/stageRunner.js +221 -0
- package/dist/runtime/stageWorker.d.ts +6 -0
- package/dist/runtime/stageWorker.js +109 -0
- package/dist/runtime/stageWorkerProtocol.d.ts +27 -0
- package/dist/runtime/stageWorkerProtocol.js +16 -0
- package/dist/runtime/taskInput.d.ts +15 -0
- package/dist/runtime/taskInput.js +38 -0
- package/dist/server/http.d.ts +31 -0
- package/dist/server/http.js +561 -0
- package/dist/server/providerRoutes.d.ts +15 -0
- package/dist/server/providerRoutes.js +186 -0
- package/dist/tools/askOperator.d.ts +185 -0
- package/dist/tools/askOperator.js +409 -0
- package/dist/tools/emitStageEnvelope.d.ts +42 -0
- package/dist/tools/emitStageEnvelope.js +61 -0
- package/dist/tools/writeStageArtifact.d.ts +26 -0
- package/dist/tools/writeStageArtifact.js +103 -0
- package/dist/types/envelope.d.ts +12 -0
- package/dist/types/envelope.js +6 -0
- package/dist/types/pipeline.d.ts +25 -0
- package/dist/types/pipeline.js +1 -0
- package/dist/types/stage.d.ts +12 -0
- package/dist/types/stage.js +6 -0
- package/dist/types/task.d.ts +7 -0
- package/dist/types/task.js +1 -0
- package/dist/ui/assets/index-Cry0Tpfx.js +118 -0
- package/dist/ui/assets/index-DCsDopak.css +1 -0
- package/dist/ui/index.html +21 -0
- package/package.json +72 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadPipeline } from "../config/loadPipeline.js";
|
|
5
|
+
import { loadTaskFromYaml } from "../config/loadTask.js";
|
|
6
|
+
import { PipelineValidationError, startPipeline, } from "./pipelineRunner.js";
|
|
7
|
+
import { resumeRun } from "./pipelineScheduler.js";
|
|
8
|
+
import { RunRetryCoordinator, } from "./runRetryCoordinator.js";
|
|
9
|
+
import { readMaxActiveStageProcesses, readMaxActiveStagesPerRun, readStageExecutionMode, } from "./stageConcurrency.js";
|
|
10
|
+
import { StageProcessLauncher } from "./stageProcessLauncher.js";
|
|
11
|
+
import { INVALID_SLOT_COUNT_MESSAGE, parseSlotCount, readMaxConcurrentFromFile, writeMaxConcurrentToFile, } from "./settingsFile.js";
|
|
12
|
+
import { StageHitlController, waitKey, } from "./stageHitl.js";
|
|
13
|
+
import { resolveAndValidateCheckout, } from "./stageRoots.js";
|
|
14
|
+
import { orchestrateAnswerResume } from "./answerResume.js";
|
|
15
|
+
import { reconstructAndContinue as resumeReconstructAndContinue } from "./resumeReconstruct.js";
|
|
16
|
+
import { resumeSessionFilePath } from "./stageAttemptContext.js";
|
|
17
|
+
import { resolveStartTaskInput } from "./taskInput.js";
|
|
18
|
+
import { failStageAsInterrupted, syncRunStatusFromStages, } from "./stageRecovery.js";
|
|
19
|
+
const DEFAULT_MAX_CONCURRENT = 3;
|
|
20
|
+
const STARTUP_RECONCILE_REASON = "process_interrupted: no active worker (server restart)";
|
|
21
|
+
const OPERATOR_ABANDON_REASON = "process_interrupted: operator abandoned stage";
|
|
22
|
+
function parseMaxConcurrent(raw) {
|
|
23
|
+
if (raw === undefined || raw.trim() === "")
|
|
24
|
+
return DEFAULT_MAX_CONCURRENT;
|
|
25
|
+
const n = Number.parseInt(raw, 10);
|
|
26
|
+
if (!Number.isFinite(n) || n < 1)
|
|
27
|
+
return DEFAULT_MAX_CONCURRENT;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
async function toCheckoutLeaseKey(absPath) {
|
|
31
|
+
try {
|
|
32
|
+
return await realpath(absPath);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
const fallback = path.resolve(absPath);
|
|
36
|
+
console.error(`invariant: checkout realpath failed for ${absPath}; using path.resolve fallback ${fallback}`);
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class RunManager {
|
|
41
|
+
options;
|
|
42
|
+
active = new Map();
|
|
43
|
+
checkoutLeases = new Map();
|
|
44
|
+
provisionalIds = new Set();
|
|
45
|
+
resumeInFlight = new Set();
|
|
46
|
+
retryInFlight = new Set();
|
|
47
|
+
trackingGeneration = 0;
|
|
48
|
+
maxConcurrent;
|
|
49
|
+
maxActiveStagesPerRun;
|
|
50
|
+
executionMode;
|
|
51
|
+
stageProcessLauncher;
|
|
52
|
+
hitl;
|
|
53
|
+
attachedWaiting = new Set();
|
|
54
|
+
retryCoordinator = new RunRetryCoordinator();
|
|
55
|
+
retryTracking = {
|
|
56
|
+
ensureResumeTracked: async (runId) => {
|
|
57
|
+
const wasActive = this.active.has(runId);
|
|
58
|
+
const tracked = await this.ensureResumeTracked(runId);
|
|
59
|
+
if (!tracked.ok) {
|
|
60
|
+
return tracked;
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, insertedForResume: !wasActive };
|
|
63
|
+
},
|
|
64
|
+
onOrchestrationStarted: (runId, promise) => {
|
|
65
|
+
this.registerResumeUntrack(runId, promise);
|
|
66
|
+
},
|
|
67
|
+
rollbackStartTracking: async (runId, state) => {
|
|
68
|
+
if (state.insertedForResume) {
|
|
69
|
+
this.removeActiveEntry(runId, false);
|
|
70
|
+
}
|
|
71
|
+
if (state.bumpedRunning) {
|
|
72
|
+
try {
|
|
73
|
+
await this.options.store.updateRunStatus(runId, state.priorRunStatus);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// ignore secondary failures
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
cwd;
|
|
82
|
+
constructor(options) {
|
|
83
|
+
this.options = options;
|
|
84
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
85
|
+
this.maxConcurrent =
|
|
86
|
+
options.maxConcurrent ??
|
|
87
|
+
readMaxConcurrentFromFile(this.cwd) ??
|
|
88
|
+
parseMaxConcurrent(process.env.STAGEFLOW_MAX_CONCURRENT_RUNS);
|
|
89
|
+
this.maxActiveStagesPerRun = readMaxActiveStagesPerRun(process.env, options.maxActiveStagesPerRun);
|
|
90
|
+
this.executionMode = readStageExecutionMode(process.env, options.executionMode);
|
|
91
|
+
this.stageProcessLauncher =
|
|
92
|
+
this.executionMode === "process"
|
|
93
|
+
? (options.stageProcessLauncher ?? new StageProcessLauncher())
|
|
94
|
+
: undefined;
|
|
95
|
+
this.hitl = new StageHitlController({
|
|
96
|
+
store: options.store,
|
|
97
|
+
seams: options.seams,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
getMaxConcurrent() {
|
|
101
|
+
return this.maxConcurrent;
|
|
102
|
+
}
|
|
103
|
+
setMaxConcurrent(n) {
|
|
104
|
+
const parsed = parseSlotCount(n);
|
|
105
|
+
if (parsed === undefined) {
|
|
106
|
+
throw new Error(INVALID_SLOT_COUNT_MESSAGE);
|
|
107
|
+
}
|
|
108
|
+
this.maxConcurrent = parsed;
|
|
109
|
+
writeMaxConcurrentToFile(this.cwd, parsed);
|
|
110
|
+
return this.getHealth();
|
|
111
|
+
}
|
|
112
|
+
getActiveCount() {
|
|
113
|
+
return this.active.size;
|
|
114
|
+
}
|
|
115
|
+
getActiveRunIds() {
|
|
116
|
+
return [...this.active.keys()];
|
|
117
|
+
}
|
|
118
|
+
getHealth() {
|
|
119
|
+
const activeRunIds = this.getActiveRunIds();
|
|
120
|
+
const activeCount = this.getActiveCount();
|
|
121
|
+
const maxConcurrent = this.getMaxConcurrent();
|
|
122
|
+
const maxActiveStageProcessesRaw = readMaxActiveStageProcesses(process.env);
|
|
123
|
+
const activeStageProcesses = this.stageProcessLauncher?.activeCount() ?? 0;
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
activeRunIds,
|
|
127
|
+
activeCount,
|
|
128
|
+
maxConcurrent,
|
|
129
|
+
slotsAvailable: Math.max(0, maxConcurrent - activeCount),
|
|
130
|
+
activeStageProcesses,
|
|
131
|
+
maxActiveStageProcesses: Number.isFinite(maxActiveStageProcessesRaw)
|
|
132
|
+
? maxActiveStageProcessesRaw
|
|
133
|
+
: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
getHitlController() {
|
|
137
|
+
return this.hitl;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* KTD7/KTD8: discover waiting stages from the store; leave waiting; do not
|
|
141
|
+
* auto-resume. Registers each waiting run into the active set (capacity)
|
|
142
|
+
* and re-acquires checkout leases from durable checkout_root (even over soft max).
|
|
143
|
+
*/
|
|
144
|
+
async attachWaitingStages() {
|
|
145
|
+
const found = [];
|
|
146
|
+
const runs = await this.options.store.listRuns();
|
|
147
|
+
for (const summary of runs) {
|
|
148
|
+
if (summary.status !== "running")
|
|
149
|
+
continue;
|
|
150
|
+
let detail;
|
|
151
|
+
try {
|
|
152
|
+
detail = await this.options.store.readRun(summary.run_id);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const waitingStages = detail.stages.filter((s) => s.status === "waiting_for_input");
|
|
158
|
+
if (waitingStages.length === 0)
|
|
159
|
+
continue;
|
|
160
|
+
const runId = summary.run_id;
|
|
161
|
+
if (this.active.has(runId)) {
|
|
162
|
+
for (const stage of waitingStages) {
|
|
163
|
+
found.push({ runId, stageId: stage.stage_id });
|
|
164
|
+
this.attachedWaiting.add(`${runId}\0${stage.stage_id}`);
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let checkoutKey;
|
|
169
|
+
let durableCheckoutRoot;
|
|
170
|
+
try {
|
|
171
|
+
const meta = await this.options.store.readRunMeta(runId);
|
|
172
|
+
const checkoutRoot = meta.checkout_root;
|
|
173
|
+
if (checkoutRoot !== undefined && checkoutRoot !== "") {
|
|
174
|
+
durableCheckoutRoot = checkoutRoot;
|
|
175
|
+
checkoutKey = await toCheckoutLeaseKey(checkoutRoot);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
console.error(`invariant: attach failed reading checkout_root for run ${runId}; tracking without checkout lease: ${err instanceof Error ? err.message : String(err)}`);
|
|
180
|
+
}
|
|
181
|
+
const conflictHolder = this.findActiveCheckoutConflict(runId, checkoutKey, durableCheckoutRoot);
|
|
182
|
+
if (conflictHolder !== undefined) {
|
|
183
|
+
const reason = `duplicate_checkout_attach: checkout already held by ${conflictHolder}`;
|
|
184
|
+
await this.quarantineAttachRun(runId, waitingStages, reason);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (checkoutKey !== undefined) {
|
|
188
|
+
this.checkoutLeases.set(checkoutKey, runId);
|
|
189
|
+
}
|
|
190
|
+
this.active.set(runId, {
|
|
191
|
+
checkoutKey,
|
|
192
|
+
durableCheckoutRoot,
|
|
193
|
+
generation: ++this.trackingGeneration,
|
|
194
|
+
});
|
|
195
|
+
for (const stage of waitingStages) {
|
|
196
|
+
found.push({ runId, stageId: stage.stage_id });
|
|
197
|
+
this.attachedWaiting.add(`${runId}\0${stage.stage_id}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return found;
|
|
201
|
+
}
|
|
202
|
+
hasActiveWorker(runId, stageId) {
|
|
203
|
+
if (this.stageProcessLauncher === undefined) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return this.stageProcessLauncher
|
|
207
|
+
.getActiveStageProcesses()
|
|
208
|
+
.some((entry) => entry.runId === runId && entry.stageId === stageId);
|
|
209
|
+
}
|
|
210
|
+
async reconcileOrphanedStages() {
|
|
211
|
+
const reconciled = [];
|
|
212
|
+
const runs = await this.options.store.listRuns();
|
|
213
|
+
for (const summary of runs) {
|
|
214
|
+
let detail;
|
|
215
|
+
try {
|
|
216
|
+
detail = await this.options.store.readRun(summary.run_id);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
console.error(`reconcileOrphanedStages: failed to read run ${summary.run_id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const runId = summary.run_id;
|
|
223
|
+
let runChanged = false;
|
|
224
|
+
for (const stage of detail.stages) {
|
|
225
|
+
if (stage.status !== "running")
|
|
226
|
+
continue;
|
|
227
|
+
if (this.hasActiveWorker(runId, stage.stage_id))
|
|
228
|
+
continue;
|
|
229
|
+
try {
|
|
230
|
+
await failStageAsInterrupted({
|
|
231
|
+
store: this.options.store,
|
|
232
|
+
runId,
|
|
233
|
+
stageId: stage.stage_id,
|
|
234
|
+
reason: STARTUP_RECONCILE_REASON,
|
|
235
|
+
});
|
|
236
|
+
reconciled.push({
|
|
237
|
+
runId,
|
|
238
|
+
stageId: stage.stage_id,
|
|
239
|
+
reason: STARTUP_RECONCILE_REASON,
|
|
240
|
+
});
|
|
241
|
+
runChanged = true;
|
|
242
|
+
console.error(`reconcileOrphanedStages: failed orphaned stage ${runId}/${stage.stage_id}: ${STARTUP_RECONCILE_REASON}`);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.error(`reconcileOrphanedStages: failed to reconcile ${runId}/${stage.stage_id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (runChanged) {
|
|
249
|
+
try {
|
|
250
|
+
await syncRunStatusFromStages(this.options.store, runId);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
console.error(`reconcileOrphanedStages: failed to sync run status for ${runId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (reconciled.length > 0) {
|
|
258
|
+
console.error(`reconcileOrphanedStages: reconciled ${reconciled.length} orphaned stage(s)`);
|
|
259
|
+
}
|
|
260
|
+
return { reconciled };
|
|
261
|
+
}
|
|
262
|
+
async abandonStage(runId, stageId) {
|
|
263
|
+
let detail;
|
|
264
|
+
try {
|
|
265
|
+
detail = await this.options.store.readRun(runId);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return { ok: false, reason: `Run not found: ${runId}`, status: 404 };
|
|
269
|
+
}
|
|
270
|
+
const stageSnap = detail.stages.find((s) => s.stage_id === stageId);
|
|
271
|
+
if (!stageSnap) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
reason: `Stage not found: ${stageId}`,
|
|
275
|
+
status: 404,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (stageSnap.status === "waiting_for_input") {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: `Stage is waiting for input and cannot be abandoned`,
|
|
282
|
+
status: 409,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (stageSnap.status !== "running") {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: `Stage is not running (status=${stageSnap.status})`,
|
|
289
|
+
status: 409,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (this.stageProcessLauncher !== undefined) {
|
|
293
|
+
const hasActive = this.stageProcessLauncher
|
|
294
|
+
.getActiveStageProcesses()
|
|
295
|
+
.some((entry) => entry.runId === runId);
|
|
296
|
+
if (hasActive) {
|
|
297
|
+
await this.stageProcessLauncher.cancelRun(runId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await failStageAsInterrupted({
|
|
301
|
+
store: this.options.store,
|
|
302
|
+
runId,
|
|
303
|
+
stageId,
|
|
304
|
+
reason: OPERATOR_ABANDON_REASON,
|
|
305
|
+
});
|
|
306
|
+
if (this.retryCoordinator.isActive(runId) &&
|
|
307
|
+
this.retryCoordinator.getActiveRoots(runId)?.has(stageId)) {
|
|
308
|
+
this.retryCoordinator.signalExternalRetryRootTerminal(runId, stageId);
|
|
309
|
+
}
|
|
310
|
+
await syncRunStatusFromStages(this.options.store, runId);
|
|
311
|
+
const after = await this.options.store.readRun(runId);
|
|
312
|
+
const hasWaiting = after.stages.some((s) => s.status === "waiting_for_input");
|
|
313
|
+
if (!hasWaiting && this.active.has(runId)) {
|
|
314
|
+
this.removeActiveEntry(runId, false);
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, runId, stageId };
|
|
317
|
+
}
|
|
318
|
+
async startRun(input) {
|
|
319
|
+
const cwd = this.options.cwd ?? process.cwd();
|
|
320
|
+
let resolved;
|
|
321
|
+
try {
|
|
322
|
+
resolved = resolveStartTaskInput(input, cwd);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
328
|
+
status: 400,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
let taskYaml;
|
|
332
|
+
let label;
|
|
333
|
+
try {
|
|
334
|
+
if (resolved.kind === "path") {
|
|
335
|
+
taskYaml = await readFile(resolved.taskPath, "utf8");
|
|
336
|
+
label = resolved.taskPath;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
taskYaml = resolved.taskYaml;
|
|
340
|
+
label = "task.yaml";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
347
|
+
status: 400,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return this.reserveAndStartPipeline(taskYaml, input.pipeline, `task file ${label}`, cwd, input.checkoutOverride);
|
|
351
|
+
}
|
|
352
|
+
async rerun(runId) {
|
|
353
|
+
const cwd = this.options.cwd ?? process.cwd();
|
|
354
|
+
let pipelineId;
|
|
355
|
+
let taskYaml;
|
|
356
|
+
try {
|
|
357
|
+
const meta = await this.options.store.readRunMeta(runId);
|
|
358
|
+
pipelineId = meta.pipeline_id;
|
|
359
|
+
taskYaml = await this.options.store.readTaskYaml(runId);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return { ok: false, reason: `Run not found: ${runId}`, status: 404 };
|
|
363
|
+
}
|
|
364
|
+
return this.reserveAndStartPipeline(taskYaml, pipelineId, `run ${runId} task`, cwd);
|
|
365
|
+
}
|
|
366
|
+
async retryStage(runId, stageId) {
|
|
367
|
+
const retryKey = waitKey(runId, stageId);
|
|
368
|
+
if (this.retryInFlight.has(retryKey)) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
reason: `Retry already in progress for run ${runId} stage ${stageId}`,
|
|
372
|
+
status: 409,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
this.retryInFlight.add(retryKey);
|
|
376
|
+
try {
|
|
377
|
+
const orchestrationConflict = this.active.has(runId) &&
|
|
378
|
+
!this.attachedWaiting.has(waitKey(runId, stageId));
|
|
379
|
+
return await this.retryCoordinator.retryStage({
|
|
380
|
+
runId,
|
|
381
|
+
stageId,
|
|
382
|
+
store: this.options.store,
|
|
383
|
+
agent: this.options.agent,
|
|
384
|
+
cwd: this.cwd,
|
|
385
|
+
operatorCatalog: this.options.operatorCatalog,
|
|
386
|
+
maxActiveStagesPerRun: this.maxActiveStagesPerRun,
|
|
387
|
+
executionMode: this.executionMode,
|
|
388
|
+
stageProcessLauncher: this.stageProcessLauncher,
|
|
389
|
+
hitl: this.hitl,
|
|
390
|
+
orchestrationConflict,
|
|
391
|
+
tracking: this.retryTracking,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
this.retryInFlight.delete(retryKey);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Deliver an operator answer for `(runId, stageId)` (KTD2 / KTD6).
|
|
400
|
+
* Same-process: unparks the live yield loop. After restart: reconstructs
|
|
401
|
+
* the stage session seam and continues that stage (KTD8).
|
|
402
|
+
*/
|
|
403
|
+
async deliverAnswer(runId, stageId, opaqueAnswer) {
|
|
404
|
+
const resumeKey = waitKey(runId, stageId);
|
|
405
|
+
const isLive = this.executionMode !== "process" &&
|
|
406
|
+
this.hitl.hasLiveWait(runId, stageId);
|
|
407
|
+
if (!isLive) {
|
|
408
|
+
if (this.resumeInFlight.has(resumeKey)) {
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
reason: `Resume already in progress for run ${runId} stage ${stageId}`,
|
|
412
|
+
status: 409,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
this.resumeInFlight.add(resumeKey);
|
|
416
|
+
}
|
|
417
|
+
let detail;
|
|
418
|
+
try {
|
|
419
|
+
detail = await this.options.store.readRun(runId);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
if (!isLive)
|
|
423
|
+
this.resumeInFlight.delete(resumeKey);
|
|
424
|
+
return { ok: false, reason: `Run not found: ${runId}`, status: 404 };
|
|
425
|
+
}
|
|
426
|
+
const stageSnap = detail.stages.find((s) => s.stage_id === stageId);
|
|
427
|
+
if (!stageSnap) {
|
|
428
|
+
if (!isLive)
|
|
429
|
+
this.resumeInFlight.delete(resumeKey);
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
reason: `Stage not found: ${stageId}`,
|
|
433
|
+
status: 404,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (stageSnap.status !== "waiting_for_input") {
|
|
437
|
+
if (!isLive)
|
|
438
|
+
this.resumeInFlight.delete(resumeKey);
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
reason: `Stage is not waiting for input (status=${stageSnap.status})`,
|
|
442
|
+
status: 409,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
let insertedForResume = false;
|
|
446
|
+
if (!isLive) {
|
|
447
|
+
const wasActive = this.active.has(runId);
|
|
448
|
+
const tracked = await this.ensureResumeTracked(runId);
|
|
449
|
+
if (!tracked.ok) {
|
|
450
|
+
this.resumeInFlight.delete(resumeKey);
|
|
451
|
+
return {
|
|
452
|
+
ok: false,
|
|
453
|
+
reason: tracked.reason,
|
|
454
|
+
status: 409,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
insertedForResume = !wasActive;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const prefix = await this.hitl.deliverAnswerPrefix(runId, stageId, opaqueAnswer);
|
|
461
|
+
if (!prefix.ok) {
|
|
462
|
+
if (insertedForResume) {
|
|
463
|
+
this.removeActiveEntry(runId, false);
|
|
464
|
+
}
|
|
465
|
+
return prefix;
|
|
466
|
+
}
|
|
467
|
+
return await orchestrateAnswerResume({
|
|
468
|
+
prefixMode: prefix.mode,
|
|
469
|
+
executionMode: this.executionMode,
|
|
470
|
+
insertedForResume,
|
|
471
|
+
removeInsertedResume: () => {
|
|
472
|
+
this.removeActiveEntry(runId, false);
|
|
473
|
+
},
|
|
474
|
+
registerResumeUntrack: (done) => {
|
|
475
|
+
this.registerResumeUntrack(runId, done);
|
|
476
|
+
},
|
|
477
|
+
resumeViaSubprocess: () => this.resumeViaSubprocess(runId, stageId, opaqueAnswer),
|
|
478
|
+
reconstructAndContinue: () => this.reconstructAndContinue(runId, stageId, opaqueAnswer),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
if (!isLive) {
|
|
483
|
+
this.resumeInFlight.delete(resumeKey);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async resumeViaSubprocess(runId, stageId, opaqueAnswer) {
|
|
488
|
+
const cwd = this.options.cwd ?? process.cwd();
|
|
489
|
+
const store = this.options.store;
|
|
490
|
+
const launcher = this.stageProcessLauncher;
|
|
491
|
+
if (!launcher) {
|
|
492
|
+
return { ok: false, reason: "stage process launcher is not configured" };
|
|
493
|
+
}
|
|
494
|
+
const latest = await store.getLatestStageExecution(runId, stageId);
|
|
495
|
+
const attempt = latest?.attempt ?? 1;
|
|
496
|
+
const workspaceDir = store.getWorkspaceDir(runId);
|
|
497
|
+
const sessionFilePath = resumeSessionFilePath(workspaceDir, stageId, attempt);
|
|
498
|
+
const eventOptions = { attempt };
|
|
499
|
+
try {
|
|
500
|
+
const launchResult = await launcher.launch({
|
|
501
|
+
runId,
|
|
502
|
+
stageId,
|
|
503
|
+
rootDir: cwd,
|
|
504
|
+
mode: "resume",
|
|
505
|
+
resumeAnswer: opaqueAnswer,
|
|
506
|
+
attempt,
|
|
507
|
+
sessionFilePath,
|
|
508
|
+
...(this.options.operatorCatalog !== undefined
|
|
509
|
+
? { operatorCatalog: this.options.operatorCatalog }
|
|
510
|
+
: {}),
|
|
511
|
+
});
|
|
512
|
+
if (launchResult.type === "waiting") {
|
|
513
|
+
return { ok: true };
|
|
514
|
+
}
|
|
515
|
+
if (launchResult.type === "failed") {
|
|
516
|
+
await store.appendStageEvent(runId, stageId, {
|
|
517
|
+
event: "failed",
|
|
518
|
+
reason: launchResult.reason,
|
|
519
|
+
}, eventOptions);
|
|
520
|
+
await store.updateRunStatus(runId, "failed");
|
|
521
|
+
return { ok: false, reason: launchResult.reason };
|
|
522
|
+
}
|
|
523
|
+
let initialPrior = null;
|
|
524
|
+
try {
|
|
525
|
+
initialPrior = await store.readEnvelope(runId, stageId);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// downstream resume may read envelope from store
|
|
529
|
+
}
|
|
530
|
+
const meta = await store.readRunMeta(runId);
|
|
531
|
+
const taskYaml = await store.readTaskYaml(runId);
|
|
532
|
+
const task = loadTaskFromYaml(taskYaml, `run ${runId} task`);
|
|
533
|
+
const loaded = await loadPipeline(meta.pipeline_id, { cwd });
|
|
534
|
+
const rest = await resumeRun({
|
|
535
|
+
prepared: {
|
|
536
|
+
task,
|
|
537
|
+
loaded,
|
|
538
|
+
run: { runId, workspaceDir: store.getWorkspaceDir(runId) },
|
|
539
|
+
agent: this.options.agent,
|
|
540
|
+
store,
|
|
541
|
+
cwd,
|
|
542
|
+
checkoutRoot: meta.checkout_root,
|
|
543
|
+
hitl: this.hitl,
|
|
544
|
+
operatorCatalog: this.options.operatorCatalog,
|
|
545
|
+
},
|
|
546
|
+
maxActiveStagesPerRun: this.maxActiveStagesPerRun,
|
|
547
|
+
resumeFromStageId: stageId,
|
|
548
|
+
initialPrior,
|
|
549
|
+
executionMode: this.executionMode,
|
|
550
|
+
stageProcessLauncher: launcher,
|
|
551
|
+
});
|
|
552
|
+
return rest.ok ? { ok: true } : { ok: false, reason: rest.reason };
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
const reason = err instanceof Error
|
|
556
|
+
? err.message
|
|
557
|
+
: `resume failed: ${String(err)}`;
|
|
558
|
+
try {
|
|
559
|
+
await store.appendStageEvent(runId, stageId, {
|
|
560
|
+
event: "failed",
|
|
561
|
+
reason,
|
|
562
|
+
}, eventOptions);
|
|
563
|
+
await store.updateRunStatus(runId, "failed");
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// ignore secondary failures
|
|
567
|
+
}
|
|
568
|
+
return { ok: false, reason };
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
this.attachedWaiting.delete(`${runId}\0${stageId}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async reconstructAndContinue(runId, stageId, opaqueAnswer) {
|
|
575
|
+
try {
|
|
576
|
+
return await resumeReconstructAndContinue({
|
|
577
|
+
runId,
|
|
578
|
+
stageId,
|
|
579
|
+
opaqueAnswer,
|
|
580
|
+
agent: this.options.agent,
|
|
581
|
+
store: this.options.store,
|
|
582
|
+
hitl: this.hitl,
|
|
583
|
+
executionMode: this.executionMode,
|
|
584
|
+
stageProcessLauncher: this.stageProcessLauncher,
|
|
585
|
+
cwd: this.cwd,
|
|
586
|
+
maxActiveStagesPerRun: this.maxActiveStagesPerRun,
|
|
587
|
+
operatorCatalog: this.options.operatorCatalog,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
this.attachedWaiting.delete(`${runId}\0${stageId}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async reserveAndStartPipeline(taskYaml, pipelineId, taskLabel, cwd, checkoutOverride) {
|
|
595
|
+
let checkoutKey;
|
|
596
|
+
try {
|
|
597
|
+
const task = loadTaskFromYaml(taskYaml, taskLabel);
|
|
598
|
+
const checkoutRoot = await resolveAndValidateCheckout(task, checkoutOverride, cwd);
|
|
599
|
+
checkoutKey =
|
|
600
|
+
checkoutRoot !== undefined
|
|
601
|
+
? await toCheckoutLeaseKey(checkoutRoot)
|
|
602
|
+
: undefined;
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
608
|
+
status: 400,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
const reserved = this.tryReserve(checkoutKey);
|
|
612
|
+
if (!reserved.ok)
|
|
613
|
+
return reserved.failure;
|
|
614
|
+
try {
|
|
615
|
+
const started = await startPipeline({
|
|
616
|
+
agent: this.options.agent,
|
|
617
|
+
store: this.options.store,
|
|
618
|
+
taskYaml,
|
|
619
|
+
pipeline: pipelineId,
|
|
620
|
+
cwd,
|
|
621
|
+
checkoutOverride,
|
|
622
|
+
hitl: this.hitl,
|
|
623
|
+
maxActiveStagesPerRun: this.maxActiveStagesPerRun,
|
|
624
|
+
executionMode: this.executionMode,
|
|
625
|
+
stageProcessLauncher: this.stageProcessLauncher,
|
|
626
|
+
operatorCatalog: this.options.operatorCatalog,
|
|
627
|
+
});
|
|
628
|
+
this.track(reserved.provisionalId, started.runId, started.done);
|
|
629
|
+
return { ok: true, runId: started.runId, done: started.done };
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
this.clearReservation(reserved.provisionalId);
|
|
633
|
+
if (err instanceof PipelineValidationError) {
|
|
634
|
+
throw err;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
639
|
+
status: 500,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
busyFailure(code, extras) {
|
|
644
|
+
const activeRunIds = this.getActiveRunIds();
|
|
645
|
+
const reason = code === "busy_capacity"
|
|
646
|
+
? `Capacity full: ${this.active.size}/${this.maxConcurrent} active runs`
|
|
647
|
+
: `Checkout in use by run ${extras?.conflictingRunId ?? "unknown"}`;
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
reason,
|
|
651
|
+
status: 409,
|
|
652
|
+
code,
|
|
653
|
+
activeCount: this.active.size,
|
|
654
|
+
maxConcurrent: this.maxConcurrent,
|
|
655
|
+
activeRunIds,
|
|
656
|
+
...extras,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
tryReserve(checkoutKey) {
|
|
660
|
+
if (this.active.size >= this.maxConcurrent) {
|
|
661
|
+
return { ok: false, failure: this.busyFailure("busy_capacity") };
|
|
662
|
+
}
|
|
663
|
+
if (checkoutKey !== undefined) {
|
|
664
|
+
const holder = this.checkoutLeases.get(checkoutKey);
|
|
665
|
+
if (holder !== undefined) {
|
|
666
|
+
const conflictingRunId = this.provisionalIds.has(holder)
|
|
667
|
+
? undefined
|
|
668
|
+
: holder;
|
|
669
|
+
return {
|
|
670
|
+
ok: false,
|
|
671
|
+
failure: this.busyFailure("busy_checkout", {
|
|
672
|
+
conflictingRunId,
|
|
673
|
+
conflictingCheckout: checkoutKey,
|
|
674
|
+
}),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const provisionalId = randomUUID();
|
|
679
|
+
this.provisionalIds.add(provisionalId);
|
|
680
|
+
this.active.set(provisionalId, {
|
|
681
|
+
checkoutKey,
|
|
682
|
+
generation: ++this.trackingGeneration,
|
|
683
|
+
});
|
|
684
|
+
if (checkoutKey !== undefined) {
|
|
685
|
+
this.checkoutLeases.set(checkoutKey, provisionalId);
|
|
686
|
+
}
|
|
687
|
+
return { ok: true, provisionalId };
|
|
688
|
+
}
|
|
689
|
+
clearReservation(provisionalId) {
|
|
690
|
+
this.removeActiveEntry(provisionalId, true);
|
|
691
|
+
}
|
|
692
|
+
track(provisionalId, runId, done) {
|
|
693
|
+
const entry = this.active.get(provisionalId) ?? {
|
|
694
|
+
generation: ++this.trackingGeneration,
|
|
695
|
+
};
|
|
696
|
+
this.active.delete(provisionalId);
|
|
697
|
+
this.provisionalIds.delete(provisionalId);
|
|
698
|
+
const generation = ++this.trackingGeneration;
|
|
699
|
+
this.active.set(runId, { ...entry, generation });
|
|
700
|
+
if (entry.checkoutKey !== undefined) {
|
|
701
|
+
this.checkoutLeases.set(entry.checkoutKey, runId);
|
|
702
|
+
}
|
|
703
|
+
void done.finally(() => {
|
|
704
|
+
this.untrackIfGeneration(runId, generation);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Acquire active tracking + checkout lease for a restart resume before
|
|
709
|
+
* reconstruct starts. Over-max is allowed. Lease held by another run → fail closed.
|
|
710
|
+
*/
|
|
711
|
+
async ensureResumeTracked(runId) {
|
|
712
|
+
if (this.active.has(runId)) {
|
|
713
|
+
return { ok: true };
|
|
714
|
+
}
|
|
715
|
+
let checkoutKey;
|
|
716
|
+
let durableCheckoutRoot;
|
|
717
|
+
try {
|
|
718
|
+
const meta = await this.options.store.readRunMeta(runId);
|
|
719
|
+
const checkoutRoot = meta.checkout_root;
|
|
720
|
+
if (checkoutRoot !== undefined && checkoutRoot !== "") {
|
|
721
|
+
durableCheckoutRoot = checkoutRoot;
|
|
722
|
+
checkoutKey = await toCheckoutLeaseKey(checkoutRoot);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
console.error(`invariant: trackResume failed reading checkout_root for run ${runId}; continuing without checkout lease: ${err instanceof Error ? err.message : String(err)}`);
|
|
727
|
+
}
|
|
728
|
+
const conflictHolder = this.findActiveCheckoutConflict(runId, checkoutKey, durableCheckoutRoot);
|
|
729
|
+
if (conflictHolder !== undefined) {
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
reason: `duplicate_checkout_resume: checkout already held by ${conflictHolder}`,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (checkoutKey !== undefined) {
|
|
736
|
+
this.checkoutLeases.set(checkoutKey, runId);
|
|
737
|
+
}
|
|
738
|
+
this.active.set(runId, {
|
|
739
|
+
checkoutKey,
|
|
740
|
+
durableCheckoutRoot,
|
|
741
|
+
generation: ++this.trackingGeneration,
|
|
742
|
+
});
|
|
743
|
+
return { ok: true };
|
|
744
|
+
}
|
|
745
|
+
registerResumeUntrack(runId, done) {
|
|
746
|
+
const entry = this.active.get(runId);
|
|
747
|
+
if (entry === undefined)
|
|
748
|
+
return;
|
|
749
|
+
const generation = ++this.trackingGeneration;
|
|
750
|
+
this.active.set(runId, { ...entry, generation });
|
|
751
|
+
void done.finally(() => {
|
|
752
|
+
this.untrackIfGeneration(runId, generation);
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
findActiveCheckoutConflict(runId, checkoutKey, durableCheckoutRoot) {
|
|
756
|
+
if (checkoutKey !== undefined) {
|
|
757
|
+
const holder = this.checkoutLeases.get(checkoutKey);
|
|
758
|
+
if (holder !== undefined && holder !== runId) {
|
|
759
|
+
return holder;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
for (const [otherId, entry] of this.active) {
|
|
763
|
+
if (otherId === runId)
|
|
764
|
+
continue;
|
|
765
|
+
if (checkoutKey !== undefined &&
|
|
766
|
+
entry.checkoutKey !== undefined &&
|
|
767
|
+
entry.checkoutKey === checkoutKey) {
|
|
768
|
+
return otherId;
|
|
769
|
+
}
|
|
770
|
+
if (durableCheckoutRoot !== undefined &&
|
|
771
|
+
durableCheckoutRoot !== "" &&
|
|
772
|
+
entry.durableCheckoutRoot !== undefined &&
|
|
773
|
+
entry.durableCheckoutRoot === durableCheckoutRoot) {
|
|
774
|
+
return otherId;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return undefined;
|
|
778
|
+
}
|
|
779
|
+
async quarantineAttachRun(runId, waitingStages, reason) {
|
|
780
|
+
for (const stage of waitingStages) {
|
|
781
|
+
try {
|
|
782
|
+
await this.options.store.appendStageEvent(runId, stage.stage_id, {
|
|
783
|
+
event: "failed",
|
|
784
|
+
reason,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// ignore secondary failures — still fail-closed in memory
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
await this.options.store.updateRunStatus(runId, "failed");
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
// ignore secondary failures — still fail-closed in memory
|
|
796
|
+
}
|
|
797
|
+
console.error(reason);
|
|
798
|
+
}
|
|
799
|
+
untrackIfGeneration(runId, generation) {
|
|
800
|
+
const entry = this.active.get(runId);
|
|
801
|
+
if (entry === undefined || entry.generation !== generation)
|
|
802
|
+
return;
|
|
803
|
+
this.removeActiveEntry(runId, false);
|
|
804
|
+
}
|
|
805
|
+
removeActiveEntry(id, isProvisional) {
|
|
806
|
+
const entry = this.active.get(id);
|
|
807
|
+
if (!entry)
|
|
808
|
+
return;
|
|
809
|
+
this.active.delete(id);
|
|
810
|
+
if (isProvisional) {
|
|
811
|
+
this.provisionalIds.delete(id);
|
|
812
|
+
}
|
|
813
|
+
if (entry.checkoutKey !== undefined &&
|
|
814
|
+
this.checkoutLeases.get(entry.checkoutKey) === id) {
|
|
815
|
+
this.checkoutLeases.delete(entry.checkoutKey);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|