@kici-dev/agent 0.1.23 → 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,8 +7,8 @@ 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
13
  import { computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
14
14
  import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
@@ -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,
@@ -1120,7 +1126,146 @@ function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs =
1120
1126
  };
1121
1127
  }
1122
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
1123
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
+ }
1124
1269
  /** Result of a rejected drift gate: the run was declined by a reviewer. */
1125
1270
  var DriftGateRejectedError = class extends Error {
1126
1271
  constructor(reason) {
@@ -1215,6 +1360,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1215
1360
  const startTime = Date.now();
1216
1361
  const abortController = new AbortController();
1217
1362
  const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
1363
+ const stepAbortSignal = opts.getStepAbortSignal?.(stepIndex);
1218
1364
  try {
1219
1365
  const phase = await Promise.race([
1220
1366
  runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts),
@@ -1232,14 +1378,22 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1232
1378
  jobDeadlineSignal.addEventListener("abort", () => {
1233
1379
  reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
1234
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)));
1235
1389
  })
1236
1390
  ]);
1237
1391
  clearTimeout(timeoutId);
1238
1392
  const durationMs = Date.now() - startTime;
1239
1393
  const outputsPayload = phase.outputs != null ? phase.outputs : void 0;
1240
1394
  if (outputsPayload) outputsMap.set(step.name, outputsPayload);
1241
- const secretsAccessed = getSecretsAccessLog?.();
1242
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1395
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1396
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1243
1397
  const stepStatus = phase.status === "skipped" ? ExecutionStepStatus.enum.skipped : ExecutionStepStatus.enum.success;
