@tagma/sdk 0.6.11 → 0.7.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.
Files changed (132) hide show
  1. package/README.md +91 -18
  2. package/dist/bootstrap.d.ts +6 -6
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +5 -6
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/config-ops.d.ts +4 -2
  7. package/dist/config-ops.d.ts.map +1 -1
  8. package/dist/config-ops.js +16 -2
  9. package/dist/config-ops.js.map +1 -1
  10. package/dist/config.d.ts +8 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +5 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/core/dataflow.d.ts +23 -0
  15. package/dist/core/dataflow.d.ts.map +1 -0
  16. package/dist/core/dataflow.js +63 -0
  17. package/dist/core/dataflow.js.map +1 -0
  18. package/dist/core/log-prune.d.ts +16 -0
  19. package/dist/core/log-prune.d.ts.map +1 -0
  20. package/dist/core/log-prune.js +34 -0
  21. package/dist/core/log-prune.js.map +1 -0
  22. package/dist/core/preflight.d.ts +13 -0
  23. package/dist/core/preflight.d.ts.map +1 -0
  24. package/dist/core/preflight.js +61 -0
  25. package/dist/core/preflight.js.map +1 -0
  26. package/dist/core/run-context.d.ts +52 -0
  27. package/dist/core/run-context.d.ts.map +1 -0
  28. package/dist/core/run-context.js +156 -0
  29. package/dist/core/run-context.js.map +1 -0
  30. package/dist/core/run-state.d.ts +25 -0
  31. package/dist/core/run-state.d.ts.map +1 -0
  32. package/dist/core/run-state.js +93 -0
  33. package/dist/core/run-state.js.map +1 -0
  34. package/dist/core/scheduler.d.ts +13 -0
  35. package/dist/core/scheduler.d.ts.map +1 -0
  36. package/dist/core/scheduler.js +35 -0
  37. package/dist/core/scheduler.js.map +1 -0
  38. package/dist/core/task-executor.d.ts +13 -0
  39. package/dist/core/task-executor.d.ts.map +1 -0
  40. package/dist/core/task-executor.js +639 -0
  41. package/dist/core/task-executor.js.map +1 -0
  42. package/dist/core/trigger-errors.d.ts +9 -0
  43. package/dist/core/trigger-errors.d.ts.map +1 -0
  44. package/dist/core/trigger-errors.js +15 -0
  45. package/dist/core/trigger-errors.js.map +1 -0
  46. package/dist/engine.d.ts +6 -14
  47. package/dist/engine.d.ts.map +1 -1
  48. package/dist/engine.js +71 -990
  49. package/dist/engine.js.map +1 -1
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +6 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/pipeline-definition.d.ts +3 -0
  55. package/dist/pipeline-definition.d.ts.map +1 -0
  56. package/dist/pipeline-definition.js +4 -0
  57. package/dist/pipeline-definition.js.map +1 -0
  58. package/dist/pipeline-runner.d.ts +2 -1
  59. package/dist/pipeline-runner.d.ts.map +1 -1
  60. package/dist/pipeline-runner.js +2 -2
  61. package/dist/pipeline-runner.js.map +1 -1
  62. package/dist/plugins.d.ts +5 -0
  63. package/dist/plugins.d.ts.map +1 -0
  64. package/dist/plugins.js +3 -0
  65. package/dist/plugins.js.map +1 -0
  66. package/dist/ports.d.ts +23 -1
  67. package/dist/ports.d.ts.map +1 -1
  68. package/dist/ports.js +160 -0
  69. package/dist/ports.js.map +1 -1
  70. package/dist/registry.d.ts +3 -19
  71. package/dist/registry.d.ts.map +1 -1
  72. package/dist/registry.js +7 -35
  73. package/dist/registry.js.map +1 -1
  74. package/dist/schema.d.ts.map +1 -1
  75. package/dist/schema.js +7 -3
  76. package/dist/schema.js.map +1 -1
  77. package/dist/tagma.d.ts +24 -0
  78. package/dist/tagma.d.ts.map +1 -0
  79. package/dist/tagma.js +23 -0
  80. package/dist/tagma.js.map +1 -0
  81. package/dist/utils-api.d.ts +2 -0
  82. package/dist/utils-api.d.ts.map +1 -0
  83. package/dist/utils-api.js +2 -0
  84. package/dist/utils-api.js.map +1 -0
  85. package/dist/validate-raw.js +118 -0
  86. package/dist/validate-raw.js.map +1 -1
  87. package/dist/yaml.d.ts +4 -0
  88. package/dist/yaml.d.ts.map +1 -0
  89. package/dist/yaml.js +3 -0
  90. package/dist/yaml.js.map +1 -0
  91. package/package.json +53 -8
  92. package/src/bootstrap.ts +6 -6
  93. package/src/config-ops.ts +12 -2
  94. package/src/config.ts +26 -0
  95. package/src/core/dataflow.test.ts +167 -0
  96. package/src/core/dataflow.ts +118 -0
  97. package/src/core/log-prune.test.ts +58 -0
  98. package/src/core/log-prune.ts +43 -0
  99. package/src/core/preflight.test.ts +49 -0
  100. package/src/core/preflight.ts +89 -0
  101. package/src/core/run-context.test.ts +244 -0
  102. package/src/core/run-context.ts +207 -0
  103. package/src/core/run-state.test.ts +98 -0
  104. package/src/core/run-state.ts +122 -0
  105. package/src/core/scheduler.test.ts +83 -0
  106. package/src/core/scheduler.ts +42 -0
  107. package/src/core/task-executor.ts +803 -0
  108. package/src/core/trigger-errors.ts +15 -0
  109. package/src/engine-ports.test.ts +66 -0
  110. package/src/engine-task-type.test.ts +56 -0
  111. package/src/engine.ts +86 -1180
  112. package/src/index.ts +28 -0
  113. package/src/pipeline-definition.ts +5 -0
  114. package/src/pipeline-runner.ts +3 -2
  115. package/src/plugin-registry.test.ts +7 -10
  116. package/src/plugins.ts +18 -0
  117. package/src/ports.test.ts +127 -0
  118. package/src/ports.ts +224 -1
  119. package/src/registry.ts +7 -49
  120. package/src/schema-ports.test.ts +86 -0
  121. package/src/schema.ts +7 -3
  122. package/src/tagma.test.ts +84 -0
  123. package/src/tagma.ts +47 -0
  124. package/src/utils-api.ts +8 -0
  125. package/src/validate-raw-ports.test.ts +66 -0
  126. package/src/validate-raw.ts +137 -0
  127. package/src/yaml.ts +11 -0
  128. package/dist/sdk.d.ts +0 -32
  129. package/dist/sdk.d.ts.map +0 -1
  130. package/dist/sdk.js +0 -41
  131. package/dist/sdk.js.map +0 -1
  132. package/src/sdk.ts +0 -147
package/dist/engine.js CHANGED
@@ -1,124 +1,18 @@
1
1
  import { resolve } from 'path';
2
- import { readdir, rm } from 'fs/promises';
3
2
  import { buildDag } from './dag';
4
- import { defaultRegistry } from './registry';
5
- import { runSpawn, runCommand } from './runner';
6
3
  import { parseDuration, nowISO, generateRunId } from './utils';
7
- import { promptDocumentFromString, serializePromptDocument, prependContext, renderInputsBlock, renderOutputSchemaBlock, } from './prompt-doc';
8
- import { extractTaskOutputs, inferPromptPorts, resolveTaskInputs, substituteInputs, } from './ports';
9
- import { executeHook, buildPipelineStartContext, buildTaskContext, buildPipelineCompleteContext, buildPipelineErrorContext, } from './hooks';
10
- import { Logger, tailLines, clip } from './logger';
4
+ import { executeHook, buildPipelineStartContext, buildPipelineCompleteContext, buildPipelineErrorContext, } from './hooks';
5
+ import { Logger } from './logger';
11
6
  import { InMemoryApprovalGateway } from './approval';
