cool-workflow 0.2.3 → 0.2.5

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 (143) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +33 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/io.js +6 -20
  18. package/dist/cli/parseargv.js +7 -1
  19. package/dist/core/capability-data.js +339 -10
  20. package/dist/core/format/help.js +80 -4
  21. package/dist/core/format/recovery-hint.js +32 -0
  22. package/dist/core/multi-agent/collaboration.js +35 -6
  23. package/dist/core/multi-agent/runtime.js +7 -0
  24. package/dist/core/multi-agent/trust-policy.js +7 -1
  25. package/dist/core/pipeline/contract.js +7 -0
  26. package/dist/core/pipeline/error-feedback.js +2 -2
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +55 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +124 -13
  32. package/dist/mcp-server.js +20 -0
  33. package/dist/shell/commit.js +8 -2
  34. package/dist/shell/coordinator-io.js +73 -1
  35. package/dist/shell/drive.js +129 -64
  36. package/dist/shell/error-feedback-io.js +6 -0
  37. package/dist/shell/execution-backend/agent.js +205 -24
  38. package/dist/shell/execution-backend/container.js +44 -10
  39. package/dist/shell/execution-backend/local.js +47 -10
  40. package/dist/shell/fs-atomic.js +93 -12
  41. package/dist/shell/ledger-cli.js +9 -2
  42. package/dist/shell/multi-agent-cli.js +5 -1
  43. package/dist/shell/onramp.js +48 -5
  44. package/dist/shell/pipeline-cli.js +20 -2
  45. package/dist/shell/pipeline.js +2 -1
  46. package/dist/shell/reclamation-io.js +76 -7
  47. package/dist/shell/registry-cli.js +4 -0
  48. package/dist/shell/run-export.js +52 -4
  49. package/dist/shell/run-registry-io.js +1 -0
  50. package/dist/shell/run-store.js +156 -0
  51. package/dist/shell/scheduler-io.js +101 -10
  52. package/dist/shell/telemetry-ledger-io.js +36 -24
  53. package/dist/shell/trust-audit.js +104 -10
  54. package/dist/shell/workbench-host.js +121 -10
  55. package/dist/shell/workbench.js +79 -5
  56. package/dist/shell/worker-isolation.js +1 -1
  57. package/dist/shell/workflow-app-loader.js +67 -1
  58. package/dist/wiring/capability-table/basics.js +7 -2
  59. package/dist/wiring/capability-table/exec-backend.js +42 -24
  60. package/dist/wiring/capability-table/multi-agent.js +69 -69
  61. package/dist/wiring/capability-table/pipeline.js +60 -28
  62. package/dist/wiring/capability-table/registry-core.js +29 -6
  63. package/dist/wiring/capability-table/reporting.js +45 -39
  64. package/dist/wiring/capability-table/scheduling-registry.js +66 -60
  65. package/dist/wiring/capability-table/state.js +34 -34
  66. package/dist/wiring/capability-table/trust-ledger.js +66 -24
  67. package/dist/wiring/capability-table/workflow-apps.js +15 -15
  68. package/docs/agent-delegation-drive.7.md +17 -0
  69. package/docs/cli-mcp-parity.7.md +43 -13
  70. package/docs/contract-migration-tooling.7.md +4 -0
  71. package/docs/control-plane-scheduling.7.md +38 -0
  72. package/docs/cross-agent-ledger.7.md +20 -8
  73. package/docs/durable-state-and-locking.7.md +4 -0
  74. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  75. package/docs/execution-backends.7.md +4 -0
  76. package/docs/fix.7.md +4 -4
  77. package/docs/getting-started.md +40 -32
  78. package/docs/index.md +17 -0
  79. package/docs/launch/demo.tape +4 -3
  80. package/docs/mcp-app-surface.7.md +6 -0
  81. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  82. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  83. package/docs/multi-agent-operator-ux.7.md +4 -0
  84. package/docs/node-snapshot-diff-replay.7.md +4 -0
  85. package/docs/observability-cost-accounting.7.md +4 -0
  86. package/docs/project-index.md +43 -9
  87. package/docs/real-execution-backends.7.md +4 -0
  88. package/docs/release-and-migration.7.md +4 -0
  89. package/docs/release-tooling.7.md +22 -0
  90. package/docs/routine.7.md +22 -0
  91. package/docs/run-registry-control-plane.7.md +4 -0
  92. package/docs/run-retention-reclamation.7.md +28 -3
  93. package/docs/state-explosion-management.7.md +4 -0
  94. package/docs/team-collaboration.7.md +4 -0
  95. package/docs/trust-audit-anchor.7.md +4 -0
  96. package/docs/web-desktop-workbench.7.md +26 -2
  97. package/docs/workflow-app-framework.7.md +31 -0
  98. package/manifest/plugin.manifest.json +1 -1
  99. package/manifest/source-context-profiles.json +4 -3
  100. package/package.json +5 -2
  101. package/scripts/agents/agent-adapter-core.js +26 -0
  102. package/scripts/agents/claude-p-agent.js +4 -1
  103. package/scripts/agents/codex-agent.js +4 -0
  104. package/scripts/agents/cw-attest-wrap.js +1 -1
  105. package/scripts/agents/gemini-agent.js +4 -0
  106. package/scripts/agents/opencode-agent.js +5 -0
  107. package/scripts/block-unapproved-tag.js +160 -0
  108. package/scripts/bump-version.js +27 -10
  109. package/scripts/canonical-apps.js +4 -4
  110. package/scripts/children/batch-delegate-child.js +72 -4
  111. package/scripts/children/http-batch-delegate-child.js +132 -0
  112. package/scripts/children/http-delegate-child.js +8 -0
  113. package/scripts/dogfood-release.js +2 -2
  114. package/scripts/fake-date-for-reproduction.js +1 -1
  115. package/scripts/golden-path.js +4 -4
  116. package/scripts/lang-policy-check.js +82 -0
  117. package/scripts/purity-baseline.json +0 -30
  118. package/scripts/purity-gate.js +16 -3
  119. package/scripts/release-check.js +2 -1
  120. package/scripts/release-flow.js +264 -41
  121. package/scripts/release-gate.js +184 -0
  122. package/scripts/release-oneclick.js +438 -0
  123. package/scripts/release-tags.js +47 -0
  124. package/scripts/verdict-keygen.js +2 -2
  125. package/scripts/verify-bump-reproduction.js +193 -0
  126. package/scripts/verify-verdict-signature.js +1 -1
  127. package/scripts/version-sync-check.js +39 -22
  128. package/ui/workbench/app.css +41 -9
  129. package/ui/workbench/app.js +160 -27
  130. package/ui/workbench/index.html +2 -2
  131. package/docs/agent-framework.md +0 -177
  132. package/docs/designs/handoff-ledger.md +0 -145
  133. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  134. package/docs/launch/launch-kit.md +0 -195
  135. package/docs/launch/pre-launch-checklist.md +0 -53
  136. package/docs/readme-v0.1.87-full.md +0 -301
  137. package/docs/routines.md +0 -101
  138. package/docs/scheduled-tasks.md +0 -80
  139. package/scripts/agents/claude-p-agent.sh +0 -9
  140. package/scripts/block-unapproved-tag.sh +0 -60
  141. package/scripts/release-gate.sh +0 -94
  142. package/scripts/verify-bump-reproduction.sh +0 -148
  143. package/scripts/verify-container-selfref.js +0 -64