1244
1398
  sendFn({
1245
1399
  type: "step.complete",
@@ -1263,10 +1417,27 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1263
1417
  clearTimeout(timeoutId);
1264
1418
  const durationMs = Date.now() - startTime;
1265
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
+ }
1266
1437
  const exitCode = extractExitCode(e);
1267
1438
  const signal = extractSignal(e);
1268
- const secretsAccessed = getSecretsAccessLog?.();
1269
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1439
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1440
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1270
1441
  sendFn({
1271
1442
  type: "step.complete",
1272
1443
  stepIndex,
@@ -1395,7 +1566,7 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
1395
1566
  stepIndex,
1396
1567
  line: `[kici] Step '${step.name}' ${why}.`
1397
1568
  });
1398
- await opts.disposeStepResources?.();
1569
+ await opts.disposeStepResources?.(stepIndex);
1399
1570
  return {
1400
1571
  result: {
1401
1572
  name: step.name,
@@ -1421,15 +1592,16 @@ async function runObserverHook(args) {
1421
1592
  startTime: opts.startTime ?? Date.now(),
1422
1593
  ...failedStep !== void 0 && { failedStep }
1423
1594
  });
1424
- const hookResult = await executeHook({
1595
+ const ctx = opts.createStepContext(stepIndex, step.name);
1596
+ const hookResult = await captureWrap(opts)(hookStepIndex, () => executeHook({
1425
1597
  hook,
1426
- stepContext: opts.createStepContext(stepIndex, step.name),
1598
+ stepContext: ctx,
1427
1599
  outcome,
1428
1600
  hookType,
1429
1601
  stepIndex: hookStepIndex,
1430
1602
  sendIpc: opts.sendIpc,
1431
1603
  timeout: 3e5
1432
- });
1604
+ }));
1433
1605
  if (!hookResult.success) opts.sendIpc({
1434
1606
  type: "log.line",
1435
1607
  stepIndex,
@@ -1482,7 +1654,7 @@ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
1482
1654
  async function runStepIteration(step, stepIndex, opts) {
1483
1655
  const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
1484
1656
  if (skippedResult) {
1485
- await opts.disposeStepResources?.();
1657
+ await opts.disposeStepResources?.(stepIndex);
1486
1658
  return {
1487
1659
  result: skippedResult,
1488
1660
  shouldBreak: false
@@ -1499,18 +1671,18 @@ async function runStepIteration(step, stepIndex, opts) {
1499
1671
  opts
1500
1672
  });
1501
1673
  const stepCacheSpecs = opts.cachePhaseDeps ? normalizeCacheSpecs(step.cache) : [];
1502
- 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();
1503
1675
  try {
1504
- await opts.beforeStepEnvFiles?.();
1676
+ await opts.beforeStepEnvFiles?.(stepIndex);
1505
1677
  const ctx = opts.createStepContext(stepIndex, step.name);
1506
1678
  const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
1507
1679
  let result;
1508
1680
  try {
1509
- result = await runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts);
1681
+ result = await captureWrap(opts)(stepIndex, () => runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts));
1510
1682
  } finally {
1511
- await opts.afterStepApplyEnvFiles?.();
1683
+ await opts.afterStepApplyEnvFiles?.(stepIndex);
1512
1684
  }
1513
- 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);
1514
1686
  if (opts.jobHooks?.afterStep) await runObserverHook({
1515
1687
  hook: opts.jobHooks.afterStep,
1516
1688
  hookType: "afterStep",
@@ -1530,7 +1702,7 @@ async function runStepIteration(step, stepIndex, opts) {
1530
1702
  shouldBreak: false
1531
1703
  };
1532
1704
  } finally {
1533
- await opts.disposeStepResources?.();
1705
+ await opts.disposeStepResources?.(stepIndex);
1534
1706
  }
1535
1707
  }
1536
1708
  /**
@@ -1546,14 +1718,15 @@ async function runCompletionHook(args) {
1546
1718
  stepIndex: -1,
1547
1719
  line: `[kici] Running ${hookType} hook...`
1548
1720
  });
1549
- const hookResult = await executeHook({
1721
+ const ctx = opts.createStepContext(hookStepIndex, hookType);
1722
+ const hookResult = await captureWrap(opts)(hookStepIndex, () => executeHook({
1550
1723
  hook,
1551
- stepContext: opts.createStepContext(hookStepIndex, hookType),
1724
+ stepContext: ctx,
1552
1725
  outcome,
1553
1726
  hookType,
1554
1727
  stepIndex: hookStepIndex,
1555
1728
  sendIpc: opts.sendIpc
1556
- });
1729
+ }));
1557
1730
  if (hookResult.success) {
1558
1731
  opts.sendIpc({
1559
1732
  type: "log.line",
@@ -1651,9 +1824,24 @@ async function executeStepLoop(opts) {
1651
1824
  const startTime = opts.startTime ?? Date.now();
1652
1825
  const stepResults = [];
1653
1826
  const state = { failed: false };
1654
- 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) {
1655
1833
  if (opts.isAborted?.()) break;
1656
- 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);
1657
1845
  stepResults.push(outcome.result);
1658
1846
  if (outcome.failedStepName) {
1659
1847
  state.failed = true;
@@ -1674,6 +1862,69 @@ async function executeStepLoop(opts) {
1674
1862
  };
1675
1863
  }
1676
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
1677
1928
  //#region src/execution/env-init/init-phase.ts
1678
1929
  /** Default init timeout when a spec sets none: 10 minutes. */
1679
1930
  const DEFAULT_INIT_TIMEOUT_MS = 600 * 1e3;
@@ -3148,8 +3399,8 @@ function logSubprocessStreams(e, tokens) {
3148
3399
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3149
3400
  * Node's normal ESM lookup against `.kici/node_modules/`.
3150
3401
  */
3151
- const AGENT_SDK_VERSION = "0.1.23";
3152
- const AGENT_SDK_BUNDLE_HASH = "3a83610d2d122b9f9b0f924b225a47050d0b35f00a8f3256a3e15f3ff3bf7ddc";
3402
+ const AGENT_SDK_VERSION = "0.1.24";
3403
+ const AGENT_SDK_BUNDLE_HASH = "031712cf8cd02f483365113aad7e946da982110d544b177eb05edcc0d4421253";
3153
3404
  /**
3154
3405
  * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
3155
3406
  * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
@@ -3511,7 +3762,7 @@ async function applyOverlay(config) {
3511
3762
  */
3512
3763
  init_download();
3513
3764
  init_dep_restore();
3514
- const AGENT_VERSION = "0.1.23";
3765
+ const AGENT_VERSION = "0.1.24";
3515
3766
  process.on("uncaughtException", (err) => {
3516
3767
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
3517
3768
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -3534,12 +3785,6 @@ const isForkMode = typeof process.send === "function";
3534
3785
  const origStdoutWrite = process.stdout.write.bind(process.stdout);
3535
3786
  const origStderrWrite = process.stderr.write.bind(process.stderr);
3536
3787
  /**
3537
- * Current step index for console.log/console.error capture.
3538
- * When >= 0, process.stdout/stderr writes are intercepted and sent as
3539
- * log.line IPC messages for the given step. Set to -1 outside step execution.
3540
- */
3541
- let captureStepIndex = -1;
3542
- /**
3543
3788
  * Workflow-level capture flag for the pre-step `prepare` phase
3544
3789
  * (module load, concurrency-group evaluation, rule evaluation).
3545
3790
  *
@@ -3550,17 +3795,18 @@ let captureStepIndex = -1;
3550
3795
  * rule check functions lands in the job's workflow-level log file
3551
3796
  * (`executions/{runId}/job-{name}/step--1.log`) alongside runner narration.
3552
3797
  *
3553
- * Mutually exclusive with `captureStepIndex >= 0` the step loop resets
3554
- * 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}).
3555
3801
  */
3556
3802
  let capturePrepareActive = false;
3557
3803
  /** The maskedSend function used by the output capture. Set during main(). */
3558
3804
  let captureSendFn = null;
3559
3805
  function captureIsActive() {
3560
- return (captureStepIndex >= 0 || capturePrepareActive) && captureSendFn !== null;
3806
+ return (currentCaptureStepIndex() >= 0 || capturePrepareActive) && captureSendFn !== null;
3561
3807
  }
3562
3808
  function captureTargetIndex() {
3563
- return captureStepIndex >= 0 ? captureStepIndex : -1;
3809
+ return currentCaptureStepIndex();
3564
3810
  }
3565
3811
  /**
3566
3812
  * Install monkey-patches on process.stdout.write and process.stderr.write
@@ -3709,6 +3955,14 @@ let jobTimedOutMs;
3709
3955
  */
3710
3956
  const jobDeadlineAbort = new AbortController();
3711
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
+ /**
3712
3966
  * Pending promises for event.emit requests awaiting responses from the agent.
3713
3967
  *
3714
3968
  * Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
@@ -4072,17 +4326,16 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
4072
4326
  * post-loop save phase skip exact-key hits.
4073
4327
  */
4074
4328
  async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
4075
- let cacheStepCursor = stepCount * 3 + 100;
4076
4329
  const cachePhaseDeps = {
4077
4330
  cache: createCacheApi(stepCwd, buildCacheTransport()),
4078
4331
  sendIpc,
4079
- nextStepIndex: () => cacheStepCursor++
4332
+ nextStepIndex: createCacheStepIndexAllocator(stepCount)
4080
4333
  };
4081
4334
  const jobCacheSpecs = normalizeCacheSpecs(jobCache);
4082
4335
  return {
4083
4336
  cachePhaseDeps,
4084
4337
  jobCacheSpecs,
4085
- jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps) : /* @__PURE__ */ new Map()
4338
+ jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps, -1) : /* @__PURE__ */ new Map()
4086
4339
  };
4087
4340
  }
4088
4341
  /**
@@ -4095,7 +4348,7 @@ async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
4095
4348
  */
4096
4349
  async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
4097
4350
  if (specs.length === 0 || !succeeded) return;
4098
- await saveCacheSpecs(specs, restoreResults, deps);
4351
+ await saveCacheSpecs(specs, restoreResults, deps, -1);
4099
4352
  }
4100
4353
  /**
4101
4354
  * Dispatch an agent-to-runner message to the appropriate pending handler.
@@ -4104,6 +4357,7 @@ async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
4104
4357
  function dispatchAgentMessage(msg) {
4105
4358
  if (msg.type === "abort") {
4106
4359
  aborted = true;
4360
+ jobCancelAbort.abort();
4107
4361
  if (msg.force) forceAborted = true;
4108
4362
  } else if (msg.type === "event.emit.response") {
4109
4363
  const pending = pendingEmitResponses.get(msg.requestId);
@@ -4136,6 +4390,7 @@ else getStdinRl().on("line", (line) => {
4136
4390
  });
4137
4391
  process.on("SIGTERM", () => {
4138
4392
  aborted = true;
4393
+ jobCancelAbort.abort();
4139
4394
  });
4140
4395
  /**
4141
4396
  * Create a Logger that sends log lines via IPC.
@@ -4370,7 +4625,7 @@ function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
4370
4625
  * NOT serialized across the process boundary. This means zx $ runs natively
4371
4626
  * inside this process with full shell access.
4372
4627
  */
4373
- 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) {
4374
4629
  const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
4375
4630
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
4376
4631
  const rawPayload = rawPayloadFromEvent(request.event);
@@ -4389,6 +4644,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4389
4644
  return {
4390
4645
  $: step$,
4391
4646
  log,
4647
+ signal,
4392
4648
  env: process.env,
4393
4649
  setEnv: (key, value) => {
4394
4650
  applyEnvDelta({
@@ -4962,7 +5218,7 @@ async function runCancelPathHooks(args) {
4962
5218
  const ctx = createStepCtxWithCapture(hookStepIndex, label);
4963
5219
  let hookResult;
4964
5220
  try {
4965
- hookResult = await executeHook({
5221
+ hookResult = await runInStepCapture(hookStepIndex, () => executeHook({
4966
5222
  hook,
4967
5223
  stepContext: ctx,
4968
5224
  outcome: failedStep ? {
@@ -4972,9 +5228,9 @@ async function runCancelPathHooks(args) {
4972
5228
  hookType,
4973
5229
  stepIndex: hookStepIndex,
4974
5230
  sendIpc: maskedSend
4975
- });
5231
+ }));
4976
5232
  } finally {
4977
- await disposeStepResources();
5233
+ await disposeStepResources(hookStepIndex);
4978
5234
  }
4979
5235
  hookStepIndex++;
4980
5236
  if (!hookResult.success) {
@@ -5025,29 +5281,66 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
5025
5281
  } else rawSteps = extractSteps(workflow, request.jobName);
5026
5282
  const refMap = /* @__PURE__ */ new WeakMap();
5027
5283
  let stepCounter = 0;
5028
- return {
5029
- normalizedSteps: rawSteps.map((stepOrFn) => {
5030
- if (typeof stepOrFn === "function") {
5031
- stepCounter++;
5032
- const name = `step-${stepCounter}`;
5033
- refMap.set(stepOrFn, name);
5034
- return {
5035
- _tag: "Step",
5036
- name,
5037
- run: stepOrFn,
5038
- outputs: void 0
5039
- };
5040
- }
5041
- const s = stepOrFn;
5042
- if (!s.name) {
5043
- 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);
5044
5317
  return {
5045
- ...s,
5046
- name: `step-${stepCounter}`
5318
+ step,
5319
+ stepIndex
5047
5320
  };
5048
- }
5049
- return s;
5050
- }),
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,
5051
5344
  refMap,
5052
5345
  driftDroppedJobs
5053
5346
  };
@@ -5144,22 +5437,40 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
5144
5437
  await truncateEnvFiles(envFiles);
5145
5438
  }
5146
5439
  /**
5147
- * Build the step loop's KICI_ENV/KICI_PATH callbacks over the shared `envFiles`.
5148
- * `beforeStepEnvFiles` points the runner's process.env at the files (each step's
5149
- * 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
5150
5444
  * before-hook, so the shell sees them; the pre-fork env allowlist does not
5151
- * re-filter runtime-set vars). `afterStepApplyEnvFiles` applies + truncates the
5152
- * delta, mirroring the init phase's env port.
5153
- */
5154
- 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();
5155
5458
  return {
5156
- beforeStepEnvFiles: async () => {
5157
- process.env.KICI_ENV = envFiles.envFile;
5158
- 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;
5159
5467
  },
5160
- afterStepApplyEnvFiles: async () => {
5468
+ afterStepApplyEnvFiles: async (stepIndex) => {
5469
+ const files = perStep.get(stepIndex);
5470
+ if (!files) return;
5471
+ perStep.delete(stepIndex);
5161
5472
  try {
5162
- await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
5473
+ await applyEnvFilesDelta(files, operatorSecretKeys, maskedSend);
5163
5474
  } catch (err) {
5164
5475
  maskedSend({
5165
5476
  type: "log.line",
@@ -5294,7 +5605,7 @@ async function main() {
5294
5605
  });
5295
5606
  return waitForApiResponse(reqId);
5296
5607
  };
5297
- const { normalizedSteps, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5608
+ const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5298
5609
  const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
5299
5610
  const job = findJob(workflow, request.jobName);
5300
5611
  await maybeSkipJobOnRules(job, request, normalizedSteps);
@@ -5304,33 +5615,43 @@ async function main() {
5304
5615
  flushOutputCapture();
5305
5616
  capturePrepareActive = false;
5306
5617
  const stepCwd = sourceDir;
5307
- const envFiles = await createEnvFiles(tmpdir());
5308
5618
  await runInitPhaseOrFailJob({
5309
5619
  job,
5310
5620
  stepCwd,
5311
- envFiles,
5621
+ envFiles: await createEnvFiles(tmpdir()),
5312
5622
  operatorSecretKeys,
5313
5623
  maskedSend
5314
5624
  });
5315
5625
  const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
5316
- let currentStepSecrets = null;
5317
- let currentStepDispose = null;
5626
+ const stepTasks = new StepTaskRegistry();
5627
+ const stepAbortControllers = /* @__PURE__ */ new Map();
5318
5628
  const createStepCtxWithCapture = (stepIndex, stepName) => {
5319
- 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
+ ]);
5320
5636
  const handle = buildStepSecrets(request, masker, () => {});
5321
- currentStepSecrets = handle.secrets;
5322
- currentStepDispose = handle.dispose;
5323
- 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);
5324
5642
  if (globalRepoInfo) {
5325
5643
  ctx.workflowRepo = globalRepoInfo.workflowRepo;
5326
5644
  ctx.sourceRepo = globalRepoInfo.sourceRepo;
5327
5645
  }
5328
5646
  return ctx;
5329
5647
  };
5330
- const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
5648
+ const stepEnvHooks = buildStepEnvFileHooks(operatorSecretKeys, maskedSend);
5331
5649
  const jobStartTime = Date.now();
5332
5650
  const loopResult = await executeStepLoop({
5333
5651
  steps: normalizedSteps,
5652
+ stepNodes: nodes,
5653
+ abortStep: (stepIndex) => stepAbortControllers.get(stepIndex)?.abort(),
5654
+ getStepAbortSignal: (stepIndex) => stepAbortControllers.get(stepIndex)?.signal,
5334
5655
  checkMode: request.checkMode,
5335
5656
  createStepContext: createStepCtxWithCapture,
5336
5657
  sendIpc: maskedSend,
@@ -5345,19 +5666,15 @@ async function main() {
5345
5666
  isAborted: () => aborted,
5346
5667
  jobDeadlineSignal: jobDeadlineAbort.signal,
5347
5668
  startTime: jobStartTime,
5348
- getSecretsAccessLog: () => {
5669
+ runWithStepCapture: runInStepCapture,
5670
+ getSecretsAccessLog: (stepIndex) => {
5349
5671
  flushOutputCapture();
5350
- captureStepIndex = -1;
5351
- return currentStepSecrets?.getAccessLog() ?? [];
5352
- },
5353
- getSecretMountRecords: () => {
5354
- return currentStepSecrets ? [...currentStepSecrets.getMountRecords()] : [];
5672
+ return stepTasks.getAccessLog(stepIndex);
5355
5673
  },
5356
- disposeStepResources: async () => {
5357
- const disposeFn = currentStepDispose;
5358
- currentStepDispose = null;
5359
- currentStepSecrets = null;
5360
- if (disposeFn) await disposeFn();
5674
+ getSecretMountRecords: (stepIndex) => stepTasks.getMountRecords(stepIndex),
5675
+ disposeStepResources: (stepIndex) => {
5676
+ stepAbortControllers.delete(stepIndex);
5677
+ return stepTasks.dispose(stepIndex);
5361
5678
  },
5362
5679
  beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
5363
5680
  afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles,
@@ -5377,11 +5694,9 @@ async function main() {
5377
5694
  jobStartTime,
5378
5695
  outputsMap,
5379
5696
  createStepCtxWithCapture,
5380
- disposeStepResources: async () => {
5381
- const disposeFn = currentStepDispose;
5382
- currentStepDispose = null;
5383
- currentStepSecrets = null;
5384
- if (disposeFn) await disposeFn();
5697
+ disposeStepResources: (stepIndex) => {
5698
+ stepAbortControllers.delete(stepIndex);
5699
+ return stepTasks.dispose(stepIndex);
5385
5700
  },
5386
5701
  maskedSend
5387
5702
  });
@@ -5448,6 +5763,6 @@ main().catch((error) => {
5448
5763
  setTimeout(() => process.exit(1), 100);
5449
5764
  });
5450
5765
  //#endregion
5451
- export { buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
5766
+ export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
5452
5767
 
5453
5768
  //# sourceMappingURL=workflow-runner.js.map