@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -0
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +5 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/internal/core/index.d.ts +77 -41
  8. package/dist/internal/core/index.d.ts.map +1 -1
  9. package/dist/internal/core/index.js +150 -26
  10. package/dist/internal/core/index.js.map +1 -1
  11. package/dist/internal/lima/index.d.ts +1 -1
  12. package/dist/internal/lima/index.d.ts.map +1 -1
  13. package/dist/internal/lima/index.js +1 -1
  14. package/dist/internal/lima/index.js.map +1 -1
  15. package/dist/internal/node/cli.d.ts +5 -2
  16. package/dist/internal/node/cli.d.ts.map +1 -1
  17. package/dist/internal/node/cli.js +396 -89
  18. package/dist/internal/node/cli.js.map +1 -1
  19. package/dist/internal/node/doctor.d.ts +1 -1
  20. package/dist/internal/node/doctor.d.ts.map +1 -1
  21. package/dist/internal/node/doctor.js +50 -1
  22. package/dist/internal/node/doctor.js.map +1 -1
  23. package/dist/internal/node/github.d.ts +2 -0
  24. package/dist/internal/node/github.d.ts.map +1 -1
  25. package/dist/internal/node/github.js +59 -11
  26. package/dist/internal/node/github.js.map +1 -1
  27. package/dist/internal/node/index.d.ts +1 -0
  28. package/dist/internal/node/index.d.ts.map +1 -1
  29. package/dist/internal/node/index.js +1 -0
  30. package/dist/internal/node/index.js.map +1 -1
  31. package/dist/internal/node/loader.d.ts.map +1 -1
  32. package/dist/internal/node/loader.js +4 -0
  33. package/dist/internal/node/loader.js.map +1 -1
  34. package/dist/internal/node/mcp.d.ts +16 -0
  35. package/dist/internal/node/mcp.d.ts.map +1 -0
  36. package/dist/internal/node/mcp.js +243 -0
  37. package/dist/internal/node/mcp.js.map +1 -0
  38. package/dist/internal/node/runner.d.ts +64 -49
  39. package/dist/internal/node/runner.d.ts.map +1 -1
  40. package/dist/internal/node/runner.js +648 -138
  41. package/dist/internal/node/runner.js.map +1 -1
  42. package/dist/internal/node/sources.d.ts +3 -0
  43. package/dist/internal/node/sources.d.ts.map +1 -1
  44. package/dist/internal/node/sources.js +24 -7
  45. package/dist/internal/node/sources.js.map +1 -1
  46. package/dist/internal/node/store.d.ts +86 -0
  47. package/dist/internal/node/store.d.ts.map +1 -1
  48. package/dist/internal/node/store.js +322 -24
  49. package/dist/internal/node/store.js.map +1 -1
  50. 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
