@kici-dev/agent 0.1.22 → 0.1.24

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.
@@ -7,10 +7,10 @@ import os, { homedir, tmpdir } from "node:os";
7
7
  import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { $ } from "zx";
9
9
  import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
10
- import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, TimeoutReason } from "@kici-dev/engine";
11
- import { buildKiciApi, buildNeedsContext, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
10
+ import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, StepConcurrencyKind, TimeoutReason } from "@kici-dev/engine";
11
+ import { buildKiciApi, buildNeedsContext, createStepSecrets, evaluateRules, isDynamicJobFn, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
12
12
  import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
13
- import { sha256File as sha256File$1 } from "@kici-dev/core";
13
+ import { computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
14
14
  import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
15
15
  import { IN_TOTO_PAYLOAD_TYPE, KICI_PROVENANCE_AUDIENCE, KICI_PROVENANCE_BUNDLE_MEDIA_TYPE } from "@kici-dev/engine/provenance/bundle";
16
16
  import { IN_TOTO_STATEMENT_TYPE, KICI_WORKFLOW_BUILD_TYPE, SLSA_PROVENANCE_PREDICATE_TYPE } from "@kici-dev/engine/provenance/schema";
@@ -23,6 +23,7 @@ import { createGunzip } from "node:zlib";
23
23
  import { fileURLToPath, pathToFileURL } from "node:url";
24
24
  import { c, x } from "tar";
25
25
  import { runIdempotentStep } from "@kici-dev/core/idempotency";
26
+ import { AsyncLocalStorage } from "node:async_hooks";
26
27
  import { execFile } from "node:child_process";
27
28
  import { promisify } from "node:util";
28
29
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
@@ -689,29 +690,34 @@ function createCacheApi(workDir, transport, roots) {
689
690
  }
690
691
  };
691
692
  }
692
- //#endregion
693
- //#region src/execution/cache/cache-phase.ts
694
- /**
695
- * Declarative cache phase (sandbox-side).
696
- *
697
- * Restores a list of {@link CacheSpec} before the work that depends on them
698
- * (a job before its steps, or a step before its `run`) and saves them after
699
- * (on an exact-key miss). Each operation surfaces as a `cache:restore` /
700
- * `cache:save` pseudo-step a `step.start` + `step.complete` IPC pair whose
701
- * `step_type` comes from {@link CacheStepType} — exactly mirroring how hooks
702
- * render as `hook:*` pseudo-steps. The `step.complete` `data` carries the
703
- * {@link CacheOutcome} (plus key / matchedKey / bytes) so the agent can feed a
704
- * `run.event` and the dashboard can render hit/miss/saved inline.
705
- */
693
+ /** Block size reserved per owner for cache pseudo-step indices. */
694
+ const CACHE_INDEX_BLOCK = 1e3;
695
+ /**
696
+ * Build the cache pseudo-step index allocator. Each owner (a real step index, or
697
+ * {@link JOB_CACHE_OWNER}) gets its own disjoint block of {@link CACHE_INDEX_BLOCK}
698
+ * indices, all above every real-step and hook index (`stepCount * 3 + 100`). A
699
+ * step's two-or-more cache pseudo-steps are a pure function of its own owner
700
+ * index, so concurrent children never collide. Under sequential execution the
701
+ * emitted indices stay above all real/hook indices exactly as before.
702
+ */
703
+ function createCacheStepIndexAllocator(stepCount) {
704
+ const cacheBase = stepCount * 3 + 100;
705
+ const counters = /* @__PURE__ */ new Map();
706
+ return (ownerStepIndex) => {
707
+ const n = counters.get(ownerStepIndex) ?? 0;
708
+ counters.set(ownerStepIndex, n + 1);
709
+ return cacheBase + (ownerStepIndex + 1) * CACHE_INDEX_BLOCK + n;
710
+ };
711
+ }
706
712
  /**
707
713
  * Restore every spec, surfacing each as a `cache:restore` pseudo-step. Returns
708
714
  * a map keyed by spec key recording whether the EXACT key hit (so the save
709
715
  * phase can skip a redundant save of an entry that already exists).
710
716
  */
