intentdna 1.8.6 → 1.8.7
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/dist/cli/commands/run-lifecycle.d.ts +73 -0
- package/dist/cli/commands/run-lifecycle.js +240 -0
- package/dist/cli/commands/run.d.ts +22 -40
- package/dist/cli/commands/run.js +674 -392
- package/dist/cli/index.js +87 -2
- package/dist/compiler/workflow.js +3 -2
- package/dist/hooks/cli.d.ts +1 -2
- package/dist/hooks/cli.js +119 -80
- package/dist/hooks/enforce.d.ts +2 -0
- package/dist/hooks/enforce.js +56 -27
- package/dist/hooks/enforcement-boundary.d.ts +13 -0
- package/dist/hooks/enforcement-boundary.js +33 -0
- package/dist/hooks/index.d.ts +3 -2
- package/dist/hooks/index.js +3 -2
- package/dist/hooks/protocol.d.ts +12 -4
- package/dist/hooks/protocol.js +20 -14
- package/dist/hooks/schema.d.ts +2 -1
- package/dist/hooks/schema.js +6 -2
- package/dist/hooks/state-manager.d.ts +5 -5
- package/dist/hooks/state-manager.js +26 -24
- package/dist/hooks/state.d.ts +19 -3
- package/dist/hooks/state.js +327 -80
- package/dist/mcp/index.js +0 -0
- package/dist/runtime/diagnosis-contract-verifier.d.ts +11 -0
- package/dist/runtime/diagnosis-contract-verifier.js +417 -0
- package/dist/runtime/execution-provider.d.ts +40 -0
- package/dist/runtime/execution-provider.js +138 -0
- package/dist/runtime/handoff-resolver.d.ts +61 -0
- package/dist/runtime/handoff-resolver.js +167 -0
- package/dist/runtime/index.d.ts +24 -0
- package/dist/runtime/index.js +13 -0
- package/dist/runtime/process-tree.d.ts +47 -0
- package/dist/runtime/process-tree.js +402 -0
- package/dist/runtime/providers/claude.d.ts +9 -0
- package/dist/runtime/providers/claude.js +64 -0
- package/dist/runtime/providers/codex.d.ts +8 -0
- package/dist/runtime/providers/codex.js +72 -0
- package/dist/runtime/result-store.d.ts +32 -0
- package/dist/runtime/result-store.js +130 -0
- package/dist/runtime/run-contracts.d.ts +290 -0
- package/dist/runtime/run-contracts.js +58 -0
- package/dist/runtime/run-controller.d.ts +149 -0
- package/dist/runtime/run-controller.js +1108 -0
- package/dist/runtime/run-store.d.ts +96 -0
- package/dist/runtime/run-store.js +725 -0
- package/dist/runtime/worker-executor.d.ts +19 -0
- package/dist/runtime/worker-executor.js +194 -0
- package/dist/runtime/workflow-plan-adapter.d.ts +26 -0
- package/dist/runtime/workflow-plan-adapter.js +416 -0
- package/dist/runtime/workflow-runner.d.ts +15 -3
- package/dist/runtime/workflow-runner.js +13 -1
- package/dist/runtime/workspace-isolation.d.ts +103 -0
- package/dist/runtime/workspace-isolation.js +373 -0
- package/dist/schema/types.d.ts +1 -0
- package/dist/schema/validate.js +64 -6
- package/dist/schema/yaml-parser.js +7 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { HandoffResolver, } from "./handoff-resolver.js";
|
|
3
|
+
import { ImmutableResultStore } from "./result-store.js";
|
|
4
|
+
import { RunStoreError, } from "./run-store.js";
|
|
5
|
+
import { executeWorkerAttempt, } from "./worker-executor.js";
|
|
6
|
+
export class RunControllerError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = "RunControllerError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const DEFAULT_SCHEDULER_POLL_MS = 100;
|
|
15
|
+
const TERMINAL_TASK_STATES = new Set([
|
|
16
|
+
"succeeded",
|
|
17
|
+
"failed",
|
|
18
|
+
"skipped",
|
|
19
|
+
"cancelled",
|
|
20
|
+
]);
|
|
21
|
+
function sleep(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
function isVersionConflict(error) {
|
|
25
|
+
return error instanceof RunStoreError && error.code === "version_conflict";
|
|
26
|
+
}
|
|
27
|
+
function terminalTask(task) {
|
|
28
|
+
return TERMINAL_TASK_STATES.has(task.state);
|
|
29
|
+
}
|
|
30
|
+
function assertPositiveInteger(name, value) {
|
|
31
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
32
|
+
throw new RunControllerError("invalid_configuration", `${name} must be a positive safe integer`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function taskByStep(snapshot, stepId) {
|
|
36
|
+
return snapshot.tasks.find((task) => task.step_id === stepId) ?? null;
|
|
37
|
+
}
|
|
38
|
+
function attemptById(snapshot, attemptId) {
|
|
39
|
+
if (attemptId === null)
|
|
40
|
+
return null;
|
|
41
|
+
return snapshot.attempts.find((attempt) => attempt.attempt_id === attemptId) ?? null;
|
|
42
|
+
}
|
|
43
|
+
function claimByAttempt(snapshot, attemptId) {
|
|
44
|
+
return snapshot.claims.find((claim) => claim.attempt_id === attemptId) ?? null;
|
|
45
|
+
}
|
|
46
|
+
function iso(date) {
|
|
47
|
+
return date.toISOString();
|
|
48
|
+
}
|
|
49
|
+
function retryDelayMs(policy, completedAttemptNumber) {
|
|
50
|
+
if (policy.backoff === "none")
|
|
51
|
+
return 0;
|
|
52
|
+
const multiplier = policy.backoff === "linear"
|
|
53
|
+
? completedAttemptNumber
|
|
54
|
+
: 2 ** Math.max(0, completedAttemptNumber - 1);
|
|
55
|
+
const delay = policy.initial_delay_ms * multiplier;
|
|
56
|
+
return policy.max_delay_ms === null
|
|
57
|
+
? delay
|
|
58
|
+
: Math.min(delay, policy.max_delay_ms);
|
|
59
|
+
}
|
|
60
|
+
function retryAllowed(policy, attempt, outcome) {
|
|
61
|
+
return (attempt.attempt_number < policy.max_attempts
|
|
62
|
+
&& policy.retryable_outcomes.includes(outcome.kind));
|
|
63
|
+
}
|
|
64
|
+
function eventPayload(value) {
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
export class RunController {
|
|
68
|
+
store;
|
|
69
|
+
resolver;
|
|
70
|
+
definitions;
|
|
71
|
+
definitionByStep;
|
|
72
|
+
providerForStep;
|
|
73
|
+
maxConcurrency;
|
|
74
|
+
leaseDurationMs;
|
|
75
|
+
leaseHeartbeatMs;
|
|
76
|
+
schedulerPollMs;
|
|
77
|
+
ownerId;
|
|
78
|
+
reconcileAttempt;
|
|
79
|
+
executeAttempt;
|
|
80
|
+
workerOptions;
|
|
81
|
+
now;
|
|
82
|
+
createAttemptId;
|
|
83
|
+
createWorkerSessionId;
|
|
84
|
+
createClaimToken;
|
|
85
|
+
createResultId;
|
|
86
|
+
createEventId;
|
|
87
|
+
active = new Map();
|
|
88
|
+
constructor(options) {
|
|
89
|
+
assertPositiveInteger("max_concurrency", options.max_concurrency);
|
|
90
|
+
assertPositiveInteger("lease_duration_ms", options.lease_duration_ms);
|
|
91
|
+
const heartbeat = options.lease_heartbeat_ms
|
|
92
|
+
?? Math.max(1, Math.floor(options.lease_duration_ms / 3));
|
|
93
|
+
assertPositiveInteger("lease_heartbeat_ms", heartbeat);
|
|
94
|
+
if (heartbeat >= options.lease_duration_ms) {
|
|
95
|
+
throw new RunControllerError("invalid_configuration", "lease_heartbeat_ms must be less than lease_duration_ms");
|
|
96
|
+
}
|
|
97
|
+
const poll = options.scheduler_poll_ms ?? DEFAULT_SCHEDULER_POLL_MS;
|
|
98
|
+
assertPositiveInteger("scheduler_poll_ms", poll);
|
|
99
|
+
this.store = options.run_store;
|
|
100
|
+
this.resolver = options.handoff_resolver
|
|
101
|
+
?? new HandoffResolver(new ImmutableResultStore(options.run_store));
|
|
102
|
+
this.definitions = [...options.steps];
|
|
103
|
+
this.definitionByStep = this.validateDefinitions(this.definitions);
|
|
104
|
+
this.providerForStep = options.provider_for_step;
|
|
105
|
+
this.maxConcurrency = options.max_concurrency;
|
|
106
|
+
this.leaseDurationMs = options.lease_duration_ms;
|
|
107
|
+
this.leaseHeartbeatMs = heartbeat;
|
|
108
|
+
this.schedulerPollMs = poll;
|
|
109
|
+
this.ownerId = options.owner_id ?? `controller:${process.pid}:${randomUUID()}`;
|
|
110
|
+
this.reconcileAttempt = options.reconcile_attempt
|
|
111
|
+
?? (async (attempt) => this.active.has(attempt.attempt_id) ? "live" : "unknown");
|
|
112
|
+
this.executeAttempt = options.execute_attempt ?? executeWorkerAttempt;
|
|
113
|
+
this.workerOptions = options.worker_options;
|
|
114
|
+
this.now = options.now ?? (() => new Date());
|
|
115
|
+
this.createAttemptId = options.create_attempt_id
|
|
116
|
+
?? (() => randomUUID());
|
|
117
|
+
this.createWorkerSessionId = options.create_worker_session_id
|
|
118
|
+
?? (() => randomUUID());
|
|
119
|
+
this.createClaimToken = options.create_claim_token
|
|
120
|
+
?? (() => randomUUID());
|
|
121
|
+
this.createResultId = options.create_result_id
|
|
122
|
+
?? (() => randomUUID());
|
|
123
|
+
this.createEventId = options.create_event_id
|
|
124
|
+
?? (() => randomUUID());
|
|
125
|
+
}
|
|
126
|
+
validateDefinitions(definitions) {
|
|
127
|
+
const byStep = new Map();
|
|
128
|
+
for (const definition of definitions) {
|
|
129
|
+
if (!definition.step_id || byStep.has(definition.step_id)) {
|
|
130
|
+
throw new RunControllerError("invalid_definition", `duplicate or empty step definition ${definition.step_id}`);
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isSafeInteger(definition.retry_policy.max_attempts)
|
|
133
|
+
|| definition.retry_policy.max_attempts < 1
|
|
134
|
+
|| !Number.isSafeInteger(definition.retry_policy.initial_delay_ms)
|
|
135
|
+
|| definition.retry_policy.initial_delay_ms < 0
|
|
136
|
+
|| (definition.retry_policy.max_delay_ms !== null
|
|
137
|
+
&& (!Number.isSafeInteger(definition.retry_policy.max_delay_ms)
|
|
138
|
+
|| definition.retry_policy.max_delay_ms < 0))) {
|
|
139
|
+
throw new RunControllerError("invalid_definition", `step ${definition.step_id} has an invalid retry policy`);
|
|
140
|
+
}
|
|
141
|
+
if (new Set(definition.dependencies).size !== definition.dependencies.length) {
|
|
142
|
+
throw new RunControllerError("invalid_definition", `step ${definition.step_id} has duplicate dependencies`);
|
|
143
|
+
}
|
|
144
|
+
byStep.set(definition.step_id, definition);
|
|
145
|
+
}
|
|
146
|
+
for (const definition of definitions) {
|
|
147
|
+
for (const dependency of definition.dependencies) {
|
|
148
|
+
if (dependency === definition.step_id || !byStep.has(dependency)) {
|
|
149
|
+
throw new RunControllerError("invalid_definition", `step ${definition.step_id} has invalid dependency ${dependency}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return byStep;
|
|
154
|
+
}
|
|
155
|
+
events(snapshot, inputs) {
|
|
156
|
+
return inputs.map((input, index) => ({
|
|
157
|
+
...input,
|
|
158
|
+
event_id: this.createEventId(),
|
|
159
|
+
sequence: snapshot.events.length + index + 1,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
async createRun(input) {
|
|
163
|
+
const createdAt = input.run.created_at;
|
|
164
|
+
const tasks = this.definitions.map((definition) => ({
|
|
165
|
+
run_id: input.run.run_id,
|
|
166
|
+
step_id: definition.step_id,
|
|
167
|
+
state: definition.initially_skipped
|
|
168
|
+
? "skipped"
|
|
169
|
+
: definition.dependencies.length === 0
|
|
170
|
+
? "ready"
|
|
171
|
+
: "dependency_blocked",
|
|
172
|
+
attempt_count: 0,
|
|
173
|
+
current_attempt_id: null,
|
|
174
|
+
committed_result_id: null,
|
|
175
|
+
created_at: createdAt,
|
|
176
|
+
updated_at: createdAt,
|
|
177
|
+
}));
|
|
178
|
+
let snapshot = await this.store.createRun({ run: input.run, tasks });
|
|
179
|
+
const occurredAt = iso(this.now());
|
|
180
|
+
const update = await this.store.updateRun({
|
|
181
|
+
run_id: input.run.run_id,
|
|
182
|
+
expected_record_version: snapshot.record_version,
|
|
183
|
+
events: this.events(snapshot, [{
|
|
184
|
+
run_id: input.run.run_id,
|
|
185
|
+
step_id: null,
|
|
186
|
+
attempt_id: null,
|
|
187
|
+
worker_session_id: null,
|
|
188
|
+
result_id: null,
|
|
189
|
+
idempotency_key: `run:${input.run.run_id}:created`,
|
|
190
|
+
type: "run_created",
|
|
191
|
+
occurred_at: occurredAt,
|
|
192
|
+
payload: eventPayload({ plan_digest: input.run.plan_digest }),
|
|
193
|
+
}]),
|
|
194
|
+
});
|
|
195
|
+
snapshot = update.snapshot;
|
|
196
|
+
return snapshot;
|
|
197
|
+
}
|
|
198
|
+
async inspect(runId) {
|
|
199
|
+
return this.store.requireRun(runId);
|
|
200
|
+
}
|
|
201
|
+
dependenciesFor(definition, snapshot) {
|
|
202
|
+
return definition.dependencies.map((stepId) => {
|
|
203
|
+
const task = taskByStep(snapshot, stepId);
|
|
204
|
+
if (!task) {
|
|
205
|
+
throw new RunControllerError("invalid_definition", `run ${snapshot.run.run_id} has no task for dependency ${stepId}`);
|
|
206
|
+
}
|
|
207
|
+
return task;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* A dependency is satisfied only by a succeeded task whose referenced result
|
|
212
|
+
* is present in the immutable committed-result collection.
|
|
213
|
+
*/
|
|
214
|
+
async refreshDependencyReadiness(runId) {
|
|
215
|
+
while (true) {
|
|
216
|
+
const snapshot = await this.store.requireRun(runId);
|
|
217
|
+
if (snapshot.run.status !== "active")
|
|
218
|
+
return snapshot;
|
|
219
|
+
const now = iso(this.now());
|
|
220
|
+
const changed = [];
|
|
221
|
+
const eventInputs = [];
|
|
222
|
+
for (const task of snapshot.tasks) {
|
|
223
|
+
if (task.state !== "dependency_blocked")
|
|
224
|
+
continue;
|
|
225
|
+
const definition = this.definitionByStep.get(task.step_id);
|
|
226
|
+
if (!definition) {
|
|
227
|
+
throw new RunControllerError("invalid_definition", `run ${runId} contains unknown step ${task.step_id}`);
|
|
228
|
+
}
|
|
229
|
+
const dependencies = this.dependenciesFor(definition, snapshot);
|
|
230
|
+
const failedDependency = dependencies.some((dependency) => terminalTask(dependency) && dependency.state !== "succeeded");
|
|
231
|
+
const ready = dependencies.every((dependency) => (dependency.state === "succeeded"
|
|
232
|
+
&& dependency.committed_result_id !== null
|
|
233
|
+
&& snapshot.results.some((result) => result.result_id === dependency.committed_result_id)));
|
|
234
|
+
if (!failedDependency && !ready)
|
|
235
|
+
continue;
|
|
236
|
+
const state = failedDependency ? "skipped" : "ready";
|
|
237
|
+
changed.push({ ...task, state, updated_at: now });
|
|
238
|
+
eventInputs.push({
|
|
239
|
+
run_id: runId,
|
|
240
|
+
step_id: task.step_id,
|
|
241
|
+
attempt_id: null,
|
|
242
|
+
worker_session_id: null,
|
|
243
|
+
result_id: null,
|
|
244
|
+
idempotency_key: `task:${task.step_id}:dependency:${state}`,
|
|
245
|
+
type: "task_state_changed",
|
|
246
|
+
occurred_at: now,
|
|
247
|
+
payload: eventPayload({
|
|
248
|
+
from: task.state,
|
|
249
|
+
to: state,
|
|
250
|
+
reason: failedDependency
|
|
251
|
+
? "dependency_terminal_without_success"
|
|
252
|
+
: "dependencies_committed",
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (changed.length === 0)
|
|
257
|
+
return snapshot;
|
|
258
|
+
try {
|
|
259
|
+
return (await this.store.updateRun({
|
|
260
|
+
run_id: runId,
|
|
261
|
+
expected_record_version: snapshot.record_version,
|
|
262
|
+
tasks: changed,
|
|
263
|
+
events: this.events(snapshot, eventInputs),
|
|
264
|
+
})).snapshot;
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (isVersionConflict(error))
|
|
268
|
+
continue;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
readyAt(definition, task, snapshot) {
|
|
274
|
+
const prior = attemptById(snapshot, task.current_attempt_id);
|
|
275
|
+
if (!prior || prior.phase !== "terminal" || prior.completed_at === null) {
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
return (Date.parse(prior.completed_at)
|
|
279
|
+
+ retryDelayMs(definition.retry_policy, prior.attempt_number));
|
|
280
|
+
}
|
|
281
|
+
async claimNextReady(runId) {
|
|
282
|
+
while (true) {
|
|
283
|
+
const snapshot = await this.refreshDependencyReadiness(runId);
|
|
284
|
+
if (snapshot.run.status !== "active")
|
|
285
|
+
return null;
|
|
286
|
+
const activeCount = snapshot.tasks.filter((task) => task.state === "claimed" || task.state === "running").length;
|
|
287
|
+
if (activeCount >= this.maxConcurrency)
|
|
288
|
+
return null;
|
|
289
|
+
const nowDate = this.now();
|
|
290
|
+
const candidate = this.definitions.find((definition) => {
|
|
291
|
+
const task = taskByStep(snapshot, definition.step_id);
|
|
292
|
+
return (task?.state === "ready"
|
|
293
|
+
&& task.committed_result_id === null
|
|
294
|
+
&& this.readyAt(definition, task, snapshot) <= nowDate.getTime());
|
|
295
|
+
});
|
|
296
|
+
if (!candidate)
|
|
297
|
+
return null;
|
|
298
|
+
const task = taskByStep(snapshot, candidate.step_id);
|
|
299
|
+
if (!task) {
|
|
300
|
+
throw new RunControllerError("invalid_definition", `run ${runId} has no task for step ${candidate.step_id}`);
|
|
301
|
+
}
|
|
302
|
+
const attemptId = this.createAttemptId();
|
|
303
|
+
const workerSessionId = this.createWorkerSessionId();
|
|
304
|
+
const claimToken = this.createClaimToken();
|
|
305
|
+
const now = iso(nowDate);
|
|
306
|
+
const expiresAt = iso(new Date(nowDate.getTime() + this.leaseDurationMs));
|
|
307
|
+
const attempt = {
|
|
308
|
+
run_id: runId,
|
|
309
|
+
step_id: candidate.step_id,
|
|
310
|
+
attempt_id: attemptId,
|
|
311
|
+
worker_session_id: workerSessionId,
|
|
312
|
+
attempt_number: task.attempt_count + 1,
|
|
313
|
+
phase: "claimed",
|
|
314
|
+
claim_token: claimToken,
|
|
315
|
+
process_id: null,
|
|
316
|
+
started_at: null,
|
|
317
|
+
completed_at: null,
|
|
318
|
+
terminal_outcome: null,
|
|
319
|
+
};
|
|
320
|
+
const claim = {
|
|
321
|
+
run_id: runId,
|
|
322
|
+
step_id: candidate.step_id,
|
|
323
|
+
attempt_id: attemptId,
|
|
324
|
+
worker_session_id: workerSessionId,
|
|
325
|
+
claim_token: claimToken,
|
|
326
|
+
owner_id: this.ownerId,
|
|
327
|
+
claimed_record_version: snapshot.record_version + 1,
|
|
328
|
+
lease: {
|
|
329
|
+
acquired_at: now,
|
|
330
|
+
heartbeat_at: now,
|
|
331
|
+
expires_at: expiresAt,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
const updatedTask = {
|
|
335
|
+
...task,
|
|
336
|
+
state: "claimed",
|
|
337
|
+
attempt_count: attempt.attempt_number,
|
|
338
|
+
current_attempt_id: attemptId,
|
|
339
|
+
updated_at: now,
|
|
340
|
+
};
|
|
341
|
+
const events = this.events(snapshot, [
|
|
342
|
+
{
|
|
343
|
+
run_id: runId,
|
|
344
|
+
step_id: task.step_id,
|
|
345
|
+
attempt_id: attemptId,
|
|
346
|
+
worker_session_id: workerSessionId,
|
|
347
|
+
result_id: null,
|
|
348
|
+
idempotency_key: `claim:${claimToken}:acquired`,
|
|
349
|
+
type: "task_claimed",
|
|
350
|
+
occurred_at: now,
|
|
351
|
+
payload: eventPayload({
|
|
352
|
+
claim_token: claimToken,
|
|
353
|
+
owner_id: this.ownerId,
|
|
354
|
+
expires_at: expiresAt,
|
|
355
|
+
}),
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
run_id: runId,
|
|
359
|
+
step_id: task.step_id,
|
|
360
|
+
attempt_id: attemptId,
|
|
361
|
+
worker_session_id: workerSessionId,
|
|
362
|
+
result_id: null,
|
|
363
|
+
idempotency_key: `task:${task.step_id}:attempt:${attemptId}:claimed`,
|
|
364
|
+
type: "task_state_changed",
|
|
365
|
+
occurred_at: now,
|
|
366
|
+
payload: eventPayload({ from: "ready", to: "claimed" }),
|
|
367
|
+
},
|
|
368
|
+
]);
|
|
369
|
+
try {
|
|
370
|
+
const update = await this.store.updateRun({
|
|
371
|
+
run_id: runId,
|
|
372
|
+
expected_record_version: snapshot.record_version,
|
|
373
|
+
tasks: [updatedTask],
|
|
374
|
+
attempts: [attempt],
|
|
375
|
+
claims: [claim],
|
|
376
|
+
events,
|
|
377
|
+
});
|
|
378
|
+
return {
|
|
379
|
+
definition: candidate,
|
|
380
|
+
attempt,
|
|
381
|
+
claim,
|
|
382
|
+
snapshot: update.snapshot,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (isVersionConflict(error))
|
|
387
|
+
continue;
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
assertCurrentClaim(snapshot, claim, requireUnexpired) {
|
|
393
|
+
const task = taskByStep(snapshot, claim.step_id);
|
|
394
|
+
const attempt = attemptById(snapshot, claim.attempt_id);
|
|
395
|
+
const storedClaim = claimByAttempt(snapshot, claim.attempt_id);
|
|
396
|
+
if (!task
|
|
397
|
+
|| !attempt
|
|
398
|
+
|| !storedClaim
|
|
399
|
+
|| task.current_attempt_id !== claim.attempt_id
|
|
400
|
+
|| attempt.claim_token !== claim.claim_token
|
|
401
|
+
|| storedClaim.claim_token !== claim.claim_token
|
|
402
|
+
|| storedClaim.owner_id !== claim.owner_id) {
|
|
403
|
+
throw new RunControllerError("claim_lost", `claim ${claim.claim_token} no longer owns step ${claim.step_id}`);
|
|
404
|
+
}
|
|
405
|
+
if (requireUnexpired
|
|
406
|
+
&& Date.parse(storedClaim.lease.expires_at) <= this.now().getTime()) {
|
|
407
|
+
throw new RunControllerError("claim_lost", `claim ${claim.claim_token} expired before mutation`);
|
|
408
|
+
}
|
|
409
|
+
return { task, attempt };
|
|
410
|
+
}
|
|
411
|
+
async renewClaim(claim) {
|
|
412
|
+
while (true) {
|
|
413
|
+
const snapshot = await this.store.requireRun(claim.run_id);
|
|
414
|
+
const { attempt } = this.assertCurrentClaim(snapshot, claim, true);
|
|
415
|
+
if (snapshot.run.status === "cancelling") {
|
|
416
|
+
throw new RunControllerError("run_cancelling", snapshot.run.cancellation?.reason ?? "run cancellation requested");
|
|
417
|
+
}
|
|
418
|
+
if (attempt.phase === "terminal") {
|
|
419
|
+
throw new RunControllerError("claim_lost", `terminal attempt ${attempt.attempt_id} cannot renew its claim`);
|
|
420
|
+
}
|
|
421
|
+
const storedClaim = claimByAttempt(snapshot, claim.attempt_id);
|
|
422
|
+
const nowDate = this.now();
|
|
423
|
+
const renewed = {
|
|
424
|
+
...storedClaim,
|
|
425
|
+
lease: {
|
|
426
|
+
...storedClaim.lease,
|
|
427
|
+
heartbeat_at: iso(nowDate),
|
|
428
|
+
expires_at: iso(new Date(nowDate.getTime() + this.leaseDurationMs)),
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
const event = this.events(snapshot, [{
|
|
432
|
+
run_id: claim.run_id,
|
|
433
|
+
step_id: claim.step_id,
|
|
434
|
+
attempt_id: claim.attempt_id,
|
|
435
|
+
worker_session_id: claim.worker_session_id,
|
|
436
|
+
result_id: null,
|
|
437
|
+
idempotency_key: `claim:${claim.claim_token}:heartbeat:${renewed.lease.heartbeat_at}`,
|
|
438
|
+
type: "task_lease_renewed",
|
|
439
|
+
occurred_at: renewed.lease.heartbeat_at,
|
|
440
|
+
payload: eventPayload({ expires_at: renewed.lease.expires_at }),
|
|
441
|
+
}]);
|
|
442
|
+
try {
|
|
443
|
+
await this.store.updateRun({
|
|
444
|
+
run_id: claim.run_id,
|
|
445
|
+
expected_record_version: snapshot.record_version,
|
|
446
|
+
claims: [renewed],
|
|
447
|
+
events: event,
|
|
448
|
+
});
|
|
449
|
+
return renewed;
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (isVersionConflict(error))
|
|
453
|
+
continue;
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async markRunning(claim) {
|
|
459
|
+
while (true) {
|
|
460
|
+
const snapshot = await this.store.requireRun(claim.run_id);
|
|
461
|
+
const { task, attempt } = this.assertCurrentClaim(snapshot, claim, true);
|
|
462
|
+
if (snapshot.run.status === "cancelling") {
|
|
463
|
+
throw new RunControllerError("run_cancelling", snapshot.run.cancellation?.reason ?? "run cancellation requested");
|
|
464
|
+
}
|
|
465
|
+
if (task.state === "running" && attempt.phase === "running")
|
|
466
|
+
return;
|
|
467
|
+
if (task.state !== "claimed" || attempt.phase !== "claimed") {
|
|
468
|
+
throw new RunControllerError("claim_lost", `attempt ${attempt.attempt_id} cannot enter running from ${attempt.phase}`);
|
|
469
|
+
}
|
|
470
|
+
const now = iso(this.now());
|
|
471
|
+
const events = this.events(snapshot, [
|
|
472
|
+
{
|
|
473
|
+
run_id: claim.run_id,
|
|
474
|
+
step_id: claim.step_id,
|
|
475
|
+
attempt_id: claim.attempt_id,
|
|
476
|
+
worker_session_id: claim.worker_session_id,
|
|
477
|
+
result_id: null,
|
|
478
|
+
idempotency_key: `attempt:${claim.attempt_id}:started`,
|
|
479
|
+
type: "attempt_started",
|
|
480
|
+
occurred_at: now,
|
|
481
|
+
payload: eventPayload({ claim_token: claim.claim_token }),
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
run_id: claim.run_id,
|
|
485
|
+
step_id: claim.step_id,
|
|
486
|
+
attempt_id: claim.attempt_id,
|
|
487
|
+
worker_session_id: claim.worker_session_id,
|
|
488
|
+
result_id: null,
|
|
489
|
+
idempotency_key: `task:${claim.step_id}:attempt:${claim.attempt_id}:running`,
|
|
490
|
+
type: "task_state_changed",
|
|
491
|
+
occurred_at: now,
|
|
492
|
+
payload: eventPayload({ from: "claimed", to: "running" }),
|
|
493
|
+
},
|
|
494
|
+
]);
|
|
495
|
+
try {
|
|
496
|
+
await this.store.updateRun({
|
|
497
|
+
run_id: claim.run_id,
|
|
498
|
+
expected_record_version: snapshot.record_version,
|
|
499
|
+
tasks: [{ ...task, state: "running", updated_at: now }],
|
|
500
|
+
attempts: [{ ...attempt, phase: "running", started_at: now }],
|
|
501
|
+
events,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (isVersionConflict(error))
|
|
507
|
+
continue;
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
handoffRequest(declaration, snapshot) {
|
|
513
|
+
if (declaration.mode === "none") {
|
|
514
|
+
return declaration;
|
|
515
|
+
}
|
|
516
|
+
const sourceTask = taskByStep(snapshot, declaration.source.step_id);
|
|
517
|
+
if (!sourceTask?.committed_result_id) {
|
|
518
|
+
if (!declaration.required) {
|
|
519
|
+
return {
|
|
520
|
+
binding_id: declaration.binding_id,
|
|
521
|
+
input_name: declaration.input_name,
|
|
522
|
+
required: false,
|
|
523
|
+
description: declaration.description,
|
|
524
|
+
mode: "none",
|
|
525
|
+
reason: "optional source task has no committed result",
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
throw new RunControllerError("invalid_packet", `handoff ${declaration.binding_id} has no committed source result`);
|
|
529
|
+
}
|
|
530
|
+
const source = {
|
|
531
|
+
result_id: sourceTask.committed_result_id,
|
|
532
|
+
step_id: declaration.source.step_id,
|
|
533
|
+
output_name: declaration.source.output_name,
|
|
534
|
+
};
|
|
535
|
+
if (declaration.mode === "reference") {
|
|
536
|
+
return { ...declaration, source };
|
|
537
|
+
}
|
|
538
|
+
if (declaration.mode === "quote") {
|
|
539
|
+
return { ...declaration, source };
|
|
540
|
+
}
|
|
541
|
+
return { ...declaration, source };
|
|
542
|
+
}
|
|
543
|
+
async persistResolvedHandoffs(claim, bindings) {
|
|
544
|
+
const occurredAt = iso(this.now());
|
|
545
|
+
while (true) {
|
|
546
|
+
const snapshot = await this.store.requireRun(claim.run_id);
|
|
547
|
+
const { task, attempt } = this.assertCurrentClaim(snapshot, claim, true);
|
|
548
|
+
if (task.state !== "running" || attempt.phase !== "running") {
|
|
549
|
+
throw new RunControllerError("claim_lost", `attempt ${claim.attempt_id} is no longer running`);
|
|
550
|
+
}
|
|
551
|
+
const existingKeys = new Set(snapshot.events.map((event) => event.idempotency_key));
|
|
552
|
+
const missing = bindings.filter((binding) => !existingKeys.has(`attempt:${claim.attempt_id}:handoff:${binding.binding_id}`));
|
|
553
|
+
const ordered = [
|
|
554
|
+
...missing,
|
|
555
|
+
...bindings.filter((binding) => !missing.includes(binding)),
|
|
556
|
+
];
|
|
557
|
+
const missingCount = missing.length;
|
|
558
|
+
const events = ordered.map((binding, index) => ({
|
|
559
|
+
event_id: this.createEventId(),
|
|
560
|
+
run_id: claim.run_id,
|
|
561
|
+
step_id: claim.step_id,
|
|
562
|
+
attempt_id: claim.attempt_id,
|
|
563
|
+
worker_session_id: claim.worker_session_id,
|
|
564
|
+
result_id: binding.mode === "none" ? null : binding.source.result_id,
|
|
565
|
+
sequence: index < missingCount
|
|
566
|
+
? snapshot.events.length + index + 1
|
|
567
|
+
: snapshot.events.length,
|
|
568
|
+
idempotency_key: `attempt:${claim.attempt_id}:handoff:${binding.binding_id}`,
|
|
569
|
+
type: "handoff_resolved",
|
|
570
|
+
occurred_at: occurredAt,
|
|
571
|
+
payload: { ...binding },
|
|
572
|
+
}));
|
|
573
|
+
try {
|
|
574
|
+
await this.store.updateRun({
|
|
575
|
+
run_id: claim.run_id,
|
|
576
|
+
expected_record_version: snapshot.record_version,
|
|
577
|
+
events,
|
|
578
|
+
});
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
if (isVersionConflict(error))
|
|
583
|
+
continue;
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async materializePacket(claimed) {
|
|
589
|
+
const snapshot = await this.store.requireRun(claimed.claim.run_id);
|
|
590
|
+
this.assertCurrentClaim(snapshot, claimed.claim, true);
|
|
591
|
+
const requests = claimed.definition.handoffs.map((declaration) => this.handoffRequest(declaration, snapshot));
|
|
592
|
+
const bindings = await this.resolver.resolveAll(claimed.claim.run_id, requests);
|
|
593
|
+
const createdAt = iso(this.now());
|
|
594
|
+
const packet = claimed.definition.materialize_packet({
|
|
595
|
+
run_id: claimed.claim.run_id,
|
|
596
|
+
step_id: claimed.claim.step_id,
|
|
597
|
+
attempt_id: claimed.claim.attempt_id,
|
|
598
|
+
worker_session_id: claimed.claim.worker_session_id,
|
|
599
|
+
attempt_number: claimed.attempt.attempt_number,
|
|
600
|
+
input_bindings: bindings,
|
|
601
|
+
retry_policy: claimed.definition.retry_policy,
|
|
602
|
+
created_at: createdAt,
|
|
603
|
+
});
|
|
604
|
+
if (packet.run_id !== claimed.claim.run_id
|
|
605
|
+
|| packet.step_id !== claimed.claim.step_id
|
|
606
|
+
|| packet.attempt_id !== claimed.claim.attempt_id
|
|
607
|
+
|| packet.worker_session_id !== claimed.claim.worker_session_id
|
|
608
|
+
|| packet.attempt_number !== claimed.attempt.attempt_number
|
|
609
|
+
|| JSON.stringify(packet.input_bindings) !== JSON.stringify(bindings)
|
|
610
|
+
|| JSON.stringify(packet.execution.retry)
|
|
611
|
+
!== JSON.stringify(claimed.definition.retry_policy)) {
|
|
612
|
+
throw new RunControllerError("invalid_packet", `materialized packet for ${claimed.claim.step_id} changed Controller-owned identities or inputs`);
|
|
613
|
+
}
|
|
614
|
+
await this.persistResolvedHandoffs(claimed.claim, bindings);
|
|
615
|
+
return packet;
|
|
616
|
+
}
|
|
617
|
+
async finalizeAttempt(claim, execution) {
|
|
618
|
+
while (true) {
|
|
619
|
+
const snapshot = await this.store.requireRun(claim.run_id);
|
|
620
|
+
const { task, attempt } = this.assertCurrentClaim(snapshot, claim, true);
|
|
621
|
+
if (attempt.phase === "terminal")
|
|
622
|
+
return snapshot;
|
|
623
|
+
if (execution.worker_session_id !== claim.worker_session_id
|
|
624
|
+
|| execution.result.run_id !== claim.run_id
|
|
625
|
+
|| execution.result.step_id !== claim.step_id
|
|
626
|
+
|| execution.result.attempt_id !== claim.attempt_id
|
|
627
|
+
|| execution.result.worker_session_id !== claim.worker_session_id) {
|
|
628
|
+
throw new RunControllerError("invalid_packet", `worker result does not match claimed attempt ${claim.attempt_id}`);
|
|
629
|
+
}
|
|
630
|
+
const now = iso(this.now());
|
|
631
|
+
const runCancelling = snapshot.run.status === "cancelling";
|
|
632
|
+
const canRetry = (!runCancelling
|
|
633
|
+
&& retryAllowed(this.definitionByStep.get(claim.step_id).retry_policy, attempt, execution.result.outcome));
|
|
634
|
+
const resultId = execution.result.outcome.kind === "success"
|
|
635
|
+
? this.createResultId()
|
|
636
|
+
: null;
|
|
637
|
+
const result = resultId === null
|
|
638
|
+
? null
|
|
639
|
+
: {
|
|
640
|
+
...execution.result,
|
|
641
|
+
result_id: resultId,
|
|
642
|
+
commit_status: "committed",
|
|
643
|
+
committed_at: now,
|
|
644
|
+
};
|
|
645
|
+
const nextState = result
|
|
646
|
+
? "succeeded"
|
|
647
|
+
: runCancelling
|
|
648
|
+
? "cancelled"
|
|
649
|
+
: canRetry
|
|
650
|
+
? "ready"
|
|
651
|
+
: "failed";
|
|
652
|
+
const terminalAttempt = {
|
|
653
|
+
...attempt,
|
|
654
|
+
phase: "terminal",
|
|
655
|
+
completed_at: execution.result.completed_at,
|
|
656
|
+
terminal_outcome: execution.result.outcome,
|
|
657
|
+
};
|
|
658
|
+
const updatedTask = {
|
|
659
|
+
...task,
|
|
660
|
+
state: nextState,
|
|
661
|
+
committed_result_id: resultId,
|
|
662
|
+
updated_at: now,
|
|
663
|
+
};
|
|
664
|
+
const eventInputs = [
|
|
665
|
+
{
|
|
666
|
+
run_id: claim.run_id,
|
|
667
|
+
step_id: claim.step_id,
|
|
668
|
+
attempt_id: claim.attempt_id,
|
|
669
|
+
worker_session_id: claim.worker_session_id,
|
|
670
|
+
result_id: resultId,
|
|
671
|
+
idempotency_key: `attempt:${claim.attempt_id}:terminal`,
|
|
672
|
+
type: "attempt_terminal",
|
|
673
|
+
occurred_at: now,
|
|
674
|
+
payload: eventPayload({
|
|
675
|
+
outcome: execution.result.outcome.kind,
|
|
676
|
+
provider_event_count: execution.events.length,
|
|
677
|
+
retry: canRetry,
|
|
678
|
+
}),
|
|
679
|
+
},
|
|
680
|
+
];
|
|
681
|
+
if (result) {
|
|
682
|
+
eventInputs.push({
|
|
683
|
+
run_id: claim.run_id,
|
|
684
|
+
step_id: claim.step_id,
|
|
685
|
+
attempt_id: claim.attempt_id,
|
|
686
|
+
worker_session_id: claim.worker_session_id,
|
|
687
|
+
result_id: result.result_id,
|
|
688
|
+
idempotency_key: `result:${result.result_id}:committed`,
|
|
689
|
+
type: "result_committed",
|
|
690
|
+
occurred_at: now,
|
|
691
|
+
payload: eventPayload({ output_count: result.outputs.length }),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
eventInputs.push({
|
|
695
|
+
run_id: claim.run_id,
|
|
696
|
+
step_id: claim.step_id,
|
|
697
|
+
attempt_id: claim.attempt_id,
|
|
698
|
+
worker_session_id: claim.worker_session_id,
|
|
699
|
+
result_id: resultId,
|
|
700
|
+
idempotency_key: `claim:${claim.claim_token}:released`,
|
|
701
|
+
type: "task_claim_released",
|
|
702
|
+
occurred_at: now,
|
|
703
|
+
payload: eventPayload({ terminal: !canRetry }),
|
|
704
|
+
}, {
|
|
705
|
+
run_id: claim.run_id,
|
|
706
|
+
step_id: claim.step_id,
|
|
707
|
+
attempt_id: claim.attempt_id,
|
|
708
|
+
worker_session_id: claim.worker_session_id,
|
|
709
|
+
result_id: resultId,
|
|
710
|
+
idempotency_key: `task:${claim.step_id}:attempt:${claim.attempt_id}:${nextState}`,
|
|
711
|
+
type: "task_state_changed",
|
|
712
|
+
occurred_at: now,
|
|
713
|
+
payload: eventPayload({ from: task.state, to: nextState }),
|
|
714
|
+
});
|
|
715
|
+
try {
|
|
716
|
+
return (await this.store.updateRun({
|
|
717
|
+
run_id: claim.run_id,
|
|
718
|
+
expected_record_version: snapshot.record_version,
|
|
719
|
+
tasks: [updatedTask],
|
|
720
|
+
attempts: [terminalAttempt],
|
|
721
|
+
events: this.events(snapshot, eventInputs),
|
|
722
|
+
...(result ? { results: [result] } : {}),
|
|
723
|
+
})).snapshot;
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
if (isVersionConflict(error))
|
|
727
|
+
continue;
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
syntheticExecution(claim, outcome, startedAt) {
|
|
733
|
+
const completedAt = iso(this.now());
|
|
734
|
+
return {
|
|
735
|
+
worker_session_id: claim.worker_session_id,
|
|
736
|
+
events: [],
|
|
737
|
+
result: {
|
|
738
|
+
run_id: claim.run_id,
|
|
739
|
+
step_id: claim.step_id,
|
|
740
|
+
attempt_id: claim.attempt_id,
|
|
741
|
+
worker_session_id: claim.worker_session_id,
|
|
742
|
+
provider: "controller",
|
|
743
|
+
provider_session_id: null,
|
|
744
|
+
started_at: startedAt,
|
|
745
|
+
completed_at: completedAt,
|
|
746
|
+
outcome,
|
|
747
|
+
outputs: [],
|
|
748
|
+
stdout_reference: null,
|
|
749
|
+
stderr_reference: null,
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
async reclaimDeadAttempt(snapshot, task, attempt, claim) {
|
|
754
|
+
const now = iso(this.now());
|
|
755
|
+
const cancelled = snapshot.run.status === "cancelling";
|
|
756
|
+
const outcome = cancelled
|
|
757
|
+
? {
|
|
758
|
+
kind: "cancelled",
|
|
759
|
+
exit_code: null,
|
|
760
|
+
signal: null,
|
|
761
|
+
reason: snapshot.run.cancellation?.reason ?? "cancelled during resume",
|
|
762
|
+
}
|
|
763
|
+
: {
|
|
764
|
+
kind: "launch_error",
|
|
765
|
+
exit_code: null,
|
|
766
|
+
signal: null,
|
|
767
|
+
error: "owned worker was confirmed dead during resume",
|
|
768
|
+
};
|
|
769
|
+
const definition = this.definitionByStep.get(task.step_id);
|
|
770
|
+
const canRetry = !cancelled && retryAllowed(definition.retry_policy, attempt, outcome);
|
|
771
|
+
const nextState = cancelled ? "cancelled" : canRetry ? "ready" : "failed";
|
|
772
|
+
const terminalAttempt = {
|
|
773
|
+
...attempt,
|
|
774
|
+
phase: "terminal",
|
|
775
|
+
completed_at: now,
|
|
776
|
+
terminal_outcome: outcome,
|
|
777
|
+
};
|
|
778
|
+
const updatedTask = {
|
|
779
|
+
...task,
|
|
780
|
+
state: nextState,
|
|
781
|
+
updated_at: now,
|
|
782
|
+
};
|
|
783
|
+
await this.store.updateRun({
|
|
784
|
+
run_id: snapshot.run.run_id,
|
|
785
|
+
expected_record_version: snapshot.record_version,
|
|
786
|
+
tasks: [updatedTask],
|
|
787
|
+
attempts: [terminalAttempt],
|
|
788
|
+
events: this.events(snapshot, [
|
|
789
|
+
{
|
|
790
|
+
run_id: snapshot.run.run_id,
|
|
791
|
+
step_id: task.step_id,
|
|
792
|
+
attempt_id: attempt.attempt_id,
|
|
793
|
+
worker_session_id: attempt.worker_session_id,
|
|
794
|
+
result_id: null,
|
|
795
|
+
idempotency_key: `attempt:${attempt.attempt_id}:reclaimed-terminal`,
|
|
796
|
+
type: "attempt_terminal",
|
|
797
|
+
occurred_at: now,
|
|
798
|
+
payload: eventPayload({
|
|
799
|
+
outcome: outcome.kind,
|
|
800
|
+
retry: canRetry,
|
|
801
|
+
reconciliation: "dead",
|
|
802
|
+
}),
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
run_id: snapshot.run.run_id,
|
|
806
|
+
step_id: task.step_id,
|
|
807
|
+
attempt_id: attempt.attempt_id,
|
|
808
|
+
worker_session_id: attempt.worker_session_id,
|
|
809
|
+
result_id: null,
|
|
810
|
+
idempotency_key: `claim:${claim.claim_token}:reclaimed`,
|
|
811
|
+
type: "task_claim_released",
|
|
812
|
+
occurred_at: now,
|
|
813
|
+
payload: eventPayload({ expired: true, reconciled: "dead" }),
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
run_id: snapshot.run.run_id,
|
|
817
|
+
step_id: task.step_id,
|
|
818
|
+
attempt_id: attempt.attempt_id,
|
|
819
|
+
worker_session_id: attempt.worker_session_id,
|
|
820
|
+
result_id: null,
|
|
821
|
+
idempotency_key: `task:${task.step_id}:attempt:${attempt.attempt_id}:${nextState}`,
|
|
822
|
+
type: "task_state_changed",
|
|
823
|
+
occurred_at: now,
|
|
824
|
+
payload: eventPayload({ from: task.state, to: nextState }),
|
|
825
|
+
},
|
|
826
|
+
]),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Resume never infers death from lease expiry alone. Reclaim is allowed only
|
|
831
|
+
* after the injected reconciler explicitly confirms the owned worker is dead.
|
|
832
|
+
*/
|
|
833
|
+
async resume(runId) {
|
|
834
|
+
while (true) {
|
|
835
|
+
let snapshot = await this.store.requireRun(runId);
|
|
836
|
+
if (snapshot.run.terminal !== null)
|
|
837
|
+
return snapshot;
|
|
838
|
+
let changed = false;
|
|
839
|
+
for (const task of snapshot.tasks) {
|
|
840
|
+
if (task.state !== "claimed" && task.state !== "running")
|
|
841
|
+
continue;
|
|
842
|
+
const attempt = attemptById(snapshot, task.current_attempt_id);
|
|
843
|
+
if (!attempt || attempt.phase === "terminal")
|
|
844
|
+
continue;
|
|
845
|
+
const claim = claimByAttempt(snapshot, attempt.attempt_id);
|
|
846
|
+
if (!claim) {
|
|
847
|
+
throw new RunControllerError("claim_lost", `active attempt ${attempt.attempt_id} has no persisted claim`);
|
|
848
|
+
}
|
|
849
|
+
if (Date.parse(claim.lease.expires_at) > this.now().getTime()) {
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const reconciliation = await this.reconcileAttempt(attempt, claim);
|
|
853
|
+
if (reconciliation !== "dead")
|
|
854
|
+
continue;
|
|
855
|
+
try {
|
|
856
|
+
await this.reclaimDeadAttempt(snapshot, task, attempt, claim);
|
|
857
|
+
changed = true;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
if (isVersionConflict(error)) {
|
|
862
|
+
changed = true;
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (!changed) {
|
|
869
|
+
snapshot = await this.refreshDependencyReadiness(runId);
|
|
870
|
+
return this.finalizeRunIfTerminal(snapshot.run.run_id);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
async requestCancellation(runId, reason = null) {
|
|
875
|
+
while (true) {
|
|
876
|
+
const snapshot = await this.store.requireRun(runId);
|
|
877
|
+
if (snapshot.run.terminal !== null)
|
|
878
|
+
return snapshot;
|
|
879
|
+
const now = iso(this.now());
|
|
880
|
+
const cancellation = snapshot.run.cancellation ?? {
|
|
881
|
+
requested_at: now,
|
|
882
|
+
reason,
|
|
883
|
+
};
|
|
884
|
+
const tasks = snapshot.tasks
|
|
885
|
+
.filter((task) => (task.state === "dependency_blocked" || task.state === "ready"))
|
|
886
|
+
.map((task) => ({
|
|
887
|
+
...task,
|
|
888
|
+
state: "cancelled",
|
|
889
|
+
updated_at: now,
|
|
890
|
+
}));
|
|
891
|
+
const eventInputs = [];
|
|
892
|
+
if (snapshot.run.status !== "cancelling") {
|
|
893
|
+
eventInputs.push({
|
|
894
|
+
run_id: runId,
|
|
895
|
+
step_id: null,
|
|
896
|
+
attempt_id: null,
|
|
897
|
+
worker_session_id: null,
|
|
898
|
+
result_id: null,
|
|
899
|
+
idempotency_key: `run:${runId}:cancel-requested`,
|
|
900
|
+
type: "run_cancel_requested",
|
|
901
|
+
occurred_at: cancellation.requested_at,
|
|
902
|
+
payload: eventPayload({ reason }),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
for (const task of tasks) {
|
|
906
|
+
const prior = taskByStep(snapshot, task.step_id);
|
|
907
|
+
eventInputs.push({
|
|
908
|
+
run_id: runId,
|
|
909
|
+
step_id: task.step_id,
|
|
910
|
+
attempt_id: null,
|
|
911
|
+
worker_session_id: null,
|
|
912
|
+
result_id: null,
|
|
913
|
+
idempotency_key: `task:${task.step_id}:cancelled`,
|
|
914
|
+
type: "task_state_changed",
|
|
915
|
+
occurred_at: now,
|
|
916
|
+
payload: eventPayload({ from: prior.state, to: "cancelled" }),
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const run = {
|
|
920
|
+
...snapshot.run,
|
|
921
|
+
status: "cancelling",
|
|
922
|
+
cancellation,
|
|
923
|
+
updated_at: now,
|
|
924
|
+
};
|
|
925
|
+
try {
|
|
926
|
+
const update = await this.store.updateRun({
|
|
927
|
+
run_id: runId,
|
|
928
|
+
expected_record_version: snapshot.record_version,
|
|
929
|
+
run,
|
|
930
|
+
tasks,
|
|
931
|
+
events: this.events(snapshot, eventInputs),
|
|
932
|
+
});
|
|
933
|
+
for (const active of this.active.values()) {
|
|
934
|
+
if (active.claim.run_id === runId) {
|
|
935
|
+
active.controller.abort(reason ?? "run cancellation requested");
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return this.finalizeRunIfTerminal(runId);
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
if (isVersionConflict(error))
|
|
942
|
+
continue;
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async finalizeRunIfTerminal(runId) {
|
|
948
|
+
while (true) {
|
|
949
|
+
const snapshot = await this.store.requireRun(runId);
|
|
950
|
+
if (snapshot.run.terminal !== null)
|
|
951
|
+
return snapshot;
|
|
952
|
+
if (snapshot.tasks.some((task) => !terminalTask(task)))
|
|
953
|
+
return snapshot;
|
|
954
|
+
const now = iso(this.now());
|
|
955
|
+
const status = snapshot.run.status === "cancelling"
|
|
956
|
+
? "cancelled"
|
|
957
|
+
: snapshot.tasks.some((task) => task.state === "failed")
|
|
958
|
+
? "failed"
|
|
959
|
+
: "succeeded";
|
|
960
|
+
const reason = status === "failed"
|
|
961
|
+
? "one or more Steps failed"
|
|
962
|
+
: status === "cancelled"
|
|
963
|
+
? snapshot.run.cancellation?.reason ?? "run cancelled"
|
|
964
|
+
: null;
|
|
965
|
+
const run = {
|
|
966
|
+
...snapshot.run,
|
|
967
|
+
status,
|
|
968
|
+
terminal: { status, reason, completed_at: now },
|
|
969
|
+
updated_at: now,
|
|
970
|
+
};
|
|
971
|
+
try {
|
|
972
|
+
return (await this.store.updateRun({
|
|
973
|
+
run_id: runId,
|
|
974
|
+
expected_record_version: snapshot.record_version,
|
|
975
|
+
run,
|
|
976
|
+
events: this.events(snapshot, [{
|
|
977
|
+
run_id: runId,
|
|
978
|
+
step_id: null,
|
|
979
|
+
attempt_id: null,
|
|
980
|
+
worker_session_id: null,
|
|
981
|
+
result_id: null,
|
|
982
|
+
idempotency_key: `run:${runId}:terminal:${status}`,
|
|
983
|
+
type: "run_terminal",
|
|
984
|
+
occurred_at: now,
|
|
985
|
+
payload: eventPayload({ status, reason }),
|
|
986
|
+
}]),
|
|
987
|
+
})).snapshot;
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
if (isVersionConflict(error))
|
|
991
|
+
continue;
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
launchClaimed(claimed) {
|
|
997
|
+
const controller = new AbortController();
|
|
998
|
+
const completion = (async () => {
|
|
999
|
+
let heartbeat;
|
|
1000
|
+
let latestClaim = claimed.claim;
|
|
1001
|
+
try {
|
|
1002
|
+
await this.markRunning(latestClaim);
|
|
1003
|
+
heartbeat = setInterval(() => {
|
|
1004
|
+
void this.renewClaim(latestClaim)
|
|
1005
|
+
.then((renewed) => {
|
|
1006
|
+
latestClaim = renewed;
|
|
1007
|
+
})
|
|
1008
|
+
.catch((error) => {
|
|
1009
|
+
controller.abort(error instanceof Error
|
|
1010
|
+
? error.message
|
|
1011
|
+
: "task lease renewal failed");
|
|
1012
|
+
});
|
|
1013
|
+
}, this.leaseHeartbeatMs);
|
|
1014
|
+
heartbeat.unref();
|
|
1015
|
+
const packet = await this.materializePacket({
|
|
1016
|
+
...claimed,
|
|
1017
|
+
claim: latestClaim,
|
|
1018
|
+
});
|
|
1019
|
+
const provider = this.providerForStep(claimed.definition);
|
|
1020
|
+
const execution = await this.executeAttempt(packet, provider, {
|
|
1021
|
+
...this.workerOptions,
|
|
1022
|
+
cancellationSignal: controller.signal,
|
|
1023
|
+
cancellationReason: controller.signal.aborted
|
|
1024
|
+
? String(controller.signal.reason ?? "controller cancellation")
|
|
1025
|
+
: null,
|
|
1026
|
+
now: this.now,
|
|
1027
|
+
});
|
|
1028
|
+
await this.finalizeAttempt(latestClaim, execution);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
const snapshot = await this.store.requireRun(latestClaim.run_id);
|
|
1032
|
+
const attempt = attemptById(snapshot, latestClaim.attempt_id);
|
|
1033
|
+
if (attempt && attempt.phase !== "terminal") {
|
|
1034
|
+
const outcome = snapshot.run.status === "cancelling"
|
|
1035
|
+
? {
|
|
1036
|
+
kind: "cancelled",
|
|
1037
|
+
exit_code: null,
|
|
1038
|
+
signal: null,
|
|
1039
|
+
reason: snapshot.run.cancellation?.reason
|
|
1040
|
+
?? "run cancellation requested",
|
|
1041
|
+
}
|
|
1042
|
+
: {
|
|
1043
|
+
kind: "launch_error",
|
|
1044
|
+
exit_code: null,
|
|
1045
|
+
signal: null,
|
|
1046
|
+
error: error instanceof Error
|
|
1047
|
+
? error.message
|
|
1048
|
+
: String(error),
|
|
1049
|
+
};
|
|
1050
|
+
const synthetic = this.syntheticExecution(latestClaim, outcome, attempt.started_at ?? iso(this.now()));
|
|
1051
|
+
await this.finalizeAttempt(latestClaim, synthetic);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
finally {
|
|
1055
|
+
if (heartbeat)
|
|
1056
|
+
clearInterval(heartbeat);
|
|
1057
|
+
this.active.delete(claimed.attempt.attempt_id);
|
|
1058
|
+
}
|
|
1059
|
+
})();
|
|
1060
|
+
this.active.set(claimed.attempt.attempt_id, {
|
|
1061
|
+
claim: claimed.claim,
|
|
1062
|
+
controller,
|
|
1063
|
+
completion,
|
|
1064
|
+
});
|
|
1065
|
+
return completion;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Runs scheduling until durable terminal state. Every successful claim creates
|
|
1069
|
+
* a fresh attempt and worker session; this Controller never gives a worker a
|
|
1070
|
+
* second Step.
|
|
1071
|
+
*/
|
|
1072
|
+
async runUntilTerminal(runId) {
|
|
1073
|
+
while (true) {
|
|
1074
|
+
let snapshot = await this.resume(runId);
|
|
1075
|
+
snapshot = await this.refreshDependencyReadiness(runId);
|
|
1076
|
+
snapshot = await this.finalizeRunIfTerminal(runId);
|
|
1077
|
+
if (snapshot.run.terminal !== null)
|
|
1078
|
+
return snapshot;
|
|
1079
|
+
let launched = false;
|
|
1080
|
+
while (this.active.size < this.maxConcurrency) {
|
|
1081
|
+
const claimed = await this.claimNextReady(runId);
|
|
1082
|
+
if (!claimed)
|
|
1083
|
+
break;
|
|
1084
|
+
launched = true;
|
|
1085
|
+
void this.launchClaimed(claimed);
|
|
1086
|
+
}
|
|
1087
|
+
if (this.active.size > 0) {
|
|
1088
|
+
await Promise.race([...this.active.values()].map((entry) => entry.completion));
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
snapshot = await this.store.requireRun(runId);
|
|
1092
|
+
const externallyActive = snapshot.tasks.some((task) => task.state === "claimed" || task.state === "running");
|
|
1093
|
+
const retryPending = snapshot.tasks.some((task) => {
|
|
1094
|
+
if (task.state !== "ready")
|
|
1095
|
+
return false;
|
|
1096
|
+
const definition = this.definitionByStep.get(task.step_id);
|
|
1097
|
+
return definition
|
|
1098
|
+
? this.readyAt(definition, task, snapshot) > this.now().getTime()
|
|
1099
|
+
: false;
|
|
1100
|
+
});
|
|
1101
|
+
if (externallyActive || retryPending || launched) {
|
|
1102
|
+
await sleep(this.schedulerPollMs);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
throw new RunControllerError("scheduler_stalled", `run ${runId} has no runnable or active Step`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|