12
- // ═══ A7: Typed trigger errors ═══
13
- // Replace string-matching on error messages with structured error types so
14
- // coincidental substrings don't cause misclassification.
15
- export class TriggerBlockedError extends Error {
16
- code = 'TRIGGER_BLOCKED';
17
- constructor(message) {
18
- super(message);
19
- this.name = 'TriggerBlockedError';
20
- }
21
- }
22
- export class TriggerTimeoutError extends Error {
23
- code = 'TRIGGER_TIMEOUT';
24
- constructor(message) {
25
- super(message);
26
- this.name = 'TriggerTimeoutError';
27
- }
28
- }
29
- // ═══ Preflight Validation ═══
30
- function preflight(config, dag, registry) {
31
- const errors = [];
32
- for (const [, node] of dag.nodes) {
33
- const task = node.task;
34
- const track = node.track;
35
- const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
36
- // Pure command tasks don't use a driver — skip driver registration check.
37
- const isCommandOnly = task.command && !task.prompt;
38
- if (!isCommandOnly && !registry.hasHandler('drivers', driverName)) {
39
- errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
40
- }
41
- if (task.trigger && !registry.hasHandler('triggers', task.trigger.type)) {
42
- errors.push(`Task "${node.taskId}": trigger type "${task.trigger.type}" not registered`);
43
- }
44
- if (task.completion && !registry.hasHandler('completions', task.completion.type)) {
45
- errors.push(`Task "${node.taskId}": completion type "${task.completion.type}" not registered`);
46
- }
47
- const mws = task.middlewares ?? track.middlewares ?? [];
48
- for (const mw of mws) {
49
- if (!registry.hasHandler('middlewares', mw.type)) {
50
- errors.push(`Task "${node.taskId}": middleware type "${mw.type}" not registered`);
51
- }
52
- }
53
- if (task.continue_from && registry.hasHandler('drivers', driverName)) {
54
- const driver = registry.getHandler('drivers', driverName);
55
- if (!driver.capabilities.sessionResume) {
56
- // buildDag has already qualified `continue_from` and stored the result
57
- // on the node; preflight runs after buildDag, so the upstream id is
58
- // always available here without re-resolving.
59
- const upstreamId = node.resolvedContinueFrom;
60
- if (upstreamId) {
61
- const upstream = dag.nodes.get(upstreamId);
62
- if (upstream) {
63
- // A handoff is possible via session resume (already ruled out above),
64
- // OR in-memory text injection through normalizedMap
65
- // (when the upstream driver implements parseResult and returns normalizedOutput).
66
- const upstreamDriverName = upstream.task.driver ?? upstream.track.driver ?? config.driver ?? 'opencode';
67
- const upstreamDriver = registry.hasHandler('drivers', upstreamDriverName)
68
- ? registry.getHandler('drivers', upstreamDriverName)
69
- : null;
70
- const canNormalize = typeof upstreamDriver?.parseResult === 'function';
71
- if (!canNormalize) {
72
- errors.push(`Task "${node.taskId}" uses continue_from: "${task.continue_from}", ` +
73
- `but upstream task "${upstreamId}" its driver ` +
74
- `does not implement parseResult for text-injection handoff. ` +
75
- `Use a driver with parseResult, or remove continue_from.`);
76
- }
77
- }
78
- }
79
- }
80
- }
81
- }
82
- if (errors.length > 0) {
83
- throw new Error(`Preflight validation failed:\n - ${errors.join('\n - ')}`);
84
- }
85
- }
86
- // ═══ Helpers ═══
87
- /**
88
- * Project the engine's internal TaskState onto the wire RunTaskState
89
- * shape. `logs` / `totalLogCount` default to empty — they are populated
90
- * on the server side from streamed `task_log` events, not from state.
91
- */
92
- function toRunTaskState(taskId, trackId, taskName, state) {
93
- const result = state.result;
94
- const cfg = state.config;
95
- return {
96
- taskId,
97
- trackId,
98
- taskName,
99
- status: state.status,
100
- startedAt: state.startedAt,
101
- finishedAt: state.finishedAt,
102
- durationMs: result?.durationMs ?? null,
103
- exitCode: result?.exitCode ?? null,
104
- stdout: result?.stdout ?? '',
105
- stderr: result?.stderr ?? '',
106
- stdoutPath: result?.stdoutPath ?? null,
107
- stderrPath: result?.stderrPath ?? null,
108
- stdoutBytes: result?.stdoutBytes ?? null,
109
- stderrBytes: result?.stderrBytes ?? null,
110
- sessionId: result?.sessionId ?? null,
111
- normalizedOutput: result?.normalizedOutput ?? null,
112
- resolvedDriver: cfg.driver ?? null,
113
- resolvedModel: cfg.model ?? null,
114
- resolvedPermissions: cfg.permissions ?? null,
115
- // Ports not yet wired through the engine's event surface. Null placeholder
116
- // keeps the wire type honest until the ports extraction pass lands.
117
- outputs: result?.outputs ?? null,
118
- inputs: null,
119
- logs: [],
120
- totalLogCount: 0,
121
- };
7
+ import { freezeStates, summarizeStates, toRunTaskState, } from './core/run-state';
8
+ import { preflight } from './core/preflight';
9
+ import { pruneLogDirs } from './core/log-prune';
10
+ import { RunContext } from './core/run-context';
11
+ import { allTasksTerminal, findLaunchableTasks, skipNonTerminalTasks, } from './core/scheduler';
12
+ import { executeTask } from './core/task-executor';
13
+ export { TriggerBlockedError, TriggerTimeoutError } from './core/trigger-errors';
14
+ function isPromptTaskConfig(task) {
15
+ return task.prompt !== undefined && task.command === undefined;
122
16
  }
123
17
  // Poll interval when no tasks are in-flight but non-terminal tasks remain
124
18
  // (e.g. tasks waiting on a file or manual trigger).
@@ -127,10 +21,13 @@ const POLL_INTERVAL_MS = 50;
127
21
  // runaway parseResult can't accumulate hundreds of MB across tasks. 1 MB
128
22
  // is generous for any text-context handoff between AI tasks.
129
23
  const MAX_NORMALIZED_BYTES = 1_000_000;
130
- export async function runPipeline(config, workDir, options = {}) {
24
+ export async function runPipeline(config, workDir, options) {
131
25
  const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
132
26
  const maxLogRuns = options.maxLogRuns ?? 20;
133
- const registry = options.registry ?? defaultRegistry;
27
+ const registry = options.registry;
28
+ if (!registry) {
29
+ throw new Error('runPipeline requires options.registry. Use createTagma().run(...) for the public SDK API.');
30
+ }
134
31
  // Load any plugins declared in the pipeline config before preflight so that
135
32
  // drivers, completions, and middlewares referenced in YAML are registered.
136
33
  // Hosts that pre-load plugins from a custom path (e.g. the editor loading
@@ -171,22 +68,22 @@ export async function runPipeline(config, workDir, options = {}) {
171
68
  log.section('DAG topology');
172
69
  for (const [id, node] of dag.nodes) {
173
70
  const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
174
- const kind = node.task.prompt ? 'ai' : 'cmd';
71
+ const kind = isPromptTaskConfig(node.task) ? 'ai' : 'cmd';
175
72
  log.quiet(` • ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
176
73
  }
177
74
  log.quiet('');
178
- // Initialize states (before hook, so we can return them even if blocked)
179
- const states = new Map();
180
- for (const [id, node] of dag.nodes) {
181
- states.set(id, {
182
- config: node.task,
183
- trackConfig: node.track,
184
- status: 'idle',
185
- result: null,
186
- startedAt: null,
187
- finishedAt: null,
188
- });
189
- }
75
+ // Per-run state container. Constructed before the pipeline_start hook
76
+ // so the early-return path (blocked pipeline) can call freezeStates on
77
+ // the populated idle-state map. The constructor has no side effects —
78
+ // no listeners installed, no events emitted.
79
+ const ctx = new RunContext({
80
+ runId,
81
+ dag,
82
+ config,
83
+ workDir,
84
+ pipelineInfo,
85
+ onEvent: options.onEvent,
86
+ });
190
87
  // Pipeline start hook (gate). Runs BEFORE the engine emits run_start so
191
88
  // a blocked pipeline produces zero wire events (the server treats the
192
89
  // thrown error as run_error). Hosts get a rich error message; nothing
@@ -207,11 +104,11 @@ export async function runPipeline(config, workDir, options = {}) {
207
104
  timeout: 0,
208
105
  blocked: 0,
209
106
  },
210
- states: freezeStates(states),
107
+ states: freezeStates(ctx.states),
211
108
  };
212
109
  }
213
110
  // Pipeline approved — transition all tasks to waiting.
214
- for (const [, state] of states) {
111
+ for (const [, state] of ctx.states) {
215
112
  state.status = 'waiting';
216
113
  }
217
114
  // Emit run_start with a wire-shape snapshot so SSE subscribers can
@@ -220,66 +117,33 @@ export async function runPipeline(config, workDir, options = {}) {
220
117
  // the engine owns the lifecycle boundary.
221
118
  const runStartTasks = [];
222
119
  for (const [id, node] of dag.nodes) {
223
- const s = states.get(id);
120
+ const s = ctx.states.get(id);
224
121
  runStartTasks.push(toRunTaskState(id, node.track.id, node.task.name ?? id, s));
225
122
  }
226
- emit({ type: 'run_start', runId, tasks: runStartTasks });
227
- const sessionMap = new Map();
228
- const normalizedMap = new Map();
229
- // Extracted port outputs keyed by fully-qualified task id. Populated
230
- // after a task succeeds when its `ports.outputs` is declared; read by
231
- // downstream tasks via `resolveTaskInputs` to assemble their inputs.
232
- // Kept separate from normalizedMap so the continue_from text handoff
233
- // and the typed-port data handoff don't pollute each other — they
234
- // solve different problems and have different lifetimes.
235
- const outputValuesMap = new Map();
236
- // Resolved port inputs keyed by fully-qualified task id. Written once,
237
- // just before a task runs, so every subsequent task_update event can
238
- // echo them to the UI without re-resolving.
239
- const resolvedInputsMap = new Map();
240
- // Reverse adjacency: for each task, list the direct-downstream task ids
241
- // (tasks whose `depends_on` includes this one after DAG qualification).
242
- // Computed once up front so Prompt-task port inference — which needs
243
- // "what Commands directly consume me?" — is O(1) instead of O(tasks)
244
- // per Prompt start. `dag.nodes` only exposes forward edges via
245
- // `dependsOn`, so we build this locally.
246
- const directDownstreams = new Map();
247
- for (const [id] of dag.nodes)
248
- directDownstreams.set(id, []);
249
- for (const [id, node] of dag.nodes) {
250
- for (const upstream of node.dependsOn) {
251
- const list = directDownstreams.get(upstream);
252
- if (list)
253
- list.push(id);
254
- }
255
- }
256
- // Pipeline timeout + abort reason tracking.
257
- //
258
- // `abortReason` replaces the previous `pipelineAborted: boolean`: it
259
- // carries the concrete cause (timeout / stop_all / external) through
260
- // to run_end and the pipeline_error hook so downstream consumers can
261
- // distinguish them without scraping message strings.
123
+ ctx.emit({ type: 'run_start', runId, tasks: runStartTasks });
124
+ // Pipeline timeout. `ctx.abortReason` carries the concrete cause
125
+ // (timeout / stop_all / external) through to run_end and the
126
+ // pipeline_error hook so downstream consumers can distinguish them
127
+ // without scraping message strings.
262
128
  const pipelineTimeoutMs = config.timeout ? parseDuration(config.timeout) : 0;
263
- let abortReason = null;
264
- const abortController = new AbortController();
265
129
  let pipelineTimer = null;
266
130
  if (pipelineTimeoutMs > 0) {
267
131
  pipelineTimer = setTimeout(() => {
268
- if (abortReason === null)
269
- abortReason = 'timeout';
270
- abortController.abort();
132
+ if (ctx.abortReason === null)
133
+ ctx.abortReason = 'timeout';
134
+ ctx.abortController.abort();
271
135
  }, pipelineTimeoutMs);
272
136
  }
273
137
  // When the pipeline is aborted (timeout, stop_all, external), drain
274
138
  // all pending approvals so waiting triggers unblock immediately.
275
- abortController.signal.addEventListener('abort', () => {
139
+ ctx.abortController.signal.addEventListener('abort', () => {
276
140
  approvalGateway.abortAll('pipeline aborted');
277
141
  });
278
142
  // Wire external cancel signal into the internal abort controller.
279
143
  const externalAbortHandler = () => {
280
- if (abortReason === null)
281
- abortReason = 'external';
282
- abortController.abort();
144
+ if (ctx.abortReason === null)
145
+ ctx.abortReason = 'external';
146
+ ctx.abortController.abort();
283
147
  };
284
148
  if (options.signal) {
285
149
  if (options.signal.aborted) {
@@ -294,7 +158,7 @@ export async function runPipeline(config, workDir, options = {}) {
294
158
  // updates. The server no longer needs its own gateway subscription.
295
159
  const unsubscribeApprovals = approvalGateway.subscribe((ev) => {
296
160
  if (ev.type === 'requested') {
297
- emit({
161
+ ctx.emit({
298
162
  type: 'approval_request',
299
163
  runId,
300
164
  request: {
@@ -315,7 +179,7 @@ export async function runPipeline(config, workDir, options = {}) {
315
179
  : ev.type === 'expired'
316
180
  ? 'timeout'
317
181
  : 'aborted';
318
- emit({
182
+ ctx.emit({
319
183
  type: 'approval_resolved',
320
184
  runId,
321
185
  requestId: ev.request.id,
@@ -323,733 +187,27 @@ export async function runPipeline(config, workDir, options = {}) {
323
187
  });
324
188
  }
325
189
  });
326
- // ── Helpers ──
327
- function emit(event) {
328
- options.onEvent?.(event);
329
- }
330
- function setTaskStatus(taskId, newStatus) {
331
- const state = states.get(taskId);
332
- // Terminal lock: once a task reaches a terminal state it must not be
333
- // re-transitioned. This prevents stop_all from marking running tasks as
334
- // skipped and then having their in-flight processTask promise overwrite
335
- // that with success/failed, producing an invalid double transition.
336
- if (isTerminal(state.status))
337
- return;
338
- state.status = newStatus;
339
- const result = state.result;
340
- const cfg = state.config;
341
- emit({
342
- type: 'task_update',
343
- runId,
344
- taskId,
345
- status: newStatus,
346
- startedAt: state.startedAt ?? undefined,
347
- finishedAt: state.finishedAt ?? undefined,
348
- durationMs: result?.durationMs,
349
- exitCode: result?.exitCode,
350
- stdout: result?.stdout,
351
- stderr: result?.stderr,
352
- stdoutPath: result?.stdoutPath ?? null,
353
- stderrPath: result?.stderrPath ?? null,
354
- stdoutBytes: result?.stdoutBytes ?? null,
355
- stderrBytes: result?.stderrBytes ?? null,
356
- sessionId: result?.sessionId ?? null,
357
- normalizedOutput: result?.normalizedOutput ?? null,
358
- inputs: resolvedInputsMap.get(taskId) ?? null,
359
- outputs: outputValuesMap.get(taskId) ?? null,
360
- resolvedDriver: cfg.driver ?? null,
361
- resolvedModel: cfg.model ?? null,
362
- resolvedPermissions: cfg.permissions ?? null,
363
- });
364
- }
365
- function getOnFailure(taskId) {
366
- return dag.nodes.get(taskId)?.track.on_failure ?? 'skip_downstream';
367
- }
368
- function isDependencySatisfied(depId) {
369
- const depState = states.get(depId);
370
- if (!depState)
371
- return 'skip';
372
- switch (depState.status) {
373
- case 'success':
374
- return 'satisfied';
375
- case 'skipped':
376
- return 'skip';
377
- case 'failed':
378
- case 'timeout':
379
- case 'blocked':
380
- return getOnFailure(depId) === 'ignore' ? 'satisfied' : 'skip';
381
- default:
382
- return 'unsatisfied';
383
- }
384
- }
385
- /**
386
- * H3: "stop_all" historically only stopped tasks within the same track,
387
- * which contradicted both its name and user expectations. It now stops
388
- * the **entire pipeline**:
389
- * - In-flight tasks are signalled via the shared abort controller so
390
- * drivers / runner.ts can cancel cooperatively (returning
391
- * `failureKind: 'timeout'`).
392
- * - Still-waiting tasks across every track are immediately marked
393
- * skipped so the run completes promptly.
394
- * The terminal lock in setTaskStatus prevents any later re-transition
395
- * should a completed running task try to overwrite the skipped state.
396
- */
397
- function applyStopAll(_failedTrackId) {
398
- if (abortReason === null)
399
- abortReason = 'stop_all';
400
- abortController.abort();
401
- for (const [id, state] of states) {
402
- if (state.status === 'waiting') {
403
- state.finishedAt = nowISO();
404
- setTaskStatus(id, 'skipped');
405
- }
406
- }
407
- }
408
- function buildTaskInfoObj(taskId) {
409
- const state = states.get(taskId);
410
- return {
411
- id: taskId,
412
- name: state.config.name,
413
- type: state.config.prompt ? 'ai' : 'command',
414
- status: state.status,
415
- exit_code: state.result?.exitCode ?? null,
416
- duration_ms: state.result?.durationMs ?? null,
417
- stderr_path: state.result?.stderrPath ?? null,
418
- session_id: state.result?.sessionId ?? null,
419
- started_at: state.startedAt,
420
- finished_at: state.finishedAt,
421
- };
422
- }
423
- function trackInfoOf(taskId) {
424
- const node = dag.nodes.get(taskId);
425
- return { id: node.track.id, name: node.track.name };
426
- }
427
- async function fireHook(taskId, event) {
428
- await executeHook(config.hooks, event, buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir, abortController.signal);
429
- }
430
190
  // ── Process a single task ──
431
- async function processTask(taskId) {
432
- const state = states.get(taskId);
433
- const node = dag.nodes.get(taskId);
434
- const task = node.task;
435
- const track = node.track;
436
- log.section(`Task ${taskId}`, taskId);
437
- log.debug(`[task:${taskId}]`, `type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`);
438
- // 1. Check dependencies
439
- for (const depId of node.dependsOn) {
440
- const result = isDependencySatisfied(depId);
441
- if (result === 'skip') {
442
- const depStatus = states.get(depId)?.status ?? 'unknown';
443
- log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
444
- state.finishedAt = nowISO();
445
- setTaskStatus(taskId, 'skipped');
446
- return;
447
- }
448
- if (result === 'unsatisfied')
449
- return; // still waiting
450
- }
451
- // 2. Check trigger
452
- if (task.trigger) {
453
- log.debug(`[task:${taskId}]`, `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`);
454
- try {
455
- const triggerPlugin = registry.getHandler('triggers', task.trigger.type);
456
- // R6: race the plugin's watch() against the pipeline's abort signal
457
- // AND the task-level timeout. Third-party triggers may forget to
458
- // wire up ctx.signal — without the abort race, an aborted pipeline
459
- // would hang forever waiting for the plugin's watch promise to
460
- // resolve. And without the timeout race, a buggy watch() that never
461
- // settles would ignore the user's `task.timeout` (which the spawn
462
- // path at step 4 already honours) — a task could wedge the whole
463
- // pipeline until pipeline-level timeout fires (or forever, if none
464
- // is set). Honouring task.timeout here makes the two stages
465
- // symmetric. The cleanup paths in finally never run on the orphaned
466
- // plugin promise (it's allowed to leak a watcher; the pipeline is
467
- // being torn down anyway).
468
- const triggerTimeoutMs = task.timeout ? parseDuration(task.timeout) : 0;
469
- await new Promise((resolve, reject) => {
470
- let settled = false;
471
- let timer = null;
472
- const onAbort = () => {
473
- if (settled)
474
- return;
475
- settled = true;
476
- if (timer !== null)
477
- clearTimeout(timer);
478
- reject(new Error('Pipeline aborted'));
479
- };
480
- if (abortController.signal.aborted) {
481
- onAbort();
482
- return;
483
- }
484
- abortController.signal.addEventListener('abort', onAbort, { once: true });
485
- if (triggerTimeoutMs > 0) {
486
- timer = setTimeout(() => {
487
- if (settled)
488
- return;
489
- settled = true;
490
- abortController.signal.removeEventListener('abort', onAbort);
491
- reject(new TriggerTimeoutError(`Trigger "${task.trigger.type}" did not settle within ${task.timeout} (task-level timeout)`));
492
- }, triggerTimeoutMs);
493
- }
494
- triggerPlugin
495
- .watch(task.trigger, {
496
- taskId: node.taskId,
497
- trackId: track.id,
498
- workDir: task.cwd ?? workDir,
499
- signal: abortController.signal,
500
- approvalGateway,
501
- })
502
- .then((v) => {
503
- if (settled)
504
- return;
505
- settled = true;
506
- if (timer !== null)
507
- clearTimeout(timer);
508
- abortController.signal.removeEventListener('abort', onAbort);
509
- resolve(v);
510
- }, (e) => {
511
- if (settled)
512
- return;
513
- settled = true;
514
- if (timer !== null)
515
- clearTimeout(timer);
516
- abortController.signal.removeEventListener('abort', onAbort);
517
- reject(e);
518
- });
519
- });
520
- log.debug(`[task:${taskId}]`, `trigger fired`);
521
- }
522
- catch (err) {
523
- // If pipeline was aborted while we were still waiting for the trigger,
524
- // this task never entered running state → skipped, not timeout.
525
- state.finishedAt = nowISO();
526
- if (abortReason !== null) {
527
- setTaskStatus(taskId, 'skipped');
528
- }
529
- else if (err instanceof TriggerBlockedError) {
530
- setTaskStatus(taskId, 'blocked'); // user/policy rejection
531
- }
532
- else if (err instanceof TriggerTimeoutError) {
533
- setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
534
- }
535
- else {
536
- // A7 fallback: also check message strings for backward-compat with
537
- // third-party trigger plugins that don't throw typed errors yet.
538
- const msg = err instanceof Error ? err.message : String(err);
539
- if (msg.includes('rejected') || msg.includes('denied')) {
540
- setTaskStatus(taskId, 'blocked');
541
- }
542
- else if (msg.includes('timeout')) {
543
- setTaskStatus(taskId, 'timeout');
544
- }
545
- else {
546
- setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
547
- }
548
- }
549
- try {
550
- await fireHook(taskId, 'task_failure');
551
- }
552
- catch (hookErr) {
553
- log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
554
- }
555
- return;
556
- }
557
- }
558
- // 3. task_start hook (gate)
559
- const hookResult = await executeHook(config.hooks, 'task_start', buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir, abortController.signal);
560
- if (hookResult.exitCode !== 0 || config.hooks?.task_start) {
561
- log.debug(`[task:${taskId}]`, `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
562
- }
563
- if (!hookResult.allowed) {
564
- state.finishedAt = nowISO();
565
- setTaskStatus(taskId, 'blocked');
566
- try {
567
- await fireHook(taskId, 'task_failure');
568
- }
569
- catch (hookErr) {
570
- log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
571
- }
572
- return;
573
- }
574
- // 3.5. Resolve port inputs from upstream outputs. This is the last
575
- // gate before execution: missing-required inputs block the task
576
- // without ever spawning a process, so the caller sees a clear
577
- // "blocked: missing input X" rather than a cryptic runtime error
578
- // from a command that expanded a placeholder to the empty string.
579
- // Resolution runs even for tasks that declare no ports — the call
580
- // is cheap and returns `{kind: 'ready', inputs: {}}` in that case,
581
- // which downstream code handles uniformly.
582
- //
583
- // Prompt Tasks have no declared ports — their I/O contract is
584
- // inferred from direct-neighbor Command Tasks (see ports.ts:
585
- // `inferPromptPorts`). We synthesize a `TaskPorts` object and
586
- // feed it into the same resolve/substitute/render/extract
587
- // pipeline the Command path uses. Collisions that a Prompt can't
588
- // disambiguate (same input name on two upstreams, incompatible
589
- // downstream output types) block the task with a clear message.
590
- const isPromptTask = task.prompt !== undefined && task.command === undefined;
591
- let effectivePorts = task.ports;
592
- let promptInferenceBlockReason = null;
593
- if (isPromptTask) {
594
- const inference = inferPromptPorts({
595
- upstreams: node.dependsOn.map((upstreamId) => {
596
- const upstream = dag.nodes.get(upstreamId);
597
- const isUpstreamCommand = !!upstream?.task.command;
598
- return {
599
- taskId: upstreamId,
600
- outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
601
- };
602
- }),
603
- downstreams: (directDownstreams.get(taskId) ?? []).map((downstreamId) => {
604
- const downstream = dag.nodes.get(downstreamId);
605
- const isDownstreamCommand = !!downstream?.task.command;
606
- return {
607
- taskId: downstreamId,
608
- inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
609
- };
610
- }),
611
- });
612
- effectivePorts = inference.ports;
613
- if (inference.inputConflicts.length > 0 || inference.outputConflicts.length > 0) {
614
- const lines = [];
615
- for (const c of inference.inputConflicts)
616
- lines.push(c.reason);
617
- for (const c of inference.outputConflicts)
618
- lines.push(c.reason);
619
- promptInferenceBlockReason = lines.join('\n');
620
- }
621
- }
622
- if (promptInferenceBlockReason !== null) {
623
- log.error(`[task:${taskId}]`, `blocked — prompt port inference failed:\n${promptInferenceBlockReason}`);
624
- state.result = {
625
- exitCode: -1,
626
- stdout: '',
627
- stderr: `[engine] prompt port inference failed:\n${promptInferenceBlockReason}`,
628
- stdoutPath: null,
629
- stderrPath: null,
630
- durationMs: 0,
631
- sessionId: null,
632
- normalizedOutput: null,
633
- failureKind: 'spawn_error',
634
- outputs: null,
635
- };
636
- state.finishedAt = nowISO();
637
- setTaskStatus(taskId, 'blocked');
638
- try {
639
- await fireHook(taskId, 'task_failure');
640
- }
641
- catch (hookErr) {
642
- log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
643
- }
644
- if (getOnFailure(taskId) === 'stop_all')
645
- applyStopAll(node.track.id);
646
- return;
647
- }
648
- // Feed effective ports into `resolveTaskInputs` by shallow-cloning
649
- // the task. Prompt tasks get the inferred ports; Command tasks are
650
- // unchanged (effectivePorts === task.ports).
651
- const taskForResolve = effectivePorts === task.ports ? task : { ...task, ports: effectivePorts };
652
- const inputResolution = resolveTaskInputs(taskForResolve, outputValuesMap, node.dependsOn);
653
- if (inputResolution.kind === 'blocked') {
654
- log.error(`[task:${taskId}]`, `blocked — cannot resolve port inputs:\n${inputResolution.reason}`);
655
- state.result = {
656
- exitCode: -1,
657
- stdout: '',
658
- stderr: `[engine] port input resolution failed:\n${inputResolution.reason}`,
659
- stdoutPath: null,
660
- stderrPath: null,
661
- durationMs: 0,
662
- sessionId: null,
663
- normalizedOutput: null,
664
- failureKind: 'spawn_error',
665
- outputs: null,
666
- };
667
- state.finishedAt = nowISO();
668
- setTaskStatus(taskId, 'blocked');
669
- try {
670
- await fireHook(taskId, 'task_failure');
671
- }
672
- catch (hookErr) {
673
- log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
674
- }
675
- if (getOnFailure(taskId) === 'stop_all')
676
- applyStopAll(node.track.id);
677
- return;
678
- }
679
- const resolvedInputs = inputResolution.inputs;
680
- resolvedInputsMap.set(taskId, resolvedInputs);
681
- if (inputResolution.missingOptional.length > 0) {
682
- log.debug(`[task:${taskId}]`, `optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`);
683
- }
684
- if (effectivePorts?.inputs && effectivePorts.inputs.length > 0) {
685
- log.debug(`[task:${taskId}]`, `resolved inputs: ${JSON.stringify(resolvedInputs)}` +
686
- (isPromptTask ? ' (inferred from upstream Commands)' : ''));
687
- }
688
- // 4. Mark running — set startedAt before emitting so subscribers see a
689
- // complete task_update (startedAt non-null) on the status transition.
690
- state.startedAt = nowISO();
691
- setTaskStatus(taskId, 'running');
692
- log.info(`[task:${taskId}]`, task.command ? `running: ${task.command}` : `running (driver task)`);
693
- // File-only: resolved config for this task
694
- const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'opencode';
695
- const resolvedModel = task.model ?? track.model ?? config.model ?? '(default)';
696
- const resolvedPerms = task.permissions ?? track.permissions ?? '(default)';
697
- const resolvedCwd = task.cwd ?? track.cwd ?? workDir;
698
- log.debug(`[task:${taskId}]`, `resolved: driver=${resolvedDriver} model=${resolvedModel} cwd=${resolvedCwd}`);
699
- log.debug(`[task:${taskId}]`, `permissions: ${JSON.stringify(resolvedPerms)}`);
700
- if (task.continue_from) {
701
- log.debug(`[task:${taskId}]`, `continue_from: "${task.continue_from}"`);
702
- }
703
- if (task.timeout) {
704
- log.debug(`[task:${taskId}]`, `timeout: ${task.timeout}`);
705
- }
706
- try {
707
- let result;
708
- const timeoutMs = task.timeout ? parseDuration(task.timeout) : undefined;
709
- // Stream child stdout/stderr directly to disk in the logger's run dir
710
- // and keep only a bounded tail in the returned TaskResult. Filenames
711
- // mirror the existing `.stderr` naming — dots in task ids are replaced
712
- // so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
713
- const fsSafeTaskId = taskId.replace(/\./g, '_');
714
- const stdoutPath = resolve(log.dir, `${fsSafeTaskId}.stdout`);
715
- const stderrPath = resolve(log.dir, `${fsSafeTaskId}.stderr`);
716
- const runOpts = {
717
- timeoutMs,
718
- signal: abortController.signal,
719
- stdoutPath,
720
- stderrPath,
721
- };
722
- if (task.command) {
723
- // Substitute `{{inputs.X}}` placeholders into the command
724
- // string. Tasks with no declared inputs always produce the same
725
- // string back (no placeholders to match). Unresolved references
726
- // render empty — validate-raw flags undeclared references as
727
- // errors, so the only way to land here with an unresolved is an
728
- // optional input that had no upstream producer and no default,
729
- // which we surface in the log.
730
- const { text: expandedCommand, unresolved } = substituteInputs(task.command, resolvedInputs);
731
- if (unresolved.length > 0) {
732
- log.debug(`[task:${taskId}]`, `command placeholders rendered empty: ${unresolved.join(', ')}`);
733
- }
734
- log.debug(`[task:${taskId}]`, `command: ${expandedCommand}`);
735
- result = await runCommand(expandedCommand, task.cwd ?? workDir, runOpts);
736
- }
737
- else {
738
- // AI task: apply middleware chain against a structured PromptDocument.
739
- const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
740
- const driver = registry.getHandler('drivers', driverName);
741
- // Substitute placeholders in the user-authored prompt before
742
- // wrapping into a PromptDocument so middlewares see the
743
- // already-resolved task text.
744
- const { text: expandedPrompt, unresolved } = substituteInputs(task.prompt, resolvedInputs);
745
- if (unresolved.length > 0) {
746
- log.debug(`[task:${taskId}]`, `prompt placeholders rendered empty: ${unresolved.join(', ')}`);
747
- }
748
- const originalLen = expandedPrompt.length;
749
- let doc = promptDocumentFromString(expandedPrompt);
750
- // Prepend port-related context blocks so the model sees them
751
- // before any middleware-added retrieval / memory blocks. Order
752
- // matters: [Output Format] first (sets the deliverable), then
753
- // [Inputs] (the concrete data to operate on). Empty blocks are
754
- // filtered out — tasks without ports get no extra blocks at all.
755
- const outputFormatBlock = renderOutputSchemaBlock(effectivePorts?.outputs);
756
- if (outputFormatBlock) {
757
- doc = prependContext(doc, outputFormatBlock);
758
- }
759
- const inputsBlock = renderInputsBlock(effectivePorts?.inputs, resolvedInputs);
760
- if (inputsBlock) {
761
- doc = prependContext(doc, inputsBlock);
762
- }
763
- const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
764
- if (mws && mws.length > 0) {
765
- log.debug(`[task:${taskId}]`, `middleware chain: ${mws.map((m) => m.type).join(' → ')}`);
766
- const mwCtx = {
767
- task,
768
- track,
769
- workDir: task.cwd ?? workDir,
770
- };
771
- for (const mwConfig of mws) {
772
- const mwPlugin = registry.getHandler('middlewares', mwConfig.type);
773
- const beforeBlocks = doc.contexts.length;
774
- const beforeLen = serializePromptDocument(doc).length;
775
- // Prefer the structured API. Fall back to the legacy
776
- // `enhance(string) → string` path so v0.x plugins keep
777
- // working — that fallback loses context structure (the
778
- // middleware's output becomes the new task body) but never
779
- // silently drops content.
780
- if (typeof mwPlugin.enhanceDoc === 'function') {
781
- const next = await mwPlugin.enhanceDoc(doc, mwConfig, mwCtx);
782
- if (!next ||
783
- typeof next !== 'object' ||
784
- !Array.isArray(next.contexts) ||
785
- typeof next.task !== 'string') {
786
- throw new Error(`middleware "${mwConfig.type}".enhanceDoc() returned a malformed PromptDocument`);
787
- }
788
- doc = next;
789
- }
790
- else if (typeof mwPlugin.enhance === 'function') {
791
- const asString = serializePromptDocument(doc);
792
- const next = await mwPlugin.enhance(asString, mwConfig, mwCtx);
793
- // R3: a middleware that returns undefined / null / a non-string
794
- // would silently corrupt the prompt. Fail loud.
795
- if (typeof next !== 'string') {
796
- throw new Error(`middleware "${mwConfig.type}".enhance() returned ${next === null ? 'null' : typeof next}, expected string`);
797
- }
798
- // Legacy fallback: collapse the returned string into a
799
- // fresh doc. Earlier structure is folded into the string
800
- // (serializePromptDocument just ran), so bytes the driver
801
- // sees match the old string pipeline.
802
- doc = { contexts: [], task: next };
803
- }
804
- else {
805
- throw new Error(`middleware "${mwConfig.type}" provides neither enhanceDoc nor enhance`);
806
- }
807
- const afterLen = serializePromptDocument(doc).length;
808
- const addedBlocks = doc.contexts.length - beforeBlocks;
809
- log.debug(`[task:${taskId}]`, ` ${mwConfig.type}: ${beforeLen} → ${afterLen} chars` +
810
- (addedBlocks > 0
811
- ? ` (+${addedBlocks} context block${addedBlocks > 1 ? 's' : ''})`
812
- : ''));
813
- }
814
- }
815
- const prompt = serializePromptDocument(doc);
816
- log.debug(`[task:${taskId}]`, `prompt: ${originalLen} chars (final: ${prompt.length} chars, ${doc.contexts.length} block${doc.contexts.length === 1 ? '' : 's'})`);
817
- log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`, taskId);
818
- // H1: hand the driver a continue_from that has already been
819
- // qualified by dag.ts. Without this, drivers like codex/opencode/
820
- // claude-code look up maps directly with
821
- // the user's raw (possibly bare) string, which races whenever two
822
- // tracks share a task name. dag.ts has the only authoritative
823
- // resolver, so we use its precomputed answer here.
824
- // Drivers key sessionMap/normalizedMap by fully-qualified id. buildDag
825
- // guarantees `resolvedContinueFrom` is set for every task that has a
826
- // `continue_from`, so if we see the bare form here something upstream
827
- // is broken — fail loud instead of silently miskeying the lookup.
828
- if (task.continue_from && !node.resolvedContinueFrom) {
829
- throw new Error(`Internal: task "${taskId}" has continue_from "${task.continue_from}" ` +
830
- `but no resolvedContinueFrom. buildDag should have qualified it.`);
831
- }
832
- const enrichedTask = {
833
- ...task,
834
- prompt,
835
- continue_from: node.resolvedContinueFrom,
836
- // Hand the driver the EFFECTIVE port schema rather than the
837
- // raw task.ports. For Prompt tasks this is the one inferred
838
- // from neighbor Commands; Command tasks are unchanged.
839
- // Drivers that introspect ports (e.g. to annotate a system
840
- // prompt with the I/O contract) otherwise saw `undefined`
841
- // for every prompt and had no way to know the contract.
842
- ports: effectivePorts,
843
- };
844
- const driverCtx = {
845
- sessionMap,
846
- normalizedMap,
847
- workDir: task.cwd ?? workDir,
848
- // Structured view for drivers that want fine-grained control
849
- // over serialization (e.g. inserting [Previous Output] between
850
- // contexts and task). Drivers that read task.prompt see the
851
- // default serialization and need no changes.
852
- promptDoc: doc,
853
- // Ports feature: resolved input values keyed by port name,
854
- // already coerced to the declared port type. Drivers that
855
- // need to re-substitute placeholders inside a custom envelope
856
- // can read this and call `substituteInputs`; most drivers can
857
- // ignore it because the engine has already expanded
858
- // `{{inputs.X}}` into `task.prompt` upstream.
859
- inputs: resolvedInputs,
860
- };
861
- const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
862
- log.debug(`[task:${taskId}]`, `driver=${driverName}`);
863
- log.debug(`[task:${taskId}]`, `spawn args: ${JSON.stringify(spec.args)}`);
864
- if (spec.cwd)
865
- log.debug(`[task:${taskId}]`, `spawn cwd: ${spec.cwd}`);
866
- if (spec.env)
867
- log.debug(`[task:${taskId}]`, `spawn env overrides: ${Object.keys(spec.env).join(', ')}`);
868
- if (spec.stdin)
869
- log.debug(`[task:${taskId}]`, `spawn stdin: ${spec.stdin.length} chars`);
870
- result = await runSpawn(spec, driver, runOpts);
871
- }
872
- // 6. Determine terminal status (without emitting yet — result must be complete first)
873
- // H2: branch on failureKind so spawn errors no longer masquerade as
874
- // timeouts. Old runners that don't set failureKind still work — we
875
- // fall back to the historical `exitCode === -1 → timeout` heuristic so
876
- // pre-existing third-party drivers don't regress.
877
- let terminalStatus;
878
- const kind = result.failureKind;
879
- if (kind === 'timeout') {
880
- terminalStatus = 'timeout';
881
- }
882
- else if (kind === 'spawn_error') {
883
- terminalStatus = 'failed';
884
- }
885
- else if (kind === undefined && result.exitCode === -1) {
886
- // Legacy path: pre-H2 driver returned -1 with no kind. Treat as
887
- // timeout for backward compatibility (the previous behaviour).
888
- terminalStatus = 'timeout';
889
- }
890
- else if (result.exitCode !== 0) {
891
- terminalStatus = 'failed';
892
- }
893
- else if (task.completion) {
894
- const plugin = registry.getHandler('completions', task.completion.type);
895
- const completionCtx = { workDir: task.cwd ?? workDir, signal: abortController.signal };
896
- const passed = await plugin.check(task.completion, result, completionCtx);
897
- // R4: strict boolean check. Truthy strings/numbers used to be coerced
898
- // to success — a check returning "ok" would let a failing task pass.
899
- if (typeof passed !== 'boolean') {
900
- throw new Error(`completion "${task.completion.type}".check() returned ${passed === null ? 'null' : typeof passed}, expected boolean`);
901
- }
902
- terminalStatus = passed ? 'success' : 'failed';
903
- }
904
- else {
905
- terminalStatus = 'success';
906
- }
907
- // Extract declared port outputs from the task's output stream.
908
- // Only meaningful on success — a failed task's output is whatever
909
- // the child happened to emit before exiting, and downstream tasks
910
- // shouldn't receive partial data. `extractTaskOutputs` is a no-op
911
- // when the task has no declared outputs, so this is free for
912
- // pre-ports tasks. Diagnostics are appended to stderr so users
913
- // see *why* a downstream input is missing without having to dig
914
- // through driver-specific logs.
915
- let extractedOutputs = null;
916
- if (terminalStatus === 'success') {
917
- // Prompt tasks use inferred ports (from direct-downstream Command
918
- // inputs); Command tasks use their declared ports. Either way,
919
- // `extractTaskOutputs` is a no-op when there are no declared
920
- // outputs to pull, so pre-ports tasks pay nothing for this call.
921
- const extraction = extractTaskOutputs(effectivePorts, result.stdout, result.normalizedOutput);
922
- if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
923
- extractedOutputs = extraction.outputs;
924
- outputValuesMap.set(taskId, extraction.outputs);
925
- log.debug(`[task:${taskId}]`, `extracted outputs: ${JSON.stringify(extraction.outputs)}` +
926
- (isPromptTask ? ' (inferred from downstream Commands)' : ''));
927
- if (extraction.diagnostic) {
928
- log.error(`[task:${taskId}]`, extraction.diagnostic);
929
- const note = `\n[engine] ${extraction.diagnostic}`;
930
- result = { ...result, stderr: result.stderr + note };
931
- }
932
- }
933
- }
934
- // Attach outputs to the result (null when task has no declared
935
- // outputs or extraction failed entirely). Consumers of TaskResult
936
- // — hooks, wire events, test assertions — all go through this
937
- // one field rather than re-running extraction.
938
- result = { ...result, outputs: extractedOutputs };
939
- // Store normalized text separately (in-memory) for continue_from handoff.
940
- // R15: clip oversized values so a runaway parseResult can't accumulate
941
- // hundreds of MB across tasks.
942
- if (result.normalizedOutput !== null) {
943
- const clipped = result.normalizedOutput.length > MAX_NORMALIZED_BYTES
944
- ? result.normalizedOutput.slice(0, MAX_NORMALIZED_BYTES) +
945
- `\n[…clipped at ${MAX_NORMALIZED_BYTES} bytes]`
946
- : result.normalizedOutput;
947
- normalizedMap.set(taskId, clipped);
948
- }
949
- // Note: stderr is already persisted by runner.ts as it streams; the
950
- // old "write full string after the fact" block is gone — that's what
951
- // the streaming rewrite fixed (unbounded in-memory buffering).
952
- if (result.sessionId) {
953
- // H1: qualified-only key.
954
- sessionMap.set(taskId, result.sessionId);
955
- }
956
- // Set result and finishedAt before emitting terminal status so listeners see complete state
957
- state.result = result;
958
- state.finishedAt = nowISO();
959
- setTaskStatus(taskId, terminalStatus);
960
- // Log task outcome with relevant details
961
- const durSec = (result.durationMs / 1000).toFixed(1);
962
- if (terminalStatus === 'success') {
963
- log.info(`[task:${taskId}]`, `success (${durSec}s)`);
964
- }
965
- else {
966
- log.error(`[task:${taskId}]`, `${terminalStatus} exit=${result.exitCode} duration=${durSec}s`);
967
- if (result.stderr) {
968
- const tail = tailLines(result.stderr, 10);
969
- log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
970
- }
971
- }
972
- // File-only: byte counts (prefer full totals from the runner over the
973
- // bounded tail length so oversized outputs show their real size) +
974
- // paths to the on-disk full copies.
975
- const stdoutSize = result.stdoutBytes ?? result.stdout.length;
976
- const stderrSize = result.stderrBytes ?? result.stderr.length;
977
- log.debug(`[task:${taskId}]`, `stdout: ${stdoutSize} bytes, stderr: ${stderrSize} bytes`);
978
- if (result.sessionId) {
979
- log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
980
- }
981
- if (result.stdoutPath) {
982
- log.debug(`[task:${taskId}]`, `wrote stdout: ${result.stdoutPath}`);
983
- }
984
- if (result.stderrPath) {
985
- log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
986
- }
987
- if (result.stdout) {
988
- log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`, taskId);
989
- }
990
- if (result.stderr) {
991
- log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`, taskId);
992
- }
993
- if (task.completion) {
994
- log.debug(`[task:${taskId}]`, `completion check: type=${task.completion.type} result=${terminalStatus}`);
995
- }
996
- }
997
- catch (err) {
998
- const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
999
- log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
1000
- state.result = {
1001
- exitCode: -1,
1002
- stdout: '',
1003
- stderr: errMsg,
1004
- stdoutPath: null,
1005
- stderrPath: null,
1006
- stdoutBytes: 0,
1007
- stderrBytes: errMsg.length,
1008
- durationMs: 0,
1009
- sessionId: null,
1010
- normalizedOutput: null,
1011
- // H2: Engine-level pre-execution errors (driver throw, middleware
1012
- // throw, getHandler 404) classify as spawn_error — the process never
1013
- // ran, so calling them "timeout" was actively misleading.
1014
- failureKind: 'spawn_error',
1015
- };
1016
- state.finishedAt = nowISO();
1017
- setTaskStatus(taskId, 'failed');
1018
- }
1019
- // 7. Fire hooks
1020
- const finalStatus = state.status;
1021
- try {
1022
- await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
1023
- }
1024
- catch (hookErr) {
1025
- log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
1026
- }
1027
- // 8. Handle stop_all for failure states
1028
- if (finalStatus !== 'success' && getOnFailure(taskId) === 'stop_all') {
1029
- applyStopAll(node.track.id);
1030
- }
1031
- }
1032
191
  // ── Event loop ──
1033
192
  // Each task is launched as soon as ALL its deps reach a terminal state.
1034
193
  // We track in-flight tasks in `running` so a task completing mid-batch
1035
194
  // immediately unblocks its dependents without waiting for sibling tasks.
1036
195
  const running = new Map();
1037
196
  try {
1038
- while (abortReason === null) {
197
+ while (ctx.abortReason === null) {
1039
198
  // Launch every task whose deps are all terminal and that isn't already in-flight
1040
- for (const [id, state] of states) {
1041
- if (state.status !== 'waiting' || running.has(id))
1042
- continue;
1043
- const node = dag.nodes.get(id);
1044
- const allDepsTerminal = node.dependsOn.length === 0 ||
1045
- node.dependsOn.every((d) => isTerminal(states.get(d).status));
1046
- if (!allDepsTerminal)
1047
- continue;
1048
- const p = processTask(id).finally(() => running.delete(id));
199
+ for (const id of findLaunchableTasks(ctx, new Set(running.keys()))) {
200
+ const p = executeTask({
201
+ taskId: id,
202
+ ctx,
203
+ registry,
204
+ log,
205
+ approvalGateway,
206
+ }).finally(() => running.delete(id));
1049
207
  running.set(id, p);
1050
208
  }
1051
209
  // All tasks terminal — done
1052
- if ([...states.values()].every((s) => isTerminal(s.status)))
210
+ if (allTasksTerminal(ctx))
1053
211
  break;
1054
212
  if (running.size === 0) {
1055
213
  // Nothing in-flight but non-terminal tasks exist (e.g. trigger-wait states
@@ -1061,19 +219,14 @@ export async function runPipeline(config, workDir, options = {}) {
1061
219
  await Promise.race(running.values());
1062
220
  }
1063
221
  }
1064
- if (abortReason !== null) {
222
+ if (ctx.abortReason !== null) {
1065
223
  // Wait for in-flight tasks to honour the abort signal before marking states.
1066
224
  if (running.size > 0)
1067
225
  await Promise.allSettled(running.values());
1068
- for (const [id, state] of states) {
1069
- if (!isTerminal(state.status)) {
1070
- // By the time allSettled resolves, processTask's try/finally has already
1071
- // set running tasks to success/failed/timeout. The only non-terminal
1072
- // statuses remaining here are waiting/idle tasks that were never started.
1073
- state.finishedAt = nowISO();
1074
- setTaskStatus(id, 'skipped');
1075
- }
1076
- }
226
+ // By the time allSettled resolves, processTask's try/finally has already
227
+ // set running tasks to success/failed/timeout. The only non-terminal
228
+ // statuses remaining here are waiting/idle tasks that were never started.
229
+ skipNonTerminalTasks(ctx);
1077
230
  }
1078
231
  }
1079
232
  finally {
@@ -1093,53 +246,33 @@ export async function runPipeline(config, workDir, options = {}) {
1093
246
  unsubscribeApprovals();
1094
247
  }
1095
248
  // ── Summary ──
1096
- const summary = { total: 0, success: 0, failed: 0, skipped: 0, timeout: 0, blocked: 0 };
1097
- for (const [, state] of states) {
1098
- summary.total++;
1099
- switch (state.status) {
1100
- case 'success':
1101
- summary.success++;
1102
- break;
1103
- case 'failed':
1104
- summary.failed++;
1105
- break;
1106
- case 'skipped':
1107
- summary.skipped++;
1108
- break;
1109
- case 'timeout':
1110
- summary.timeout++;
1111
- break;
1112
- case 'blocked':
1113
- summary.blocked++;
1114
- break;
1115
- }
1116
- }
249
+ const summary = summarizeStates(ctx.states);
1117
250
  const finishedAt = nowISO();
1118
251
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
1119
- if (abortReason !== null) {
1120
- const reasonText = abortReason === 'timeout'
252
+ if (ctx.abortReason !== null) {
253
+ const reasonText = ctx.abortReason === 'timeout'
1121
254
  ? 'Pipeline timeout exceeded'
1122
- : abortReason === 'stop_all'
255
+ : ctx.abortReason === 'stop_all'
1123
256
  ? 'Pipeline stopped (on_failure: stop_all)'
1124
257
  : 'Pipeline aborted by host';
1125
- await executeHook(config.hooks, 'pipeline_error', buildPipelineErrorContext(pipelineInfo, reasonText, undefined, abortReason), workDir);
258
+ await executeHook(config.hooks, 'pipeline_error', buildPipelineErrorContext(pipelineInfo, reasonText, undefined, ctx.abortReason), workDir);
1126
259
  }
1127
260
  else {
1128
261
  await executeHook(config.hooks, 'pipeline_complete', buildPipelineCompleteContext({ ...pipelineInfo, finished_at: finishedAt, duration_ms: durationMs }, summary), workDir);
1129
262
  }
1130
- const allSuccess = abortReason === null &&
263
+ const allSuccess = ctx.abortReason === null &&
1131
264
  summary.failed === 0 &&
1132
265
  summary.timeout === 0 &&
1133
266
  summary.blocked === 0;
1134
267
  log.section('Pipeline summary');
1135
- log.quiet(`status: ${abortReason !== null ? `aborted (${abortReason})` : 'completed'}`);
268
+ log.quiet(`status: ${ctx.abortReason !== null ? `aborted (${ctx.abortReason})` : 'completed'}`);
1136
269
  log.quiet(`duration: ${(durationMs / 1000).toFixed(1)}s`);
1137
270
  log.quiet(`counts: total=${summary.total} success=${summary.success} ` +
1138
271
  `failed=${summary.failed} skipped=${summary.skipped} ` +
1139
272
  `timeout=${summary.timeout} blocked=${summary.blocked}`);
1140
273
  log.quiet('');
1141
274
  log.quiet('per-task:');
1142
- for (const [id, state] of states) {
275
+ for (const [id, state] of ctx.states) {
1143
276
  const dur = state.result?.durationMs != null ? `${(state.result.durationMs / 1000).toFixed(1)}s` : '-';
1144
277
  const exit = state.result?.exitCode ?? '-';
1145
278
  log.quiet(` ${state.status.padEnd(8)} ${id} (exit=${exit}, ${dur})`);
@@ -1148,8 +281,8 @@ export async function runPipeline(config, workDir, options = {}) {
1148
281
  log.info('[pipeline]', `Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`);
1149
282
  log.info('[pipeline]', `Duration: ${(durationMs / 1000).toFixed(1)}s`);
1150
283
  log.info('[pipeline]', `Log: ${log.path}`);
1151
- emit({ type: 'run_end', runId, success: allSuccess, abortReason });
1152
- return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
284
+ ctx.emit({ type: 'run_end', runId, success: allSuccess, abortReason: ctx.abortReason });
285
+ return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(ctx.states) };
1153
286
  }
1154
287
  finally {
1155
288
  // Close the persistent log file handle before pruning.
@@ -1161,56 +294,4 @@ export async function runPipeline(config, workDir, options = {}) {
1161
294
  }
1162
295
  }
1163
296
  }
1164
- /**
1165
- * Delete the oldest subdirectories under `logsDir`, keeping only the most recent `keep`
1166
- * total runs (including the currently-live run identified by `excludeRunId`).
1167
- * Directories are sorted lexicographically; because runIds are prefixed with a base-36
1168
- * timestamp, lexicographic order equals chronological order.
1169
- *
1170
- * `excludeRunId` is always skipped from deletion even if it would otherwise be pruned —
1171
- * this prevents a concurrent run from removing a live log directory that is still in use.
1172
- *
1173
- * D10: The live run occupies one slot out of `keep`, so the maximum number of
1174
- * *historical* dirs to retain is `keep - 1`. Without this adjustment the function
1175
- * kept `keep` historical dirs plus 1 live dir = `keep + 1` total on disk.
1176
- */
1177
- async function pruneLogDirs(logsDir, keep, excludeRunId) {
1178
- let entries;
1179
- try {
1180
- entries = await readdir(logsDir);
1181
- }
1182
- catch {
1183
- return; // logsDir doesn't exist yet — nothing to prune
1184
- }
1185
- // Only consider directories that look like run IDs (run_<...>), excluding the live run.
1186
- const runDirs = entries.filter((e) => e.startsWith('run_') && e !== excludeRunId).sort();
1187
- // keep - 1 historical slots (1 slot is reserved for the live excludeRunId).
1188
- const historyKeep = Math.max(0, keep - 1);
1189
- const toDelete = runDirs.slice(0, Math.max(0, runDirs.length - historyKeep));
1190
- await Promise.all(toDelete.map((dir) => rm(resolve(logsDir, dir), { recursive: true, force: true }).catch(() => {
1191
- // Ignore deletion errors — stale dirs are better than a crash
1192
- })));
1193
- }
1194
- function isTerminal(status) {
1195
- return (status === 'success' ||
1196
- status === 'failed' ||
1197
- status === 'timeout' ||
1198
- status === 'skipped' ||
1199
- status === 'blocked');
1200
- }
1201
- /** Return a deep-copied, caller-safe snapshot of the states map. */
1202
- function freezeStates(states) {
1203
- const copy = new Map();
1204
- for (const [id, s] of states) {
1205
- copy.set(id, {
1206
- config: { ...s.config },
1207
- trackConfig: { ...s.trackConfig },
1208
- status: s.status,
1209
- result: s.result ? { ...s.result } : null,
1210
- startedAt: s.startedAt,
1211
- finishedAt: s.finishedAt,
1212
- });
1213
- }
1214
- return copy;
1215
- }
1216
297
  //# sourceMappingURL=engine.js.map