@tagma/sdk 0.6.12 → 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 (110) hide show
  1. package/README.md +56 -15
  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.d.ts +8 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +5 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/core/dataflow.d.ts +23 -0
  11. package/dist/core/dataflow.d.ts.map +1 -0
  12. package/dist/core/dataflow.js +63 -0
  13. package/dist/core/dataflow.js.map +1 -0
  14. package/dist/core/log-prune.d.ts +16 -0
  15. package/dist/core/log-prune.d.ts.map +1 -0
  16. package/dist/core/log-prune.js +34 -0
  17. package/dist/core/log-prune.js.map +1 -0
  18. package/dist/core/preflight.d.ts +13 -0
  19. package/dist/core/preflight.d.ts.map +1 -0
  20. package/dist/core/preflight.js +61 -0
  21. package/dist/core/preflight.js.map +1 -0
  22. package/dist/core/run-context.d.ts +52 -0
  23. package/dist/core/run-context.d.ts.map +1 -0
  24. package/dist/core/run-context.js +156 -0
  25. package/dist/core/run-context.js.map +1 -0
  26. package/dist/core/run-state.d.ts +25 -0
  27. package/dist/core/run-state.d.ts.map +1 -0
  28. package/dist/core/run-state.js +93 -0
  29. package/dist/core/run-state.js.map +1 -0
  30. package/dist/core/scheduler.d.ts +13 -0
  31. package/dist/core/scheduler.d.ts.map +1 -0
  32. package/dist/core/scheduler.js +35 -0
  33. package/dist/core/scheduler.js.map +1 -0
  34. package/dist/core/task-executor.d.ts +13 -0
  35. package/dist/core/task-executor.d.ts.map +1 -0
  36. package/dist/core/task-executor.js +639 -0
  37. package/dist/core/task-executor.js.map +1 -0
  38. package/dist/core/trigger-errors.d.ts +9 -0
  39. package/dist/core/trigger-errors.d.ts.map +1 -0
  40. package/dist/core/trigger-errors.js +15 -0
  41. package/dist/core/trigger-errors.js.map +1 -0
  42. package/dist/engine.d.ts +6 -14
  43. package/dist/engine.d.ts.map +1 -1
  44. package/dist/engine.js +68 -1035
  45. package/dist/engine.js.map +1 -1
  46. package/dist/index.d.ts +9 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +6 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/pipeline-definition.d.ts +3 -0
  51. package/dist/pipeline-definition.d.ts.map +1 -0
  52. package/dist/pipeline-definition.js +4 -0
  53. package/dist/pipeline-definition.js.map +1 -0
  54. package/dist/pipeline-runner.d.ts +2 -1
  55. package/dist/pipeline-runner.d.ts.map +1 -1
  56. package/dist/pipeline-runner.js +2 -2
  57. package/dist/pipeline-runner.js.map +1 -1
  58. package/dist/plugins.d.ts +5 -0
  59. package/dist/plugins.d.ts.map +1 -0
  60. package/dist/plugins.js +3 -0
  61. package/dist/plugins.js.map +1 -0
  62. package/dist/registry.d.ts +3 -19
  63. package/dist/registry.d.ts.map +1 -1
  64. package/dist/registry.js +7 -35
  65. package/dist/registry.js.map +1 -1
  66. package/dist/tagma.d.ts +24 -0
  67. package/dist/tagma.d.ts.map +1 -0
  68. package/dist/tagma.js +23 -0
  69. package/dist/tagma.js.map +1 -0
  70. package/dist/utils-api.d.ts +2 -0
  71. package/dist/utils-api.d.ts.map +1 -0
  72. package/dist/utils-api.js +2 -0
  73. package/dist/utils-api.js.map +1 -0
  74. package/dist/yaml.d.ts +4 -0
  75. package/dist/yaml.d.ts.map +1 -0
  76. package/dist/yaml.js +3 -0
  77. package/dist/yaml.js.map +1 -0
  78. package/package.json +52 -7
  79. package/src/bootstrap.ts +6 -6
  80. package/src/config.ts +26 -0
  81. package/src/core/dataflow.test.ts +167 -0
  82. package/src/core/dataflow.ts +118 -0
  83. package/src/core/log-prune.test.ts +58 -0
  84. package/src/core/log-prune.ts +43 -0
  85. package/src/core/preflight.test.ts +49 -0
  86. package/src/core/preflight.ts +89 -0
  87. package/src/core/run-context.test.ts +244 -0
  88. package/src/core/run-context.ts +207 -0
  89. package/src/core/run-state.test.ts +98 -0
  90. package/src/core/run-state.ts +122 -0
  91. package/src/core/scheduler.test.ts +83 -0
  92. package/src/core/scheduler.ts +42 -0
  93. package/src/core/task-executor.ts +803 -0
  94. package/src/core/trigger-errors.ts +15 -0
  95. package/src/engine.ts +80 -1248
  96. package/src/index.ts +28 -0
  97. package/src/pipeline-definition.ts +5 -0
  98. package/src/pipeline-runner.ts +3 -2
  99. package/src/plugin-registry.test.ts +7 -10
  100. package/src/plugins.ts +18 -0
  101. package/src/registry.ts +7 -49
  102. package/src/tagma.test.ts +84 -0
  103. package/src/tagma.ts +47 -0
  104. package/src/utils-api.ts +8 -0
  105. package/src/yaml.ts +11 -0
  106. package/dist/sdk.d.ts +0 -32
  107. package/dist/sdk.d.ts.map +0 -1
  108. package/dist/sdk.js +0 -41
  109. package/dist/sdk.js.map +0 -1
  110. package/src/sdk.ts +0 -151
