@tagma/sdk 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -1,714 +1,743 @@
1
- import { resolve, dirname } from 'path';
2
- import { mkdir, readdir, rm } from 'fs/promises';
3
- import type {
4
- PipelineConfig, TaskConfig, TrackConfig, TaskState, TaskStatus,
5
- TaskResult, DriverPlugin, TriggerPlugin, CompletionPlugin,
6
- MiddlewarePlugin, MiddlewareContext, DriverContext,
7
- OnFailure,
8
- } from './types';
9
- import { buildDag, type Dag, type DagNode } from './dag';
10
- import { getHandler, hasHandler, loadPlugins } from './registry';
11
- import { runSpawn, runCommand } from './runner';
12
- import { parseDuration, nowISO, generateRunId } from './utils';
13
- import {
14
- executeHook,
15
- buildPipelineStartContext, buildTaskContext,
16
- buildPipelineCompleteContext, buildPipelineErrorContext,
17
- type PipelineInfo, type TrackInfo, type TaskInfo,
18
- } from './hooks';
19
- import { Logger, tailLines, clip } from './logger';
20
- import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
21
-
22
- // ═══ Preflight Validation ═══
23
-
24
- function preflight(config: PipelineConfig, dag: Dag): void {
25
- const errors: string[] = [];
26
-
27
- for (const [, node] of dag.nodes) {
28
- const task = node.task;
29
- const track = node.track;
30
- const driverName = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
31
-
32
- if (!hasHandler('drivers', driverName)) {
33
- errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
34
- }
35
-
36
- if (task.trigger && !hasHandler('triggers', task.trigger.type)) {
37
- errors.push(`Task "${node.taskId}": trigger type "${task.trigger.type}" not registered`);
38
- }
39
-
40
- if (task.completion && !hasHandler('completions', task.completion.type)) {
41
- errors.push(`Task "${node.taskId}": completion type "${task.completion.type}" not registered`);
42
- }
43
-
44
- const mws = task.middlewares ?? track.middlewares ?? [];
45
- for (const mw of mws) {
46
- if (!hasHandler('middlewares', mw.type)) {
47
- errors.push(`Task "${node.taskId}": middleware type "${mw.type}" not registered`);
48
- }
49
- }
50
-
51
- if (task.continue_from && hasHandler('drivers', driverName)) {
52
- const driver = getHandler<DriverPlugin>('drivers', driverName);
53
- if (!driver.capabilities.sessionResume) {
54
- const upstreamId = resolveRefInDag(dag, task.continue_from, track.id);
55
- if (upstreamId) {
56
- const upstream = dag.nodes.get(upstreamId);
57
- if (upstream && !upstream.task.output) {
58
- errors.push(
59
- `Task "${node.taskId}" uses continue_from: "${task.continue_from}", ` +
60
- `but upstream task "${upstreamId}" has no "output" field. ` +
61
- `Add output to the upstream task, or remove continue_from.`
62
- );
63
- }
64
- }
65
- }
66
- }
67
- }
68
-
69
- if (errors.length > 0) {
70
- throw new Error(`Preflight validation failed:\n - ${errors.join('\n - ')}`);
71
- }
72
- }
73
-
74
- function resolveRefInDag(dag: Dag, ref: string, fromTrackId: string): string | null {
75
- if (dag.nodes.has(ref)) return ref;
76
- const sameTrack = `${fromTrackId}.${ref}`;
77
- if (dag.nodes.has(sameTrack)) return sameTrack;
78
- for (const [id] of dag.nodes) {
79
- if (id.endsWith(`.${ref}`)) return id;
80
- }
81
- return null;
82
- }
83
-
84
- // ═══ Engine ═══
85
-
86
- export interface EngineResult {
87
- readonly success: boolean;
88
- readonly runId: string;
89
- readonly logPath: string;
90
- readonly summary: {
91
- total: number; success: number; failed: number;
92
- skipped: number; timeout: number; blocked: number;
93
- };
94
- readonly states: ReadonlyMap<string, TaskState>;
95
- }
96
-
97
- // ═══ Pipeline Events ═══
98
-
99
- export type PipelineEvent =
100
- | { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
101
- | { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
102
- | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
103
-
104
- export interface RunPipelineOptions {
105
- readonly approvalGateway?: ApprovalGateway;
106
- /**
107
- * Maximum number of per-run log directories to retain under `<workDir>/logs/`.
108
- * Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
109
- */
110
- readonly maxLogRuns?: number;
111
- /**
112
- * External AbortSignal — aborting it cancels the pipeline immediately.
113
- * Equivalent to the pipeline timeout firing, but caller-controlled.
114
- */
115
- readonly signal?: AbortSignal;
116
- /**
117
- * Called on every pipeline/task status transition.
118
- * Use for real-time UI updates (e.g. updating a visual workflow graph).
119
- */
120
- readonly onEvent?: (event: PipelineEvent) => void;
121
- }
122
-
123
- export async function runPipeline(
124
- config: PipelineConfig,
125
- workDir: string,
126
- options: RunPipelineOptions = {},
127
- ): Promise<EngineResult> {
128
- const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
129
- const maxLogRuns = options.maxLogRuns ?? 20;
130
-
131
- // Load any plugins declared in the pipeline config before preflight so that
132
- // drivers, completions, and middlewares referenced in YAML are registered.
133
- if (config.plugins?.length) {
134
- await loadPlugins(config.plugins);
135
- }
136
-
137
- const dag = buildDag(config);
138
- const runId = generateRunId();
139
- preflight(config, dag);
140
-
141
- const startedAt = nowISO();
142
- const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
143
- const log = new Logger(workDir, runId);
144
- log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
145
-
146
- // File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
147
- log.section('Pipeline configuration');
148
- log.quiet(`name: ${config.name}`);
149
- log.quiet(`driver: ${config.driver ?? '(default: claude-code)'}`);
150
- log.quiet(`timeout: ${config.timeout ?? '(none)'}`);
151
- log.quiet(`tracks: ${config.tracks.length}`);
152
- log.quiet(`tasks (total): ${dag.nodes.size}`);
153
- log.quiet(`plugins: ${(config.plugins ?? []).join(', ') || '(none)'}`);
154
- log.quiet(`hooks: ${config.hooks ? Object.keys(config.hooks).join(', ') || '(none)' : '(none)'}`);
155
-
156
- log.section('DAG topology');
157
- for (const [id, node] of dag.nodes) {
158
- const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
159
- const kind = node.task.prompt ? 'ai' : 'cmd';
160
- log.quiet(` • ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
161
- }
162
- log.quiet('');
163
-
164
- // Initialize states (before hook, so we can return them even if blocked)
165
- const states = new Map<string, TaskState>();
166
- for (const [id, node] of dag.nodes) {
167
- states.set(id, {
168
- config: node.task,
169
- trackConfig: node.track,
170
- status: 'idle',
171
- result: null,
172
- startedAt: null,
173
- finishedAt: null,
174
- });
175
- }
176
-
177
- try {
178
-
179
- // Pipeline start hook (gate)
180
- const startHook = await executeHook(
181
- config.hooks, 'pipeline_start', buildPipelineStartContext(pipelineInfo), workDir,
182
- );
183
- if (!startHook.allowed) {
184
- console.error(`Pipeline blocked by pipeline_start hook (exit code ${startHook.exitCode})`);
185
- await executeHook(config.hooks, 'pipeline_error',
186
- buildPipelineErrorContext(pipelineInfo, 'pipeline_blocked', 'pipeline_blocked'), workDir);
187
- // All tasks stay idle — pipeline never started
188
- return {
189
- success: false,
190
- runId,
191
- logPath: log.path,
192
- summary: { total: dag.nodes.size, success: 0, failed: 0, skipped: 0, timeout: 0, blocked: 0 },
193
- states,
194
- };
195
- }
196
-
197
- // Pipeline approved transition all tasks to waiting
198
- for (const [, state] of states) {
199
- state.status = 'waiting';
200
- }
201
- // Include a full states snapshot so listeners can initialize their mirrors without missing events
202
- const statesSnapshot: ReadonlyMap<string, TaskState> = new Map(
203
- [...states.entries()].map(([id, s]) => [id, { ...s }])
204
- );
205
- options.onEvent?.({ type: 'pipeline_start', runId, states: statesSnapshot });
206
-
207
- const sessionMap = new Map<string, string>();
208
- const outputMap = new Map<string, string>();
209
- const normalizedMap = new Map<string, string>();
210
-
211
- // Pipeline timeout
212
- const pipelineTimeoutMs = config.timeout ? parseDuration(config.timeout) : 0;
213
- let pipelineAborted = false;
214
- const abortController = new AbortController();
215
- let pipelineTimer: ReturnType<typeof setTimeout> | null = null;
216
-
217
- if (pipelineTimeoutMs > 0) {
218
- pipelineTimer = setTimeout(() => {
219
- pipelineAborted = true;
220
- abortController.abort();
221
- }, pipelineTimeoutMs);
222
- }
223
-
224
- // When the pipeline is aborted (timeout, external shutdown), drain all
225
- // pending approvals so waiting triggers unblock immediately.
226
- abortController.signal.addEventListener('abort', () => {
227
- approvalGateway.abortAll('pipeline aborted');
228
- });
229
-
230
- // Wire external cancel signal into the internal abort controller.
231
- if (options.signal) {
232
- if (options.signal.aborted) {
233
- pipelineAborted = true;
234
- abortController.abort();
235
- } else {
236
- options.signal.addEventListener('abort', () => {
237
- pipelineAborted = true;
238
- abortController.abort();
239
- }, { once: true });
240
- }
241
- }
242
-
243
- // ── Helpers ──
244
-
245
- function emit(event: PipelineEvent): void {
246
- options.onEvent?.(event);
247
- }
248
-
249
- function setTaskStatus(taskId: string, newStatus: TaskStatus): void {
250
- const state = states.get(taskId)!;
251
- const prevStatus = state.status;
252
- state.status = newStatus;
253
- // Snapshot state at emit time — result and finishedAt must be set before calling this for terminal statuses
254
- const snapshot: TaskState = {
255
- config: state.config,
256
- trackConfig: state.trackConfig,
257
- status: state.status,
258
- result: state.result,
259
- startedAt: state.startedAt,
260
- finishedAt: state.finishedAt,
261
- };
262
- emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId, state: snapshot });
263
- }
264
-
265
- function getOnFailure(taskId: string): OnFailure {
266
- return dag.nodes.get(taskId)?.track.on_failure ?? 'skip_downstream';
267
- }
268
-
269
- function isDependencySatisfied(depId: string): 'satisfied' | 'unsatisfied' | 'skip' {
270
- const depState = states.get(depId);
271
- if (!depState) return 'skip';
272
- switch (depState.status) {
273
- case 'success': return 'satisfied';
274
- case 'skipped': return 'skip';
275
- case 'failed': case 'timeout': case 'blocked':
276
- return getOnFailure(depId) === 'ignore' ? 'satisfied' : 'skip';
277
- default: return 'unsatisfied';
278
- }
279
- }
280
-
281
- function applyStopAll(trackId: string): void {
282
- for (const [id, state] of states) {
283
- if (state.trackConfig.id === trackId && !isTerminal(state.status)) {
284
- setTaskStatus(id, 'skipped');
285
- state.finishedAt = nowISO();
286
- }
287
- }
288
- }
289
-
290
- function buildTaskInfoObj(taskId: string): TaskInfo {
291
- const state = states.get(taskId)!;
292
- return {
293
- id: taskId,
294
- name: state.config.name,
295
- type: state.config.prompt ? 'ai' : 'command',
296
- status: state.status,
297
- exit_code: state.result?.exitCode ?? null,
298
- duration_ms: state.result?.durationMs ?? null,
299
- output_path: state.result?.outputPath ?? null,
300
- stderr_path: state.result?.stderrPath ?? null,
301
- session_id: state.result?.sessionId ?? null,
302
- started_at: state.startedAt,
303
- finished_at: state.finishedAt,
304
- };
305
- }
306
-
307
- function trackInfoOf(taskId: string): TrackInfo {
308
- const node = dag.nodes.get(taskId)!;
309
- return { id: node.track.id, name: node.track.name };
310
- }
311
-
312
- async function fireHook(taskId: string, event: 'task_success' | 'task_failure'): Promise<void> {
313
- await executeHook(config.hooks, event,
314
- buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
315
- }
316
-
317
- // ── Process a single task ──
318
-
319
- async function processTask(taskId: string): Promise<void> {
320
- const state = states.get(taskId)!;
321
- const node = dag.nodes.get(taskId)!;
322
- const task = node.task;
323
- const track = node.track;
324
-
325
- log.section(`Task ${taskId}`);
326
- log.debug(`[task:${taskId}]`,
327
- `type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`);
328
-
329
- // 1. Check dependencies
330
- for (const depId of node.dependsOn) {
331
- const result = isDependencySatisfied(depId);
332
- if (result === 'skip') {
333
- const depStatus = states.get(depId)?.status ?? 'unknown';
334
- log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
335
- state.finishedAt = nowISO();
336
- setTaskStatus(taskId, 'skipped');
337
- return;
338
- }
339
- if (result === 'unsatisfied') return; // still waiting
340
- }
341
-
342
- // 2. Check trigger
343
- if (task.trigger) {
344
- log.debug(`[task:${taskId}]`, `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`);
345
- try {
346
- const triggerPlugin = getHandler<TriggerPlugin>('triggers', task.trigger.type);
347
- await triggerPlugin.watch(task.trigger as Record<string, unknown>, {
348
- taskId: node.taskId,
349
- trackId: track.id,
350
- workDir: task.cwd ?? workDir,
351
- signal: abortController.signal,
352
- approvalGateway,
353
- });
354
- log.debug(`[task:${taskId}]`, `trigger fired`);
355
- } catch (err: unknown) {
356
- const msg = err instanceof Error ? err.message : String(err);
357
- // If pipeline was aborted while we were still waiting for the trigger,
358
- // this task never entered running state → skipped, not timeout.
359
- state.finishedAt = nowISO();
360
- if (pipelineAborted) {
361
- setTaskStatus(taskId, 'skipped');
362
- } else if (msg.includes('rejected') || msg.includes('denied')) {
363
- setTaskStatus(taskId, 'blocked'); // user/policy rejection
364
- } else if (msg.includes('timeout')) {
365
- setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
366
- } else {
367
- setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
368
- }
369
- await fireHook(taskId, 'task_failure');
370
- return;
371
- }
372
- }
373
-
374
- // 3. task_start hook (gate)
375
- const hookResult = await executeHook(config.hooks, 'task_start',
376
- buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
377
- if (hookResult.exitCode !== 0 || config.hooks?.task_start) {
378
- log.debug(`[task:${taskId}]`,
379
- `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
380
- }
381
- if (!hookResult.allowed) {
382
- state.finishedAt = nowISO();
383
- setTaskStatus(taskId, 'blocked');
384
- await fireHook(taskId, 'task_failure');
385
- return;
386
- }
387
-
388
- // 4. Mark running
389
- setTaskStatus(taskId, 'running');
390
- state.startedAt = nowISO();
391
- log.info(`[task:${taskId}]`, task.command ? `running: ${task.command}` : `running (driver task)`);
392
-
393
- // File-only: resolved config for this task
394
- const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
395
- const resolvedTier = task.model_tier ?? track.model_tier ?? '(default)';
396
- const resolvedPerms = task.permissions ?? track.permissions ?? '(default)';
397
- const resolvedCwd = task.cwd ?? track.cwd ?? workDir;
398
- log.debug(`[task:${taskId}]`,
399
- `resolved: driver=${resolvedDriver} tier=${resolvedTier} cwd=${resolvedCwd}`);
400
- log.debug(`[task:${taskId}]`, `permissions: ${JSON.stringify(resolvedPerms)}`);
401
- if (task.continue_from) {
402
- log.debug(`[task:${taskId}]`, `continue_from: "${task.continue_from}"`);
403
- }
404
- if (task.timeout) {
405
- log.debug(`[task:${taskId}]`, `timeout: ${task.timeout}`);
406
- }
407
-
408
- try {
409
- let result: TaskResult;
410
- const timeoutMs = task.timeout ? parseDuration(task.timeout) : undefined;
411
-
412
- const runOpts = { timeoutMs, signal: abortController.signal };
413
-
414
- if (task.command) {
415
- log.debug(`[task:${taskId}]`, `command: ${task.command}`);
416
- result = await runCommand(task.command, task.cwd ?? workDir, runOpts);
417
- } else {
418
- // AI task: apply middleware chain
419
- const driverName = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
420
- const driver = getHandler<DriverPlugin>('drivers', driverName);
421
-
422
- let prompt = task.prompt!;
423
- const originalLen = prompt.length;
424
- const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
425
- if (mws && mws.length > 0) {
426
- log.debug(`[task:${taskId}]`,
427
- `middleware chain: ${mws.map(m => m.type).join(' → ')}`);
428
- const mwCtx: MiddlewareContext = {
429
- task, track, outputMap, workDir: task.cwd ?? workDir,
430
- };
431
- for (const mwConfig of mws) {
432
- const before = prompt.length;
433
- const mwPlugin = getHandler<MiddlewarePlugin>('middlewares', mwConfig.type);
434
- prompt = await mwPlugin.enhance(prompt, mwConfig as Record<string, unknown>, mwCtx);
435
- log.debug(`[task:${taskId}]`,
436
- ` ${mwConfig.type}: ${before} → ${prompt.length} chars`);
437
- }
438
- }
439
- log.debug(`[task:${taskId}]`,
440
- `prompt: ${originalLen} chars (final: ${prompt.length} chars)`);
441
- log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`);
442
-
443
- const enrichedTask: TaskConfig = { ...task, prompt };
444
- const driverCtx: DriverContext = {
445
- sessionMap, outputMap, normalizedMap, workDir: task.cwd ?? workDir,
446
- };
447
- const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
448
- log.debug(`[task:${taskId}]`, `driver=${driverName}`);
449
- log.debug(`[task:${taskId}]`,
450
- `spawn args: ${JSON.stringify(spec.args)}`);
451
- if (spec.cwd) log.debug(`[task:${taskId}]`, `spawn cwd: ${spec.cwd}`);
452
- if (spec.env) log.debug(`[task:${taskId}]`,
453
- `spawn env overrides: ${Object.keys(spec.env).join(', ')}`);
454
- if (spec.stdin) log.debug(`[task:${taskId}]`,
455
- `spawn stdin: ${spec.stdin.length} chars`);
456
- result = await runSpawn(spec, driver, runOpts);
457
- }
458
-
459
- // 5. Determine terminal status (without emitting yet result must be complete first)
460
- let terminalStatus: TaskStatus;
461
- if (result.exitCode === -1) {
462
- terminalStatus = 'timeout';
463
- } else if (result.exitCode !== 0) {
464
- terminalStatus = 'failed';
465
- } else if (task.completion) {
466
- const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
467
- const completionCtx = { workDir: task.cwd ?? workDir };
468
- const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
469
- terminalStatus = passed ? 'success' : 'failed';
470
- } else {
471
- terminalStatus = 'success';
472
- }
473
-
474
- // 6. Write output file with RAW stdout (preserves driver output format).
475
- // The separate normalizedMap holds canonical text for continue_from.
476
- if (task.output) {
477
- const outPath = resolve(workDir, task.output);
478
- await mkdir(dirname(outPath), { recursive: true });
479
- await Bun.write(outPath, result.stdout);
480
- result = { ...result, outputPath: outPath };
481
- outputMap.set(taskId, outPath);
482
- const bareId = taskId.includes('.') ? taskId.split('.').pop()! : taskId;
483
- if (!outputMap.has(bareId)) outputMap.set(bareId, outPath);
484
- }
485
-
486
- // Store normalized text separately (in-memory) for continue_from handoff
487
- if (result.normalizedOutput !== null) {
488
- normalizedMap.set(taskId, result.normalizedOutput);
489
- const bareId = taskId.includes('.') ? taskId.split('.').pop()! : taskId;
490
- if (!normalizedMap.has(bareId)) normalizedMap.set(bareId, result.normalizedOutput);
491
- }
492
-
493
- if (result.stderr) {
494
- const stderrPath = resolve(log.dir, `${taskId.replace(/\./g, '_')}.stderr`);
495
- await Bun.write(stderrPath, result.stderr);
496
- result = { ...result, stderrPath };
497
- }
498
-
499
- if (result.sessionId) {
500
- sessionMap.set(taskId, result.sessionId);
501
- const bareId = taskId.includes('.') ? taskId.split('.').pop()! : taskId;
502
- if (!sessionMap.has(bareId)) sessionMap.set(bareId, result.sessionId);
503
- }
504
-
505
- // Set result and finishedAt before emitting terminal status so listeners see complete state
506
- state.result = result;
507
- state.finishedAt = nowISO();
508
- setTaskStatus(taskId, terminalStatus);
509
-
510
- // Log task outcome with relevant details
511
- const durSec = (result.durationMs / 1000).toFixed(1);
512
- if (terminalStatus === 'success') {
513
- log.info(`[task:${taskId}]`, `success (${durSec}s)`);
514
- } else {
515
- log.error(`[task:${taskId}]`,
516
- `${terminalStatus} exit=${result.exitCode} duration=${durSec}s`);
517
- if (result.stderr) {
518
- const tail = tailLines(result.stderr, 10);
519
- log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
520
- }
521
- }
522
-
523
- // File-only: full stdout/stderr dump (clipped) + extracted metadata
524
- log.debug(`[task:${taskId}]`,
525
- `stdout: ${result.stdout.length} chars, stderr: ${result.stderr.length} chars`);
526
- if (result.sessionId) {
527
- log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
528
- }
529
- if (result.outputPath) {
530
- log.debug(`[task:${taskId}]`, `wrote output: ${result.outputPath}`);
531
- }
532
- if (result.stderrPath) {
533
- log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
534
- }
535
- if (result.stdout) {
536
- log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`);
537
- }
538
- if (result.stderr) {
539
- log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`);
540
- }
541
- if (task.completion) {
542
- log.debug(`[task:${taskId}]`,
543
- `completion check: type=${task.completion.type} result=${terminalStatus}`);
544
- }
545
-
546
- } catch (err: unknown) {
547
- const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
548
- log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
549
- state.result = {
550
- exitCode: -1,
551
- stdout: '',
552
- stderr: errMsg,
553
- outputPath: null, stderrPath: null, durationMs: 0,
554
- sessionId: null, normalizedOutput: null,
555
- };
556
- state.finishedAt = nowISO();
557
- setTaskStatus(taskId, 'failed');
558
- }
559
-
560
- // 7. Fire hooks
561
- const finalStatus: TaskStatus = state.status;
562
- await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
563
-
564
- // 8. Handle stop_all for failure states
565
- if (finalStatus !== 'success' && getOnFailure(taskId) === 'stop_all') {
566
- applyStopAll(node.track.id);
567
- }
568
- }
569
-
570
- // ── Event loop ──
571
- // Each task is launched as soon as ALL its deps reach a terminal state.
572
- // We track in-flight tasks in `running` so a task completing mid-batch
573
- // immediately unblocks its dependents without waiting for sibling tasks.
574
- const running = new Map<string, Promise<void>>();
575
-
576
- try {
577
- while (!pipelineAborted) {
578
- // Launch every task whose deps are all terminal and that isn't already in-flight
579
- for (const [id, state] of states) {
580
- if (state.status !== 'waiting' || running.has(id)) continue;
581
- const node = dag.nodes.get(id)!;
582
- const allDepsTerminal = node.dependsOn.length === 0 ||
583
- node.dependsOn.every(d => isTerminal(states.get(d)!.status));
584
- if (!allDepsTerminal) continue;
585
- const p = processTask(id).finally(() => running.delete(id));
586
- running.set(id, p);
587
- }
588
-
589
- // All tasks terminal — done
590
- if ([...states.values()].every(s => isTerminal(s.status))) break;
591
-
592
- if (running.size === 0) {
593
- // Nothing in-flight but non-terminal tasks exist (e.g. trigger-wait states
594
- // that processTask hasn't been called for yet). Poll briefly.
595
- await new Promise(r => setTimeout(r, 50));
596
- } else {
597
- // Wait for any one task to finish, then re-scan for new launchables.
598
- await Promise.race(running.values());
599
- }
600
- }
601
-
602
- if (pipelineAborted) {
603
- // Wait for in-flight tasks to honour the abort signal before marking states.
604
- if (running.size > 0) await Promise.allSettled(running.values());
605
- for (const [id, state] of states) {
606
- if (!isTerminal(state.status)) {
607
- // Running tasks get timeout (they were killed); waiting tasks get skipped
608
- state.finishedAt = nowISO();
609
- setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
610
- }
611
- }
612
- }
613
- } finally {
614
- if (pipelineTimer) clearTimeout(pipelineTimer);
615
- // Safety net: drain any approvals still pending at shutdown (e.g. crash path).
616
- if (approvalGateway.pending().length > 0) {
617
- approvalGateway.abortAll('pipeline finished');
618
- }
619
- }
620
-
621
- // ── Summary ──
622
- const summary = { total: 0, success: 0, failed: 0, skipped: 0, timeout: 0, blocked: 0 };
623
- for (const [, state] of states) {
624
- summary.total++;
625
- switch (state.status) {
626
- case 'success': summary.success++; break;
627
- case 'failed': summary.failed++; break;
628
- case 'skipped': summary.skipped++; break;
629
- case 'timeout': summary.timeout++; break;
630
- case 'blocked': summary.blocked++; break;
631
- }
632
- }
633
-
634
- const finishedAt = nowISO();
635
- const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
636
-
637
- if (pipelineAborted) {
638
- await executeHook(config.hooks, 'pipeline_error',
639
- buildPipelineErrorContext(pipelineInfo, 'Pipeline timeout exceeded'), workDir);
640
- } else {
641
- await executeHook(config.hooks, 'pipeline_complete',
642
- buildPipelineCompleteContext(
643
- { ...pipelineInfo, finished_at: finishedAt, duration_ms: durationMs }, summary), workDir);
644
- }
645
-
646
- const allSuccess = !pipelineAborted
647
- && summary.failed === 0 && summary.timeout === 0 && summary.blocked === 0;
648
-
649
- log.section('Pipeline summary');
650
- log.quiet(`status: ${pipelineAborted ? 'aborted (timeout)' : 'completed'}`);
651
- log.quiet(`duration: ${(durationMs / 1000).toFixed(1)}s`);
652
- log.quiet(
653
- `counts: total=${summary.total} success=${summary.success} ` +
654
- `failed=${summary.failed} skipped=${summary.skipped} ` +
655
- `timeout=${summary.timeout} blocked=${summary.blocked}`);
656
- log.quiet('');
657
- log.quiet('per-task:');
658
- for (const [id, state] of states) {
659
- const dur = state.result?.durationMs != null
660
- ? `${(state.result.durationMs / 1000).toFixed(1)}s` : '-';
661
- const exit = state.result?.exitCode ?? '-';
662
- log.quiet(` ${state.status.padEnd(8)} ${id} (exit=${exit}, ${dur})`);
663
- }
664
-
665
- console.log(`\n[Pipeline "${config.name}"] completed`);
666
- console.log(` Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`);
667
- console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`);
668
- console.log(` Log: ${log.path}`);
669
-
670
- emit({ type: 'pipeline_end', runId, success: allSuccess });
671
- return { success: allSuccess, runId, logPath: log.path, summary, states };
672
-
673
- } finally {
674
- // Prune old per-run log directories on every exit path (normal, blocked, or thrown).
675
- // Exclude the current runId so a concurrent run cannot delete its own live directory.
676
- if (maxLogRuns > 0) {
677
- await pruneLogDirs(resolve(workDir, 'logs'), maxLogRuns, runId);
678
- }
679
- }
680
- }
681
-
682
- /**
683
- * Delete the oldest subdirectories under `logsDir`, keeping only the most recent `keep`.
684
- * Directories are sorted lexicographically; because runIds are prefixed with a base-36
685
- * timestamp, lexicographic order equals chronological order.
686
- *
687
- * `excludeRunId` is always skipped from deletion even if it would otherwise be pruned —
688
- * this prevents a concurrent run from removing a live log directory that is still in use.
689
- */
690
- async function pruneLogDirs(logsDir: string, keep: number, excludeRunId: string): Promise<void> {
691
- let entries: string[];
692
- try {
693
- entries = await readdir(logsDir);
694
- } catch {
695
- return; // logsDir doesn't exist yet nothing to prune
696
- }
697
-
698
- // Only consider directories that look like run IDs (run_<...>), excluding the live run.
699
- const runDirs = entries.filter(e => e.startsWith('run_') && e !== excludeRunId).sort();
700
- const toDelete = runDirs.slice(0, Math.max(0, runDirs.length - keep));
701
-
702
- await Promise.all(
703
- toDelete.map(dir =>
704
- rm(resolve(logsDir, dir), { recursive: true, force: true }).catch(() => {
705
- // Ignore deletion errors — stale dirs are better than a crash
706
- })
707
- )
708
- );
709
- }
710
-
711
- function isTerminal(status: TaskStatus): boolean {
712
- return status === 'success' || status === 'failed' || status === 'timeout'
713
- || status === 'skipped' || status === 'blocked';
714
- }
1
+ import { resolve, dirname } from 'path';
2
+ import { mkdir, readdir, rm } from 'fs/promises';
3
+ import type {
4
+ PipelineConfig, TaskConfig, TrackConfig, TaskState, TaskStatus,
5
+ TaskResult, DriverPlugin, TriggerPlugin, CompletionPlugin,
6
+ MiddlewarePlugin, MiddlewareContext, DriverContext,
7
+ OnFailure,
8
+ } from './types';
9
+ import { buildDag, type Dag, type DagNode } from './dag';
10
+ import { getHandler, hasHandler, loadPlugins } from './registry';
11
+ import { runSpawn, runCommand } from './runner';
12
+ import { parseDuration, nowISO, generateRunId, validatePath } from './utils';
13
+ import {
14
+ executeHook,
15
+ buildPipelineStartContext, buildTaskContext,
16
+ buildPipelineCompleteContext, buildPipelineErrorContext,
17
+ type PipelineInfo, type TrackInfo, type TaskInfo,
18
+ } from './hooks';
19
+ import { Logger, tailLines, clip } from './logger';
20
+ import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
21
+
22
+ // ═══ Preflight Validation ═══
23
+
24
+ function preflight(config: PipelineConfig, dag: Dag): void {
25
+ const errors: string[] = [];
26
+
27
+ for (const [, node] of dag.nodes) {
28
+ const task = node.task;
29
+ const track = node.track;
30
+ const driverName = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
31
+
32
+ if (!hasHandler('drivers', driverName)) {
33
+ errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
34
+ }
35
+
36
+ if (task.trigger && !hasHandler('triggers', task.trigger.type)) {
37
+ errors.push(`Task "${node.taskId}": trigger type "${task.trigger.type}" not registered`);
38
+ }
39
+
40
+ if (task.completion && !hasHandler('completions', task.completion.type)) {
41
+ errors.push(`Task "${node.taskId}": completion type "${task.completion.type}" not registered`);
42
+ }
43
+
44
+ const mws = task.middlewares ?? track.middlewares ?? [];
45
+ for (const mw of mws) {
46
+ if (!hasHandler('middlewares', mw.type)) {
47
+ errors.push(`Task "${node.taskId}": middleware type "${mw.type}" not registered`);
48
+ }
49
+ }
50
+
51
+ if (task.continue_from && hasHandler('drivers', driverName)) {
52
+ const driver = getHandler<DriverPlugin>('drivers', driverName);
53
+ if (!driver.capabilities.sessionResume) {
54
+ const upstreamId = resolveRefInDag(dag, task.continue_from, track.id);
55
+ if (upstreamId) {
56
+ const upstream = dag.nodes.get(upstreamId);
57
+ if (upstream) {
58
+ // A handoff is possible via session resume (already ruled out above),
59
+ // an output file, OR in-memory text injection through normalizedMap
60
+ // (when the upstream driver implements parseResult and returns normalizedOutput).
61
+ const upstreamDriverName = upstream.task.driver ?? upstream.track.driver
62
+ ?? config.driver ?? 'claude-code';
63
+ const upstreamDriver = hasHandler('drivers', upstreamDriverName)
64
+ ? getHandler<DriverPlugin>('drivers', upstreamDriverName)
65
+ : null;
66
+ const canNormalize = typeof upstreamDriver?.parseResult === 'function';
67
+
68
+ if (!upstream.task.output && !canNormalize) {
69
+ errors.push(
70
+ `Task "${node.taskId}" uses continue_from: "${task.continue_from}", ` +
71
+ `but upstream task "${upstreamId}" has no "output" field and its driver ` +
72
+ `does not implement parseResult for text-injection handoff. ` +
73
+ `Add output to the upstream task, use a driver with parseResult, or remove continue_from.`
74
+ );
75
+ }
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
+
87
+ function resolveRefInDag(dag: Dag, ref: string, fromTrackId: string): string | null {
88
+ if (dag.nodes.has(ref)) return ref;
89
+ const sameTrack = `${fromTrackId}.${ref}`;
90
+ if (dag.nodes.has(sameTrack)) return sameTrack;
91
+ for (const [id] of dag.nodes) {
92
+ if (id.endsWith(`.${ref}`)) return id;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ // ═══ Engine ═══
98
+
99
+ export interface EngineResult {
100
+ readonly success: boolean;
101
+ readonly runId: string;
102
+ readonly logPath: string;
103
+ readonly summary: {
104
+ total: number; success: number; failed: number;
105
+ skipped: number; timeout: number; blocked: number;
106
+ };
107
+ readonly states: ReadonlyMap<string, TaskState>;
108
+ }
109
+
110
+ // ═══ Pipeline Events ═══
111
+
112
+ export type PipelineEvent =
113
+ | { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
114
+ | { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
115
+ | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
116
+
117
+ export interface RunPipelineOptions {
118
+ readonly approvalGateway?: ApprovalGateway;
119
+ /**
120
+ * Maximum number of per-run log directories to retain under `<workDir>/logs/`.
121
+ * Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
122
+ */
123
+ readonly maxLogRuns?: number;
124
+ /**
125
+ * External AbortSignal — aborting it cancels the pipeline immediately.
126
+ * Equivalent to the pipeline timeout firing, but caller-controlled.
127
+ */
128
+ readonly signal?: AbortSignal;
129
+ /**
130
+ * Called on every pipeline/task status transition.
131
+ * Use for real-time UI updates (e.g. updating a visual workflow graph).
132
+ */
133
+ readonly onEvent?: (event: PipelineEvent) => void;
134
+ }
135
+
136
+ // Poll interval when no tasks are in-flight but non-terminal tasks remain
137
+ // (e.g. tasks waiting on a file or manual trigger).
138
+ const POLL_INTERVAL_MS = 50;
139
+
140
+ export async function runPipeline(
141
+ config: PipelineConfig,
142
+ workDir: string,
143
+ options: RunPipelineOptions = {},
144
+ ): Promise<EngineResult> {
145
+ const approvalGateway = options.approvalGateway ?? new InMemoryApprovalGateway();
146
+ const maxLogRuns = options.maxLogRuns ?? 20;
147
+
148
+ // Load any plugins declared in the pipeline config before preflight so that
149
+ // drivers, completions, and middlewares referenced in YAML are registered.
150
+ if (config.plugins?.length) {
151
+ await loadPlugins(config.plugins);
152
+ }
153
+
154
+ const dag = buildDag(config);
155
+ const runId = generateRunId();
156
+ preflight(config, dag);
157
+
158
+ const startedAt = nowISO();
159
+ const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
160
+ const log = new Logger(workDir, runId);
161
+ log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
162
+
163
+ // File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
164
+ log.section('Pipeline configuration');
165
+ log.quiet(`name: ${config.name}`);
166
+ log.quiet(`driver: ${config.driver ?? '(default: claude-code)'}`);
167
+ log.quiet(`timeout: ${config.timeout ?? '(none)'}`);
168
+ log.quiet(`tracks: ${config.tracks.length}`);
169
+ log.quiet(`tasks (total): ${dag.nodes.size}`);
170
+ log.quiet(`plugins: ${(config.plugins ?? []).join(', ') || '(none)'}`);
171
+ log.quiet(`hooks: ${config.hooks ? Object.keys(config.hooks).join(', ') || '(none)' : '(none)'}`);
172
+
173
+ log.section('DAG topology');
174
+ for (const [id, node] of dag.nodes) {
175
+ const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
176
+ const kind = node.task.prompt ? 'ai' : 'cmd';
177
+ log.quiet(` ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
178
+ }
179
+ log.quiet('');
180
+
181
+ // Initialize states (before hook, so we can return them even if blocked)
182
+ const states = new Map<string, TaskState>();
183
+ for (const [id, node] of dag.nodes) {
184
+ states.set(id, {
185
+ config: node.task,
186
+ trackConfig: node.track,
187
+ status: 'idle',
188
+ result: null,
189
+ startedAt: null,
190
+ finishedAt: null,
191
+ });
192
+ }
193
+
194
+ try {
195
+
196
+ // Pipeline start hook (gate)
197
+ const startHook = await executeHook(
198
+ config.hooks, 'pipeline_start', buildPipelineStartContext(pipelineInfo), workDir,
199
+ );
200
+ if (!startHook.allowed) {
201
+ console.error(`Pipeline blocked by pipeline_start hook (exit code ${startHook.exitCode})`);
202
+ await executeHook(config.hooks, 'pipeline_error',
203
+ buildPipelineErrorContext(pipelineInfo, 'pipeline_blocked', 'pipeline_blocked'), workDir);
204
+ // All tasks stay idle — pipeline never started
205
+ return {
206
+ success: false,
207
+ runId,
208
+ logPath: log.path,
209
+ summary: { total: dag.nodes.size, success: 0, failed: 0, skipped: 0, timeout: 0, blocked: 0 },
210
+ states,
211
+ };
212
+ }
213
+
214
+ // Pipeline approved transition all tasks to waiting
215
+ for (const [, state] of states) {
216
+ state.status = 'waiting';
217
+ }
218
+ // Include a full states snapshot so listeners can initialize their mirrors without missing events
219
+ const statesSnapshot: ReadonlyMap<string, TaskState> = new Map(
220
+ [...states.entries()].map(([id, s]) => [id, { ...s }])
221
+ );
222
+ options.onEvent?.({ type: 'pipeline_start', runId, states: statesSnapshot });
223
+
224
+ const sessionMap = new Map<string, string>();
225
+ const outputMap = new Map<string, string>();
226
+ const normalizedMap = new Map<string, string>();
227
+
228
+ // Pipeline timeout
229
+ const pipelineTimeoutMs = config.timeout ? parseDuration(config.timeout) : 0;
230
+ let pipelineAborted = false;
231
+ const abortController = new AbortController();
232
+ let pipelineTimer: ReturnType<typeof setTimeout> | null = null;
233
+
234
+ if (pipelineTimeoutMs > 0) {
235
+ pipelineTimer = setTimeout(() => {
236
+ pipelineAborted = true;
237
+ abortController.abort();
238
+ }, pipelineTimeoutMs);
239
+ }
240
+
241
+ // When the pipeline is aborted (timeout, external shutdown), drain all
242
+ // pending approvals so waiting triggers unblock immediately.
243
+ abortController.signal.addEventListener('abort', () => {
244
+ approvalGateway.abortAll('pipeline aborted');
245
+ });
246
+
247
+ // Wire external cancel signal into the internal abort controller.
248
+ if (options.signal) {
249
+ if (options.signal.aborted) {
250
+ pipelineAborted = true;
251
+ abortController.abort();
252
+ } else {
253
+ options.signal.addEventListener('abort', () => {
254
+ pipelineAborted = true;
255
+ abortController.abort();
256
+ }, { once: true });
257
+ }
258
+ }
259
+
260
+ // ── Helpers ──
261
+
262
+ function emit(event: PipelineEvent): void {
263
+ options.onEvent?.(event);
264
+ }
265
+
266
+ function setTaskStatus(taskId: string, newStatus: TaskStatus): void {
267
+ const state = states.get(taskId)!;
268
+ // Terminal lock: once a task reaches a terminal state it must not be
269
+ // re-transitioned. This prevents stop_all from marking running tasks as
270
+ // skipped and then having their in-flight processTask promise overwrite
271
+ // that with success/failed, producing an invalid double transition.
272
+ if (isTerminal(state.status)) return;
273
+ const prevStatus = state.status;
274
+ state.status = newStatus;
275
+ // Snapshot state at emit time — result and finishedAt must be set before calling this for terminal statuses
276
+ const snapshot: TaskState = {
277
+ config: state.config,
278
+ trackConfig: state.trackConfig,
279
+ status: state.status,
280
+ result: state.result,
281
+ startedAt: state.startedAt,
282
+ finishedAt: state.finishedAt,
283
+ };
284
+ emit({ type: 'task_status_change', taskId, status: newStatus, prevStatus, runId, state: snapshot });
285
+ }
286
+
287
+ function getOnFailure(taskId: string): OnFailure {
288
+ return dag.nodes.get(taskId)?.track.on_failure ?? 'skip_downstream';
289
+ }
290
+
291
+ function isDependencySatisfied(depId: string): 'satisfied' | 'unsatisfied' | 'skip' {
292
+ const depState = states.get(depId);
293
+ if (!depState) return 'skip';
294
+ switch (depState.status) {
295
+ case 'success': return 'satisfied';
296
+ case 'skipped': return 'skip';
297
+ case 'failed': case 'timeout': case 'blocked':
298
+ return getOnFailure(depId) === 'ignore' ? 'satisfied' : 'skip';
299
+ default: return 'unsatisfied';
300
+ }
301
+ }
302
+
303
+ function applyStopAll(trackId: string): void {
304
+ for (const [id, state] of states) {
305
+ // Only skip tasks that are still waiting — tasks already running must be
306
+ // allowed to complete naturally so their process is not orphaned and their
307
+ // final status (success/failed/timeout) is recorded correctly.
308
+ // The terminal lock in setTaskStatus prevents any later re-transition
309
+ // should a completed running task try to overwrite the skipped state.
310
+ if (state.trackConfig.id === trackId && state.status === 'waiting') {
311
+ state.finishedAt = nowISO();
312
+ setTaskStatus(id, 'skipped');
313
+ }
314
+ }
315
+ }
316
+
317
+ function buildTaskInfoObj(taskId: string): TaskInfo {
318
+ const state = states.get(taskId)!;
319
+ return {
320
+ id: taskId,
321
+ name: state.config.name,
322
+ type: state.config.prompt ? 'ai' : 'command',
323
+ status: state.status,
324
+ exit_code: state.result?.exitCode ?? null,
325
+ duration_ms: state.result?.durationMs ?? null,
326
+ output_path: state.result?.outputPath ?? null,
327
+ stderr_path: state.result?.stderrPath ?? null,
328
+ session_id: state.result?.sessionId ?? null,
329
+ started_at: state.startedAt,
330
+ finished_at: state.finishedAt,
331
+ };
332
+ }
333
+
334
+ function trackInfoOf(taskId: string): TrackInfo {
335
+ const node = dag.nodes.get(taskId)!;
336
+ return { id: node.track.id, name: node.track.name };
337
+ }
338
+
339
+ async function fireHook(taskId: string, event: 'task_success' | 'task_failure'): Promise<void> {
340
+ await executeHook(config.hooks, event,
341
+ buildTaskContext(event, pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
342
+ }
343
+
344
+ // ── Process a single task ──
345
+
346
+ async function processTask(taskId: string): Promise<void> {
347
+ const state = states.get(taskId)!;
348
+ const node = dag.nodes.get(taskId)!;
349
+ const task = node.task;
350
+ const track = node.track;
351
+
352
+ log.section(`Task ${taskId}`);
353
+ log.debug(`[task:${taskId}]`,
354
+ `type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`);
355
+
356
+ // 1. Check dependencies
357
+ for (const depId of node.dependsOn) {
358
+ const result = isDependencySatisfied(depId);
359
+ if (result === 'skip') {
360
+ const depStatus = states.get(depId)?.status ?? 'unknown';
361
+ log.debug(`[task:${taskId}]`, `skipped (upstream "${depId}" status=${depStatus})`);
362
+ state.finishedAt = nowISO();
363
+ setTaskStatus(taskId, 'skipped');
364
+ return;
365
+ }
366
+ if (result === 'unsatisfied') return; // still waiting
367
+ }
368
+
369
+ // 2. Check trigger
370
+ if (task.trigger) {
371
+ log.debug(`[task:${taskId}]`, `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`);
372
+ try {
373
+ const triggerPlugin = getHandler<TriggerPlugin>('triggers', task.trigger.type);
374
+ await triggerPlugin.watch(task.trigger as Record<string, unknown>, {
375
+ taskId: node.taskId,
376
+ trackId: track.id,
377
+ workDir: task.cwd ?? workDir,
378
+ signal: abortController.signal,
379
+ approvalGateway,
380
+ });
381
+ log.debug(`[task:${taskId}]`, `trigger fired`);
382
+ } catch (err: unknown) {
383
+ const msg = err instanceof Error ? err.message : String(err);
384
+ // If pipeline was aborted while we were still waiting for the trigger,
385
+ // this task never entered running state → skipped, not timeout.
386
+ state.finishedAt = nowISO();
387
+ if (pipelineAborted) {
388
+ setTaskStatus(taskId, 'skipped');
389
+ } else if (msg.includes('rejected') || msg.includes('denied')) {
390
+ setTaskStatus(taskId, 'blocked'); // user/policy rejection
391
+ } else if (msg.includes('timeout')) {
392
+ setTaskStatus(taskId, 'timeout'); // genuine trigger wait timeout
393
+ } else {
394
+ setTaskStatus(taskId, 'failed'); // plugin error, watcher crash, etc.
395
+ }
396
+ await fireHook(taskId, 'task_failure');
397
+ return;
398
+ }
399
+ }
400
+
401
+ // 3. task_start hook (gate)
402
+ const hookResult = await executeHook(config.hooks, 'task_start',
403
+ buildTaskContext('task_start', pipelineInfo, trackInfoOf(taskId), buildTaskInfoObj(taskId)), workDir);
404
+ if (hookResult.exitCode !== 0 || config.hooks?.task_start) {
405
+ log.debug(`[task:${taskId}]`,
406
+ `task_start hook exit=${hookResult.exitCode} allowed=${hookResult.allowed}`);
407
+ }
408
+ if (!hookResult.allowed) {
409
+ state.finishedAt = nowISO();
410
+ setTaskStatus(taskId, 'blocked');
411
+ await fireHook(taskId, 'task_failure');
412
+ return;
413
+ }
414
+
415
+ // 4. Mark running — set startedAt before emitting so subscribers see a
416
+ // complete snapshot (startedAt non-null) in the task_status_change event.
417
+ state.startedAt = nowISO();
418
+ setTaskStatus(taskId, 'running');
419
+ log.info(`[task:${taskId}]`, task.command ? `running: ${task.command}` : `running (driver task)`);
420
+
421
+ // File-only: resolved config for this task
422
+ const resolvedDriver = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
423
+ const resolvedTier = task.model_tier ?? track.model_tier ?? '(default)';
424
+ const resolvedPerms = task.permissions ?? track.permissions ?? '(default)';
425
+ const resolvedCwd = task.cwd ?? track.cwd ?? workDir;
426
+ log.debug(`[task:${taskId}]`,
427
+ `resolved: driver=${resolvedDriver} tier=${resolvedTier} cwd=${resolvedCwd}`);
428
+ log.debug(`[task:${taskId}]`, `permissions: ${JSON.stringify(resolvedPerms)}`);
429
+ if (task.continue_from) {
430
+ log.debug(`[task:${taskId}]`, `continue_from: "${task.continue_from}"`);
431
+ }
432
+ if (task.timeout) {
433
+ log.debug(`[task:${taskId}]`, `timeout: ${task.timeout}`);
434
+ }
435
+
436
+ try {
437
+ let result: TaskResult;
438
+ const timeoutMs = task.timeout ? parseDuration(task.timeout) : undefined;
439
+
440
+ const runOpts = { timeoutMs, signal: abortController.signal };
441
+
442
+ if (task.command) {
443
+ log.debug(`[task:${taskId}]`, `command: ${task.command}`);
444
+ result = await runCommand(task.command, task.cwd ?? workDir, runOpts);
445
+ } else {
446
+ // AI task: apply middleware chain
447
+ const driverName = task.driver ?? track.driver ?? config.driver ?? 'claude-code';
448
+ const driver = getHandler<DriverPlugin>('drivers', driverName);
449
+
450
+ let prompt = task.prompt!;
451
+ const originalLen = prompt.length;
452
+ const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
453
+ if (mws && mws.length > 0) {
454
+ log.debug(`[task:${taskId}]`,
455
+ `middleware chain: ${mws.map(m => m.type).join(' → ')}`);
456
+ const mwCtx: MiddlewareContext = {
457
+ task, track, outputMap, workDir: task.cwd ?? workDir,
458
+ };
459
+ for (const mwConfig of mws) {
460
+ const before = prompt.length;
461
+ const mwPlugin = getHandler<MiddlewarePlugin>('middlewares', mwConfig.type);
462
+ prompt = await mwPlugin.enhance(prompt, mwConfig as Record<string, unknown>, mwCtx);
463
+ log.debug(`[task:${taskId}]`,
464
+ ` ${mwConfig.type}: ${before} → ${prompt.length} chars`);
465
+ }
466
+ }
467
+ log.debug(`[task:${taskId}]`,
468
+ `prompt: ${originalLen} chars (final: ${prompt.length} chars)`);
469
+ log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`);
470
+
471
+ const enrichedTask: TaskConfig = { ...task, prompt };
472
+ const driverCtx: DriverContext = {
473
+ sessionMap, outputMap, normalizedMap, workDir: task.cwd ?? workDir,
474
+ };
475
+ const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
476
+ log.debug(`[task:${taskId}]`, `driver=${driverName}`);
477
+ log.debug(`[task:${taskId}]`,
478
+ `spawn args: ${JSON.stringify(spec.args)}`);
479
+ if (spec.cwd) log.debug(`[task:${taskId}]`, `spawn cwd: ${spec.cwd}`);
480
+ if (spec.env) log.debug(`[task:${taskId}]`,
481
+ `spawn env overrides: ${Object.keys(spec.env).join(', ')}`);
482
+ if (spec.stdin) log.debug(`[task:${taskId}]`,
483
+ `spawn stdin: ${spec.stdin.length} chars`);
484
+ result = await runSpawn(spec, driver, runOpts);
485
+ }
486
+
487
+ // 5. Determine terminal status (without emitting yet — result must be complete first)
488
+ let terminalStatus: TaskStatus;
489
+ if (result.exitCode === -1) {
490
+ terminalStatus = 'timeout';
491
+ } else if (result.exitCode !== 0) {
492
+ terminalStatus = 'failed';
493
+ } else if (task.completion) {
494
+ const plugin = getHandler<CompletionPlugin>('completions', task.completion.type);
495
+ const completionCtx = { workDir: task.cwd ?? workDir };
496
+ const passed = await plugin.check(task.completion as Record<string, unknown>, result, completionCtx);
497
+ terminalStatus = passed ? 'success' : 'failed';
498
+ } else {
499
+ terminalStatus = 'success';
500
+ }
501
+
502
+ // 6. Write output file with RAW stdout (preserves driver output format).
503
+ // The separate normalizedMap holds canonical text for continue_from.
504
+ if (task.output) {
505
+ // validatePath enforces no .. traversal and no absolute paths escaping workDir.
506
+ const outPath = validatePath(task.output, workDir);
507
+ await mkdir(dirname(outPath), { recursive: true });
508
+ await Bun.write(outPath, result.stdout);
509
+ result = { ...result, outputPath: outPath };
510
+ outputMap.set(taskId, outPath);
511
+ const bareId = taskId.includes('.') ? taskId.split('.').pop()! : taskId;
512
+ if (!outputMap.has(bareId)) outputMap.set(bareId, outPath);
513
+ }
514
+
515
+ // Store normalized text separately (in-memory) for continue_from handoff
516
+ if (result.normalizedOutput !== null) {
517
+ normalizedMap.set(taskId, result.normalizedOutput);
518
+ const bareId = taskId.includes('.') ? taskId.split('.').pop()! : taskId;
519
+ if (!normalizedMap.has(bareId)) normalizedMap.set(bareId, result.normalizedOutput);
520
+ }
521
+
522
+ if (result.stderr) {
523
+ const stderrPath = resolve(log.dir, `${taskId.replace(/\./g, '_')}.stderr`);
524
+ await Bun.write(stderrPath, result.stderr);
525
+ result = { ...result, stderrPath };
526
+ }
527
+
528
+ if (result.sessionId) {
529
+ sessionMap.set(taskId, result.sessionId);
530
+ const bareId = taskId.includes('.') ? taskId.split('.').pop()! : taskId;
531
+ if (!sessionMap.has(bareId)) sessionMap.set(bareId, result.sessionId);
532
+ }
533
+
534
+ // Set result and finishedAt before emitting terminal status so listeners see complete state
535
+ state.result = result;
536
+ state.finishedAt = nowISO();
537
+ setTaskStatus(taskId, terminalStatus);
538
+
539
+ // Log task outcome with relevant details
540
+ const durSec = (result.durationMs / 1000).toFixed(1);
541
+ if (terminalStatus === 'success') {
542
+ log.info(`[task:${taskId}]`, `success (${durSec}s)`);
543
+ } else {
544
+ log.error(`[task:${taskId}]`,
545
+ `${terminalStatus} exit=${result.exitCode} duration=${durSec}s`);
546
+ if (result.stderr) {
547
+ const tail = tailLines(result.stderr, 10);
548
+ log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
549
+ }
550
+ }
551
+
552
+ // File-only: full stdout/stderr dump (clipped) + extracted metadata
553
+ log.debug(`[task:${taskId}]`,
554
+ `stdout: ${result.stdout.length} chars, stderr: ${result.stderr.length} chars`);
555
+ if (result.sessionId) {
556
+ log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
557
+ }
558
+ if (result.outputPath) {
559
+ log.debug(`[task:${taskId}]`, `wrote output: ${result.outputPath}`);
560
+ }
561
+ if (result.stderrPath) {
562
+ log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
563
+ }
564
+ if (result.stdout) {
565
+ log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`);
566
+ }
567
+ if (result.stderr) {
568
+ log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`);
569
+ }
570
+ if (task.completion) {
571
+ log.debug(`[task:${taskId}]`,
572
+ `completion check: type=${task.completion.type} result=${terminalStatus}`);
573
+ }
574
+
575
+ } catch (err: unknown) {
576
+ const errMsg = err instanceof Error ? (err.stack ?? err.message) : String(err);
577
+ log.error(`[task:${taskId}]`, `failed before execution: ${errMsg}`);
578
+ state.result = {
579
+ exitCode: -1,
580
+ stdout: '',
581
+ stderr: errMsg,
582
+ outputPath: null, stderrPath: null, durationMs: 0,
583
+ sessionId: null, normalizedOutput: null,
584
+ };
585
+ state.finishedAt = nowISO();
586
+ setTaskStatus(taskId, 'failed');
587
+ }
588
+
589
+ // 7. Fire hooks
590
+ const finalStatus: TaskStatus = state.status;
591
+ await fireHook(taskId, finalStatus === 'success' ? 'task_success' : 'task_failure');
592
+
593
+ // 8. Handle stop_all for failure states
594
+ if (finalStatus !== 'success' && getOnFailure(taskId) === 'stop_all') {
595
+ applyStopAll(node.track.id);
596
+ }
597
+ }
598
+
599
+ // ── Event loop ──
600
+ // Each task is launched as soon as ALL its deps reach a terminal state.
601
+ // We track in-flight tasks in `running` so a task completing mid-batch
602
+ // immediately unblocks its dependents without waiting for sibling tasks.
603
+ const running = new Map<string, Promise<void>>();
604
+
605
+ try {
606
+ while (!pipelineAborted) {
607
+ // Launch every task whose deps are all terminal and that isn't already in-flight
608
+ for (const [id, state] of states) {
609
+ if (state.status !== 'waiting' || running.has(id)) continue;
610
+ const node = dag.nodes.get(id)!;
611
+ const allDepsTerminal = node.dependsOn.length === 0 ||
612
+ node.dependsOn.every(d => isTerminal(states.get(d)!.status));
613
+ if (!allDepsTerminal) continue;
614
+ const p = processTask(id).finally(() => running.delete(id));
615
+ running.set(id, p);
616
+ }
617
+
618
+ // All tasks terminal — done
619
+ if ([...states.values()].every(s => isTerminal(s.status))) break;
620
+
621
+ if (running.size === 0) {
622
+ // Nothing in-flight but non-terminal tasks exist (e.g. trigger-wait states
623
+ // that processTask hasn't been called for yet). Poll briefly.
624
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
625
+ } else {
626
+ // Wait for any one task to finish, then re-scan for new launchables.
627
+ await Promise.race(running.values());
628
+ }
629
+ }
630
+
631
+ if (pipelineAborted) {
632
+ // Wait for in-flight tasks to honour the abort signal before marking states.
633
+ if (running.size > 0) await Promise.allSettled(running.values());
634
+ for (const [id, state] of states) {
635
+ if (!isTerminal(state.status)) {
636
+ // Running tasks get timeout (they were killed); waiting tasks get skipped
637
+ state.finishedAt = nowISO();
638
+ setTaskStatus(id, state.status === 'running' ? 'timeout' : 'skipped');
639
+ }
640
+ }
641
+ }
642
+ } finally {
643
+ if (pipelineTimer) clearTimeout(pipelineTimer);
644
+ // Safety net: drain any approvals still pending at shutdown (e.g. crash path).
645
+ if (approvalGateway.pending().length > 0) {
646
+ approvalGateway.abortAll('pipeline finished');
647
+ }
648
+ }
649
+
650
+ // ── Summary ──
651
+ const summary = { total: 0, success: 0, failed: 0, skipped: 0, timeout: 0, blocked: 0 };
652
+ for (const [, state] of states) {
653
+ summary.total++;
654
+ switch (state.status) {
655
+ case 'success': summary.success++; break;
656
+ case 'failed': summary.failed++; break;
657
+ case 'skipped': summary.skipped++; break;
658
+ case 'timeout': summary.timeout++; break;
659
+ case 'blocked': summary.blocked++; break;
660
+ }
661
+ }
662
+
663
+ const finishedAt = nowISO();
664
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
665
+
666
+ if (pipelineAborted) {
667
+ await executeHook(config.hooks, 'pipeline_error',
668
+ buildPipelineErrorContext(pipelineInfo, 'Pipeline timeout exceeded'), workDir);
669
+ } else {
670
+ await executeHook(config.hooks, 'pipeline_complete',
671
+ buildPipelineCompleteContext(
672
+ { ...pipelineInfo, finished_at: finishedAt, duration_ms: durationMs }, summary), workDir);
673
+ }
674
+
675
+ const allSuccess = !pipelineAborted
676
+ && summary.failed === 0 && summary.timeout === 0 && summary.blocked === 0;
677
+
678
+ log.section('Pipeline summary');
679
+ log.quiet(`status: ${pipelineAborted ? 'aborted (timeout)' : 'completed'}`);
680
+ log.quiet(`duration: ${(durationMs / 1000).toFixed(1)}s`);
681
+ log.quiet(
682
+ `counts: total=${summary.total} success=${summary.success} ` +
683
+ `failed=${summary.failed} skipped=${summary.skipped} ` +
684
+ `timeout=${summary.timeout} blocked=${summary.blocked}`);
685
+ log.quiet('');
686
+ log.quiet('per-task:');
687
+ for (const [id, state] of states) {
688
+ const dur = state.result?.durationMs != null
689
+ ? `${(state.result.durationMs / 1000).toFixed(1)}s` : '-';
690
+ const exit = state.result?.exitCode ?? '-';
691
+ log.quiet(` ${state.status.padEnd(8)} ${id} (exit=${exit}, ${dur})`);
692
+ }
693
+
694
+ console.log(`\n[Pipeline "${config.name}"] completed`);
695
+ console.log(` Total: ${summary.total} | Success: ${summary.success} | Failed: ${summary.failed} | Skipped: ${summary.skipped} | Timeout: ${summary.timeout} | Blocked: ${summary.blocked}`);
696
+ console.log(` Duration: ${(durationMs / 1000).toFixed(1)}s`);
697
+ console.log(` Log: ${log.path}`);
698
+
699
+ emit({ type: 'pipeline_end', runId, success: allSuccess });
700
+ return { success: allSuccess, runId, logPath: log.path, summary, states };
701
+
702
+ } finally {
703
+ // Prune old per-run log directories on every exit path (normal, blocked, or thrown).
704
+ // Exclude the current runId so a concurrent run cannot delete its own live directory.
705
+ if (maxLogRuns > 0) {
706
+ await pruneLogDirs(resolve(workDir, '.tagma', 'logs'), maxLogRuns, runId);
707
+ }
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Delete the oldest subdirectories under `logsDir`, keeping only the most recent `keep`.
713
+ * Directories are sorted lexicographically; because runIds are prefixed with a base-36
714
+ * timestamp, lexicographic order equals chronological order.
715
+ *
716
+ * `excludeRunId` is always skipped from deletion even if it would otherwise be pruned —
717
+ * this prevents a concurrent run from removing a live log directory that is still in use.
718
+ */
719
+ async function pruneLogDirs(logsDir: string, keep: number, excludeRunId: string): Promise<void> {
720
+ let entries: string[];
721
+ try {
722
+ entries = await readdir(logsDir);
723
+ } catch {
724
+ return; // logsDir doesn't exist yet — nothing to prune
725
+ }
726
+
727
+ // Only consider directories that look like run IDs (run_<...>), excluding the live run.
728
+ const runDirs = entries.filter(e => e.startsWith('run_') && e !== excludeRunId).sort();
729
+ const toDelete = runDirs.slice(0, Math.max(0, runDirs.length - keep));
730
+
731
+ await Promise.all(
732
+ toDelete.map(dir =>
733
+ rm(resolve(logsDir, dir), { recursive: true, force: true }).catch(() => {
734
+ // Ignore deletion errors — stale dirs are better than a crash
735
+ })
736
+ )
737
+ );
738
+ }
739
+
740
+ function isTerminal(status: TaskStatus): boolean {
741
+ return status === 'success' || status === 'failed' || status === 'timeout'
742
+ || status === 'skipped' || status === 'blocked';
743
+ }