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.
Files changed (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -0
  4. package/dist/cli/commands/run-lifecycle.d.ts +73 -0
  5. package/dist/cli/commands/run-lifecycle.js +240 -0
  6. package/dist/cli/commands/run.d.ts +22 -40
  7. package/dist/cli/commands/run.js +674 -392
  8. package/dist/cli/index.js +87 -2
  9. package/dist/compiler/workflow.js +3 -2
  10. package/dist/hooks/cli.d.ts +1 -2
  11. package/dist/hooks/cli.js +119 -80
  12. package/dist/hooks/enforce.d.ts +2 -0
  13. package/dist/hooks/enforce.js +56 -27
  14. package/dist/hooks/enforcement-boundary.d.ts +13 -0
  15. package/dist/hooks/enforcement-boundary.js +33 -0
  16. package/dist/hooks/index.d.ts +3 -2
  17. package/dist/hooks/index.js +3 -2
  18. package/dist/hooks/protocol.d.ts +12 -4
  19. package/dist/hooks/protocol.js +20 -14
  20. package/dist/hooks/schema.d.ts +2 -1
  21. package/dist/hooks/schema.js +6 -2
  22. package/dist/hooks/state-manager.d.ts +5 -5
  23. package/dist/hooks/state-manager.js +26 -24
  24. package/dist/hooks/state.d.ts +19 -3
  25. package/dist/hooks/state.js +327 -80
  26. package/dist/mcp/index.js +0 -0
  27. package/dist/runtime/diagnosis-contract-verifier.d.ts +11 -0
  28. package/dist/runtime/diagnosis-contract-verifier.js +417 -0
  29. package/dist/runtime/execution-provider.d.ts +40 -0
  30. package/dist/runtime/execution-provider.js +138 -0
  31. package/dist/runtime/handoff-resolver.d.ts +61 -0
  32. package/dist/runtime/handoff-resolver.js +167 -0
  33. package/dist/runtime/index.d.ts +24 -0
  34. package/dist/runtime/index.js +13 -0
  35. package/dist/runtime/process-tree.d.ts +47 -0
  36. package/dist/runtime/process-tree.js +402 -0
  37. package/dist/runtime/providers/claude.d.ts +9 -0
  38. package/dist/runtime/providers/claude.js +64 -0
  39. package/dist/runtime/providers/codex.d.ts +8 -0
  40. package/dist/runtime/providers/codex.js +72 -0
  41. package/dist/runtime/result-store.d.ts +32 -0
  42. package/dist/runtime/result-store.js +130 -0
  43. package/dist/runtime/run-contracts.d.ts +290 -0
  44. package/dist/runtime/run-contracts.js +58 -0
  45. package/dist/runtime/run-controller.d.ts +149 -0
  46. package/dist/runtime/run-controller.js +1108 -0
  47. package/dist/runtime/run-store.d.ts +96 -0
  48. package/dist/runtime/run-store.js +725 -0
  49. package/dist/runtime/worker-executor.d.ts +19 -0
  50. package/dist/runtime/worker-executor.js +194 -0
  51. package/dist/runtime/workflow-plan-adapter.d.ts +26 -0
  52. package/dist/runtime/workflow-plan-adapter.js +416 -0
  53. package/dist/runtime/workflow-runner.d.ts +15 -3
  54. package/dist/runtime/workflow-runner.js +13 -1
  55. package/dist/runtime/workspace-isolation.d.ts +103 -0
  56. package/dist/runtime/workspace-isolation.js +373 -0
  57. package/dist/schema/types.d.ts +1 -0
  58. package/dist/schema/validate.js +64 -6
  59. package/dist/schema/yaml-parser.js +7 -2
  60. 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
+ }