@@ -0,0 +1,803 @@
1
+ import { resolve } from 'path';
2
+ import type {
3
+ CompletionPlugin,
4
+ DriverContext,
5
+ DriverPlugin,
6
+ MiddlewareContext,
7
+ MiddlewarePlugin,
8
+ PromptDocument,
9
+ TaskConfig,
10
+ TaskResult,
11
+ TaskStatus,
12
+ TriggerPlugin,
13
+ } from '../types';
14
+ import type { PluginRegistry } from '../registry';
15
+ import { runCommand, runSpawn } from '../runner';
16
+ import { parseDuration, nowISO } from '../utils';
17
+ import {
18
+ promptDocumentFromString,
19
+ serializePromptDocument,
20
+ prependContext,
21
+ renderInputsBlock,
22
+ renderOutputSchemaBlock,
23
+ } from '../prompt-doc';
24
+ import {
25
+ resolveTaskBindingInputs,
26
+ resolveTaskInputs,
27
+ substituteInputs,
28
+ } from '../ports';
29
+ import { executeHook, buildTaskContext } from '../hooks';
30
+ import { clip, tailLines, type Logger } from '../logger';
31
+ import type { ApprovalGateway } from '../approval';
32
+ import type { RunContext } from './run-context';
33
+ import { extractSuccessfulOutputs, inferEffectivePorts } from './dataflow';
34
+ import { TriggerBlockedError, TriggerTimeoutError } from './trigger-errors';
35
+
36
+ const MAX_NORMALIZED_BYTES = 1_000_000;
37
+
38
+ function isPromptTaskConfig(
39
+ task: { readonly prompt?: string; readonly command?: string },
40
+ ): task is { readonly prompt: string; readonly command?: undefined } {
41
+ return task.prompt !== undefined && task.command === undefined;
42
+ }
43
+
44
+ function isCommandTaskConfig(
45
+ task: { readonly command?: string; readonly prompt?: string },
46
+ ): task is { readonly command: string; readonly prompt?: undefined } {
47
+ return task.command !== undefined && task.prompt === undefined;
48
+ }
49
+
50
+ export interface ExecuteTaskOptions {
51
+ readonly taskId: string;
52
+ readonly ctx: RunContext;
53
+ readonly registry: PluginRegistry;
54
+ readonly log: Logger;
55
+ readonly approvalGateway: ApprovalGateway;
56
+ }
57
+
58
+ export async function executeTask(options: ExecuteTaskOptions): Promise<void> {
59
+ const { taskId, ctx, registry, log, approvalGateway } = options;
60
+ const dag = ctx.dag;
61
+ const config = ctx.config;
62
+ const workDir = ctx.workDir;
63
+ const pipelineInfo = ctx.pipelineInfo;
64
+ const state = ctx.states.get(taskId)!;
65
+ const node = dag.nodes.get(taskId)!;
66
+ const task = node.task;
67
+ const track = node.track;
68
+
69
+ log.section(`Task ${taskId}`, taskId);
70
+ log.debug(
71
+ `[task:${taskId}]`,
72
+ `type=${isPromptTaskConfig(task) ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`,
73
+ );
74
+
75
+ // 1. Check dependencies
76
+ for (const depId of node.dependsOn) {
77
+ const result = ctx.isDependencySatisfied(depId);
78
+ if (result === 'skip') {
79
+ const depStatus = ctx.states.get(depId)?.status ?? 'unknown';
80
+ log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
81
+ state.finishedAt = nowISO();
82
+ ctx.setTaskStatus(taskId, 'skipped');
83
+ return;
84
+ }
85
+ if (result === 'unsatisfied') return; // still waiting
86
+ }
87
+
88
+ // 2. Check trigger
89
+ if (task.trigger) {
90
+ log.debug(
91
+ `[task:${taskId}]`,
92
+ `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`,
93
+ );
94
+ try {
95
+ const triggerPlugin = registry.getHandler<TriggerPlugin>('triggers', task.trigger.type);
96
+ // R6: race the plugin's watch() against the pipeline's abort signal
97
+ // AND the task-level timeout. Third-party triggers may forget to
98
+ // wire up ctx.signal — without the abort race, an aborted pipeline
99
+ // would hang forever waiting for the plugin's watch promise to
100
+ // resolve. And without the timeout race, a buggy watch() that never
101
+ // settles would ignore the user's `task.timeout` (which the spawn
102
+ // path at step 4 already honours) — a task could wedge the whole
103
+ // pipeline until pipeline-level timeout fires (or forever, if none
104
+ // is set). Honouring task.timeout here makes the two stages
105
+ // symmetric. The cleanup paths in finally never run on the orphaned
106
+ // plugin promise (it's allowed to leak a watcher; the pipeline is
107
+ // being torn down anyway).
108
+ const triggerTimeoutMs = task.timeout ? parseDuration(task.timeout) : 0;
109
+ await new Promise<unknown>((resolve, reject) => {
110
+ let settled = false;
111
+ let timer: ReturnType<typeof setTimeout> | null = null;
112
+ const onAbort = () => {
113
+ if (settled) return;
114
+ settled = true;
115
+ if (timer !== null) clearTimeout(timer);
116
+ reject(new Error('Pipeline aborted'));
117
+ };
118
+ if (ctx.abortController.signal.aborted) {
119
+ onAbort();
120
+ return;
121
+ }
122
+ ctx.abortController.signal.addEventListener('abort', onAbort, { once: true });
123
+ if (triggerTimeoutMs > 0) {
124
+ timer = setTimeout(() => {
125
+ if (settled) return;
126
+ settled = true;
127
+ ctx.abortController.signal.removeEventListener('abort', onAbort);
128
+ reject(
129
+ new TriggerTimeoutError(
130
+ `Trigger "${task.trigger!.type}" did not settle within ${task.timeout} (task-level timeout)`,
131
+ ),
132
+ );
133
+ }, triggerTimeoutMs);
134
+ }
135
+ triggerPlugin
136
+ .watch(task.trigger as Record<string, unknown>, {
137
+ taskId: node.taskId,
138
+ trackId: track.id,
139
+ workDir: task.cwd ?? workDir,
140
+ signal: ctx.abortController.signal,
141
+ approvalGateway,
142
+ })
143
+ .then(
144
+ (v) => {
145
+ if (settled) return;
146
+ settled = true;
147
+ if (timer !== null) clearTimeout(timer);
148
+ ctx.abortController.signal.removeEventListener('abort', onAbort);
149
+ resolve(v);
150
+ },
151
+ (e) => {
152
+ if (settled) return;
153
+ settled = true;
154
+ if (timer !== null) clearTimeout(timer);
155
+ ctx.abortController.signal.removeEventListener('abort', onAbort);
156
+ reject(e);
157
+ },
158
+ );
159
+ });
160
+ log.debug(`[task:${taskId}]`, `trigger fired`);
161
+ } catch (err: unknown) {
162
+ // If pipeline was aborted while we were still waiting for the trigger,
163
+ // this task never entered running state → skipped, not timeout.
164
+ state.finishedAt = nowISO();
165
+ if (ctx.abortReason !== null) {
166
+ ctx.setTaskStatus(taskId, 'skipped');
167
+ } else if (err instanceof TriggerBlockedError) {
168
+ ctx.setTaskStatus(taskId, 'blocked'); // user/policy rejection
169
+ } else if (err instanceof TriggerTimeoutError) {
170
+ ctx.setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
171
+ } else {
172
+ // A7 fallback: also check message strings for backward-compat with
173
+ // third-party trigger plugins that don't throw typed errors yet.
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ if (msg.includes('rejected') || msg.includes('denied')) {
176
+ ctx.setTaskStatus(taskId, 'blocked');
177
+ } else if (msg.includes('timeout')) {
178
+ ctx.setTaskStatus(taskId, 'timeout');
179
+ } else {
180
+ ctx.setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
181
+ }
182
+ }
183
+ try {
184
+ await ctx.fireHook(taskId, 'task_failure');
185
+ } catch (hookErr) {
186
+ log.error(
187
+ `[task:${taskId}]`,
188
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
189
+ );
190
+ }
191
+ return;
192
+ }
193
+ }
194
+
195
+ // 3. task_start hook (gate)
196
+ const hookResult = await executeHook(
197
+ config.hooks,
198
+ 'task_start',
199
+ buildTaskContext('task_start', pipelineInfo, ctx.trackInfoOf(taskId), ctx.buildTaskInfoObj(taskId)),
200
+ workDir,
201
+ ctx.abortController.signal,
202
+ );
203
+ if (hookResult.exitCode !== 0 || config.hooks?.task_start) {
204
+ log.debug(
205
+ `[task:${taskId}]`,
206
+ `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`,
207
+ );
208
+ }
209
+ if (!hookResult.allowed) {
210
+ state.finishedAt = nowISO();
211
+ ctx.setTaskStatus(taskId, 'blocked');
212
+ try {
213
+ await ctx.fireHook(taskId, 'task_failure');
214
+ } catch (hookErr) {
215
+ log.error(
216
+ `[task:${taskId}]`,
217
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
218
+ );
219
+ }
220
+ return;
221
+ }
222
+
223
+ // 3.5. Resolve port inputs from upstream outputs. This is the last
224
+ // gate before execution: missing-required inputs block the task
225
+ // without ever spawning a process, so the caller sees a clear
226
+ // "blocked: missing input X" rather than a cryptic runtime error
227
+ // from a command that expanded a placeholder to the empty string.
228
+ // Resolution runs even for tasks that declare no ports — the call
229
+ // is cheap and returns `{kind: 'ready', inputs: {}}` in that case,
230
+ // which downstream code handles uniformly.
231
+ //
232
+ // Prompt Tasks have no declared ports — their I/O contract is
233
+ // inferred from direct-neighbor Command Tasks (see ports.ts:
234
+ // `inferPromptPorts`). We synthesize a `TaskPorts` object and
235
+ // feed it into the same resolve/substitute/render/extract
236
+ // pipeline the Command path uses. Collisions that a Prompt can't
237
+ // disambiguate (same input name on two upstreams, incompatible
238
+ // downstream output types) block the task with a clear message.
239
+ const effectivePortsResult = inferEffectivePorts(ctx, taskId);
240
+
241
+ if (effectivePortsResult.kind === 'blocked') {
242
+ log.error(
243
+ `[task:${taskId}]`,
244
+ `blocked — prompt port inference failed:\n${effectivePortsResult.reason}`,
245
+ );
246
+ state.result = {
247
+ exitCode: -1,
248
+ stdout: '',
249
+ stderr: `[engine] prompt port inference failed:\n${effectivePortsResult.reason}`,
250
+ stdoutPath: null,
251
+ stderrPath: null,
252
+ durationMs: 0,
253
+ sessionId: null,
254
+ normalizedOutput: null,
255
+ failureKind: 'spawn_error',
256
+ outputs: null,
257
+ };
258
+ state.finishedAt = nowISO();
259
+ ctx.setTaskStatus(taskId, 'blocked');
260
+ try {
261
+ await ctx.fireHook(taskId, 'task_failure');
262
+ } catch (hookErr) {
263
+ log.error(
264
+ `[task:${taskId}]`,
265
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
266
+ );
267
+ }
268
+ if (ctx.getOnFailure(taskId) === 'stop_all') ctx.applyStopAll();
269
+ return;
270
+ }
271
+ const isPromptTask = effectivePortsResult.isPromptTask;
272
+ const effectivePorts = effectivePortsResult.effectivePorts;
273
+
274
+ const bindingResolution = resolveTaskBindingInputs(task, ctx.bindingDataMap, node.dependsOn);
275
+ if (bindingResolution.kind === 'blocked') {
276
+ log.error(
277
+ `[task:${taskId}]`,
278
+ `blocked — cannot resolve task input bindings:\n${bindingResolution.reason}`,
279
+ );
280
+ state.result = {
281
+ exitCode: -1,
282
+ stdout: '',
283
+ stderr: `[engine] task input binding resolution failed:\n${bindingResolution.reason}`,
284
+ stdoutPath: null,
285
+ stderrPath: null,
286
+ durationMs: 0,
287
+ sessionId: null,
288
+ normalizedOutput: null,
289
+ failureKind: 'spawn_error',
290
+ outputs: null,
291
+ };
292
+ state.finishedAt = nowISO();
293
+ ctx.setTaskStatus(taskId, 'blocked');
294
+ try {
295
+ await ctx.fireHook(taskId, 'task_failure');
296
+ } catch (hookErr) {
297
+ log.error(
298
+ `[task:${taskId}]`,
299
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
300
+ );
301
+ }
302
+ if (ctx.getOnFailure(taskId) === 'stop_all') ctx.applyStopAll();
303
+ return;
304
+ }
305
+ if (bindingResolution.missingOptional.length > 0) {
306
+ log.debug(
307
+ `[task:${taskId}]`,
308
+ `optional input bindings unresolved (empty in placeholders): ${bindingResolution.missingOptional.join(', ')}`,
309
+ );
310
+ }
311
+
312
+ // Feed effective ports into `resolveTaskInputs` by shallow-cloning
313
+ // the task. Prompt tasks get the inferred ports; Command tasks are
314
+ // unchanged (effectivePorts === task.ports).
315
+ const taskForResolve: TaskConfig =
316
+ effectivePorts === task.ports ? task : { ...task, ports: effectivePorts };
317
+ const inputResolution = resolveTaskInputs(taskForResolve, ctx.outputValuesMap, node.dependsOn);
318
+ if (inputResolution.kind === 'blocked') {
319
+ log.error(
320
+ `[task:${taskId}]`,
321
+ `blocked — cannot resolve port inputs:\n${inputResolution.reason}`,
322
+ );
323
+ state.result = {
324
+ exitCode: -1,
325
+ stdout: '',
326
+ stderr: `[engine] port input resolution failed:\n${inputResolution.reason}`,
327
+ stdoutPath: null,
328
+ stderrPath: null,
329
+ durationMs: 0,
330
+ sessionId: null,
331
+ normalizedOutput: null,
332
+ failureKind: 'spawn_error',
333
+ outputs: null,
334
+ };
335
+ state.finishedAt = nowISO();
336
+ ctx.setTaskStatus(taskId, 'blocked');
337
+ try {
338
+ await ctx.fireHook(taskId, 'task_failure');
339
+ } catch (hookErr) {
340
+ log.error(
341
+ `[task:${taskId}]`,
342
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
343
+ );
344
+ }
345
+ if (ctx.getOnFailure(taskId) === 'stop_all') ctx.applyStopAll();
346
+ return;
347
+ }
348
+ const resolvedInputs = { ...bindingResolution.inputs, ...inputResolution.inputs };
349
+ ctx.resolvedInputsMap.set(taskId, resolvedInputs);
350
+ if (inputResolution.missingOptional.length > 0) {
351
+ log.debug(
352
+ `[task:${taskId}]`,
353
+ `optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`,
354
+ );
355
+ }
356
+ if (effectivePorts?.inputs && effectivePorts.inputs.length > 0) {
357
+ log.debug(
358
+ `[task:${taskId}]`,
359
+ `resolved inputs: ${JSON.stringify(resolvedInputs)}` +
360
+ (isPromptTask ? ' (inferred from upstream Commands)' : ''),
361
+ );
362
+ }
363
+
364
+ // 4. Mark running — set startedAt before emitting so subscribers see a
365
+ // complete task_update (startedAt non-null) on the status transition.
366
+ state.startedAt = nowISO();
367
+ ctx.setTaskStatus(taskId, 'running');
368
+ log.info(
369
+ `[task:${taskId}]`,
370
+ isCommandTaskConfig(task) ? `running: ${task.command}` : `running (driver task)`,
371
+ );
372
+
373
+ // File-only: resolved config for this task
374
+ const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'opencode';
375
+ const resolvedModel = task.model ?? track.model ?? config.model ?? '(default)';
376
+ const resolvedPerms = task.permissions ?? track.permissions ?? '(default)';
377
+ const resolvedCwd = task.cwd ?? track.cwd ?? workDir;
378
+ log.debug(
379
+ `[task:${taskId}]`,
380
+ `resolved: driver=${resolvedDriver} model=${resolvedModel} cwd=${resolvedCwd}`,
381
+ );
382
+ log.debug(`[task:${taskId}]`, `permissions: ${JSON.stringify(resolvedPerms)}`);
383
+ if (task.continue_from) {
384
+ log.debug(`[task:${taskId}]`, `continue_from: "${task.continue_from}"`);
385
+ }
386
+ if (task.timeout) {
387
+ log.debug(`[task:${taskId}]`, `timeout: ${task.timeout}`);
388
+ }
389
+
390
+ try {
391
+ let result: TaskResult;
392
+ const timeoutMs = task.timeout ? parseDuration(task.timeout) : undefined;
393
+
394
+ // Stream child stdout/stderr directly to disk in the logger's run dir
395
+ // and keep only a bounded tail in the returned TaskResult. Filenames
396
+ // mirror the existing `.stderr` naming — dots in task ids are replaced
397
+ // so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
398
+ const fsSafeTaskId = taskId.replace(/\./g, '_');
399
+ const stdoutPath = resolve(log.dir, `${fsSafeTaskId}.stdout`);
400
+ const stderrPath = resolve(log.dir, `${fsSafeTaskId}.stderr`);
401
+ const runOpts = {
402
+ timeoutMs,
403
+ signal: ctx.abortController.signal,
404
+ stdoutPath,
405
+ stderrPath,
406
+ };
407
+
408
+ if (isCommandTaskConfig(task)) {
409
+ // Substitute `{{inputs.X}}` placeholders into the command
410
+ // string. Tasks with no declared inputs always produce the same
411
+ // string back (no placeholders to match). Unresolved references
412
+ // render empty — validate-raw flags undeclared references as
413
+ // errors, so the only way to land here with an unresolved is an
414
+ // optional input that had no upstream producer and no default,
415
+ // which we surface in the log.
416
+ const { text: expandedCommand, unresolved } = substituteInputs(
417
+ task.command,
418
+ resolvedInputs,
419
+ );
420
+ if (unresolved.length > 0) {
421
+ log.debug(
422
+ `[task:${taskId}]`,
423
+ `command placeholders rendered empty: ${unresolved.join(', ')}`,
424
+ );
425
+ }
426
+ log.debug(`[task:${taskId}]`, `command: ${expandedCommand}`);
427
+ result = await runCommand(expandedCommand, task.cwd ?? workDir, runOpts);
428
+ } else {
429
+ // AI task: apply middleware chain against a structured PromptDocument.
430
+ const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
431
+ const driver = registry.getHandler<DriverPlugin>('drivers', driverName);
432
+
433
+ // Substitute placeholders in the user-authored prompt before
434
+ // wrapping into a PromptDocument so middlewares see the
435
+ // already-resolved task text.
436
+ const { text: expandedPrompt, unresolved } = substituteInputs(
437
+ task.prompt!,
438
+ resolvedInputs,
439
+ );
440
+ if (unresolved.length > 0) {
441
+ log.debug(
442
+ `[task:${taskId}]`,
443
+ `prompt placeholders rendered empty: ${unresolved.join(', ')}`,
444
+ );
445
+ }
446
+ const originalLen = expandedPrompt.length;
447
+ let doc: PromptDocument = promptDocumentFromString(expandedPrompt);
448
+ // Prepend port-related context blocks so the model sees them
449
+ // before any middleware-added retrieval / memory blocks. Order
450
+ // matters: [Output Format] first (sets the deliverable), then
451
+ // [Inputs] (the concrete data to operate on). Empty blocks are
452
+ // filtered out — tasks without ports get no extra blocks at all.
453
+ const outputFormatBlock = renderOutputSchemaBlock(effectivePorts?.outputs);
454
+ if (outputFormatBlock) {
455
+ doc = prependContext(doc, outputFormatBlock);
456
+ }
457
+ const inputsBlock = renderInputsBlock(effectivePorts?.inputs, resolvedInputs);
458
+ if (inputsBlock) {
459
+ doc = prependContext(doc, inputsBlock);
460
+ }
461
+ const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
462
+ if (mws && mws.length > 0) {
463
+ log.debug(
464
+ `[task:${taskId}]`,
465
+ `middleware chain: ${mws.map((m) => m.type).join(' → ')}`,
466
+ );
467
+ const mwCtx: MiddlewareContext = {
468
+ task,
469
+ track,
470
+ workDir: task.cwd ?? workDir,
471
+ };
472
+ for (const mwConfig of mws) {
473
+ const mwPlugin = registry.getHandler<MiddlewarePlugin>('middlewares', mwConfig.type);
474
+ const beforeBlocks = doc.contexts.length;
475
+ const beforeLen = serializePromptDocument(doc).length;
476
+
477
+ // Prefer the structured API. Fall back to the legacy
478
+ // `enhance(string) → string` path so v0.x plugins keep
479
+ // working — that fallback loses context structure (the
480
+ // middleware's output becomes the new task body) but never
481
+ // silently drops content.
482
+ if (typeof mwPlugin.enhanceDoc === 'function') {
483
+ const next = await mwPlugin.enhanceDoc(
484
+ doc,
485
+ mwConfig as Record<string, unknown>,
486
+ mwCtx,
487
+ );
488
+ if (
489
+ !next ||
490
+ typeof next !== 'object' ||
491
+ !Array.isArray((next as PromptDocument).contexts) ||
492
+ typeof (next as PromptDocument).task !== 'string'
493
+ ) {
494
+ throw new Error(
495
+ `middleware "${mwConfig.type}".enhanceDoc() returned a malformed PromptDocument`,
496
+ );
497
+ }
498
+ doc = next as PromptDocument;
499
+ } else if (typeof mwPlugin.enhance === 'function') {
500
+ const asString = serializePromptDocument(doc);
501
+ const next = await mwPlugin.enhance(
502
+ asString,
503
+ mwConfig as Record<string, unknown>,
504
+ mwCtx,
505
+ );
506
+ // R3: a middleware that returns undefined / null / a non-string
507
+ // would silently corrupt the prompt. Fail loud.
508
+ if (typeof next !== 'string') {
509
+ throw new Error(
510
+ `middleware "${mwConfig.type}".enhance() returned ${next === null ? 'null' : typeof next}, expected string`,
511
+ );
512
+ }
513
+ // Legacy fallback: collapse the returned string into a
514
+ // fresh doc. Earlier structure is folded into the string
515
+ // (serializePromptDocument just ran), so bytes the driver
516
+ // sees match the old string pipeline.
517
+ doc = { contexts: [], task: next };
518
+ } else {
519
+ throw new Error(
520
+ `middleware "${mwConfig.type}" provides neither enhanceDoc nor enhance`,
521
+ );
522
+ }
523
+ const afterLen = serializePromptDocument(doc).length;
524
+ const addedBlocks = doc.contexts.length - beforeBlocks;
525
+ log.debug(
526
+ `[task:${taskId}]`,
527
+ ` ${mwConfig.type}: ${beforeLen} → ${afterLen} chars` +
528
+ (addedBlocks > 0
529
+ ? ` (+${addedBlocks} context block${addedBlocks > 1 ? 's' : ''})`
530
+ : ''),
531
+ );
532
+ }
533
+ }
534
+ const prompt = serializePromptDocument(doc);
535
+ log.debug(
536
+ `[task:${taskId}]`,
537
+ `prompt: ${originalLen} chars (final: ${prompt.length} chars, ${doc.contexts.length} block${doc.contexts.length === 1 ? '' : 's'})`,
538
+ );
539
+ log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`, taskId);
540
+
541
+ // H1: hand the driver a continue_from that has already been
542
+ // qualified by dag.ts. Without this, drivers like codex/opencode/
543
+ // claude-code look up maps directly with
544
+ // the user's raw (possibly bare) string, which races whenever two
545
+ // tracks share a task name. dag.ts has the only authoritative
546
+ // resolver, so we use its precomputed answer here.
547
+ // Drivers key sessionMap/normalizedMap by fully-qualified id. buildDag
548
+ // guarantees `resolvedContinueFrom` is set for every task that has a
549
+ // `continue_from`, so if we see the bare form here something upstream
550
+ // is broken — fail loud instead of silently miskeying the lookup.
551
+ if (task.continue_from && !node.resolvedContinueFrom) {
552
+ throw new Error(
553
+ `Internal: task "${taskId}" has continue_from "${task.continue_from}" ` +
554
+ `but no resolvedContinueFrom. buildDag should have qualified it.`,
555
+ );
556
+ }
557
+ const enrichedTask: TaskConfig = {
558
+ ...task,
559
+ prompt,
560
+ continue_from: node.resolvedContinueFrom,
561
+ // Hand the driver the EFFECTIVE port schema rather than the
562
+ // raw task.ports. For Prompt tasks this is the one inferred
563
+ // from neighbor Commands; Command tasks are unchanged.
564
+ // Drivers that introspect ports (e.g. to annotate a system
565
+ // prompt with the I/O contract) otherwise saw `undefined`
566
+ // for every prompt and had no way to know the contract.
567
+ ports: effectivePorts,
568
+ };
569
+ const driverCtx: DriverContext = {
570
+ sessionMap: ctx.sessionMap,
571
+ normalizedMap: ctx.normalizedMap,
572
+ workDir: task.cwd ?? workDir,
573
+ // Structured view for drivers that want fine-grained control
574
+ // over serialization (e.g. inserting [Previous Output] between
575
+ // contexts and task). Drivers that read task.prompt see the
576
+ // default serialization and need no changes.
577
+ promptDoc: doc,
578
+ // Ports feature: resolved input values keyed by port name,
579
+ // already coerced to the declared port type. Drivers that
580
+ // need to re-substitute placeholders inside a custom envelope
581
+ // can read this and call `substituteInputs`; most drivers can
582
+ // ignore it because the engine has already expanded
583
+ // `{{inputs.X}}` into `task.prompt` upstream.
584
+ inputs: resolvedInputs,
585
+ };
586
+ const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
587
+ log.debug(`[task:${taskId}]`, `driver=${driverName}`);
588
+ log.debug(`[task:${taskId}]`, `spawn args: ${JSON.stringify(spec.args)}`);
589
+ if (spec.cwd) log.debug(`[task:${taskId}]`, `spawn cwd: ${spec.cwd}`);
590
+ if (spec.env)
591
+ log.debug(
592
+ `[task:${taskId}]`,
593
+ `spawn env overrides: ${Object.keys(spec.env).join(', ')}`,
594
+ );
595
+ if (spec.stdin) log.debug(`[task:${taskId}]`, `spawn stdin: ${spec.stdin.length} chars`);
596
+ result = await runSpawn(spec, driver, runOpts);
597
+ }
598
+
599
+ // 6. Determine terminal status (without emitting yet — result must be complete first)
600
+ // H2: branch on failureKind so spawn errors no longer masquerade as
601
+ // timeouts. Old runners that don't set failureKind still work — we
602
+ // fall back to the historical `exitCode === -1 → timeout` heuristic so
603
+ // pre-existing third-party drivers don't regress.
604
+ let terminalStatus: TaskStatus;
605
+ const kind = result.failureKind;
606
+ if (kind === 'timeout') {
607
+ terminalStatus = 'timeout';
608
+ } else if (kind === 'spawn_error') {
609
+ terminalStatus = 'failed';
610
+ } else if (kind === undefined && result.exitCode === -1) {
611
+ // Legacy path: pre-H2 driver returned -1 with no kind. Treat as
612
+ // timeout for backward compatibility (the previous behaviour).
613
+ terminalStatus = 'timeout';
614
+ } else if (result.exitCode !== 0) {
615
+ terminalStatus = 'failed';
616
+ } else if (task.completion) {
617
+ const plugin = registry.getHandler<CompletionPlugin>('completions', task.completion.type);
618
+ const completionCtx = { workDir: task.cwd ?? workDir, signal: ctx.abortController.signal };
619
+ const passed = await plugin.check(
620
+ task.completion as Record<string, unknown>,
621
+ result,
622
+ completionCtx,
623
+ );
624
+ // R4: strict boolean check. Truthy strings/numbers used to be coerced
625
+ // to success — a check returning "ok" would let a failing task pass.
626
+ if (typeof passed !== 'boolean') {
627
+ throw new Error(
628
+ `completion "${task.completion.type}".check() returned ${passed === null ? 'null' : typeof passed}, expected boolean`,
629
+ );
630
+ }
631
+ terminalStatus = passed ? 'success' : 'failed';
632
+ } else {
633
+ terminalStatus = 'success';
634
+ }
635
+
636
+ // Extract declared outputs from the task's output stream. Only
637
+ // meaningful on success — a failed task's output is whatever the
638
+ // child happened to emit before exiting, and downstream tasks
639
+ // shouldn't receive partial data.
640
+ let extractedOutputs: Readonly<Record<string, unknown>> | null = null;
641
+ if (terminalStatus === 'success') {
642
+ const outputExtraction = extractSuccessfulOutputs({
643
+ task,
644
+ effectivePorts,
645
+ result,
646
+ });
647
+ extractedOutputs = outputExtraction.outputs;
648
+ if (task.outputs && Object.keys(task.outputs).length > 0) {
649
+ log.debug(
650
+ `[task:${taskId}]`,
651
+ `extracted binding outputs: ${JSON.stringify(extractedOutputs ?? {})}`,
652
+ );
653
+ if (outputExtraction.bindingDiagnostic) {
654
+ log.debug(`[task:${taskId}]`, outputExtraction.bindingDiagnostic);
655
+ }
656
+ }
657
+
658
+ // Prompt tasks use inferred ports (from direct-downstream Command
659
+ // inputs); Command tasks use their declared ports. Either way,
660
+ // `extractTaskOutputs` is a no-op when there are no declared
661
+ // outputs to pull, so pre-ports tasks pay nothing for this call.
662
+ if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
663
+ log.debug(
664
+ `[task:${taskId}]`,
665
+ `extracted outputs: ${JSON.stringify(extractedOutputs ?? {})}` +
666
+ (isPromptTask ? ' (inferred from downstream Commands)' : ''),
667
+ );
668
+ if (outputExtraction.portDiagnostic) {
669
+ log.error(`[task:${taskId}]`, outputExtraction.portDiagnostic);
670
+ const note = `\n[engine] ${outputExtraction.portDiagnostic}`;
671
+ result = { ...result, stderr: result.stderr + note };
672
+ }
673
+ }
674
+ }
675
+ // Attach outputs to the result (null when task has no declared
676
+ // outputs or extraction failed entirely). Consumers of TaskResult
677
+ // — hooks, wire events, test assertions — all go through this
678
+ // one field rather than re-running extraction.
679
+ result = { ...result, outputs: extractedOutputs };
680
+ if (extractedOutputs !== null) {
681
+ ctx.outputValuesMap.set(taskId, extractedOutputs);
682
+ }
683
+ ctx.bindingDataMap.set(taskId, {
684
+ outputs: extractedOutputs,
685
+ stdout: result.stdout,
686
+ stderr: result.stderr,
687
+ normalizedOutput: result.normalizedOutput,
688
+ exitCode: result.exitCode,
689
+ });
690
+
691
+ // Store normalized text separately (in-memory) for continue_from handoff.
692
+ // R15: clip oversized values so a runaway parseResult can't accumulate
693
+ // hundreds of MB across tasks.
694
+ if (result.normalizedOutput !== null) {
695
+ const clipped =
696
+ result.normalizedOutput.length > MAX_NORMALIZED_BYTES
697
+ ? result.normalizedOutput.slice(0, MAX_NORMALIZED_BYTES) +
698
+ `\n[…clipped at ${MAX_NORMALIZED_BYTES} bytes]`
699
+ : result.normalizedOutput;
700
+ ctx.normalizedMap.set(taskId, clipped);
701
+ }
702
+
703
+ // Note: stderr is already persisted by runner.ts as it streams; the
704
+ // old "write full string after the fact" block is gone — that's what
705
+ // the streaming rewrite fixed (unbounded in-memory buffering).
706
+
707
+ if (result.sessionId) {
708
+ // H1: qualified-only key.
709
+ ctx.sessionMap.set(taskId, result.sessionId);
710
+ }
711
+
712
+ // Set result and finishedAt before emitting terminal status so listeners see complete state
713
+ state.result = result;
714
+ state.finishedAt = nowISO();
715
+ ctx.setTaskStatus(taskId, terminalStatus);
716
+
717
+ // Log task outcome with relevant details
718
+ const durSec = (result.durationMs / 1000).toFixed(1);
719
+ if (terminalStatus === 'success') {
720
+ log.info(`[task:${taskId}]`, `success (${durSec}s)`);
721
+ } else {
722
+ log.error(
723
+ `[task:${taskId}]`,
724
+ `${terminalStatus} exit=${result.exitCode} duration=${durSec}s`,
725
+ );
726
+ if (result.stderr) {
727
+ const tail = tailLines(result.stderr, 10);
728
+ log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
729
+ }
730
+ }
731
+
732
+ // File-only: byte counts (prefer full totals from the runner over the
733
+ // bounded tail length so oversized outputs show their real size) +
734
+ // paths to the on-disk full copies.
735
+ const stdoutSize = result.stdoutBytes ?? result.stdout.length;
736
+ const stderrSize = result.stderrBytes ?? result.stderr.length;
737
+ log.debug(`[task:${taskId}]`, `stdout: ${stdoutSize} bytes, stderr: ${stderrSize} bytes`);
738
+ if (result.sessionId) {
739
+ log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
740
+ }
741
+ if (result.stdoutPath) {
742
+ log.debug(`[task:${taskId}]`, `wrote stdout: ${result.stdoutPath}`);
743
+ }
744
+ if (result.stderrPath) {
745
+ log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
746
+ }
747
+ if (result.stdout) {
748
+ log.quiet(
749
+ `--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`,
750
+ taskId,
751
+ );
752
+ }
753
+ if (result.stderr) {
754
+ log.quiet(
755
+ `--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`,
756
+ taskId,
757
+ );
758
+ }
759
+ if (task.completion) {
760
+ log.debug(
761
+ `[task:${taskId}]`,
762
+ `completion check: type=${task.completion.type} result=${terminalStatus}`,
763
+ );
764
+ }
765
+ } catch (err: unknown) {
766
+ const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
767
+ log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
768
+ state.result = {
769
+ exitCode: -1,
770
+ stdout: '',
771
+ stderr: errMsg,
772
+ stdoutPath: null,
773
+ stderrPath: null,
774
+ stdoutBytes: 0,
775
+ stderrBytes: errMsg.length,
776
+ durationMs: 0,
777
+ sessionId: null,
778
+ normalizedOutput: null,
779
+ // H2: Engine-level pre-execution errors (driver throw, middleware
780
+ // throw, getHandler 404) classify as spawn_error — the process never
781
+ // ran, so calling them "timeout" was actively misleading.
782
+ failureKind: 'spawn_error',
783
+ };
784
+ state.finishedAt = nowISO();
785
+ ctx.setTaskStatus(taskId, 'failed');
786
+ }
787
+
788
+ // 7. Fire hooks
789
+ const finalStatus: TaskStatus = state.status;
790
+ try {
791
+ await ctx.fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
792
+ } catch (hookErr) {
793
+ log.error(
794
+ `[task:${taskId}]`,
795
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
796
+ );
797
+ }
798
+
799
+ // 8. Handle stop_all for failure states
800
+ if (finalStatus !== 'success' && ctx.getOnFailure(taskId) === 'stop_all') {
801
+ ctx.applyStopAll();
802
+ }
803
+ }