- ...Object.keys(env).filter((key) => env[key] !== undefined).flatMap((key) => ["-e", shellEscape(key)]),
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
- export function hostWorkspace(options = {}) {
80
- return {
81
- cwd: options.cwd ?? process.cwd(),
82
- env: options.env ?? process.env,
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: options.executor ?? new HostCommandExecutor(),
85
- commands: options.commands
94
+ executor: target.executor ?? new HostCommandExecutor(),
95
+ commands: target.commands
86
96
  };
87
- }
88
- export function dockerWorkspace(options) {
89
- const cwd = options.cwd ?? process.cwd();
90
- const workdir = options.workdir ?? "/workspace";
91
- return hostWorkspace({
92
- cwd,
93
- env: options.env,
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: options.image,
113
+ image: definition.image,
96
114
  hostCwd: cwd,
97
115
  workdir,
98
- volumes: options.volumes ?? [{ source: cwd, target: workdir }]
99
- }),
100
- commands: options.commands
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
- const workspace = options.workspace ?? hostWorkspace();
148
- const store = await createStore(workspace.cwd);
149
- const plan = await createRunPlan(pipeline, workspace.cwd, store);
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: workspace.cwd,
179
+ cwd: context.cwd,
180
+ pid: process.pid,
156
181
  startedAt: new Date().toISOString(),
157
182
  status: "running",
158
- mode: options.mode ?? (workspace.env.CI ? "ci" : "manual"),
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: workspace.executor,
203
- rootCwd: workspace.cwd,
227
+ executor: context.executor,
228
+ rootCwd: context.cwd,
204
229
  runId: record.id,
205
- workspaceEnv: workspace.env,
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 || workspace.cwd,
237
- executor: workspace.executor,
238
- rootCwd: workspace.cwd,
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
- workspaceEnv: workspace.env,
268
+ contextEnv: context.env,
243
269
  sourcePrepareCommands: taskSource ? await resolvePrepareCommands(taskSource, {
244
270
  candidate: plan.candidate,
245
- rootCwd: workspace.cwd,
271
+ rootCwd: context.cwd,
246
272
  runId: record.id,
247
- workspaceEnv: workspace.env
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
- while (ready.length > 0 || running.size > 0) {
259
- while (!failed && ready.length > 0 && running.size < concurrency) {
260
- const taskId = ready.shift();
261
- if (!taskId)
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.set(taskId, runScheduledTask(taskId));
264
- }
265
- if (running.size === 0)
266
- break;
267
- const completed = await Promise.race(running.values());
268
- running.delete(completed.taskId);
269
- for (const entry of completed.results) {
270
- recordedResults.set(entry.result.id, entry);
271
- }
272
- if (completed.taskResult) {
273
- taskFingerprints.set(completed.taskId, completed.taskResult.cacheKey ?? `${completed.taskId}:${completed.taskResult.status}`);
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
- ready.sort((left, right) => (graphIndex.get(left) ?? 0) - (graphIndex.get(right) ?? 0));
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
- let combinedLog = "";
459
+ const taskLog = cappedBuffer(resolveMaxLogBytes(options.contextEnv));
316
460
  let taskEnv;
461
+ let envSecretValues = [];
462
+ let forwardEnvKeys = [];
317
463
  try {
318
- taskEnv = buildTaskEnv(options.workspaceEnv, {
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
- combinedLog += `${message}\n`;
499
+ taskLog.append(`${message}\n`);
351
500
  }
352
501
  });
353
- const resolvedSteps = await resolveTaskSteps(taskDefinition.steps, context);
354
- const cacheKey = await computeTaskCacheKey(pipeline, taskDefinition, options.cwd, {
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
- combinedLog += `[cache miss] ${cacheHit.reason}\n`;
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, { ...options, env: taskEnv });
409
- combinedLog += result.stdout;
410
- combinedLog += result.stderr;
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, combinedLog);
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
- combinedLog += `[attempt ${attempts}] ${lastError}\n`;
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
- await writeTaskLog(options.store, options.runId, taskDefinition.id, combinedLog);
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
- let log = "";
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: buildTaskEnv(options.workspaceEnv, {
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 += `${message}\n`;
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: buildTaskEnv(options.workspaceEnv, {
504
- candidate: options.candidate,
505
- rootCwd: options.rootCwd,
506
- source: sourceTaskContext
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 += result.stdout;
511
- log += result.stderr;
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 += `[prepare] ${result.error}\n`;
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.workspaceEnv, {
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
- return {
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
- let stdout = "";
743
- let stderr = "";
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
- child.kill("SIGTERM");
751
- forceKillTimeout = setTimeout(() => child.kill("SIGKILL"), 500);
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 += chunk;
1218
+ stdout.append(chunk);
758
1219
  if (options.echo !== false)
759
- process.stdout.write(chunk);
1220
+ echoStdout.write(chunk);
760
1221
  });
761
1222
  child.stderr.on("data", (chunk) => {
762
- stderr += chunk;
1223
+ stderr.append(chunk);
763
1224
  if (options.echo !== false)
764
- process.stderr.write(chunk);
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: `${stderr}${timeoutMessage}`, timedOut: true });
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
- return next();
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,