@async/pipeline 0.1.5 → 0.2.3
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/LICENSE +21 -0
- package/README.md +2 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -1
- package/dist/cli.js.map +1 -1
- package/dist/internal/core/index.d.ts +77 -41
- package/dist/internal/core/index.d.ts.map +1 -1
- package/dist/internal/core/index.js +150 -26
- package/dist/internal/core/index.js.map +1 -1
- package/dist/internal/lima/index.d.ts +1 -1
- package/dist/internal/lima/index.d.ts.map +1 -1
- package/dist/internal/lima/index.js +1 -1
- package/dist/internal/lima/index.js.map +1 -1
- package/dist/internal/node/cli.d.ts +5 -2
- package/dist/internal/node/cli.d.ts.map +1 -1
- package/dist/internal/node/cli.js +396 -89
- package/dist/internal/node/cli.js.map +1 -1
- package/dist/internal/node/doctor.d.ts +1 -1
- package/dist/internal/node/doctor.d.ts.map +1 -1
- package/dist/internal/node/doctor.js +50 -1
- package/dist/internal/node/doctor.js.map +1 -1
- package/dist/internal/node/github.d.ts +2 -0
- package/dist/internal/node/github.d.ts.map +1 -1
- package/dist/internal/node/github.js +59 -11
- package/dist/internal/node/github.js.map +1 -1
- package/dist/internal/node/index.d.ts +1 -0
- package/dist/internal/node/index.d.ts.map +1 -1
- package/dist/internal/node/index.js +1 -0
- package/dist/internal/node/index.js.map +1 -1
- package/dist/internal/node/loader.d.ts.map +1 -1
- package/dist/internal/node/loader.js +4 -0
- package/dist/internal/node/loader.js.map +1 -1
- package/dist/internal/node/mcp.d.ts +16 -0
- package/dist/internal/node/mcp.d.ts.map +1 -0
- package/dist/internal/node/mcp.js +243 -0
- package/dist/internal/node/mcp.js.map +1 -0
- package/dist/internal/node/runner.d.ts +64 -49
- package/dist/internal/node/runner.d.ts.map +1 -1
- package/dist/internal/node/runner.js +648 -138
- package/dist/internal/node/runner.js.map +1 -1
- package/dist/internal/node/sources.d.ts +3 -0
- package/dist/internal/node/sources.d.ts.map +1 -1
- package/dist/internal/node/sources.js +24 -7
- package/dist/internal/node/sources.js.map +1 -1
- package/dist/internal/node/store.d.ts +86 -0
- package/dist/internal/node/store.d.ts.map +1 -1
- package/dist/internal/node/store.js +322 -24
- package/dist/internal/node/store.js.map +1 -1
- package/package.json +7 -5
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { availableParallelism } from "node:os";
|
|
4
|
-
import { posix, relative } from "node:path";
|
|
5
|
+
import { join, posix, relative } from "node:path";
|
|
5
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
-
import { sh, tasksForJob } from "../core/index.js";
|
|
7
|
-
import { computeTaskCacheKey, createStore, outputFilesExist, readCacheEntry, resolveOutputFiles, restoreCacheOutputs, writeCacheEntry, writeExecution, writeTaskLog } from "./store.js";
|
|
7
|
+
import { isAgentStep, isResolvedAgentStep, pipelineError, sh, tasksForJob } from "../core/index.js";
|
|
8
|
+
import { acquireRunLock, computeTaskCacheKey, computeTaskCacheKeyDetailed, createStore, diffInputManifests, outputFilesExist, readCacheEntry, readCacheInputManifest, readTaskBaseline, resolveOutputFiles, restoreCacheOutputs, writeAgentPrompt, writeAgentTranscript, writeCacheEntry, writeCacheInputManifest, writeContextPack, writeExecution, writeTaskBaseline, writeTaskLog } from "./store.js";
|
|
8
9
|
import { createRunPlan, sourceContext } from "./sources.js";
|
|
9
10
|
export class HostCommandExecutor {
|
|
10
11
|
name = "host";
|
|
11
12
|
runShell(command, options) {
|
|
12
|
-
return runProcess(command, { cwd: options.cwd, env: options.env, timeoutMs: options.timeoutMs });
|
|
13
|
+
return runProcess(command, { cwd: options.cwd, env: options.env, echo: options.echo, timeoutMs: options.timeoutMs, label: options.task?.id, redactValues: options.redactValues });
|
|
13
14
|
}
|
|
14
15
|
async checkTool(tool) {
|
|
15
16
|
const result = await runProcess(`command -v ${shellEscape(tool)}`, { cwd: process.cwd(), env: process.env, echo: false });
|
|
@@ -23,17 +24,20 @@ export class DockerCommandExecutor {
|
|
|
23
24
|
this.options = options;
|
|
24
25
|
}
|
|
25
26
|
runShell(command, options) {
|
|
26
|
-
return runProcess(this.dockerCommand(command, options.cwd, options.env), { cwd: this.options.hostCwd, env: options.env, timeoutMs: options.timeoutMs });
|
|
27
|
+
return runProcess(this.dockerCommand(command, options.cwd, options.env, options.forwardEnvKeys ?? []), { cwd: this.options.hostCwd, env: options.env, echo: options.echo, timeoutMs: options.timeoutMs, label: options.task?.id, redactValues: options.redactValues });
|
|
27
28
|
}
|
|
28
29
|
async checkTool(tool) {
|
|
29
|
-
const result = await runProcess(this.dockerCommand(`command -v ${shellEscape(tool)}`, this.options.hostCwd, process.env), {
|
|
30
|
+
const result = await runProcess(this.dockerCommand(`command -v ${shellEscape(tool)}`, this.options.hostCwd, process.env, []), {
|
|
30
31
|
cwd: this.options.hostCwd,
|
|
31
32
|
env: process.env,
|
|
32
33
|
echo: false
|
|
33
34
|
});
|
|
34
35
|
return result.code === 0;
|
|
35
36
|
}
|
|
36
|
-
dockerCommand(command, cwd, env) {
|
|
37
|
+
dockerCommand(command, cwd, env, forwardEnvKeys) {
|
|
38
|
+
// Only explicitly forwarded keys cross the container boundary. Forwarding
|
|
39
|
+
// the whole host env would leak unrelated secrets into every container.
|
|
40
|
+
const forwarded = [...new Set(forwardEnvKeys)].filter((key) => env[key] !== undefined).sort();
|
|
37
41
|
return [
|
|
38
42
|
"docker",
|
|
39
43
|
"run",
|
|
@@ -41,7 +45,7 @@ export class DockerCommandExecutor {
|
|
|
41
45
|
"-w",
|
|
42
46
|
shellEscape(this.containerCwd(cwd)),
|
|
43
47
|
...this.options.volumes.flatMap((volume) => ["-v", shellEscape(`${volume.source}:${volume.target}${volume.readonly ? ":ro" : ""}`)]),
|
|
44
|
-
...
|
|
48
|
+
...forwarded.flatMap((key) => ["-e", shellEscape(key)]),
|
|
45
49
|
shellEscape(this.options.image),
|
|
46
50
|
"bash",
|
|
47
51
|
"-lc",
|
|
@@ -64,9 +68,8 @@ export class LimaCommandExecutor {
|
|
|
64
68
|
this.vm = vm;
|
|
65
69
|
}
|
|
66
70
|
runShell(command, options) {
|
|
67
|
-
const vm = options.task.environment?.vm ?? this.vm;
|
|
68
71
|
const escaped = shellEscape(`cd ${shellEscape(options.cwd)} && ${command}`);
|
|
69
|
-
return runProcess(`limactl shell ${shellEscape(vm)} -- bash -lc ${escaped}`, { cwd: options.cwd, env: options.env, timeoutMs: options.timeoutMs });
|
|
72
|
+
return runProcess(`limactl shell ${shellEscape(this.vm)} -- bash -lc ${escaped}`, { cwd: options.cwd, env: options.env, echo: options.echo, timeoutMs: options.timeoutMs, label: options.task?.id, redactValues: options.redactValues });
|
|
70
73
|
}
|
|
71
74
|
async checkTool(tool) {
|
|
72
75
|
const result = await runProcess(`limactl shell ${shellEscape(this.vm)} -- bash -lc ${shellEscape(`command -v ${shellEscape(tool)}`)}`, {
|
|
@@ -76,37 +79,43 @@ export class LimaCommandExecutor {
|
|
|
76
79
|
return result.code === 0;
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Resolve flat run options into the execution context a run uses: host by
|
|
84
|
+
* default, or the selected sandbox (by id from `pipeline.sandboxes`, or an
|
|
85
|
+
* inline definition).
|
|
86
|
+
*/
|
|
87
|
+
export function resolveExecutionContext(pipeline, target = {}) {
|
|
88
|
+
const cwd = target.cwd ?? process.cwd();
|
|
89
|
+
const env = target.env ?? process.env;
|
|
90
|
+
const base = {
|
|
91
|
+
cwd,
|
|
92
|
+
env,
|
|
83
93
|
fs: { kind: "host" },
|
|
84
|
-
executor:
|
|
85
|
-
commands:
|
|
94
|
+
executor: target.executor ?? new HostCommandExecutor(),
|
|
95
|
+
commands: target.commands
|
|
86
96
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
const ref = target.sandbox;
|
|
98
|
+
if (!ref || ref === "host")
|
|
99
|
+
return base;
|
|
100
|
+
const definition = typeof ref === "string" ? pipeline.sandboxes[ref] : ref;
|
|
101
|
+
if (!definition) {
|
|
102
|
+
throw new Error(`Unknown sandbox "${String(ref)}". Declare it under \`sandboxes\` in the pipeline config.`);
|
|
103
|
+
}
|
|
104
|
+
if (definition.kind === "host")
|
|
105
|
+
return base;
|
|
106
|
+
if (definition.kind === "lima") {
|
|
107
|
+
return { ...base, executor: new LimaCommandExecutor(definition.vm) };
|
|
108
|
+
}
|
|
109
|
+
const workdir = definition.workdir ?? "/workspace";
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
94
112
|
executor: new DockerCommandExecutor({
|
|
95
|
-
image:
|
|
113
|
+
image: definition.image,
|
|
96
114
|
hostCwd: cwd,
|
|
97
115
|
workdir,
|
|
98
|
-
volumes:
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
export function limaWorkspace(options = {}) {
|
|
104
|
-
return hostWorkspace({
|
|
105
|
-
cwd: options.cwd,
|
|
106
|
-
env: options.env,
|
|
107
|
-
executor: new LimaCommandExecutor(options.vm),
|
|
108
|
-
commands: options.commands
|
|
109
|
-
});
|
|
116
|
+
volumes: definition.volumes ?? [{ source: cwd, target: workdir }]
|
|
117
|
+
})
|
|
118
|
+
};
|
|
110
119
|
}
|
|
111
120
|
export function commandProxy(policy = { rules: [] }) {
|
|
112
121
|
const records = [];
|
|
@@ -116,7 +125,7 @@ export function commandProxy(policy = { rules: [] }) {
|
|
|
116
125
|
const started = Date.now();
|
|
117
126
|
const action = matchingAction(policy, invocation.argv);
|
|
118
127
|
const status = commandStatus(action);
|
|
119
|
-
const result = await runCommandAction(action, next);
|
|
128
|
+
const result = await runCommandAction(action, invocation, next);
|
|
120
129
|
const outputPolicy = action.output ?? policy.output ?? {};
|
|
121
130
|
if (policy.record) {
|
|
122
131
|
records.push({
|
|
@@ -144,18 +153,34 @@ export function commandProxy(policy = { rules: [] }) {
|
|
|
144
153
|
const memoryCacheEntries = new Map();
|
|
145
154
|
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
146
155
|
export async function runJob(pipeline, options) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
156
|
+
// Install before any task spawns so an early Ctrl-C still finalizes the record.
|
|
157
|
+
ensureSignalForwarding();
|
|
158
|
+
const context = resolveExecutionContext(pipeline, options);
|
|
159
|
+
const store = await createStore(context.cwd);
|
|
160
|
+
// One run at a time per project: concurrent runs would race on the task
|
|
161
|
+
// cache, run records, and synced outputs. A lock whose holder process is
|
|
162
|
+
// dead is reclaimed automatically.
|
|
163
|
+
const lock = await acquireRunLock(store);
|
|
164
|
+
try {
|
|
165
|
+
return await runJobLocked(pipeline, options, context, store);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await lock.release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function runJobLocked(pipeline, options, context, store) {
|
|
172
|
+
const plan = await createRunPlan(pipeline, context.cwd, store);
|
|
150
173
|
const graph = tasksForJob(plan.pipeline, options.id);
|
|
151
174
|
const record = {
|
|
175
|
+
schemaVersion: 1,
|
|
152
176
|
id: `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`,
|
|
153
177
|
pipelineName: plan.pipeline.name,
|
|
154
178
|
jobId: options.id,
|
|
155
|
-
cwd:
|
|
179
|
+
cwd: context.cwd,
|
|
180
|
+
pid: process.pid,
|
|
156
181
|
startedAt: new Date().toISOString(),
|
|
157
182
|
status: "running",
|
|
158
|
-
mode: options.mode ?? (
|
|
183
|
+
mode: options.mode ?? (context.env.CI ? "ci" : "manual"),
|
|
159
184
|
tasks: [],
|
|
160
185
|
sources: Object.fromEntries(Object.entries(plan.sources).map(([sourceId, resolved]) => [sourceId, resolved.record]))
|
|
161
186
|
};
|
|
@@ -199,10 +224,11 @@ export async function runJob(pipeline, options) {
|
|
|
199
224
|
if (!promise) {
|
|
200
225
|
promise = runSourcePrepare(source, {
|
|
201
226
|
candidate: plan.candidate,
|
|
202
|
-
executor:
|
|
203
|
-
rootCwd:
|
|
227
|
+
executor: context.executor,
|
|
228
|
+
rootCwd: context.cwd,
|
|
204
229
|
runId: record.id,
|
|
205
|
-
|
|
230
|
+
contextEnv: context.env,
|
|
231
|
+
echo: options.echo,
|
|
206
232
|
store
|
|
207
233
|
});
|
|
208
234
|
sourcePreparePromises.set(source.id, promise);
|
|
@@ -233,70 +259,188 @@ export async function runJob(pipeline, options) {
|
|
|
233
259
|
}
|
|
234
260
|
const result = await runTask(plan.pipeline, taskDefinition, {
|
|
235
261
|
candidate: plan.candidate,
|
|
236
|
-
cwd: taskDefinition.source?.dir ||
|
|
237
|
-
executor:
|
|
238
|
-
rootCwd:
|
|
262
|
+
cwd: taskDefinition.source?.dir || context.cwd,
|
|
263
|
+
executor: context.executor,
|
|
264
|
+
rootCwd: context.cwd,
|
|
239
265
|
runId: record.id,
|
|
240
266
|
source: taskDefinition.source,
|
|
241
267
|
envDefinitions,
|
|
242
|
-
|
|
268
|
+
contextEnv: context.env,
|
|
243
269
|
sourcePrepareCommands: taskSource ? await resolvePrepareCommands(taskSource, {
|
|
244
270
|
candidate: plan.candidate,
|
|
245
|
-
rootCwd:
|
|
271
|
+
rootCwd: context.cwd,
|
|
246
272
|
runId: record.id,
|
|
247
|
-
|
|
273
|
+
contextEnv: context.env
|
|
248
274
|
}) : [],
|
|
249
275
|
dependencyFingerprints: Object.fromEntries(taskDefinition.dependsOn.map((dependency) => [
|
|
250
276
|
dependency,
|
|
251
277
|
taskFingerprints.get(dependency) ?? null
|
|
252
278
|
])),
|
|
279
|
+
force: options.force,
|
|
280
|
+
echo: options.echo,
|
|
253
281
|
store
|
|
254
282
|
});
|
|
255
283
|
results.push({ order: graphIndex.get(taskId) ?? results.length, result });
|
|
256
284
|
return { taskId, failed: result.status === "failed", results, taskResult: result };
|
|
257
285
|
};
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
286
|
+
const scheduleTask = (taskId) =>
|
|
287
|
+
// The scheduler races these promises, so they must never reject: an
|
|
288
|
+
// unexpected throw becomes a failed task result and the record finalizes.
|
|
289
|
+
runScheduledTask(taskId).catch((error) => {
|
|
290
|
+
const result = {
|
|
291
|
+
id: taskId,
|
|
292
|
+
status: "failed",
|
|
293
|
+
startedAt: new Date().toISOString(),
|
|
294
|
+
finishedAt: new Date().toISOString(),
|
|
295
|
+
durationMs: 0,
|
|
296
|
+
attempts: 0,
|
|
297
|
+
cacheHit: false,
|
|
298
|
+
error: error instanceof Error ? error.message : String(error)
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
taskId,
|
|
302
|
+
failed: true,
|
|
303
|
+
results: [{ order: graphIndex.get(taskId) ?? 0, result }],
|
|
304
|
+
taskResult: result
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
while (ready.length > 0 || running.size > 0) {
|
|
309
|
+
while (!failed && ready.length > 0 && running.size < concurrency) {
|
|
310
|
+
const taskId = ready.shift();
|
|
311
|
+
if (!taskId)
|
|
312
|
+
break;
|
|
313
|
+
running.set(taskId, scheduleTask(taskId));
|
|
314
|
+
}
|
|
315
|
+
if (running.size === 0)
|
|
262
316
|
break;
|
|
263
|
-
running.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
await updateRecord();
|
|
276
|
-
if (completed.failed) {
|
|
277
|
-
failed = true;
|
|
278
|
-
ready.length = 0;
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
if (failed || !completed.taskResult)
|
|
282
|
-
continue;
|
|
283
|
-
const node = graphNodes.get(completed.taskId);
|
|
284
|
-
for (const dependent of node?.dependents ?? []) {
|
|
285
|
-
if (!plan.pipeline.tasks[dependent])
|
|
317
|
+
const completed = await Promise.race(running.values());
|
|
318
|
+
running.delete(completed.taskId);
|
|
319
|
+
for (const entry of completed.results) {
|
|
320
|
+
recordedResults.set(entry.result.id, entry);
|
|
321
|
+
}
|
|
322
|
+
if (completed.taskResult) {
|
|
323
|
+
taskFingerprints.set(completed.taskId, completed.taskResult.cacheKey ?? `${completed.taskId}:${completed.taskResult.status}`);
|
|
324
|
+
}
|
|
325
|
+
await updateRecord();
|
|
326
|
+
if (completed.failed) {
|
|
327
|
+
failed = true;
|
|
328
|
+
ready.length = 0;
|
|
286
329
|
continue;
|
|
287
|
-
const remaining = Math.max(0, (dependencyCounts.get(dependent) ?? 0) - 1);
|
|
288
|
-
dependencyCounts.set(dependent, remaining);
|
|
289
|
-
if (remaining === 0) {
|
|
290
|
-
ready.push(dependent);
|
|
291
330
|
}
|
|
331
|
+
if (failed || !completed.taskResult)
|
|
332
|
+
continue;
|
|
333
|
+
const node = graphNodes.get(completed.taskId);
|
|
334
|
+
for (const dependent of node?.dependents ?? []) {
|
|
335
|
+
if (!plan.pipeline.tasks[dependent])
|
|
336
|
+
continue;
|
|
337
|
+
const remaining = Math.max(0, (dependencyCounts.get(dependent) ?? 0) - 1);
|
|
338
|
+
dependencyCounts.set(dependent, remaining);
|
|
339
|
+
if (remaining === 0) {
|
|
340
|
+
ready.push(dependent);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
ready.sort((left, right) => (graphIndex.get(left) ?? 0) - (graphIndex.get(right) ?? 0));
|
|
292
344
|
}
|
|
293
|
-
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
// Never leave an execution record stuck in "running" on disk.
|
|
348
|
+
record.status = "failed";
|
|
349
|
+
record.finishedAt = new Date().toISOString();
|
|
350
|
+
await writeExecution(store, record).catch(() => { });
|
|
351
|
+
throw error;
|
|
294
352
|
}
|
|
295
353
|
record.status = failed ? "failed" : "passed";
|
|
296
354
|
record.finishedAt = new Date().toISOString();
|
|
297
355
|
await writeExecution(store, record);
|
|
298
356
|
return record;
|
|
299
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Computes the execution order and predicted cache behavior for a job without running it.
|
|
360
|
+
* Predictions reuse the real cache-key chain but do not validate cached output files.
|
|
361
|
+
*/
|
|
362
|
+
export async function planJob(pipeline, options) {
|
|
363
|
+
const context = resolveExecutionContext(pipeline, options);
|
|
364
|
+
const store = await createStore(context.cwd);
|
|
365
|
+
const plan = await createRunPlan(pipeline, context.cwd, store);
|
|
366
|
+
const graph = tasksForJob(plan.pipeline, options.id);
|
|
367
|
+
const jobDefinition = plan.pipeline.jobs[options.id];
|
|
368
|
+
const envDefinitions = {
|
|
369
|
+
...plan.pipeline.env,
|
|
370
|
+
...(jobDefinition?.env ?? {})
|
|
371
|
+
};
|
|
372
|
+
const fingerprints = new Map();
|
|
373
|
+
const entries = [];
|
|
374
|
+
for (const taskId of graph.executionOrder) {
|
|
375
|
+
const taskDefinition = plan.pipeline.tasks[taskId];
|
|
376
|
+
if (!taskDefinition)
|
|
377
|
+
continue;
|
|
378
|
+
try {
|
|
379
|
+
const taskCwd = taskDefinition.source?.dir || context.cwd;
|
|
380
|
+
const resolvedEnv = buildTaskEnv(context.env, {
|
|
381
|
+
candidate: plan.candidate,
|
|
382
|
+
envDefinitions,
|
|
383
|
+
rootCwd: context.cwd,
|
|
384
|
+
source: taskDefinition.source,
|
|
385
|
+
taskId
|
|
386
|
+
});
|
|
387
|
+
const taskContext = createTaskContext(taskDefinition, {
|
|
388
|
+
candidate: plan.candidate,
|
|
389
|
+
cwd: taskCwd,
|
|
390
|
+
env: resolvedEnv.env,
|
|
391
|
+
metadata: {},
|
|
392
|
+
rootCwd: context.cwd,
|
|
393
|
+
runId: "dry-run",
|
|
394
|
+
source: taskDefinition.source,
|
|
395
|
+
writeLog() { }
|
|
396
|
+
});
|
|
397
|
+
const steps = await resolveTaskSteps(plan.pipeline.agents, taskDefinition.steps, taskContext);
|
|
398
|
+
const taskSource = taskDefinition.source?.name ? plan.sources[taskDefinition.source.name] : undefined;
|
|
399
|
+
const prepareCommands = taskSource
|
|
400
|
+
? (await resolvePrepareCommands(taskSource, {
|
|
401
|
+
candidate: plan.candidate,
|
|
402
|
+
rootCwd: context.cwd,
|
|
403
|
+
runId: "dry-run",
|
|
404
|
+
contextEnv: context.env
|
|
405
|
+
})).map((command) => command.command)
|
|
406
|
+
: [];
|
|
407
|
+
const cacheKey = await computeTaskCacheKey(plan.pipeline, taskDefinition, taskCwd, {
|
|
408
|
+
candidate: plan.candidate,
|
|
409
|
+
dependencyFingerprints: Object.fromEntries(taskDefinition.dependsOn.map((dependency) => [
|
|
410
|
+
dependency,
|
|
411
|
+
fingerprints.get(dependency) ?? null
|
|
412
|
+
])),
|
|
413
|
+
prepareCommands,
|
|
414
|
+
source: taskDefinition.source,
|
|
415
|
+
steps
|
|
416
|
+
});
|
|
417
|
+
fingerprints.set(taskId, cacheKey);
|
|
418
|
+
if (!taskDefinition.cache.enabled) {
|
|
419
|
+
entries.push({ id: taskId, cacheEnabled: false, predicted: "run", cacheKey });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const cached = await readTaskCacheEntry(taskDefinition, store, cacheKey);
|
|
423
|
+
const fresh = cached?.status === "passed" && isCacheEntryFresh(cached, taskDefinition.cache.ttlMs);
|
|
424
|
+
entries.push({
|
|
425
|
+
id: taskId,
|
|
426
|
+
cacheEnabled: true,
|
|
427
|
+
predicted: fresh ? "cached" : "run",
|
|
428
|
+
cacheKey,
|
|
429
|
+
reason: fresh ? undefined : cached ? "stale or unusable cache entry" : "no cache entry"
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
fingerprints.set(taskId, null);
|
|
434
|
+
entries.push({
|
|
435
|
+
id: taskId,
|
|
436
|
+
cacheEnabled: taskDefinition.cache.enabled ?? false,
|
|
437
|
+
predicted: "unknown",
|
|
438
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { jobId: options.id, executionOrder: graph.executionOrder, entries };
|
|
443
|
+
}
|
|
300
444
|
export async function runSingleTask(pipeline, taskId, options = {}) {
|
|
301
445
|
const syntheticJobId = `task:${taskId}`;
|
|
302
446
|
const syntheticPipeline = {
|
|
@@ -312,16 +456,21 @@ async function runTask(pipeline, taskDefinition, options) {
|
|
|
312
456
|
const started = Date.now();
|
|
313
457
|
const startedAt = new Date().toISOString();
|
|
314
458
|
const metadata = {};
|
|
315
|
-
|
|
459
|
+
const taskLog = cappedBuffer(resolveMaxLogBytes(options.contextEnv));
|
|
316
460
|
let taskEnv;
|
|
461
|
+
let envSecretValues = [];
|
|
462
|
+
let forwardEnvKeys = [];
|
|
317
463
|
try {
|
|
318
|
-
|
|
464
|
+
const resolvedTaskEnv = buildTaskEnv(options.contextEnv, {
|
|
319
465
|
candidate: options.candidate,
|
|
320
466
|
envDefinitions: options.envDefinitions,
|
|
321
467
|
rootCwd: options.rootCwd,
|
|
322
468
|
source: options.source,
|
|
323
469
|
taskId: taskDefinition.id
|
|
324
470
|
});
|
|
471
|
+
taskEnv = resolvedTaskEnv.env;
|
|
472
|
+
envSecretValues = resolvedTaskEnv.secretValues;
|
|
473
|
+
forwardEnvKeys = [...resolvedTaskEnv.definedKeys, ...(taskDefinition.requires?.secrets ?? [])];
|
|
325
474
|
}
|
|
326
475
|
catch (error) {
|
|
327
476
|
const lastError = error instanceof Error ? error.message : String(error);
|
|
@@ -347,18 +496,25 @@ async function runTask(pipeline, taskDefinition, options) {
|
|
|
347
496
|
runId: options.runId,
|
|
348
497
|
source: options.source,
|
|
349
498
|
writeLog(message) {
|
|
350
|
-
|
|
499
|
+
taskLog.append(`${message}\n`);
|
|
351
500
|
}
|
|
352
501
|
});
|
|
353
|
-
const
|
|
354
|
-
|
|
502
|
+
const redactValues = [
|
|
503
|
+
...envSecretValues,
|
|
504
|
+
...(taskDefinition.requires?.secrets ?? [])
|
|
505
|
+
.map((secret) => taskEnv[secret])
|
|
506
|
+
.filter((value) => typeof value === "string" && value.length > 0)
|
|
507
|
+
];
|
|
508
|
+
const redactLog = (log) => redactKnownValues(log, redactValues);
|
|
509
|
+
const resolvedSteps = await resolveTaskSteps(pipeline.agents, taskDefinition.steps, context);
|
|
510
|
+
const { cacheKey, inputs: inputManifest } = await computeTaskCacheKeyDetailed(pipeline, taskDefinition, options.cwd, {
|
|
355
511
|
candidate: options.candidate,
|
|
356
512
|
dependencyFingerprints: options.dependencyFingerprints,
|
|
357
513
|
prepareCommands: (options.sourcePrepareCommands ?? []).map((command) => command.command),
|
|
358
514
|
source: options.source,
|
|
359
515
|
steps: resolvedSteps
|
|
360
516
|
});
|
|
361
|
-
if (taskDefinition.cache.enabled) {
|
|
517
|
+
if (taskDefinition.cache.enabled && !options.force) {
|
|
362
518
|
const cached = await readTaskCacheEntry(taskDefinition, options.store, cacheKey);
|
|
363
519
|
if (cached?.status === "passed") {
|
|
364
520
|
const cacheHit = await validateTaskCacheHit(taskDefinition, options.store, cacheKey, options.cwd, cached);
|
|
@@ -375,9 +531,15 @@ async function runTask(pipeline, taskDefinition, options) {
|
|
|
375
531
|
durationMs: Date.now() - started
|
|
376
532
|
};
|
|
377
533
|
await writeTaskLog(options.store, options.runId, taskDefinition.id, `[cache hit] ${cacheKey}\n`);
|
|
534
|
+
// A hit proves this input state passes: refresh the diff baseline, and
|
|
535
|
+
// backfill the digest manifest for entries created before 0.2.3.
|
|
536
|
+
if ((await readCacheInputManifest(options.store, cacheKey)) === null) {
|
|
537
|
+
await writeCacheInputManifest(options.store, cacheKey, inputManifest);
|
|
538
|
+
}
|
|
539
|
+
await writeTaskBaseline(options.store, taskDefinition.id, cacheKey);
|
|
378
540
|
return result;
|
|
379
541
|
}
|
|
380
|
-
|
|
542
|
+
taskLog.append(`[cache miss] ${cacheHit.reason}\n`);
|
|
381
543
|
}
|
|
382
544
|
}
|
|
383
545
|
let attempts = 0;
|
|
@@ -402,12 +564,36 @@ async function runTask(pipeline, taskDefinition, options) {
|
|
|
402
564
|
await runFunctionStep(step, context, taskDefinition.timeoutMs);
|
|
403
565
|
continue;
|
|
404
566
|
}
|
|
567
|
+
if (isAgentStep(step)) {
|
|
568
|
+
if (!isResolvedAgentStep(step)) {
|
|
569
|
+
throw new Error(`Agent step for task "${taskDefinition.id}" was not resolved.`);
|
|
570
|
+
}
|
|
571
|
+
const result = await runAgentStep(step, taskDefinition, {
|
|
572
|
+
executor: options.executor,
|
|
573
|
+
cwd: options.cwd,
|
|
574
|
+
env: taskEnv,
|
|
575
|
+
echo: options.echo,
|
|
576
|
+
redactValues,
|
|
577
|
+
forwardEnvKeys,
|
|
578
|
+
store: options.store,
|
|
579
|
+
runId: options.runId
|
|
580
|
+
});
|
|
581
|
+
taskLog.append(result.stdout);
|
|
582
|
+
taskLog.append(result.stderr);
|
|
583
|
+
if (result.timedOut) {
|
|
584
|
+
throw new Error(`Task "${taskDefinition.id}" timed out after ${taskDefinition.timeoutMs}ms.`);
|
|
585
|
+
}
|
|
586
|
+
if (result.code !== 0) {
|
|
587
|
+
throw new Error(`Agent step failed with exit code ${result.code} (profile "${step.use}", model "${step.model}").`);
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
405
591
|
if (!isShellCommand(step)) {
|
|
406
592
|
throw new Error(`Deferred shell step for task "${taskDefinition.id}" was not resolved.`);
|
|
407
593
|
}
|
|
408
|
-
const result = await runShellStep(step, taskDefinition, {
|
|
409
|
-
|
|
410
|
-
|
|
594
|
+
const result = await runShellStep(step, taskDefinition, { executor: options.executor, cwd: options.cwd, env: taskEnv, echo: options.echo, redactValues, forwardEnvKeys });
|
|
595
|
+
taskLog.append(result.stdout);
|
|
596
|
+
taskLog.append(result.stderr);
|
|
411
597
|
if (result.timedOut) {
|
|
412
598
|
throw new Error(`Task "${taskDefinition.id}" timed out after ${taskDefinition.timeoutMs}ms.`);
|
|
413
599
|
}
|
|
@@ -427,15 +613,21 @@ async function runTask(pipeline, taskDefinition, options) {
|
|
|
427
613
|
cacheHit: false,
|
|
428
614
|
metadata
|
|
429
615
|
};
|
|
430
|
-
await writeTaskLog(options.store, options.runId, taskDefinition.id,
|
|
616
|
+
await writeTaskLog(options.store, options.runId, taskDefinition.id, redactLog(taskLog.read()));
|
|
431
617
|
if (taskDefinition.cache.enabled) {
|
|
432
618
|
await writeTaskCacheEntry(taskDefinition, options.store, cacheKey, result, options.cwd);
|
|
619
|
+
await writeCacheInputManifest(options.store, cacheKey, inputManifest);
|
|
620
|
+
await writeTaskBaseline(options.store, taskDefinition.id, cacheKey);
|
|
433
621
|
}
|
|
434
622
|
return result;
|
|
435
623
|
}
|
|
436
624
|
catch (error) {
|
|
437
625
|
lastError = error instanceof Error ? error.message : String(error);
|
|
438
|
-
|
|
626
|
+
taskLog.append(`[attempt ${attempts}] ${lastError}\n`);
|
|
627
|
+
// Don't retry (or sleep through a retry delay) while shutting down;
|
|
628
|
+
// the failure is the shutdown itself, not a flaky task.
|
|
629
|
+
if (shutdownState)
|
|
630
|
+
break;
|
|
439
631
|
if (attempts < maxAttempts && taskDefinition.retry.delayMs) {
|
|
440
632
|
await delay(taskDefinition.retry.delayMs);
|
|
441
633
|
}
|
|
@@ -453,7 +645,111 @@ async function runTask(pipeline, taskDefinition, options) {
|
|
|
453
645
|
error: lastError,
|
|
454
646
|
metadata
|
|
455
647
|
};
|
|
456
|
-
|
|
648
|
+
const redactedLog = redactLog(taskLog.read());
|
|
649
|
+
await writeTaskLog(options.store, options.runId, taskDefinition.id, redactedLog);
|
|
650
|
+
try {
|
|
651
|
+
await writeFailureContextPack({
|
|
652
|
+
store: options.store,
|
|
653
|
+
runId: options.runId,
|
|
654
|
+
taskDefinition,
|
|
655
|
+
error: redactLog(lastError),
|
|
656
|
+
attempts,
|
|
657
|
+
cacheKey,
|
|
658
|
+
redactedLog,
|
|
659
|
+
inputManifest,
|
|
660
|
+
rootCwd: options.rootCwd
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// Packs are evidence, not state: failing to write one must not mask the task failure.
|
|
665
|
+
}
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
const CONTEXT_PACK_LOG_TAIL_BYTES = 4096;
|
|
669
|
+
/**
|
|
670
|
+
* ADR-0003: a bounded, machine-readable failure summary next to the run
|
|
671
|
+
* record — the failing task, a redacted log tail, the reproduction command,
|
|
672
|
+
* the input diff against the task's last passing cache entry (digests only,
|
|
673
|
+
* never contents), and the registered claims whose test titles appear in the
|
|
674
|
+
* log.
|
|
675
|
+
*/
|
|
676
|
+
async function writeFailureContextPack(options) {
|
|
677
|
+
let inputDiff = { baselineMissing: true };
|
|
678
|
+
const baseline = await readTaskBaseline(options.store, options.taskDefinition.id);
|
|
679
|
+
if (baseline) {
|
|
680
|
+
const baselineManifest = await readCacheInputManifest(options.store, baseline.cacheKey);
|
|
681
|
+
if (baselineManifest) {
|
|
682
|
+
inputDiff = {
|
|
683
|
+
baselineCacheKey: baseline.cacheKey,
|
|
684
|
+
baselineRecordedAt: baseline.recordedAt,
|
|
685
|
+
...diffInputManifests(baselineManifest, options.inputManifest)
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const claims = await matchRegisteredClaims(options.rootCwd, options.redactedLog);
|
|
690
|
+
const pack = {
|
|
691
|
+
schemaVersion: 1,
|
|
692
|
+
task: options.taskDefinition.id,
|
|
693
|
+
runId: options.runId,
|
|
694
|
+
status: "failed",
|
|
695
|
+
error: options.error,
|
|
696
|
+
attempts: options.attempts,
|
|
697
|
+
cacheKey: options.cacheKey,
|
|
698
|
+
reproduce: `async-pipeline run-task ${options.taskDefinition.id}`,
|
|
699
|
+
logTail: options.redactedLog.slice(-CONTEXT_PACK_LOG_TAIL_BYTES),
|
|
700
|
+
inputDiff,
|
|
701
|
+
...(claims.length > 0 ? { claims } : {})
|
|
702
|
+
};
|
|
703
|
+
await writeContextPack(options.store, options.runId, options.taskDefinition.id, pack);
|
|
704
|
+
}
|
|
705
|
+
/** When the project keeps a claims registry, name the promises this failure touches. */
|
|
706
|
+
async function matchRegisteredClaims(rootCwd, log) {
|
|
707
|
+
try {
|
|
708
|
+
const registry = JSON.parse(await readFile(join(rootCwd, "tests", "claims.json"), "utf8"));
|
|
709
|
+
const matched = [];
|
|
710
|
+
for (const claim of registry.claims ?? []) {
|
|
711
|
+
if (!claim.id || !Array.isArray(claim.tests))
|
|
712
|
+
continue;
|
|
713
|
+
if (claim.tests.some((title) => typeof title === "string" && title.length > 0 && log.includes(title))) {
|
|
714
|
+
matched.push(claim.id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return matched.sort();
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function runAgentStep(step, taskDefinition, options) {
|
|
724
|
+
// The prompt travels as a file redirected to the adapter's stdin: shell-safe
|
|
725
|
+
// for arbitrary prompt text, and the prompt becomes run evidence alongside
|
|
726
|
+
// the transcript. The adapter also sees profile/model/prompt-file env keys
|
|
727
|
+
// so wrapper scripts can stay generic.
|
|
728
|
+
const promptPath = await writeAgentPrompt(options.store, options.runId, taskDefinition.id, step.prompt);
|
|
729
|
+
const command = `${step.command.map((part) => shellEscape(part)).join(" ")} < ${shellEscape(promptPath)}`;
|
|
730
|
+
const env = {
|
|
731
|
+
...options.env,
|
|
732
|
+
ASYNC_PIPELINE_AGENT_PROFILE: step.use,
|
|
733
|
+
ASYNC_PIPELINE_AGENT_MODEL: step.model,
|
|
734
|
+
ASYNC_PIPELINE_AGENT_PROMPT_FILE: promptPath
|
|
735
|
+
};
|
|
736
|
+
const startedAt = new Date().toISOString();
|
|
737
|
+
const started = Date.now();
|
|
738
|
+
const result = await options.executor.runShell(command, {
|
|
739
|
+
cwd: options.cwd,
|
|
740
|
+
env,
|
|
741
|
+
task: taskDefinition,
|
|
742
|
+
timeoutMs: taskDefinition.timeoutMs,
|
|
743
|
+
echo: options.echo,
|
|
744
|
+
redactValues: options.redactValues,
|
|
745
|
+
forwardEnvKeys: options.forwardEnvKeys
|
|
746
|
+
});
|
|
747
|
+
const redact = (value) => redactKnownValues(value, options.redactValues ?? []);
|
|
748
|
+
const transcript = [
|
|
749
|
+
JSON.stringify({ type: "request", at: startedAt, task: taskDefinition.id, profile: step.use, model: step.model, prompt: redact(step.prompt) }),
|
|
750
|
+
JSON.stringify({ type: "response", at: new Date().toISOString(), durationMs: Date.now() - started, code: result.code, timedOut: result.timedOut ?? false, stdout: redact(result.stdout), stderr: redact(result.stderr) })
|
|
751
|
+
].join("\n");
|
|
752
|
+
await writeAgentTranscript(options.store, options.runId, taskDefinition.id, `${transcript}\n`);
|
|
457
753
|
return result;
|
|
458
754
|
}
|
|
459
755
|
async function runShellStep(step, taskDefinition, options) {
|
|
@@ -461,7 +757,10 @@ async function runShellStep(step, taskDefinition, options) {
|
|
|
461
757
|
cwd: options.cwd,
|
|
462
758
|
env: options.env,
|
|
463
759
|
task: taskDefinition,
|
|
464
|
-
timeoutMs: taskDefinition.timeoutMs
|
|
760
|
+
timeoutMs: taskDefinition.timeoutMs,
|
|
761
|
+
echo: options.echo,
|
|
762
|
+
redactValues: options.redactValues,
|
|
763
|
+
forwardEnvKeys: options.forwardEnvKeys
|
|
465
764
|
});
|
|
466
765
|
}
|
|
467
766
|
async function runSourcePrepare(source, options) {
|
|
@@ -471,24 +770,25 @@ async function runSourcePrepare(source, options) {
|
|
|
471
770
|
const startedAt = new Date().toISOString();
|
|
472
771
|
const taskId = `${source.id}:prepare`;
|
|
473
772
|
const sourceTaskContext = sourceContext(source);
|
|
474
|
-
|
|
773
|
+
const log = cappedBuffer(resolveMaxLogBytes(options.contextEnv));
|
|
774
|
+
const prepareEnv = buildTaskEnv(options.contextEnv, {
|
|
775
|
+
candidate: options.candidate,
|
|
776
|
+
rootCwd: options.rootCwd,
|
|
777
|
+
source: sourceTaskContext
|
|
778
|
+
});
|
|
475
779
|
const context = createTaskContext({ id: taskId }, {
|
|
476
780
|
candidate: options.candidate,
|
|
477
781
|
cwd: source.dir,
|
|
478
|
-
env:
|
|
479
|
-
candidate: options.candidate,
|
|
480
|
-
rootCwd: options.rootCwd,
|
|
481
|
-
source: sourceTaskContext
|
|
482
|
-
}),
|
|
782
|
+
env: prepareEnv.env,
|
|
483
783
|
metadata: {},
|
|
484
784
|
rootCwd: options.rootCwd,
|
|
485
785
|
runId: options.runId,
|
|
486
786
|
source: sourceTaskContext,
|
|
487
787
|
writeLog(message) {
|
|
488
|
-
log
|
|
788
|
+
log.append(`${message}\n`);
|
|
489
789
|
}
|
|
490
790
|
});
|
|
491
|
-
const steps = await resolveTaskSteps(source.definition.prepare, context);
|
|
791
|
+
const steps = await resolveTaskSteps({}, source.definition.prepare, context);
|
|
492
792
|
try {
|
|
493
793
|
for (const step of steps) {
|
|
494
794
|
if (typeof step === "function") {
|
|
@@ -500,15 +800,14 @@ async function runSourcePrepare(source, options) {
|
|
|
500
800
|
}
|
|
501
801
|
const result = await options.executor.runShell(step.command, {
|
|
502
802
|
cwd: source.dir,
|
|
503
|
-
env:
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
task: { id: taskId }
|
|
803
|
+
env: prepareEnv.env,
|
|
804
|
+
task: { id: taskId },
|
|
805
|
+
echo: options.echo,
|
|
806
|
+
redactValues: prepareEnv.secretValues,
|
|
807
|
+
forwardEnvKeys: prepareEnv.definedKeys
|
|
509
808
|
});
|
|
510
|
-
log
|
|
511
|
-
log
|
|
809
|
+
log.append(result.stdout);
|
|
810
|
+
log.append(result.stderr);
|
|
512
811
|
if (result.code !== 0) {
|
|
513
812
|
throw new Error(`Command failed with exit code ${result.code}: ${step.command}`);
|
|
514
813
|
}
|
|
@@ -522,7 +821,7 @@ async function runSourcePrepare(source, options) {
|
|
|
522
821
|
attempts: 1,
|
|
523
822
|
cacheHit: false
|
|
524
823
|
};
|
|
525
|
-
await writeTaskLog(options.store, options.runId, taskId, log);
|
|
824
|
+
await writeTaskLog(options.store, options.runId, taskId, log.read());
|
|
526
825
|
return result;
|
|
527
826
|
}
|
|
528
827
|
catch (error) {
|
|
@@ -536,8 +835,8 @@ async function runSourcePrepare(source, options) {
|
|
|
536
835
|
cacheHit: false,
|
|
537
836
|
error: error instanceof Error ? error.message : String(error)
|
|
538
837
|
};
|
|
539
|
-
log
|
|
540
|
-
await writeTaskLog(options.store, options.runId, taskId, log);
|
|
838
|
+
log.append(`[prepare] ${result.error}\n`);
|
|
839
|
+
await writeTaskLog(options.store, options.runId, taskId, log.read());
|
|
541
840
|
return result;
|
|
542
841
|
}
|
|
543
842
|
}
|
|
@@ -545,27 +844,31 @@ async function resolvePrepareCommands(source, options) {
|
|
|
545
844
|
const context = createTaskContext({ id: `${source.id}:prepare` }, {
|
|
546
845
|
candidate: options.candidate,
|
|
547
846
|
cwd: source.dir,
|
|
548
|
-
env: buildTaskEnv(options.
|
|
847
|
+
env: buildTaskEnv(options.contextEnv, {
|
|
549
848
|
candidate: options.candidate,
|
|
550
849
|
rootCwd: options.rootCwd,
|
|
551
850
|
source: sourceContext(source)
|
|
552
|
-
}),
|
|
851
|
+
}).env,
|
|
553
852
|
metadata: {},
|
|
554
853
|
rootCwd: options.rootCwd,
|
|
555
854
|
runId: options.runId,
|
|
556
855
|
source: sourceContext(source),
|
|
557
856
|
writeLog() { }
|
|
558
857
|
});
|
|
559
|
-
const steps = await resolveTaskSteps(source.definition.prepare, context);
|
|
858
|
+
const steps = await resolveTaskSteps({}, source.definition.prepare, context);
|
|
560
859
|
return steps.filter(isShellCommand);
|
|
561
860
|
}
|
|
562
|
-
async function resolveTaskSteps(steps, context) {
|
|
861
|
+
async function resolveTaskSteps(agents, steps, context) {
|
|
563
862
|
const resolved = [];
|
|
564
863
|
for (const step of steps) {
|
|
565
864
|
if (typeof step === "function" || step.kind === "shell") {
|
|
566
865
|
resolved.push(step);
|
|
567
866
|
continue;
|
|
568
867
|
}
|
|
868
|
+
if (step.kind === "agent") {
|
|
869
|
+
resolved.push(resolveAgentStep(agents, step, context));
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
569
872
|
const command = await step.command(context);
|
|
570
873
|
if (command.kind !== "shell") {
|
|
571
874
|
throw new Error(`Deferred shell step for task "${context.taskId}" must return sh\`...\`.`);
|
|
@@ -574,6 +877,33 @@ async function resolveTaskSteps(steps, context) {
|
|
|
574
877
|
}
|
|
575
878
|
return resolved;
|
|
576
879
|
}
|
|
880
|
+
/** Mirrors env.var(...) resolution in resolveEnvDefinitions: selector from the task env, optional default, optional value map. */
|
|
881
|
+
function resolveAgentValue(value, env, what, taskId) {
|
|
882
|
+
if (typeof value === "string")
|
|
883
|
+
return value;
|
|
884
|
+
const selector = env[value.name] ?? value.default;
|
|
885
|
+
if (selector === undefined || selector === "") {
|
|
886
|
+
throw new Error(`Required variable "${value.name}" for ${what} is not available for task "${taskId}".`);
|
|
887
|
+
}
|
|
888
|
+
if (value.values) {
|
|
889
|
+
const mapped = value.values[selector];
|
|
890
|
+
if (mapped === undefined) {
|
|
891
|
+
throw new Error(`Variable "${value.name}" value "${selector}" is not mapped for ${what} in task "${taskId}".`);
|
|
892
|
+
}
|
|
893
|
+
return mapped;
|
|
894
|
+
}
|
|
895
|
+
return selector;
|
|
896
|
+
}
|
|
897
|
+
function resolveAgentStep(agents, step, context) {
|
|
898
|
+
const use = resolveAgentValue(step.use, context.env, "the agent profile selection", context.taskId);
|
|
899
|
+
const profile = agents[use];
|
|
900
|
+
if (!profile) {
|
|
901
|
+
const known = Object.keys(agents).sort();
|
|
902
|
+
throw pipelineError("ASYNC_PIPELINE_AGENT_UNKNOWN", `Task "${context.taskId}" resolved agent profile "${use}", which is not declared in the pipeline's agents block.${known.length > 0 ? ` Known profiles: ${known.join(", ")}.` : " No agent profiles are declared."}`);
|
|
903
|
+
}
|
|
904
|
+
const model = resolveAgentValue(step.model ?? profile.model, context.env, `agent profile "${use}" model`, context.taskId);
|
|
905
|
+
return { kind: "agent", use, prompt: step.prompt, model, command: [...profile.command] };
|
|
906
|
+
}
|
|
577
907
|
function createTaskContext(taskDefinition, options) {
|
|
578
908
|
return {
|
|
579
909
|
taskId: taskDefinition.id,
|
|
@@ -595,10 +925,8 @@ function createTaskContext(taskDefinition, options) {
|
|
|
595
925
|
};
|
|
596
926
|
}
|
|
597
927
|
function buildTaskEnv(baseEnv, options) {
|
|
598
|
-
const resolvedEnv = resolveEnvDefinitions(options.envDefinitions ?? {}, baseEnv, options.taskId);
|
|
599
|
-
|
|
600
|
-
...baseEnv,
|
|
601
|
-
...resolvedEnv,
|
|
928
|
+
const { resolved: resolvedEnv, secretValues } = resolveEnvDefinitions(options.envDefinitions ?? {}, baseEnv, options.taskId);
|
|
929
|
+
const contextEnv = {
|
|
602
930
|
ASYNC_PIPELINE_ROOT_DIR: options.rootCwd,
|
|
603
931
|
ASYNC_PIPELINE_CANDIDATE_DIR: options.candidate.dir,
|
|
604
932
|
ASYNC_PIPELINE_CANDIDATE_FINGERPRINT: options.candidate.fingerprint,
|
|
@@ -607,9 +935,19 @@ function buildTaskEnv(baseEnv, options) {
|
|
|
607
935
|
ASYNC_PIPELINE_SOURCE_REF: options.source?.ref,
|
|
608
936
|
ASYNC_PIPELINE_SOURCE_COMMIT: options.source?.commit
|
|
609
937
|
};
|
|
938
|
+
return {
|
|
939
|
+
env: {
|
|
940
|
+
...baseEnv,
|
|
941
|
+
...resolvedEnv,
|
|
942
|
+
...contextEnv
|
|
943
|
+
},
|
|
944
|
+
secretValues,
|
|
945
|
+
definedKeys: [...Object.keys(resolvedEnv), ...Object.keys(contextEnv), "CI"]
|
|
946
|
+
};
|
|
610
947
|
}
|
|
611
948
|
function resolveEnvDefinitions(definitions, baseEnv, taskId = "unknown") {
|
|
612
949
|
const resolved = {};
|
|
950
|
+
const secretValues = [];
|
|
613
951
|
for (const [key, value] of Object.entries(definitions)) {
|
|
614
952
|
if (typeof value === "string") {
|
|
615
953
|
resolved[key] = value;
|
|
@@ -621,6 +959,7 @@ function resolveEnvDefinitions(definitions, baseEnv, taskId = "unknown") {
|
|
|
621
959
|
throw new Error(`Required secret "${value.name}" for env "${key}" is not available for task "${taskId}".`);
|
|
622
960
|
}
|
|
623
961
|
resolved[key] = secretValue;
|
|
962
|
+
secretValues.push(secretValue);
|
|
624
963
|
continue;
|
|
625
964
|
}
|
|
626
965
|
if (value.kind === "async-pipeline.env.var") {
|
|
@@ -641,7 +980,7 @@ function resolveEnvDefinitions(definitions, baseEnv, taskId = "unknown") {
|
|
|
641
980
|
continue;
|
|
642
981
|
}
|
|
643
982
|
}
|
|
644
|
-
return resolved;
|
|
983
|
+
return { resolved, secretValues };
|
|
645
984
|
}
|
|
646
985
|
function isShellCommand(step) {
|
|
647
986
|
return typeof step !== "function" && step.kind === "shell";
|
|
@@ -731,52 +1070,215 @@ async function runFunctionStep(step, context, timeoutMs) {
|
|
|
731
1070
|
clearTimeout(timeout);
|
|
732
1071
|
}
|
|
733
1072
|
}
|
|
1073
|
+
// Track live task processes so terminal signals terminate the whole run.
|
|
1074
|
+
// detached children live in their own process groups and would otherwise
|
|
1075
|
+
// survive Ctrl-C on the CLI.
|
|
1076
|
+
const activeKillers = new Set();
|
|
1077
|
+
const installedSignalForwarders = new Set();
|
|
1078
|
+
let shutdownState = null;
|
|
1079
|
+
const SHUTDOWN_ESCALATE_DELAY_MS = 500;
|
|
1080
|
+
const SHUTDOWN_EXIT_DEADLINE_MS = 10_000;
|
|
1081
|
+
/**
|
|
1082
|
+
* Abort the run: terminate every live task process group (SIGTERM-style
|
|
1083
|
+
* signal first, SIGKILL after a grace period), refuse to start new task
|
|
1084
|
+
* processes, and hard-exit if the run has not finalized its execution
|
|
1085
|
+
* record by the deadline. Idempotent; the first caller wins.
|
|
1086
|
+
*/
|
|
1087
|
+
export function beginShutdown(signal, exitCode) {
|
|
1088
|
+
if (shutdownState)
|
|
1089
|
+
return;
|
|
1090
|
+
shutdownState = { signal, exitCode };
|
|
1091
|
+
for (const kill of activeKillers)
|
|
1092
|
+
kill(signal);
|
|
1093
|
+
const escalate = setTimeout(() => {
|
|
1094
|
+
for (const kill of activeKillers)
|
|
1095
|
+
kill("SIGKILL");
|
|
1096
|
+
}, SHUTDOWN_ESCALATE_DELAY_MS);
|
|
1097
|
+
escalate.unref();
|
|
1098
|
+
const deadline = setTimeout(() => process.exit(exitCode), SHUTDOWN_EXIT_DEADLINE_MS);
|
|
1099
|
+
deadline.unref();
|
|
1100
|
+
}
|
|
1101
|
+
/** Exit code requested by an in-progress shutdown, if any. */
|
|
1102
|
+
export function shutdownExitCode() {
|
|
1103
|
+
return shutdownState ? shutdownState.exitCode : null;
|
|
1104
|
+
}
|
|
1105
|
+
function ensureSignalForwarding() {
|
|
1106
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1107
|
+
if (installedSignalForwarders.has(signal))
|
|
1108
|
+
continue;
|
|
1109
|
+
installedSignalForwarders.add(signal);
|
|
1110
|
+
process.once(signal, () => {
|
|
1111
|
+
// 128 + signal number, the conventional interrupted-exit status.
|
|
1112
|
+
const exitCode = signal === "SIGINT" ? 130 : 143;
|
|
1113
|
+
beginShutdown(signal, exitCode);
|
|
1114
|
+
process.exitCode = exitCode;
|
|
1115
|
+
// Stay alive so the dead tasks surface as failures and the execution
|
|
1116
|
+
// record is finalized instead of being left "running". A second
|
|
1117
|
+
// signal or the shutdown deadline exits immediately.
|
|
1118
|
+
process.once(signal, () => process.exit(exitCode));
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const DEFAULT_MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
1123
|
+
function resolveMaxLogBytes(env) {
|
|
1124
|
+
const raw = env.ASYNC_PIPELINE_MAX_LOG_BYTES ?? process.env.ASYNC_PIPELINE_MAX_LOG_BYTES;
|
|
1125
|
+
if (raw === undefined || raw === "")
|
|
1126
|
+
return DEFAULT_MAX_LOG_BYTES;
|
|
1127
|
+
const parsed = Number(raw);
|
|
1128
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
1129
|
+
return DEFAULT_MAX_LOG_BYTES;
|
|
1130
|
+
return parsed === 0 ? Number.POSITIVE_INFINITY : Math.max(parsed, 4096);
|
|
1131
|
+
}
|
|
1132
|
+
/** Keeps the byte-accurate tail of a stream bounded so huge task output cannot exhaust memory. */
|
|
1133
|
+
function cappedBuffer(maxBytes) {
|
|
1134
|
+
const chunks = [];
|
|
1135
|
+
let byteLength = 0;
|
|
1136
|
+
let dropped = 0;
|
|
1137
|
+
return {
|
|
1138
|
+
append(chunk) {
|
|
1139
|
+
const buffer = Buffer.from(chunk, "utf8");
|
|
1140
|
+
chunks.push(buffer);
|
|
1141
|
+
byteLength += buffer.byteLength;
|
|
1142
|
+
while (byteLength > maxBytes) {
|
|
1143
|
+
const head = chunks[0];
|
|
1144
|
+
if (!head)
|
|
1145
|
+
break;
|
|
1146
|
+
const excess = byteLength - maxBytes;
|
|
1147
|
+
if (head.byteLength <= excess) {
|
|
1148
|
+
chunks.shift();
|
|
1149
|
+
byteLength -= head.byteLength;
|
|
1150
|
+
dropped += head.byteLength;
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
chunks[0] = head.subarray(excess);
|
|
1154
|
+
byteLength -= excess;
|
|
1155
|
+
dropped += excess;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
},
|
|
1159
|
+
read() {
|
|
1160
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
1161
|
+
if (dropped === 0)
|
|
1162
|
+
return text;
|
|
1163
|
+
return `[async-pipeline] output truncated: dropped ${dropped} leading bytes (ASYNC_PIPELINE_MAX_LOG_BYTES).\n${text}`;
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
734
1167
|
function runProcess(command, options) {
|
|
735
1168
|
return new Promise((resolve) => {
|
|
1169
|
+
if (shutdownState) {
|
|
1170
|
+
// The run is shutting down (signal or closed output pipe): fail fast
|
|
1171
|
+
// instead of spawning processes that would immediately be killed.
|
|
1172
|
+
resolve({ code: 1, stdout: "", stderr: `[interrupted] Run is shutting down (${shutdownState.signal}); not starting: ${options.label ?? command}\n` });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
// detached puts the shell in its own process group on POSIX so a timeout
|
|
1176
|
+
// can terminate the whole tree, not only the wrapping shell.
|
|
1177
|
+
const detached = process.platform !== "win32";
|
|
736
1178
|
const child = spawn(command, {
|
|
737
1179
|
cwd: options.cwd,
|
|
738
1180
|
env: options.env,
|
|
739
1181
|
shell: true,
|
|
1182
|
+
detached,
|
|
740
1183
|
stdio: ["ignore", "pipe", "pipe"]
|
|
741
1184
|
});
|
|
742
|
-
|
|
743
|
-
|
|
1185
|
+
const maxLogBytes = resolveMaxLogBytes(options.env);
|
|
1186
|
+
const stdout = cappedBuffer(maxLogBytes);
|
|
1187
|
+
const stderr = cappedBuffer(maxLogBytes);
|
|
744
1188
|
let timedOut = false;
|
|
745
1189
|
let timeout;
|
|
746
1190
|
let forceKillTimeout;
|
|
1191
|
+
const redactValues = options.redactValues ?? [];
|
|
1192
|
+
const echoStdout = createEchoWriter(process.stdout, options.label, redactValues);
|
|
1193
|
+
const echoStderr = createEchoWriter(process.stderr, options.label, redactValues);
|
|
1194
|
+
const killTree = (signal) => {
|
|
1195
|
+
if (detached && child.pid) {
|
|
1196
|
+
try {
|
|
1197
|
+
process.kill(-child.pid, signal);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
catch {
|
|
1201
|
+
// Fall through to killing the direct child.
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
child.kill(signal);
|
|
1205
|
+
};
|
|
1206
|
+
activeKillers.add(killTree);
|
|
1207
|
+
ensureSignalForwarding();
|
|
747
1208
|
if (options.timeoutMs) {
|
|
748
1209
|
timeout = setTimeout(() => {
|
|
749
1210
|
timedOut = true;
|
|
750
|
-
|
|
751
|
-
forceKillTimeout = setTimeout(() =>
|
|
1211
|
+
killTree("SIGTERM");
|
|
1212
|
+
forceKillTimeout = setTimeout(() => killTree("SIGKILL"), 500);
|
|
752
1213
|
}, options.timeoutMs);
|
|
753
1214
|
}
|
|
754
1215
|
child.stdout.setEncoding("utf8");
|
|
755
1216
|
child.stderr.setEncoding("utf8");
|
|
756
1217
|
child.stdout.on("data", (chunk) => {
|
|
757
|
-
stdout
|
|
1218
|
+
stdout.append(chunk);
|
|
758
1219
|
if (options.echo !== false)
|
|
759
|
-
|
|
1220
|
+
echoStdout.write(chunk);
|
|
760
1221
|
});
|
|
761
1222
|
child.stderr.on("data", (chunk) => {
|
|
762
|
-
stderr
|
|
1223
|
+
stderr.append(chunk);
|
|
763
1224
|
if (options.echo !== false)
|
|
764
|
-
|
|
1225
|
+
echoStderr.write(chunk);
|
|
765
1226
|
});
|
|
766
1227
|
child.on("close", (code) => {
|
|
1228
|
+
activeKillers.delete(killTree);
|
|
767
1229
|
if (timeout)
|
|
768
1230
|
clearTimeout(timeout);
|
|
769
1231
|
if (forceKillTimeout)
|
|
770
1232
|
clearTimeout(forceKillTimeout);
|
|
1233
|
+
if (options.echo !== false) {
|
|
1234
|
+
echoStdout.flush();
|
|
1235
|
+
echoStderr.flush();
|
|
1236
|
+
}
|
|
1237
|
+
const finalStdout = redactKnownValues(stdout.read(), redactValues);
|
|
1238
|
+
const finalStderr = redactKnownValues(stderr.read(), redactValues);
|
|
771
1239
|
if (timedOut) {
|
|
772
1240
|
const timeoutMessage = `[timeout] Command timed out after ${options.timeoutMs}ms.\n`;
|
|
773
|
-
resolve({ code: 124, stdout, stderr: `${
|
|
1241
|
+
resolve({ code: 124, stdout: finalStdout, stderr: `${finalStderr}${timeoutMessage}`, timedOut: true });
|
|
774
1242
|
return;
|
|
775
1243
|
}
|
|
776
|
-
resolve({ code: code ?? 1, stdout, stderr });
|
|
1244
|
+
resolve({ code: code ?? 1, stdout: finalStdout, stderr: finalStderr });
|
|
777
1245
|
});
|
|
778
1246
|
});
|
|
779
1247
|
}
|
|
1248
|
+
function createEchoWriter(stream, label, redactValues) {
|
|
1249
|
+
let buffered = "";
|
|
1250
|
+
const prefix = label ? `[${label}] ` : "";
|
|
1251
|
+
const writeLine = (line) => {
|
|
1252
|
+
stream.write(`${prefix}${redactKnownValues(line, redactValues)}\n`);
|
|
1253
|
+
};
|
|
1254
|
+
return {
|
|
1255
|
+
write(chunk) {
|
|
1256
|
+
buffered += chunk;
|
|
1257
|
+
const lastNewline = buffered.lastIndexOf("\n");
|
|
1258
|
+
if (lastNewline < 0)
|
|
1259
|
+
return;
|
|
1260
|
+
for (const line of buffered.slice(0, lastNewline).split("\n"))
|
|
1261
|
+
writeLine(line);
|
|
1262
|
+
buffered = buffered.slice(lastNewline + 1);
|
|
1263
|
+
},
|
|
1264
|
+
flush() {
|
|
1265
|
+
if (!buffered)
|
|
1266
|
+
return;
|
|
1267
|
+
writeLine(buffered);
|
|
1268
|
+
buffered = "";
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
const MIN_REDACTED_VALUE_LENGTH = 4;
|
|
1273
|
+
function redactKnownValues(output, values) {
|
|
1274
|
+
let redacted = output;
|
|
1275
|
+
for (const value of [...new Set(values)].sort((left, right) => right.length - left.length)) {
|
|
1276
|
+
if (!value || value.length < MIN_REDACTED_VALUE_LENGTH)
|
|
1277
|
+
continue;
|
|
1278
|
+
redacted = redacted.split(value).join("[redacted]");
|
|
1279
|
+
}
|
|
1280
|
+
return redacted;
|
|
1281
|
+
}
|
|
780
1282
|
function matchingAction(policy, argv) {
|
|
781
1283
|
for (const rule of policy.rules) {
|
|
782
1284
|
if (matchesCommandRule(rule, argv))
|
|
@@ -806,11 +1308,19 @@ function commandStatus(action) {
|
|
|
806
1308
|
return "approval-required";
|
|
807
1309
|
return "allowed";
|
|
808
1310
|
}
|
|
809
|
-
async function runCommandAction(action, next) {
|
|
1311
|
+
async function runCommandAction(action, invocation, next) {
|
|
810
1312
|
if (action.kind === "async-pipeline.command.allow")
|
|
811
1313
|
return next();
|
|
812
|
-
if (action.kind === "async-pipeline.command.requireEnvironment")
|
|
813
|
-
|
|
1314
|
+
if (action.kind === "async-pipeline.command.requireEnvironment") {
|
|
1315
|
+
const current = invocation.env.ASYNC_PIPELINE_ENVIRONMENT;
|
|
1316
|
+
if (current === action.name)
|
|
1317
|
+
return next();
|
|
1318
|
+
return {
|
|
1319
|
+
code: 1,
|
|
1320
|
+
stdout: "",
|
|
1321
|
+
stderr: `Command requires environment "${action.name}" (current: ${current ? `"${current}"` : "unset"}). Set ASYNC_PIPELINE_ENVIRONMENT to allow it.\n`
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
814
1324
|
if (action.kind === "async-pipeline.command.mock") {
|
|
815
1325
|
return {
|
|
816
1326
|
code: action.code ?? 0,
|