@@ -630,7 +630,7 @@ function maybeExpandLoop(run) {
630
630
  requiresEvidence: t.requiresEvidence,
631
631
  sandboxProfileId: t.sandboxProfileId,
632
632
  },
633
- }, { persist: false, persistNode: (r, node) => void (0, node_store_1.appendRunNode)(r, node) });
633
+ }, { persist: false, persistNode: (r, node) => void (0, node_store_1.appendRunNode)(r, node), pathExists: fs.existsSync });
634
634
  t.stateNodeId = result.outputNodeId;
635
635
  }
636
636
  (0, dispatch_1.updatePhaseStatuses)(run);
@@ -656,17 +656,32 @@ function driveStep(ctx) {
656
656
  return processSelectedTask(ctx, selected.id);
657
657
  }
658
658
  /** Dispatch every batch task (sequential — dispatch mutates state), then
659
- * collect ALL spawn-style agent child outcomes in one concurrent window
660
- * (one batch delegate child process, per-job timeout kill). Returns
661
- * outcomes keyed by task id; a cache-hit or endpoint-configured agent
662
- * gets no prepared outcome and settles through the serial accept path
663
- * inside processSelectedTask. Dispatch failures become recorded fail
664
- * steps up front, exactly what the serial path would emit. Byte-exact
665
- * to the old build's src/drive.ts's prepareConcurrentOutcomes. */
659
+ * collect ALL agent child outcomes in one concurrent window: spawn-style
660
+ * (CLI-binary) agents through the batch delegate child, and endpoint-mode
661
+ * agents through the HTTP batch delegate child so `--concurrency N` runs N
662
+ * endpoint delegations in parallel instead of one-at-a-time on the serial
663
+ * path. Returns outcomes keyed by task id; a cache-hit, endpoint-configured
664
+ * agent, or sub-workflow task gets no prepared outcome and settles through
665
+ * the serial accept path inside processSelectedTask (a sub-workflow task is
666
+ * never spawn-eligible in the first place — processSelectedTask always
667
+ * takes its own runSubWorkflow branch for it, so building a spawn job here
668
+ * would only be spawned and thrown away unused). Dispatch failures become
669
+ * recorded fail steps up front, exactly what the serial path would emit. */
666
670
  function prepareConcurrentOutcomes(ctx, batch) {
667
671
  const failSteps = new Map();
668
672
  const jobs = [];
669
673
  const jobTaskIds = [];
674
+ const endpointJobs = [];
675
+ const endpointTaskIds = [];
676
+ // Set whenever a task's status flips pending -> running (workerId
677
+ // assigned) this round, regardless of whether it later becomes a real
678
+ // spawn job, an endpoint job, a cache hit, or a sub-workflow task. That
679
+ // dispatch is a real state mutation on the round-cached run object that
680
+ // nothing has written to disk yet — the pre-spawn checkpoint below must
681
+ // flush it even when the batch produces zero spawn/endpoint jobs (e.g. an
682
+ // all-sub-workflow batch), or a crash before the round-end flush loses
683
+ // the dispatch.
684
+ let dispatchedThisRound = false;
670
685
  for (const taskId of batch) {
671
686
  const run = loadRun(ctx);
672
687
  const task = run.tasks.find((candidate) => candidate.id === taskId);
@@ -681,6 +696,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
681
696
  continue;
682
697
  }
683
698
  workerId = dispatchedTask.workerId;
699
+ dispatchedThisRound = true;
684
700
  }