711
- async function restoreCacheSpecs(specs, deps) {
717
+ async function restoreCacheSpecs(specs, deps, ownerStepIndex) {
712
718
  const results = /* @__PURE__ */ new Map();
713
719
  for (const spec of specs) {
714
- const stepIndex = deps.nextStepIndex();
720
+ const stepIndex = deps.nextStepIndex(ownerStepIndex);
715
721
  deps.sendIpc({
716
722
  type: "step.start",
717
723
  stepIndex,
@@ -761,10 +767,10 @@ async function restoreCacheSpecs(specs, deps) {
761
767
  * whose restore matched a different key via a `restoreKeys` prefix is still
762
768
  * saved under its exact key.
763
769
  */
764
- async function saveCacheSpecs(specs, restoreResults, deps) {
770
+ async function saveCacheSpecs(specs, restoreResults, deps, ownerStepIndex) {
765
771
  for (const spec of specs) {
766
772
  if (restoreResults.get(spec.key)?.matchedKey === spec.key) continue;
767
- const stepIndex = deps.nextStepIndex();
773
+ const stepIndex = deps.nextStepIndex(ownerStepIndex);
768
774
  deps.sendIpc({
769
775
  type: "step.start",
770
776
  stepIndex,
@@ -1106,17 +1112,160 @@ initZx();
1106
1112
  * @param event - Event payload from the dispatch message
1107
1113
  * @param changedFiles - List of files changed in this event
1108
1114
  * @param env - Merged environment variables
1115
+ * @param dispatchInputs - Operator dispatch inputs (`ctx.dispatchInputs`)
1116
+ * @param fanout - Fan-out position (`ctx.fanout`); undefined on a non-fan-out job
1109
1117
  */
1110
- function createRuleContext(event, changedFiles = [], env = {}) {
1118
+ function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs = {}, fanout) {
1111
1119
  return {
1112
1120
  event,
1113
1121
  changedFiles,
1114
1122
  env,
1123
+ dispatchInputs,
1124
+ ...fanout && { fanout },
1115
1125
  $
1116
1126
  };
1117
1127
  }
1118
1128
  //#endregion
1129
+ //#region src/execution/sandbox/parallel-scheduler.ts
1130
+ /**
1131
+ * Concurrency-aware scheduler for `parallel()` step groups.
1132
+ *
1133
+ * A parallel group's children each run as their own observable step (own logs,
1134
+ * status, timing, retry, cache, hooks — all task-scoped by the Phase 0 per-task
1135
+ * isolation) through the same `runStepIteration` machinery the sequential loop
1136
+ * uses. Children launch behind a `maxParallel` window (queued children report
1137
+ * `pending`); the group joins at a barrier. On the first non-`continueOnError`
1138
+ * child failure when `failFast`, every in-flight sibling's per-task abort
1139
+ * controller is fired so its step race rejects and it is reported `cancelled`
1140
+ * (which is NOT a failure).
1141
+ */
1142
+ /**
1143
+ * Wrap `sendIpc` so a child's own `step.start` / `step.complete` messages carry
1144
+ * the parallel-child concurrency role + the group id. Cache/secret pseudo-step
1145
+ * messages (different stepIndex) pass through untouched.
1146
+ */
1147
+ function stampChildSend(sendIpc, childStepIndex, groupId) {
1148
+ return (msg) => {
1149
+ if ((msg.type === "step.start" || msg.type === "step.complete") && msg.stepIndex === childStepIndex) {
1150
+ sendIpc({
1151
+ ...msg,
1152
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1153
+ groupId
1154
+ });
1155
+ return;
1156
+ }
1157
+ sendIpc(msg);
1158
+ };
1159
+ }
1160
+ /** Announce a child queued behind the `maxParallel` window as `pending`. */
1161
+ function emitPending(opts, child, groupId) {
1162
+ opts.sendIpc({
1163
+ type: "step.start",
1164
+ stepIndex: child.stepIndex,
1165
+ stepName: child.step.name,
1166
+ state: "pending",
1167
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1168
+ groupId
1169
+ });
1170
+ }
1171
+ /** Mark a child that never launched (fail-fast already tripped) as `cancelled`. */
1172
+ function emitCancelledSkip(opts, child, groupId) {
1173
+ opts.sendIpc({
1174
+ type: "step.start",
1175
+ stepIndex: child.stepIndex,
1176
+ stepName: child.step.name,
1177
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1178
+ groupId
1179
+ });
1180
+ opts.sendIpc({
1181
+ type: "step.complete",
1182
+ stepIndex: child.stepIndex,
1183
+ status: "cancelled",
1184
+ durationMs: 0,
1185
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1186
+ groupId
1187
+ });
1188
+ return {
1189
+ name: child.step.name,
1190
+ stepIndex: child.stepIndex,
1191
+ status: "cancelled",
1192
+ durationMs: 0
1193
+ };
1194
+ }
1195
+ /**
1196
+ * Run a parallel group: launch children with a bounded-concurrency window, join
1197
+ * at a barrier, and fail-fast-cancel in-flight siblings on the first hard
1198
+ * failure.
1199
+ */
1200
+ async function runParallelGroup(node, opts) {
1201
+ const { children, failFast, groupId } = node;
1202
+ const limit = node.maxParallel && node.maxParallel > 0 ? node.maxParallel : children.length;
1203
+ const results = new Array(children.length);
1204
+ const inFlight = /* @__PURE__ */ new Set();
1205
+ let failed = false;
1206
+ let failedStepName;
1207
+ for (let i = limit; i < children.length; i++) emitPending(opts, children[i], groupId);
1208
+ let cursor = 0;
1209
+ const worker = async () => {
1210
+ for (;;) {
1211
+ const idx = cursor++;
1212
+ if (idx >= children.length) return;
1213
+ const child = children[idx];
1214
+ if (failFast && failed) {
1215
+ results[idx] = emitCancelledSkip(opts, child, groupId);
1216
+ continue;
1217
+ }
1218
+ const childOpts = {
1219
+ ...opts,
1220
+ sendIpc: stampChildSend(opts.sendIpc, child.stepIndex, groupId)
1221
+ };
1222
+ inFlight.add(child.stepIndex);
1223
+ try {
1224
+ const outcome = await runStepIteration(child.step, child.stepIndex, childOpts);
1225
+ results[idx] = outcome.result;
1226
+ if (outcome.shouldBreak) {
1227
+ if (!failed) {
1228
+ failed = true;
1229
+ failedStepName = outcome.failedStepName ?? child.step.name;
1230
+ }
1231
+ if (failFast) {
1232
+ for (const sibling of inFlight) if (sibling !== child.stepIndex) opts.abortStep?.(sibling);
1233
+ }
1234
+ }
1235
+ } finally {
1236
+ inFlight.delete(child.stepIndex);
1237
+ }
1238
+ }
1239
+ };
1240
+ await Promise.all(Array.from({ length: Math.min(limit, children.length) }, () => worker()));
1241
+ return {
1242
+ failed,
1243
+ failedStepName,
1244
+ results
1245
+ };
1246
+ }
1247
+ //#endregion
1119
1248
  //#region src/execution/sandbox/step-loop.ts
1249
+ /**
1250
+ * Thrown inside the step race when a step's own per-task abort controller fires
1251
+ * (parallel fail-fast cancels an in-flight sibling). Distinguished from a
1252
+ * timeout/job-deadline reject so the loop reports the step as `cancelled`
1253
+ * (which is NOT a failure) rather than `failed`.
1254
+ */
1255
+ var StepCancelledError = class StepCancelledError extends Error {
1256
+ name = "StepCancelledError";
1257
+ constructor(stepName) {
1258
+ super(`Step '${stepName}' was cancelled by parallel fail-fast`);
1259
+ Object.setPrototypeOf(this, StepCancelledError.prototype);
1260
+ }
1261
+ };
1262
+ /**
1263
+ * Resolve the capture-scope wrapper from options, falling back to a direct call
1264
+ * when no capture wiring is present (unit harnesses).
1265
+ */
1266
+ function captureWrap(opts) {
1267
+ return opts.runWithStepCapture ?? ((_stepIndex, fn) => fn());
1268
+ }
1120
1269
  /** Result of a rejected drift gate: the run was declined by a reviewer. */
1121
1270
  var DriftGateRejectedError = class extends Error {
1122
1271
  constructor(reason) {
@@ -1211,6 +1360,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1211
1360
  const startTime = Date.now();
1212
1361
  const abortController = new AbortController();
1213
1362
  const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
1363
+ const stepAbortSignal = opts.getStepAbortSignal?.(stepIndex);
1214
1364
  try {
1215
1365
  const phase = await Promise.race([
1216
1366
  runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts),
@@ -1228,14 +1378,22 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1228
1378
  jobDeadlineSignal.addEventListener("abort", () => {
1229
1379
  reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
1230
1380
  });
1381
+ }),
1382
+ new Promise((_, reject) => {
1383
+ if (!stepAbortSignal) return;
1384
+ if (stepAbortSignal.aborted) {
1385
+ reject(new StepCancelledError(step.name));
1386
+ return;
1387
+ }
1388
+ stepAbortSignal.addEventListener("abort", () => reject(new StepCancelledError(step.name)));
1231
1389
  })
1232
1390
  ]);
1233
1391
  clearTimeout(timeoutId);
1234
1392
  const durationMs = Date.now() - startTime;
1235
1393
  const outputsPayload = phase.outputs != null ? phase.outputs : void 0;
1236
1394
  if (outputsPayload) outputsMap.set(step.name, outputsPayload);
1237
- const secretsAccessed = getSecretsAccessLog?.();
1238
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1395
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1396
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1239
1397
  const stepStatus = phase.status === "skipped" ? ExecutionStepStatus.enum.skipped : ExecutionStepStatus.enum.success;
1240
1398
  sendFn({
1241
1399
  type: "step.complete",
@@ -1259,10 +1417,27 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1259
1417
  clearTimeout(timeoutId);
1260
1418
  const durationMs = Date.now() - startTime;
1261
1419
  const error = e instanceof Error ? e : new Error(String(e));
1420
+ if (e instanceof StepCancelledError) {
1421
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1422
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1423
+ sendFn({
1424
+ type: "step.complete",
1425
+ stepIndex,
1426
+ status: ExecutionStepStatus.enum.cancelled,
1427
+ durationMs,
1428
+ ...secretsAccessed !== void 0 && { secretsAccessed }
1429
+ });
1430
+ return {
1431
+ name: step.name,
1432
+ stepIndex,
1433
+ status: ExecutionStepStatus.enum.cancelled,
1434
+ durationMs
1435
+ };
1436
+ }
1262
1437
  const exitCode = extractExitCode(e);
1263
1438
  const signal = extractSignal(e);
1264
- const secretsAccessed = getSecretsAccessLog?.();
1265
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1439
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1440
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1266
1441
  sendFn({
1267
1442
  type: "step.complete",
1268
1443
  stepIndex,
@@ -1316,7 +1491,7 @@ function extractSignal(error) {
1316
1491
  */
1317
1492
  async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1318
1493
  if (!step.rules || step.rules.length === 0) return null;
1319
- const ruleCtx = createRuleContext(opts.event, [], opts.env);
1494
+ const ruleCtx = createRuleContext(opts.event, [], opts.env, opts.dispatchInputs ?? {}, opts.fanout);
1320
1495
  const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
1321
1496
  if (ruleResult.allPassed) return null;
1322
1497
  opts.sendIpc({
@@ -1391,7 +1566,7 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
1391
1566
  stepIndex,
1392
1567
  line: `[kici] Step '${step.name}' ${why}.`
1393
1568
  });
1394
- await opts.disposeStepResources?.();
1569
+ await opts.disposeStepResources?.(stepIndex);
1395
1570
  return {
1396
1571
  result: {
1397
1572
  name: step.name,
@@ -1417,15 +1592,16 @@ async function runObserverHook(args) {
1417
1592
  startTime: opts.startTime ?? Date.now(),
1418
1593
  ...failedStep !== void 0 && { failedStep }
1419
1594
  });
1420
- const hookResult = await executeHook({
1595
+ const ctx = opts.createStepContext(stepIndex, step.name);
1596
+ const hookResult = await captureWrap(opts)(hookStepIndex, () => executeHook({
1421
1597
  hook,
1422
- stepContext: opts.createStepContext(stepIndex, step.name),
1598
+ stepContext: ctx,
1423
1599
  outcome,
1424
1600
  hookType,
1425
1601
  stepIndex: hookStepIndex,
1426
1602
  sendIpc: opts.sendIpc,
1427
1603
  timeout: 3e5
1428
- });
1604
+ }));
1429
1605
  if (!hookResult.success) opts.sendIpc({
1430
1606
  type: "log.line",
1431
1607
  stepIndex,
@@ -1442,10 +1618,43 @@ async function runObserverHook(args) {
1442
1618
  * `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
1443
1619
  * removed even when the step throws, times out, or rule-skips.
1444
1620
  */
1621
+ /**
1622
+ * Run a step through its retry policy. Each call to `executeStepInLoop` is one
1623
+ * attempt: it sets up its own per-attempt timeout from `step.timeout` and returns
1624
+ * a `SandboxStepResult` (it never throws — a failed attempt is reported as a
1625
+ * `failed` status with an `error`). A failed attempt is retried while attempts
1626
+ * remain AND `retryIf(reconstructedError)` is true; backoff sleeps between
1627
+ * attempts. The retry loop runs to completion BEFORE the caller applies
1628
+ * `continueOnError` to the final outcome.
1629
+ */
1630
+ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
1631
+ const retry = step.retry;
1632
+ const max = retry?.maxAttempts ?? 1;
1633
+ let result;
1634
+ for (let n = 1; n <= max; n++) {
1635
+ result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal, opts.checkMode ?? CheckMode.enum.apply, opts);
1636
+ if (result.status !== ExecutionStepStatus.enum.failed) return result;
1637
+ const err = new Error(result.error?.message ?? `Step '${step.name}' failed`);
1638
+ if (!(n < max && (retry?.retryIf?.(err) ?? true))) break;
1639
+ const delay = computeBackoffDelay(n, {
1640
+ maxAttempts: max,
1641
+ delayMs: retry.delayMs,
1642
+ backoff: retry.backoff,
1643
+ maxDelayMs: retry.maxDelayMs
1644
+ });
1645
+ opts.sendIpc({
1646
+ type: "log.line",
1647
+ stepIndex,
1648
+ line: `[kici] Step '${step.name}' attempt ${n}/${max} failed: ${err.message}; retrying in ${delay}ms`
1649
+ });
1650
+ await new Promise((r) => setTimeout(r, delay));
1651
+ }
1652
+ return result;
1653
+ }
1445
1654
  async function runStepIteration(step, stepIndex, opts) {
1446
1655
  const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
1447
1656
  if (skippedResult) {
1448
- await opts.disposeStepResources?.();
1657
+ await opts.disposeStepResources?.(stepIndex);
1449
1658
  return {
1450
1659
  result: skippedResult,
1451
1660
  shouldBreak: false
@@ -1462,18 +1671,18 @@ async function runStepIteration(step, stepIndex, opts) {
1462
1671
  opts
1463
1672
  });
1464
1673
  const stepCacheSpecs = opts.cachePhaseDeps ? normalizeCacheSpecs(step.cache) : [];
1465
- const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps) : /* @__PURE__ */ new Map();
1674
+ const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps, stepIndex) : /* @__PURE__ */ new Map();
1466
1675
  try {
1467
- await opts.beforeStepEnvFiles?.();
1676
+ await opts.beforeStepEnvFiles?.(stepIndex);
1468
1677
  const ctx = opts.createStepContext(stepIndex, step.name);
1469
1678
  const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
1470
1679
  let result;
1471
1680
  try {
1472
- result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal, opts.checkMode ?? CheckMode.enum.apply, opts);
1681
+ result = await captureWrap(opts)(stepIndex, () => runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts));
1473
1682
  } finally {
1474
- await opts.afterStepApplyEnvFiles?.();
1683
+ await opts.afterStepApplyEnvFiles?.(stepIndex);
1475
1684
  }
1476
- if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps);
1685
+ if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps, stepIndex);
1477
1686
  if (opts.jobHooks?.afterStep) await runObserverHook({
1478
1687
  hook: opts.jobHooks.afterStep,
1479
1688
  hookType: "afterStep",
@@ -1493,7 +1702,7 @@ async function runStepIteration(step, stepIndex, opts) {
1493
1702
  shouldBreak: false
1494
1703
  };
1495
1704
  } finally {
1496
- await opts.disposeStepResources?.();
1705
+ await opts.disposeStepResources?.(stepIndex);
1497
1706
  }
1498
1707
  }
1499
1708
  /**
@@ -1509,14 +1718,15 @@ async function runCompletionHook(args) {
1509
1718
  stepIndex: -1,
1510
1719
  line: `[kici] Running ${hookType} hook...`
1511
1720
  });
1512
- const hookResult = await executeHook({
1721
+ const ctx = opts.createStepContext(hookStepIndex, hookType);
1722
+ const hookResult = await captureWrap(opts)(hookStepIndex, () => executeHook({
1513
1723
  hook,
1514
- stepContext: opts.createStepContext(hookStepIndex, hookType),
1724
+ stepContext: ctx,
1515
1725
  outcome,
1516
1726
  hookType,
1517
1727
  stepIndex: hookStepIndex,
1518
1728
  sendIpc: opts.sendIpc
1519
- });
1729
+ }));
1520
1730
  if (hookResult.success) {
1521
1731
  opts.sendIpc({
1522
1732
  type: "log.line",
@@ -1614,9 +1824,24 @@ async function executeStepLoop(opts) {
1614
1824
  const startTime = opts.startTime ?? Date.now();
1615
1825
  const stepResults = [];
1616
1826
  const state = { failed: false };
1617
- for (const [i, step] of opts.steps.entries()) {
1827
+ const nodes = opts.stepNodes ?? opts.steps.map((step, i) => ({
1828
+ kind: "sequential",
1829
+ step,
1830
+ stepIndex: i
1831
+ }));
1832
+ for (const node of nodes) {
1618
1833
  if (opts.isAborted?.()) break;
1619
- const outcome = await runStepIteration(step, i, opts);
1834
+ if (node.kind === "parallel") {
1835
+ const groupOutcome = await runParallelGroup(node, opts);
1836
+ stepResults.push(...groupOutcome.results);
1837
+ if (groupOutcome.failed) {
1838
+ state.failed = true;
1839
+ state.failedStepName = groupOutcome.failedStepName;
1840
+ break;
1841
+ }
1842
+ continue;
1843
+ }
1844
+ const outcome = await runStepIteration(node.step, node.stepIndex, opts);
1620
1845
  stepResults.push(outcome.result);
1621
1846
  if (outcome.failedStepName) {
1622
1847
  state.failed = true;
@@ -1637,6 +1862,69 @@ async function executeStepLoop(opts) {
1637
1862
  };
1638
1863
  }
1639
1864
  //#endregion
1865
+ //#region src/execution/sandbox/capture-context.ts
1866
+ /**
1867
+ * Attributes captured console output to the step whose run is currently on the
1868
+ * async call stack.
1869
+ *
1870
+ * The monkey-patched `process.stdout/stderr.write` reads
1871
+ * {@link currentCaptureStepIndex} to decide which `step-N.log` a console line
1872
+ * belongs to. Keying this on the async execution context (rather than a single
1873
+ * module global) makes attribution correct when more than one step body runs
1874
+ * concurrently: each step's `run` executes inside its own
1875
+ * {@link runInStepCapture} scope, so its writes resolve to its own index even
1876
+ * while a sibling step is mid-flight. Under sequential execution exactly one
1877
+ * scope is active at a time, identical to the former global.
1878
+ */
1879
+ const stepCaptureStore = new AsyncLocalStorage();
1880
+ /**
1881
+ * Run `fn` with `stepIndex` as the active console-capture attribution for the
1882
+ * duration of its async execution (including everything it awaits).
1883
+ */
1884
+ function runInStepCapture(stepIndex, fn) {
1885
+ return stepCaptureStore.run(stepIndex, fn);
1886
+ }
1887
+ /**
1888
+ * The step index whose run is currently on the async stack, or `-1` when no
1889
+ * capture scope is active (workflow-level / between-steps output).
1890
+ */
1891
+ function currentCaptureStepIndex() {
1892
+ return stepCaptureStore.getStore() ?? -1;
1893
+ }
1894
+ //#endregion
1895
+ //#region src/execution/sandbox/step-task-registry.ts
1896
+ /**
1897
+ * Per-task replacement for the runner's former `currentStepSecrets` /
1898
+ * `currentStepDispose` single-slots.
1899
+ *
1900
+ * The runner used to remember only the *most recent* step's secrets handle and
1901
+ * dispose closure; the access-log / mount-record / dispose reader callbacks read
1902
+ * that single slot. Under sequential execution that is correct (one step at a
1903
+ * time), but two concurrently-running steps would clobber each other's
1904
+ * secrets-audit trail. Keying every slot by the step's index keeps each step's
1905
+ * audit trail and teardown isolated — sequential behavior is identical, Phase 1
1906
+ * concurrency is correct.
1907
+ */
1908
+ var StepTaskRegistry = class {
1909
+ #slots = /* @__PURE__ */ new Map();
1910
+ set(stepIndex, slot) {
1911
+ this.#slots.set(stepIndex, slot);
1912
+ }
1913
+ getAccessLog(stepIndex) {
1914
+ return this.#slots.get(stepIndex)?.secrets.getAccessLog() ?? [];
1915
+ }
1916
+ getMountRecords(stepIndex) {
1917
+ const slot = this.#slots.get(stepIndex);
1918
+ return slot ? [...slot.secrets.getMountRecords()] : [];
1919
+ }
1920
+ async dispose(stepIndex) {
1921
+ const slot = this.#slots.get(stepIndex);
1922
+ if (!slot) return;
1923
+ this.#slots.delete(stepIndex);
1924
+ await slot.dispose();
1925
+ }
1926
+ };
1927
+ //#endregion
1640
1928
  //#region src/execution/env-init/init-phase.ts
1641
1929
  /** Default init timeout when a spec sets none: 10 minutes. */
1642
1930
  const DEFAULT_INIT_TIMEOUT_MS = 600 * 1e3;
@@ -3111,8 +3399,8 @@ function logSubprocessStreams(e, tokens) {
3111
3399
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3112
3400
  * Node's normal ESM lookup against `.kici/node_modules/`.
3113
3401
  */
3114
- const AGENT_SDK_VERSION = "0.1.22";
3115
- const AGENT_SDK_BUNDLE_HASH = "4b26a42978f77e29db110d47523ec947d3d673c3109fc48141c1a62814bde7a1";
3402
+ const AGENT_SDK_VERSION = "0.1.24";
3403
+ const AGENT_SDK_BUNDLE_HASH = "031712cf8cd02f483365113aad7e946da982110d544b177eb05edcc0d4421253";
3116
3404
  /**
3117
3405
  * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
3118
3406
  * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
@@ -3474,7 +3762,7 @@ async function applyOverlay(config) {
3474
3762
  */
3475
3763
  init_download();
3476
3764
  init_dep_restore();
3477
- const AGENT_VERSION = "0.1.22";
3765
+ const AGENT_VERSION = "0.1.24";
3478
3766
  process.on("uncaughtException", (err) => {
3479
3767
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
3480
3768
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -3497,12 +3785,6 @@ const isForkMode = typeof process.send === "function";
3497
3785
  const origStdoutWrite = process.stdout.write.bind(process.stdout);
3498
3786
  const origStderrWrite = process.stderr.write.bind(process.stderr);
3499
3787
  /**
3500
- * Current step index for console.log/console.error capture.
3501
- * When >= 0, process.stdout/stderr writes are intercepted and sent as
3502
- * log.line IPC messages for the given step. Set to -1 outside step execution.
3503
- */
3504
- let captureStepIndex = -1;
3505
- /**
3506
3788
  * Workflow-level capture flag for the pre-step `prepare` phase
3507
3789
  * (module load, concurrency-group evaluation, rule evaluation).
3508
3790
  *
@@ -3513,17 +3795,18 @@ let captureStepIndex = -1;
3513
3795
  * rule check functions lands in the job's workflow-level log file
3514
3796
  * (`executions/{runId}/job-{name}/step--1.log`) alongside runner narration.
3515
3797
  *
3516
- * Mutually exclusive with `captureStepIndex >= 0` the step loop resets
3517
- * this flag to false before setting `captureStepIndex` to a real step index.
3798
+ * Mutually exclusive with a step capture scope: the step loop resets this flag
3799
+ * to false before any step runs inside its {@link runInStepCapture} scope (the
3800
+ * async-context source of {@link currentCaptureStepIndex}).
3518
3801
  */
3519
3802
  let capturePrepareActive = false;
3520
3803
  /** The maskedSend function used by the output capture. Set during main(). */
3521
3804
  let captureSendFn = null;
3522
3805
  function captureIsActive() {
3523
- return (captureStepIndex >= 0 || capturePrepareActive) && captureSendFn !== null;
3806
+ return (currentCaptureStepIndex() >= 0 || capturePrepareActive) && captureSendFn !== null;
3524
3807
  }
3525
3808
  function captureTargetIndex() {
3526
- return captureStepIndex >= 0 ? captureStepIndex : -1;
3809
+ return currentCaptureStepIndex();
3527
3810
  }
3528
3811
  /**
3529
3812
  * Install monkey-patches on process.stdout.write and process.stderr.write
@@ -3672,6 +3955,14 @@ let jobTimedOutMs;
3672
3955
  */
3673
3956
  const jobDeadlineAbort = new AbortController();
3674
3957
  /**
3958
+ * Aborted when the job is being torn down by cancellation (abort IPC / SIGTERM),
3959
+ * as distinct from the wall-clock deadline (`jobDeadlineAbort`). Each step's
3960
+ * `ctx.signal` composes this with the deadline signal and its own per-step
3961
+ * controller, so a cancelled job cooperatively unwinds in-flight step bodies
3962
+ * that opted into `ctx.signal`. Inert for the rest of the runner today.
3963
+ */
3964
+ const jobCancelAbort = new AbortController();
3965
+ /**
3675
3966
  * Pending promises for event.emit requests awaiting responses from the agent.
3676
3967
  *
3677
3968
  * Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
@@ -4035,17 +4326,16 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
4035
4326
  * post-loop save phase skip exact-key hits.
4036
4327
  */
4037
4328
  async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
4038
- let cacheStepCursor = stepCount * 3 + 100;
4039
4329
  const cachePhaseDeps = {
4040
4330
  cache: createCacheApi(stepCwd, buildCacheTransport()),
4041
4331
  sendIpc,
4042
- nextStepIndex: () => cacheStepCursor++
4332
+ nextStepIndex: createCacheStepIndexAllocator(stepCount)
4043
4333
  };
4044
4334
  const jobCacheSpecs = normalizeCacheSpecs(jobCache);
4045
4335
  return {
4046
4336
  cachePhaseDeps,
4047
4337
  jobCacheSpecs,
4048
- jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps) : /* @__PURE__ */ new Map()
4338
+ jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps, -1) : /* @__PURE__ */ new Map()
4049
4339
  };
4050
4340
  }
4051
4341
  /**
@@ -4058,7 +4348,7 @@ async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
4058
4348
  */
4059
4349
  async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
4060
4350
  if (specs.length === 0 || !succeeded) return;
4061
- await saveCacheSpecs(specs, restoreResults, deps);
4351
+ await saveCacheSpecs(specs, restoreResults, deps, -1);
4062
4352
  }
4063
4353
  /**
4064
4354
  * Dispatch an agent-to-runner message to the appropriate pending handler.
@@ -4067,6 +4357,7 @@ async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
4067
4357
  function dispatchAgentMessage(msg) {
4068
4358
  if (msg.type === "abort") {
4069
4359
  aborted = true;
4360
+ jobCancelAbort.abort();
4070
4361
  if (msg.force) forceAborted = true;
4071
4362
  } else if (msg.type === "event.emit.response") {
4072
4363
  const pending = pendingEmitResponses.get(msg.requestId);
@@ -4099,6 +4390,7 @@ else getStdinRl().on("line", (line) => {
4099
4390
  });
4100
4391
  process.on("SIGTERM", () => {
4101
4392
  aborted = true;
4393
+ jobCancelAbort.abort();
4102
4394
  });
4103
4395
  /**
4104
4396
  * Create a Logger that sends log lines via IPC.
@@ -4333,7 +4625,7 @@ function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
4333
4625
  * NOT serialized across the process boundary. This means zx $ runs natively
4334
4626
  * inside this process with full shell access.
4335
4627
  */
4336
- function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker) {
4628
+ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal) {
4337
4629
  const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
4338
4630
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
4339
4631
  const rawPayload = rawPayloadFromEvent(request.event);
@@ -4352,6 +4644,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4352
4644
  return {
4353
4645
  $: step$,
4354
4646
  log,
4647
+ signal,
4355
4648
  env: process.env,
4356
4649
  setEnv: (key, value) => {
4357
4650
  applyEnvDelta({
@@ -4407,12 +4700,32 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4407
4700
  ...request.matrixValues && { matrix: request.matrixValues },
4408
4701
  ...request.host && { host: request.host },
4409
4702
  ...request.agent && { agent: request.agent },
4703
+ ...(() => {
4704
+ const fanout = deriveFanout(request);
4705
+ return fanout ? { fanout } : {};
4706
+ })(),
4707
+ dispatchInputs: request.dispatchInputs ?? {},
4410
4708
  ...(() => {
4411
4709
  const needs = buildStepNeedsContext(request.jobNeeds, request.upstreamJobOutputs, request.upstreamJobStatuses);
4412
4710
  return needs ? { needs } : {};
4413
4711
  })()
4414
4712
  };
4415
4713
  }
4714
+ /**
4715
+ * Derive the fan-out position (`ctx.fanout`) from a dispatch request. Returns
4716
+ * `undefined` for a non-fan-out job (no `fanoutTotal`), so `ctx.fanout` is only
4717
+ * set for `runsOnAll` host children and matrix combinations.
4718
+ */
4719
+ function deriveFanout(request) {
4720
+ if (request.fanoutTotal === void 0) return void 0;
4721
+ const index = request.fanoutIndex ?? 0;
4722
+ return {
4723
+ index,
4724
+ total: request.fanoutTotal,
4725
+ first: index === 0,
4726
+ last: index === request.fanoutTotal - 1
4727
+ };
4728
+ }
4416
4729
  /** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
4417
4730
  function rawPayloadFromEvent(event) {
4418
4731
  if (!event) return void 0;
@@ -4905,7 +5218,7 @@ async function runCancelPathHooks(args) {
4905
5218
  const ctx = createStepCtxWithCapture(hookStepIndex, label);
4906
5219
  let hookResult;
4907
5220
  try {
4908
- hookResult = await executeHook({
5221
+ hookResult = await runInStepCapture(hookStepIndex, () => executeHook({
4909
5222
  hook,
4910
5223
  stepContext: ctx,
4911
5224
  outcome: failedStep ? {
@@ -4915,9 +5228,9 @@ async function runCancelPathHooks(args) {
4915
5228
  hookType,
4916
5229
  stepIndex: hookStepIndex,
4917
5230
  sendIpc: maskedSend
4918
- });
5231
+ }));
4919
5232
  } finally {
4920
- await disposeStepResources();
5233
+ await disposeStepResources(hookStepIndex);
4921
5234
  }
4922
5235
  hookStepIndex++;
4923
5236
  if (!hookResult.success) {
@@ -4968,29 +5281,66 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
4968
5281
  } else rawSteps = extractSteps(workflow, request.jobName);
4969
5282
  const refMap = /* @__PURE__ */ new WeakMap();
4970
5283
  let stepCounter = 0;
4971
- return {
4972
- normalizedSteps: rawSteps.map((stepOrFn) => {
4973
- if (typeof stepOrFn === "function") {
4974
- stepCounter++;
4975
- const name = `step-${stepCounter}`;
4976
- refMap.set(stepOrFn, name);
4977
- return {
4978
- _tag: "Step",
4979
- name,
4980
- run: stepOrFn,
4981
- outputs: void 0
4982
- };
4983
- }
4984
- const s = stepOrFn;
4985
- if (!s.name) {
4986
- stepCounter++;
5284
+ const coerce = (stepOrFn) => {
5285
+ if (typeof stepOrFn === "function") {
5286
+ stepCounter++;
5287
+ const name = `step-${stepCounter}`;
5288
+ refMap.set(stepOrFn, name);
5289
+ return {
5290
+ _tag: "Step",
5291
+ name,
5292
+ run: stepOrFn,
5293
+ outputs: void 0
5294
+ };
5295
+ }
5296
+ const s = stepOrFn;
5297
+ if (!s.name) {
5298
+ stepCounter++;
5299
+ return {
5300
+ ...s,
5301
+ name: `step-${stepCounter}`
5302
+ };
5303
+ }
5304
+ return s;
5305
+ };
5306
+ const normalizedSteps = [];
5307
+ const nodes = [];
5308
+ let flatIndex = 0;
5309
+ let groupOrdinal = 0;
5310
+ for (const entry of rawSteps) {
5311
+ if (isParallelGroup(entry)) {
5312
+ const groupId = `g${groupOrdinal++}`;
5313
+ const children = entry.steps.map((child) => {
5314
+ const step = coerce(child);
5315
+ const stepIndex = flatIndex++;
5316
+ normalizedSteps.push(step);
4987
5317
  return {
4988
- ...s,
4989
- name: `step-${stepCounter}`
5318
+ step,
5319
+ stepIndex
4990
5320
  };
4991
- }
4992
- return s;
4993
- }),
5321
+ });
5322
+ nodes.push({
5323
+ kind: "parallel",
5324
+ groupId,
5325
+ name: entry.name ?? groupId,
5326
+ failFast: entry.failFast,
5327
+ ...entry.maxParallel !== void 0 && { maxParallel: entry.maxParallel },
5328
+ children
5329
+ });
5330
+ continue;
5331
+ }
5332
+ const step = coerce(entry);
5333
+ const stepIndex = flatIndex++;
5334
+ normalizedSteps.push(step);
5335
+ nodes.push({
5336
+ kind: "sequential",
5337
+ step,
5338
+ stepIndex
5339
+ });
5340
+ }
5341
+ return {
5342
+ normalizedSteps,
5343
+ nodes,
4994
5344
  refMap,
4995
5345
  driftDroppedJobs
4996
5346
  };
@@ -5026,7 +5376,7 @@ function buildOutputInfrastructure(request, refMap) {
5026
5376
  */
5027
5377
  async function maybeSkipJobOnRules(job, request, normalizedSteps) {
5028
5378
  if (!job?.rules || job.rules.length === 0) return false;
5029
- const ruleCtx = createRuleContext(request.event ?? {}, [], process.env);
5379
+ const ruleCtx = createRuleContext(request.event ?? {}, [], process.env, request.dispatchInputs ?? {}, deriveFanout(request));
5030
5380
  if ((await evaluateRules(job.rules, ruleCtx, request.jobName)).allPassed) return false;
5031
5381
  const skippedResults = normalizedSteps.map((s, i) => ({
5032
5382
  name: s.name,
@@ -5087,22 +5437,40 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
5087
5437
  await truncateEnvFiles(envFiles);
5088
5438
  }
5089
5439
  /**
5090
- * Build the step loop's KICI_ENV/KICI_PATH callbacks over the shared `envFiles`.
5091
- * `beforeStepEnvFiles` points the runner's process.env at the files (each step's
5092
- * zx $ snapshots process.env at context creation, which happens AFTER this
5440
+ * Build the step loop's KICI_ENV/KICI_PATH callbacks with a per-step delta-file
5441
+ * pair (keyed by step index). `beforeStepEnvFiles(stepIndex)` lazily creates the
5442
+ * step's pair and points the runner's process.env at it (each step's zx $
5443
+ * snapshots process.env at context creation, which happens AFTER this
5093
5444
  * before-hook, so the shell sees them; the pre-fork env allowlist does not
5094
- * re-filter runtime-set vars). `afterStepApplyEnvFiles` applies + truncates the
5095
- * delta, mirroring the init phase's env port.
5096
- */
5097
- function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
5445
+ * re-filter runtime-set vars). `afterStepApplyEnvFiles(stepIndex)` applies that
5446
+ * step's delta and releases the pair.
5447
+ *
5448
+ * Env-isolation contract: under sequential execution this is identical to a
5449
+ * single shared pair truncated between steps — each step still sees only its own
5450
+ * delta. The pair is now per-step so two concurrently-running steps cannot
5451
+ * corrupt each other's delta file. `process.env.KICI_ENV` / `process.env.KICI_PATH`
5452
+ * remain process-global, so Phase 1 forbids `setEnv` / `addPath` / `$KICI_ENV`
5453
+ * writes inside `parallel()` children (compile-time validation); Phase 0 only
5454
+ * makes the file pair per-task.
5455
+ */
5456
+ function buildStepEnvFileHooks(operatorSecretKeys, maskedSend) {
5457
+ const perStep = /* @__PURE__ */ new Map();
5098
5458
  return {
5099
- beforeStepEnvFiles: async () => {
5100
- process.env.KICI_ENV = envFiles.envFile;
5101
- process.env.KICI_PATH = envFiles.pathFile;
5459
+ beforeStepEnvFiles: async (stepIndex) => {
5460
+ let files = perStep.get(stepIndex);
5461
+ if (!files) {
5462
+ files = await createEnvFiles(tmpdir());
5463
+ perStep.set(stepIndex, files);
5464
+ }
5465
+ process.env.KICI_ENV = files.envFile;
5466
+ process.env.KICI_PATH = files.pathFile;
5102
5467
  },
5103
- afterStepApplyEnvFiles: async () => {
5468
+ afterStepApplyEnvFiles: async (stepIndex) => {
5469
+ const files = perStep.get(stepIndex);
5470
+ if (!files) return;
5471
+ perStep.delete(stepIndex);
5104
5472
  try {
5105
- await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
5473
+ await applyEnvFilesDelta(files, operatorSecretKeys, maskedSend);
5106
5474
  } catch (err) {
5107
5475
  maskedSend({
5108
5476
  type: "log.line",
@@ -5237,7 +5605,7 @@ async function main() {
5237
5605
  });
5238
5606
  return waitForApiResponse(reqId);
5239
5607
  };
5240
- const { normalizedSteps, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5608
+ const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5241
5609
  const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
5242
5610
  const job = findJob(workflow, request.jobName);
5243
5611
  await maybeSkipJobOnRules(job, request, normalizedSteps);
@@ -5247,33 +5615,43 @@ async function main() {
5247
5615
  flushOutputCapture();
5248
5616
  capturePrepareActive = false;
5249
5617
  const stepCwd = sourceDir;
5250
- const envFiles = await createEnvFiles(tmpdir());
5251
5618
  await runInitPhaseOrFailJob({
5252
5619
  job,
5253
5620
  stepCwd,
5254
- envFiles,
5621
+ envFiles: await createEnvFiles(tmpdir()),
5255
5622
  operatorSecretKeys,
5256
5623
  maskedSend
5257
5624
  });
5258
5625
  const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
5259
- let currentStepSecrets = null;
5260
- let currentStepDispose = null;
5626
+ const stepTasks = new StepTaskRegistry();
5627
+ const stepAbortControllers = /* @__PURE__ */ new Map();
5261
5628
  const createStepCtxWithCapture = (stepIndex, stepName) => {
5262
- captureStepIndex = stepIndex;
5629
+ const stepAbort = new AbortController();
5630
+ stepAbortControllers.set(stepIndex, stepAbort);
5631
+ const signal = AbortSignal.any([
5632
+ stepAbort.signal,
5633
+ jobCancelAbort.signal,
5634
+ jobDeadlineAbort.signal
5635
+ ]);
5263
5636
  const handle = buildStepSecrets(request, masker, () => {});
5264
- currentStepSecrets = handle.secrets;
5265
- currentStepDispose = handle.dispose;
5266
- const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker);
5637
+ stepTasks.set(stepIndex, {
5638
+ secrets: handle.secrets,
5639
+ dispose: handle.dispose
5640
+ });
5641
+ const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker, signal);
5267
5642
  if (globalRepoInfo) {
5268
5643
  ctx.workflowRepo = globalRepoInfo.workflowRepo;
5269
5644
  ctx.sourceRepo = globalRepoInfo.sourceRepo;
5270
5645
  }
5271
5646
  return ctx;
5272
5647
  };
5273
- const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
5648
+ const stepEnvHooks = buildStepEnvFileHooks(operatorSecretKeys, maskedSend);
5274
5649
  const jobStartTime = Date.now();
5275
5650
  const loopResult = await executeStepLoop({
5276
5651
  steps: normalizedSteps,
5652
+ stepNodes: nodes,
5653
+ abortStep: (stepIndex) => stepAbortControllers.get(stepIndex)?.abort(),
5654
+ getStepAbortSignal: (stepIndex) => stepAbortControllers.get(stepIndex)?.signal,
5277
5655
  checkMode: request.checkMode,
5278
5656
  createStepContext: createStepCtxWithCapture,
5279
5657
  sendIpc: maskedSend,
@@ -5281,24 +5659,22 @@ async function main() {
5281
5659
  outputsMap,
5282
5660
  event: request.event ?? {},
5283
5661
  env: process.env,
5662
+ dispatchInputs: request.dispatchInputs ?? {},
5663
+ fanout: deriveFanout(request),
5284
5664
  jobHooks,
5285
5665
  cachePhaseDeps,
5286
5666
  isAborted: () => aborted,
5287
5667
  jobDeadlineSignal: jobDeadlineAbort.signal,
5288
5668
  startTime: jobStartTime,
5289
- getSecretsAccessLog: () => {
5669
+ runWithStepCapture: runInStepCapture,
5670
+ getSecretsAccessLog: (stepIndex) => {
5290
5671
  flushOutputCapture();
5291
- captureStepIndex = -1;
5292
- return currentStepSecrets?.getAccessLog() ?? [];
5293
- },
5294
- getSecretMountRecords: () => {
5295
- return currentStepSecrets ? [...currentStepSecrets.getMountRecords()] : [];
5672
+ return stepTasks.getAccessLog(stepIndex);
5296
5673
  },
5297
- disposeStepResources: async () => {
5298
- const disposeFn = currentStepDispose;
5299
- currentStepDispose = null;
5300
- currentStepSecrets = null;
5301
- if (disposeFn) await disposeFn();
5674
+ getSecretMountRecords: (stepIndex) => stepTasks.getMountRecords(stepIndex),
5675
+ disposeStepResources: (stepIndex) => {
5676
+ stepAbortControllers.delete(stepIndex);
5677
+ return stepTasks.dispose(stepIndex);
5302
5678
  },
5303
5679
  beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
5304
5680
  afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles,
@@ -5318,11 +5694,9 @@ async function main() {
5318
5694
  jobStartTime,
5319
5695
  outputsMap,
5320
5696
  createStepCtxWithCapture,
5321
- disposeStepResources: async () => {
5322
- const disposeFn = currentStepDispose;
5323
- currentStepDispose = null;
5324
- currentStepSecrets = null;
5325
- if (disposeFn) await disposeFn();
5697
+ disposeStepResources: (stepIndex) => {
5698
+ stepAbortControllers.delete(stepIndex);
5699
+ return stepTasks.dispose(stepIndex);
5326
5700
  },
5327
5701
  maskedSend
5328
5702
  });
@@ -5389,6 +5763,6 @@ main().catch((error) => {
5389
5763
  setTimeout(() => process.exit(1), 100);
5390
5764
  });
5391
5765
  //#endregion
5392
- export { buildStepNeedsContext, createSandboxStepContext, rawPayloadFromEvent };
5766
+ export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
5393
5767
 
5394
5768
  //# sourceMappingURL=workflow-runner.js.map