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