685
701
  if (!workerId) {
686
702
  failSteps.set(taskId, (0, drive_decide_1.makeStep)("dispatch", "failed", { runId: ctx.runId, taskId, phase: task.phase, reason: "no worker scope for task" }));
@@ -694,7 +710,15 @@ function prepareConcurrentOutcomes(ctx, batch) {
694
710
  const cachePath = resultCachePath(freshRun, task, (0, hash_1.sha256)(task.prompt || ""), ctx.incremental, delegationDigest);
695
711
  if (cachePath && fs.existsSync(cachePath))
696
712
  continue;
697
- const job = (0, agent_1.prepareAgentSpawn)({
713
+ // A sub-workflow task never goes through the spawn path — processSelectedTask
714
+ // checks task.subWorkflow before it ever looks at a prepared spawn outcome
715
+ // and always takes its own runSubWorkflow branch instead (mirrors that
716
+ // check byte-for-byte). Building a spawn OR endpoint job for it here would
717
+ // just be a real agent child spawned/delegated and thrown away unused.
718
+ const subWorkflow = task.subWorkflow;
719
+ if (subWorkflow)
720
+ continue;
721
+ const request = {
698
722
  schemaVersion: 1,
699
723
  runId: ctx.runId,
700
724
  taskId: task.id,
@@ -705,7 +729,8 @@ function prepareConcurrentOutcomes(ctx, batch) {
705
729
  label: task.id,
706
730
  timeoutMs: ctx.config.timeoutMs,
707
731
  delegation: { command: ctx.config.command, args: ctx.config.args, endpoint: ctx.config.endpoint, model: task.model || ctx.config.model },
708
- });
732
+ };
733
+ const job = (0, agent_1.prepareAgentSpawn)(request);
709
734
  if (job) {
710
735
  const sandboxPolicy = manifest.sandboxPolicy;
711
736
  if (sandboxPolicy) {
@@ -713,24 +738,53 @@ function prepareConcurrentOutcomes(ctx, batch) {
713
738
  }
714
739
  jobs.push(job);
715
740
  jobTaskIds.push(taskId);
741
+ continue;
742
+ }
743
+ // Endpoint-configured agent: no CLI binary to batch-spawn, but its HTTP
744
+ // delegation joins the concurrent window through the endpoint batch child
745
+ // (runEndpointBatchOutcomes) rather than settling one-at-a-time on the
746
+ // serial path. An unconfigured agent (neither command nor endpoint) returns
747
+ // undefined from both prepares here and falls to the serial refusal.
748
+ const endpointJob = (0, agent_1.prepareEndpointJob)(request);
749
+ if (endpointJob) {
750
+ endpointJobs.push(endpointJob);
751
+ endpointTaskIds.push(taskId);
716
752
  }
717
753
  }
718
- if (jobs.length) {
719
- emitProgress(`⇉ concurrent round: ${jobs.length} agent${jobs.length > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
754
+ const totalJobs = jobs.length + endpointJobs.length;
755
+ if (totalJobs) {
756
+ emitProgress(`⇉ concurrent round: ${totalJobs} agent${totalJobs > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
757
+ }
758
+ if (totalJobs || dispatchedThisRound) {
759
+ // Every task above that reached "pending" got dispatched (workerId
760
+ // assigned) in the round-cached run object, but nothing durable was
761
+ // written for it — unlike the serial path's own dispatch branch, which
762
+ // always checkpoints immediately. Flush now, BEFORE the batch's long
763
+ // settle window opens: a crash mid-spawn OR mid-sub-workflow-recursion
764
+ // (both can run for minutes) then leaves state.json correctly showing
765
+ // these tasks as dispatched, instead of losing the dispatch entirely.
766
+ // Gated on dispatchedThisRound (not just totalJobs) so a batch made
767
+ // entirely of sub-workflow and/or cache-hit tasks — which builds zero
768
+ // spawn/endpoint jobs — still gets this flush before its long-running
769
+ // settle loop starts.
770
+ (0, run_store_1.saveCheckpoint)(loadRun(ctx));
720
771
  }
721
- const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
722
772
  const outcomes = new Map();
773
+ const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
723
774
  jobTaskIds.forEach((taskId, index) => outcomes.set(taskId, settled[index]));
775
+ const endpointSettled = (0, agent_1.runEndpointBatchOutcomes)(endpointJobs);
776
+ endpointTaskIds.forEach((taskId, index) => outcomes.set(taskId, endpointSettled[index]));
724
777
  return { outcomes, failSteps };
725
778
  }
726
779
  /** One concurrent round inside one cached in-memory run: dispatches every
727
780
  * batch task, spawns all spawn-style agent children in one concurrent
728
- * window, then settles + accepts in DETERMINISTIC batch (task-id) order
729
- * regardless of wall-clock finish order. At round end it flushes once:
730
- * commitState(run, "concurrent-round:<n>-tasks") + writeReport +
731
- * saveCheckpoint. Cache-hit tasks and endpoint-only agents get no
732
- * prepared outcome and settle through the serial path (still inside
733
- * this one deferred-persist round). If no step was produced (nothing
781
+ * window (CLI-binary agents through the batch child, endpoint agents
782
+ * through the HTTP batch child), then settles + accepts in DETERMINISTIC
783
+ * batch (task-id) order regardless of wall-clock finish order. At round end
784
+ * it flushes once: commitState(run, "concurrent-round:<n>-tasks") +
785
+ * writeReport + saveCheckpoint. Cache-hit tasks get no prepared outcome and
786
+ * settle through the serial path (still inside this one deferred-persist
787
+ * round). If no step was produced (nothing
734
788
  * runnable at round entry — terminal/blocked/token-budget gate) the
735
789
  * round degrades to one plain driveStep. Byte-exact to the old build's
736
790
  * src/drive.ts's driveConcurrentRound. */
@@ -896,14 +950,14 @@ function driveOneRound(ctx, options, steps, emitPhaseProgress) {
896
950
  * finishes its own bookkeeping first. A SECOND signal means the caller
897
951
  * wants out right now, not a graceful stop, and force-exits immediately
898
952
  * with the conventional 128+signum code. */
899
- function createStopSignalController() {
953
+ function createStopSignalController(runId) {
900
954
  let interruptedBy;
901
955
  let stopSignalHits = 0;
902
956
  const onStopSignal = (signal) => {
903
957
  stopSignalHits += 1;
904
958
  if (stopSignalHits === 1) {
905
959
  interruptedBy = signal;
906
- emitProgress(`received ${signal} — stopping after the current step (run again with the same run id to resume)`);
960
+ emitProgress(`received ${signal} — stopping after the current step (resume: cw run resume ${runId} --drive)`);
907
961
  return;
908
962
  }
909
963
  // A second signal means the caller wants out right now, not a graceful stop.
@@ -952,11 +1006,11 @@ function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhau
952
1006
  if (interruptedBy) {
953
1007
  exhaustedMaxIterations = false;
954
1008
  if (!alreadyComplete) {
955
- steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive interrupted by ${interruptedBy} — run again with the same run id to resume` }));
1009
+ steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive interrupted by ${interruptedBy} — resume: cw run resume ${ctx.runId} --drive` }));
956
1010
  }
957
1011
  }
958
1012
  else if (exhaustedMaxIterations) {
959
- steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state` }));
1013
+ steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state — resume: cw run resume ${ctx.runId} --drive` }));
960
1014
  }
961
1015
  const statusInputs = {
962
1016
  once: Boolean(options.once),
@@ -996,28 +1050,35 @@ function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhau
996
1050
  * exact synchronous shape) and fixed only in driveAsync(). */
997
1051
  function drive(runId, cwd, options = {}) {
998
1052
  const ctx = buildDriveContext(runId, cwd, options);
999
- const steps = [];
1000
- const run0 = loadRun(ctx);
1001
- const plannedWorkers = run0.tasks.length;
1002
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1003
- const emitPhaseProgress = createPhaseProgressEmitter();
1004
- let exhaustedMaxIterations = !options.once;
1005
- const stopSignal = createStopSignalController();
1006
- stopSignal.install();
1007
- try {
1008
- for (let i = 0; i < maxIter; i++) {
1009
- if (stopSignal.getInterruptedBy())
1010
- break;
1011
- if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1012
- exhaustedMaxIterations = false;
1013
- break;
1053
+ // Resolve the run dir before the mutex: a missing run throws "Run not found"
1054
+ // here (never a drive.lock side effect on a nonexistent run), via a stat only
1055
+ // so the per-round read accounting is unchanged. Mirrors withRunStateLock's
1056
+ // own probe-before-lock.
1057
+ const runDir = (0, run_store_1.resolveRunDir)(runId, cwd);
1058
+ return (0, run_store_1.withDriveLock)(runDir, runId, () => {
1059
+ const steps = [];
1060
+ const run0 = loadRun(ctx);
1061
+ const plannedWorkers = run0.tasks.length;
1062
+ const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1063
+ const emitPhaseProgress = createPhaseProgressEmitter();
1064
+ let exhaustedMaxIterations = !options.once;
1065
+ const stopSignal = createStopSignalController(runId);
1066
+ stopSignal.install();
1067
+ try {
1068
+ for (let i = 0; i < maxIter; i++) {
1069
+ if (stopSignal.getInterruptedBy())
1070
+ break;
1071
+ if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1072
+ exhaustedMaxIterations = false;
1073
+ break;
1074
+ }
1014
1075
  }
1015
1076
  }
1016
- }
1017
- finally {
1018
- stopSignal.remove();
1019
- }
1020
- return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1077
+ finally {
1078
+ stopSignal.remove();
1079
+ }
1080
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1081
+ });
1021
1082
  }
1022
1083
  /** Same contract and same DriveResult as drive() -- but actually
1023
1084
  * interruptible. After every round it awaits yieldToEventLoop(), a real
@@ -1045,29 +1106,33 @@ function drive(runId, cwd, options = {}) {
1045
1106
  * future cycle if it proves needed in practice. */
1046
1107
  async function driveAsync(runId, cwd, options = {}) {
1047
1108
  const ctx = buildDriveContext(runId, cwd, options);
1048
- const steps = [];
1049
- const run0 = loadRun(ctx);
1050
- const plannedWorkers = run0.tasks.length;
1051
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1052
- const emitPhaseProgress = createPhaseProgressEmitter();
1053
- let exhaustedMaxIterations = !options.once;
1054
- const stopSignal = createStopSignalController();
1055
- stopSignal.install();
1056
- try {
1057
- for (let i = 0; i < maxIter; i++) {
1058
- if (stopSignal.getInterruptedBy())
1059
- break;
1060
- if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1061
- exhaustedMaxIterations = false;
1062
- break;
1109
+ // Resolve the run dir before the mutex — see drive() above.
1110
+ const runDir = (0, run_store_1.resolveRunDir)(runId, cwd);
1111
+ return (0, run_store_1.withDriveLockAsync)(runDir, runId, async () => {
1112
+ const steps = [];
1113
+ const run0 = loadRun(ctx);
1114
+ const plannedWorkers = run0.tasks.length;
1115
+ const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1116
+ const emitPhaseProgress = createPhaseProgressEmitter();
1117
+ let exhaustedMaxIterations = !options.once;
1118
+ const stopSignal = createStopSignalController(runId);
1119
+ stopSignal.install();
1120
+ try {
1121
+ for (let i = 0; i < maxIter; i++) {
1122
+ if (stopSignal.getInterruptedBy())
1123
+ break;
1124
+ if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1125
+ exhaustedMaxIterations = false;
1126
+ break;
1127
+ }
1128
+ await yieldToEventLoop();
1063
1129
  }
1064
- await yieldToEventLoop();
1065
1130
  }
1066
- }
1067
- finally {
1068
- stopSignal.remove();
1069
- }
1070
- return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1131
+ finally {
1132
+ stopSignal.remove();
1133
+ }
1134
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1135
+ });
1071
1136
  }
1072
1137
  function drivePreview(runId, cwd, args = {}) {
1073
1138
  const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
@@ -290,6 +290,12 @@ function resolveFeedback(run, feedbackId, result) {
290
290
  function runPipelineStage(run, stageId, inputNodeId, options = {}, runnerOptions = {}) {
291
291
  return (0, runner_1.runPipelineStage)(run, stageId, inputNodeId, options, {
292
292
  ...runnerOptions,
293
+ // The core gate's `pathExists` defaults to `() => true` (a pure core/
294
+ // module never reads the filesystem). This is the shell seam, so default
295
+ // it to the real `fs.existsSync` — that is what makes the contract's
296
+ // `requireReadablePaths` / `missing-artifact-path` gate live. A caller may
297
+ // still override it (e.g. a replay against a captured path set).
298
+ pathExists: runnerOptions.pathExists || fs.existsSync,
293
299
  // Keep the caller-named failure node id (the old build honored outputNodeId
294
300
  // for the preserved failure node); the raw core auto-mints when unset.
295
301
  failureNodeId: runnerOptions.failureNodeId || options.outputNodeId,
@@ -5,7 +5,10 @@
5
5
  // MILESTONE 5 (docs/rebuild/PLAN.md build order, step 5). Byte-exact port of
6
6
  // plugins/cool-workflow/src/execution-backend/agent.ts. This module holds
7
7
  // the PURE data-transform helpers (invocation resolution, arg substitution,
8
- // secret redaction, report parsing) plus the batch delegate-child spawn.
8
+ // secret redaction, report parsing) plus the batch delegate-child spawn — both
9
+ // the CLI-binary batch (runAgentBatchOutcomes) and, added later, its
10
+ // HTTP-endpoint sibling (runEndpointBatchOutcomes) so `--concurrency N` gives
11
+ // endpoint-mode agents real concurrency instead of a serial per-task spawn.
9
12
  //
10
13
  // THE RED LINE: CW spawns the agent and records its attested output. It
11
14
  // NEVER imports a model SDK, holds an API key, or constructs a model API
@@ -49,6 +52,7 @@ var __importStar = (this && this.__importStar) || (function () {
49
52
  };
50
53
  })();
51
54
  Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.reapRecordedVendor = reapRecordedVendor;
52
56
  exports.buildAgentChildEnv = buildAgentChildEnv;
53
57
  exports.resolveAgentInvocation = resolveAgentInvocation;
54
58
  exports.stripSecretArgs = stripSecretArgs;
@@ -59,12 +63,15 @@ exports.recordedAgentHandle = recordedAgentHandle;
59
63
  exports.extractEndpointResult = extractEndpointResult;
60
64
  exports.agentHandle = agentHandle;
61
65
  exports.prepareAgentSpawn = prepareAgentSpawn;
66
+ exports.prepareEndpointJob = prepareEndpointJob;
62
67
  exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
63
68
  exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
69
+ exports.runEndpointBatchOutcomes = runEndpointBatchOutcomes;
64
70
  exports.shouldStreamAgentStderr = shouldStreamAgentStderr;
65
71
  exports.runAgentProcess = runAgentProcess;
66
72
  const fs = __importStar(require("node:fs"));
67
73
  const path = __importStar(require("node:path"));
74
+ const os = __importStar(require("node:os"));
68
75
  const node_child_process_1 = require("node:child_process");
69
76
  const local_1 = require("./local");
70
77
  const envelopes_1 = require("./envelopes");
@@ -85,16 +92,73 @@ const AGENT_PROVIDER_KEY_ENV_RE = /^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CO
85
92
  * call it, so the allowlist cannot drift into a second silent copy again.
86
93
  * Returns the forwarded var NAMES too (never values) for the
87
94
  * worker.agent-env trust-audit event. */
95
+ /** A unique sidecar path a shipped wrapper writes its vendor child's PID to
96
+ * (via the env CW_AGENT_VENDOR_PIDFILE we set below), so cw can reap the
97
+ * vendor if it has to SIGKILL the wrapper on a timeout. */
98
+ function vendorPidFilePath() {
99
+ return path.join(os.tmpdir(), `cw-agent-vendor-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.pid`);
100
+ }
101
+ /** Reap the vendor process a shipped wrapper recorded, then remove the sidecar.
102
+ * A wrapper that exited cleanly already removed the file, so this is a no-op
103
+ * then; only a wrapper cw SIGKILLed on a timeout leaves a live vendor PID
104
+ * here. Best-effort and race-tolerant: a missing file, an unparseable/too-low
105
+ * PID, or an already-gone process are all silently fine. Scope: kills the
106
+ * vendor process itself (the token spender), not a deeper grandchild tree,
107
+ * and only for the shipped vendor wrappers (claude, codex, gemini, and
108
+ * opencode/deepseek) -- an arbitrary CW_AGENT_COMMAND records no PID and is
109
+ * not covered. Returns true only if it
110
+ * actually signalled a process. Exported for tests; agent.ts is not part of
111
+ * the package public surface (index.ts). */
112
+ function reapRecordedVendor(pidFile) {
113
+ let raw;
114
+ try {
115
+ raw = fs.readFileSync(pidFile, "utf8");
116
+ }
117
+ catch {
118
+ return false; // no sidecar => the wrapper cleaned up (or never recorded one)
119
+ }
120
+ try {
121
+ fs.unlinkSync(pidFile);
122
+ }
123
+ catch { /* already gone */ }
124
+ const pid = Number(raw.trim());
125
+ if (!Number.isInteger(pid) || pid <= 1)
126
+ return false;
127
+ try {
128
+ process.kill(pid, "SIGKILL");
129
+ return true;
130
+ }
131
+ catch {
132
+ return false; // already exited, or a pid we may not signal
133
+ }
134
+ }
88
135
  function buildAgentChildEnv(policy, baseEnv = process.env) {
89
136
  const env = (0, local_1.buildChildEnv)(policy, baseEnv);
137
+ // deny must win here too: buildChildEnv already applied it above, but the
138
+ // provider-key/USER re-add below exists specifically to put vars BACK
139
+ // that buildChildEnv's readonly/locked-down defaults strip — without this
140
+ // check that re-add silently overrode an operator's explicit deny (e.g.
141
+ // deny:["AWS_SECRET_ACCESS_KEY"] still forwarded it, since AWS_ matches
142
+ // AGENT_PROVIDER_KEY_ENV_RE).
143
+ const denied = new Set(policy.env.deny || []);
90
144
  const forwarded = [];
91
145
  for (const key of Object.keys(baseEnv)) {
146
+ if (denied.has(key))
147
+ continue;
148
+ // Parent-only CW secrets are never re-added, even though they match the
149
+ // CW_ arm of AGENT_PROVIDER_KEY_ENV_RE and even if the operator forgot to
150
+ // deny them. buildChildEnv already stripped them above; this keeps the
151
+ // re-add loop from putting them back (and out of the trust-audit
152
+ // `forwarded` list). CW_AGENT_ATTEST_PRIVKEY is intentionally NOT in that
153
+ // set, so the attest wrapper still gets its signing key.
154
+ if (local_1.CW_NEVER_FORWARD_ENV.has(key))
155
+ continue;
92
156
  if (AGENT_PROVIDER_KEY_ENV_RE.test(key)) {
93
157
  env[key] = baseEnv[key];
94
158
  forwarded.push(key);
95
159
  }
96
160
  }
97
- if (baseEnv.USER !== undefined && env.USER === undefined) {
161
+ if (!denied.has("USER") && baseEnv.USER !== undefined && env.USER === undefined) {
98
162
  env.USER = baseEnv.USER;
99
163
  forwarded.push("USER");
100
164
  }
@@ -288,9 +352,9 @@ function agentHandle(request, env = process.env) {
288
352
  },
289
353
  };
290
354
  }
291
- /** Resolve a request to a spawn-style batch job, or undefined when the agent
292
- * is endpoint-configured/unconfigured (those settle through the serial
293
- * path). */
355
+ /** Resolve a request to a spawn-style (CLI-binary) batch job, or undefined when
356
+ * the agent is endpoint-configured (see prepareEndpointJob) or unconfigured
357
+ * (those settle through the serial path). */
294
358
  function prepareAgentSpawn(request) {
295
359
  const resolved = resolveAgentInvocation(request);
296
360
  if (!resolved.binary)
@@ -303,6 +367,27 @@ function prepareAgentSpawn(request) {
303
367
  timeoutMs: resolved.timeoutMs || 600000,
304
368
  };
305
369
  }
370
+ /** Resolve a request to an endpoint (HTTP-delegate) batch job, or undefined when
371
+ * the agent has a CLI binary (see prepareAgentSpawn) or is unconfigured. The
372
+ * POST body mirrors runAgentEndpoint's exactly: {manifest, prompt, model,
373
+ * resultPath, sandboxProfileId} — sandboxProfileId comes from
374
+ * request.sandboxPolicy.id, which runBackend sets to the same policy the serial
375
+ * path passes (registry.ts: `const policy = request.sandboxPolicy`), so the two
376
+ * paths post identical bytes. */
377
+ function prepareEndpointJob(request) {
378
+ const resolved = resolveAgentInvocation(request);
379
+ if (resolved.binary || !resolved.endpoint)
380
+ return undefined;
381
+ const manifest = request.manifest;
382
+ const job = JSON.stringify({
383
+ manifest,
384
+ prompt: manifest?.prompt,
385
+ model: resolved.model,
386
+ resultPath: manifest?.resultPath,
387
+ sandboxProfileId: request.sandboxPolicy.id,
388
+ });
389
+ return { endpoint: resolved.endpoint, job, timeoutMs: resolved.timeoutMs || 600000 };
390
+ }
306
391
  // __dirname is dist/shell/execution-backend at runtime — THREE levels up
307
392
  // (execution-backend -> shell -> dist -> package root) reaches scripts/,
308
393
  // a sibling of dist/, not two (that was a bug: it resolved to a
@@ -367,6 +452,36 @@ function runAgentBatchOutcomes(jobs) {
367
452
  }
368
453
  return reconcileBatchOutcomes(jobs, child);
369
454
  }
455
+ // Same package-root depth fix as BATCH_DELEGATE_CHILD_SCRIPT above.
456
+ const HTTP_BATCH_DELEGATE_CHILD_SCRIPT = path.resolve(__dirname, "..", "..", "..", "scripts", "children", "http-batch-delegate-child.js");
457
+ /** Run a batch of ENDPOINT (HTTP-delegate) jobs concurrently; outcomes
458
+ * index-align with jobs. The endpoint sibling of runAgentBatchOutcomes: one
459
+ * spawnSync of http-batch-delegate-child.js POSTs all N at once from a single
460
+ * process, so `--concurrency N` with an endpoint agent runs N delegations in
461
+ * parallel instead of serially. The child streams the SAME NDJSON line shape as
462
+ * batch-delegate-child.js — `{i, spawnError?, exitCode, stdout}` — so the same
463
+ * reconcileBatchOutcomes reads it (byte-boundary split on a Buffer, index-aligned,
464
+ * every job that produced no full line failed closed). Env is inherited (the
465
+ * serial runAgentEndpoint likewise runs the child with the full process env for
466
+ * any endpoint auth); no sandbox child-env is built here, matching the serial
467
+ * endpoint path. */
468
+ function runEndpointBatchOutcomes(jobs) {
469
+ if (!jobs.length)
470
+ return [];
471
+ const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
472
+ let child;
473
+ try {
474
+ child = (0, node_child_process_1.spawnSync)(process.execPath, [HTTP_BATCH_DELEGATE_CHILD_SCRIPT], {
475
+ input: JSON.stringify(jobs),
476
+ maxBuffer: 34 * 1024 * 1024 * jobs.length,
477
+ timeout: maxTimeout + 30000,
478
+ });
479
+ }
480
+ catch (error) {
481
+ child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
482
+ }
483
+ return reconcileBatchOutcomes(jobs, child);
484
+ }
370
485
  // ---------------------------------------------------------------------------
371
486
  // The stateful agent runners (spawn + settle). Byte-exact port of
372
487
  // src/execution-backend.ts's runAgentProcess (:915-995), runAgentEndpoint
@@ -406,9 +521,15 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
406
521
  else {
407
522
  const streamStderr = shouldStreamAgentStderr(process.env, Boolean(process.stderr.isTTY));
408
523
  const built = buildAgentChildEnv(policy);
409
- const childEnv = built.env;
410
524
  forwardedEnvVars = built.forwarded;
411
525
  const timeoutMs = resolved.timeoutMs || 600000;
526
+ // A shipped wrapper (claude/codex/gemini) records its vendor child's PID
527
+ // here so we can reap the vendor if the timeout below SIGKILLs the
528
+ // wrapper -- a SIGKILL is uncatchable, so the wrapper cannot forward the
529
+ // stop itself. Not a forwarded secret; it is cw plumbing, so it stays out
530
+ // of `built.forwarded` / the trust-audit forwarded list.
531
+ const vendorPidFile = vendorPidFilePath();
532
+ const childEnv = { ...built.env, CW_AGENT_VENDOR_PIDFILE: vendorPidFile };
412
533
  const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
413
534
  cwd: request.cwd,
414
535
  env: childEnv,
@@ -430,12 +551,30 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
430
551
  // and the null-exit-code "timed out or killed" branch was dead code
431
552
  // for real timeouts.
432
553
  if (child.error && child.error.code === "ETIMEDOUT") {
554
+ // spawnSync SIGKILLed the wrapper (uncatchable, so the wrapper could
555
+ // not forward the stop to its vendor child). Reap the vendor a shipped
556
+ // wrapper recorded, so a timed-out agent's vendor process does not live
557
+ // on as an orphan and keep spending. Scoped: reaps the vendor process
558
+ // itself for the shipped wrappers (claude, codex, gemini, opencode/
559
+ // deepseek), not a deeper grandchild tree nor an arbitrary
560
+ // CW_AGENT_COMMAND (which records no PID). We do
561
+ // NOT use `detached:true` -- that would make the wrapper its own group
562
+ // leader, so an interactive Ctrl-C / a group SIGINT to cw would no
563
+ // longer reach it (strictly worse); reaping from the recorded PID keeps
564
+ // the wrapper in cw's group.
565
+ reapRecordedVendor(vendorPidFile);
433
566
  const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
434
567
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
435
568
  ...attestation,
436
569
  handle: handleOut,
437
570
  });
438
571
  }
572
+ // Non-timeout paths: a wrapper that ran to any normal end removed its own
573
+ // sidecar on exit; clear any stray file best-effort so tmp cannot grow.
574
+ try {
575
+ fs.unlinkSync(vendorPidFile);
576
+ }
577
+ catch { /* usually already gone */ }
439
578
  outcome = {
440
579
  ...(child.error ? { spawnError: messageOf(child.error) } : {}),
441
580
  exitCode: typeof child.status === "number" ? child.status : null,
@@ -464,12 +603,70 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
464
603
  }
465
604
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-target-missing", `Backend ${descriptor.id} has no command-template or endpoint configured`, attestation);
466
605
  }
606
+ /** Settle a completed endpoint delegation (a numeric exitCode + stdout) into
607
+ * its envelope: parse the agent report, write result.md as transport when the
608
+ * runner returned a body and none exists yet, record the handle. Shared by the
609
+ * serial spawn path and the concurrent prepared-outcome path so the two never
610
+ * drift. The result-write stays HERE (the cw shell process), never in the batch
611
+ * child, so the child does no filesystem writes into the run dir. */
612
+ function settleEndpointResult(descriptor, label, endpoint, resolvedModel, manifest, attestation, exitCode, stdout) {
613
+ const report = parseAgentReport(stdout);
614
+ if (manifest?.resultPath && report.usage === undefined) {
615
+ const body = extractEndpointResult(stdout);
616
+ if (body && !fs.existsSync(manifest.resultPath)) {
617
+ try {
618
+ fs.writeFileSync(manifest.resultPath, body, "utf8");
619
+ }
620
+ catch {
621
+ /* the accept layer will fail closed on a missing result.md */
622
+ }
623
+ }
624
+ }
625
+ const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
626
+ const handleOut = recordedAgentHandle(undefined, endpoint, [], resolvedModel, reportedModel, report.usage, report.usageSignature);
627
+ return (0, envelopes_1.delegatedEnvelope)(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], exitCode, stdout);
628
+ }
467
629
  /** Agent HTTP endpoint variant — POSTs the worker manifest/prompt to a
468
630
  * configured agent endpoint via the shared Node delegate child. Byte-exact
469
- * port of src/execution-backend.ts:1002-1062. */
631
+ * port of src/execution-backend.ts:1002-1062, plus a prepared-outcome branch:
632
+ * in a concurrent round the POST already ran in the batch child
633
+ * (runEndpointBatchOutcomes), so settle that pre-collected outcome instead of
634
+ * spawning again. The serial (no prepared outcome) path is unchanged. */
470
635
  function runAgentEndpoint(descriptor, policy, request, label, resolved, attestation) {
471
636
  const endpoint = resolved.endpoint;
472
637
  const manifest = request.manifest;
638
+ const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
639
+ // Concurrent round: the batch child already POSTed and settled this job. Any
640
+ // failure (network, non-2xx, poll, unparseable, timeout, no exitCode) is a
641
+ // spawnError; a success carries a numeric exitCode + stdout.
642
+ if (request.preparedAgentOutcome) {
643
+ const prep = request.preparedAgentOutcome;
644
+ if (prep.spawnError) {
645
+ // Match the SERIAL path's refusal wording so a concurrent round records the
646
+ // same reason bytes as a serial one. A per-job HTTP failure (non-2xx, poll,
647
+ // timeout, no exitCode) is the serial `{error}` branch — `agent endpoint
648
+ // error:`. A whole-batch-child process failure surfaces as
649
+ // reconcileBatchOutcomes' `batch delegate failed:` fallback — the serial
650
+ // analog is `child.error` — `agent endpoint delegation failed:`.
651
+ const summary = prep.spawnError.startsWith("batch delegate failed:")
652
+ ? `agent endpoint delegation failed: ${prep.spawnError}`
653
+ : `agent endpoint error: ${prep.spawnError}`;
654
+ return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", summary, {
655
+ ...attestation,
656
+ handle: baseHandle,
657
+ });
658
+ }
659
+ // Defensive: the batch child always pairs a null exitCode with a spawnError,
660
+ // so this is unreachable in practice — kept as a fail-closed backstop with
661
+ // the same wording the serial no-exitCode refusal uses.
662
+ if (typeof prep.exitCode !== "number") {
663
+ return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent endpoint error: no exitCode reported`, {
664
+ ...attestation,
665
+ handle: baseHandle,
666
+ });
667
+ }
668
+ return settleEndpointResult(descriptor, label, endpoint, resolved.model, manifest, attestation, prep.exitCode, prep.stdout);
669
+ }
473
670
  const job = JSON.stringify({
474
671
  manifest,
475
672
  prompt: manifest?.prompt,
@@ -484,7 +681,6 @@ function runAgentEndpoint(descriptor, policy, request, label, resolved, attestat
484
681
  timeout: resolved.timeoutMs || 600000,
485
682
  maxBuffer: 32 * 1024 * 1024,
486
683
  });
487
- const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
488
684
  if (child.error) {
489
685
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent endpoint delegation failed: ${messageOf(child.error)}`, {
490
686
  ...attestation,
@@ -507,20 +703,5 @@ function runAgentEndpoint(descriptor, policy, request, label, resolved, attestat
507
703
  handle: baseHandle,
508
704
  });
509
705
  }
510
- const stdout = String(parsed.stdout || "");
511
- const report = parseAgentReport(stdout);
512
- if (manifest?.resultPath && report.usage === undefined) {
513
- const body = extractEndpointResult(stdout);
514
- if (body && !fs.existsSync(manifest.resultPath)) {
515
- try {
516
- fs.writeFileSync(manifest.resultPath, body, "utf8");
517
- }
518
- catch {
519
- /* the accept layer will fail closed on a missing result.md */
520
- }
521
- }
522
- }
523
- const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
524
- const handleOut = recordedAgentHandle(undefined, endpoint, [], resolved.model, reportedModel, report.usage, report.usageSignature);
525
- return (0, envelopes_1.delegatedEnvelope)(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], parsed.exitCode, stdout);
706
+ return settleEndpointResult(descriptor, label, endpoint, resolved.model, manifest, attestation, parsed.exitCode, String(parsed.stdout || ""));
526
707
  }