cool-workflow 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/cli/dispatch.js +11 -5
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +1 -0
  13. package/dist/core/capability-data.js +1 -0
  14. package/dist/core/format/completion.js +68 -0
  15. package/dist/core/format/help.js +25 -6
  16. package/dist/core/format/safe-json.js +73 -0
  17. package/dist/core/format/state-explosion-text.js +1 -1
  18. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  19. package/dist/core/multi-agent/collaboration.js +3 -3
  20. package/dist/core/multi-agent/coordinator.js +5 -5
  21. package/dist/core/multi-agent/runtime.js +4 -4
  22. package/dist/core/multi-agent/topology.js +3 -3
  23. package/dist/core/pipeline/dispatch.js +18 -4
  24. package/dist/core/pipeline/drive-decide.js +2 -1
  25. package/dist/core/state/migrations.js +16 -1
  26. package/dist/core/state/state-explosion/digest.js +13 -13
  27. package/dist/core/state/state-explosion/graph.js +17 -11
  28. package/dist/core/state/state-explosion/report.js +6 -6
  29. package/dist/core/util/numeric-flag.js +40 -0
  30. package/dist/core/version.js +1 -1
  31. package/dist/mcp/server.js +99 -11
  32. package/dist/shell/audit-cli.js +57 -25
  33. package/dist/shell/coordinator-io.js +73 -13
  34. package/dist/shell/dispatch.js +1 -1
  35. package/dist/shell/doctor.js +80 -1
  36. package/dist/shell/drive.js +243 -49
  37. package/dist/shell/eval-text.js +2 -2
  38. package/dist/shell/evidence-reasoning.js +4 -4
  39. package/dist/shell/execution-backend/agent.js +20 -1
  40. package/dist/shell/execution-backend/container.js +4 -1
  41. package/dist/shell/execution-backend/local.js +4 -1
  42. package/dist/shell/feedback-cli.js +6 -6
  43. package/dist/shell/fs-atomic.js +218 -29
  44. package/dist/shell/man-cli.js +6 -0
  45. package/dist/shell/metrics-cli.js +2 -1
  46. package/dist/shell/multi-agent-cli.js +367 -323
  47. package/dist/shell/multi-agent-host.js +9 -9
  48. package/dist/shell/multi-agent-operator-ux.js +80 -38
  49. package/dist/shell/node-store.js +10 -4
  50. package/dist/shell/observability.js +1 -1
  51. package/dist/shell/operator-ux-text.js +22 -22
  52. package/dist/shell/operator-ux.js +15 -14
  53. package/dist/shell/orchestrator.js +49 -38
  54. package/dist/shell/pipeline-cli.js +87 -40
  55. package/dist/shell/reclamation-io.js +14 -4
  56. package/dist/shell/registry-cli.js +19 -17
  57. package/dist/shell/remote-source.js +13 -8
  58. package/dist/shell/report-cli.js +45 -0
  59. package/dist/shell/report.js +2 -1
  60. package/dist/shell/run-registry-io.js +77 -19
  61. package/dist/shell/run-store.js +74 -2
  62. package/dist/shell/scheduling-io.js +12 -13
  63. package/dist/shell/state-cli.js +2 -7
  64. package/dist/shell/state-explosion-cli.js +17 -9
  65. package/dist/shell/topology-io.js +36 -5
  66. package/dist/shell/trust-audit.js +224 -22
  67. package/dist/shell/trust-policy-io.js +1 -1
  68. package/dist/shell/worker-cli.js +35 -31
  69. package/dist/wiring/capability-table/basics.js +33 -8
  70. package/dist/wiring/capability-table/exec-backend.js +20 -11
  71. package/dist/wiring/capability-table/multi-agent.js +172 -165
  72. package/dist/wiring/capability-table/parity.js +1 -0
  73. package/dist/wiring/capability-table/pipeline.js +54 -42
  74. package/dist/wiring/capability-table/registry-core.js +29 -10
  75. package/dist/wiring/capability-table/reporting.js +137 -96
  76. package/dist/wiring/capability-table/scheduling-registry.js +195 -161
  77. package/dist/wiring/capability-table/state.js +36 -28
  78. package/dist/wiring/capability-table/trust-ledger.js +46 -22
  79. package/dist/wiring/capability-table/workflow-apps.js +83 -68
  80. package/docs/agent-delegation-drive.7.md +2 -0
  81. package/docs/candidate-scoring.7.md +1 -1
  82. package/docs/canonical-workflow-apps.7.md +7 -7
  83. package/docs/cli-mcp-parity.7.md +8 -3
  84. package/docs/contract-migration-tooling.7.md +2 -0
  85. package/docs/control-plane-scheduling.7.md +2 -0
  86. package/docs/coordinator-blackboard.7.md +17 -17
  87. package/docs/dogfood-one-real-repo.7.md +11 -11
  88. package/docs/durable-state-and-locking.7.md +2 -0
  89. package/docs/end-to-end-golden-path.7.md +14 -14
  90. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  91. package/docs/execution-backends.7.md +2 -0
  92. package/docs/getting-started.md +37 -37
  93. package/docs/multi-agent-cli-mcp-surface.7.md +19 -17
  94. package/docs/multi-agent-eval-replay-harness.7.md +15 -13
  95. package/docs/multi-agent-operator-ux.7.md +21 -19
  96. package/docs/multi-agent-runtime-core.7.md +22 -22
  97. package/docs/multi-agent-topologies.7.md +6 -6
  98. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  99. package/docs/node-snapshot-diff-replay.7.md +2 -0
  100. package/docs/observability-cost-accounting.7.md +2 -0
  101. package/docs/operator-ux.7.md +34 -34
  102. package/docs/pipeline-runner.7.md +4 -4
  103. package/docs/project-index.md +27 -4
  104. package/docs/real-execution-backends.7.md +2 -0
  105. package/docs/release-and-migration.7.md +3 -1
  106. package/docs/release-tooling.7.md +2 -0
  107. package/docs/routines.md +4 -4
  108. package/docs/run-registry-control-plane.7.md +21 -19
  109. package/docs/run-retention-reclamation.7.md +2 -0
  110. package/docs/scheduled-tasks.md +14 -14
  111. package/docs/security-trust-hardening.7.md +15 -15
  112. package/docs/state-explosion-management.7.md +12 -10
  113. package/docs/team-collaboration.7.md +2 -0
  114. package/docs/trust-audit-anchor.7.md +2 -0
  115. package/docs/unix-principles.md +3 -1
  116. package/docs/verifier-gated-commit.7.md +1 -1
  117. package/docs/web-desktop-workbench.7.md +2 -0
  118. package/docs/workflow-app-framework.7.md +13 -13
  119. package/manifest/plugin.manifest.json +1 -1
  120. package/package.json +2 -2
  121. package/scripts/agents/claude-p-agent.js +2 -2
  122. package/scripts/block-unapproved-tag.sh +23 -2
  123. package/scripts/canonical-apps.js +4 -4
  124. package/scripts/children/batch-delegate-child.js +52 -2
  125. package/scripts/dogfood-release.js +1 -1
  126. package/scripts/fake-date-for-reproduction.js +44 -0
  127. package/scripts/golden-path.js +4 -4
  128. package/scripts/purity-baseline.json +3 -0
  129. package/scripts/release-flow.js +57 -1
  130. package/scripts/verdict-keygen.js +83 -0
  131. package/scripts/verify-bump-reproduction.sh +148 -0
  132. package/scripts/verify-verdict-signature.js +61 -0
  133. package/skills/cool-workflow/SKILL.md +9 -9
  134. package/skills/cool-workflow/references/commands.md +89 -88
  135. package/ui/workbench/app.css +37 -1
  136. package/ui/workbench/app.js +124 -6
@@ -51,6 +51,7 @@ exports.MAX_SUB_WORKFLOW_DEPTH = exports.DRIVE_SCHEMA_VERSION = void 0;
51
51
  exports.maybeExpandLoop = maybeExpandLoop;
52
52
  exports.driveStep = driveStep;
53
53
  exports.drive = drive;
54
+ exports.driveAsync = driveAsync;
54
55
  exports.drivePreview = drivePreview;
55
56
  const fs = __importStar(require("node:fs"));
56
57
  const path = __importStar(require("node:path"));
@@ -153,15 +154,22 @@ function loadRun(ctx) {
153
154
  /** Runs `fn` with `runId`'s loadRun calls served from one shared cached
154
155
  * object (seeded fresh from disk), and always clears the cache entry
155
156
  * afterward — even on throw — so a round never leaks its cache into a
156
- * later, unrelated drive call. */
157
+ * later, unrelated drive call. Re-entrant: a nested call (e.g. the
158
+ * serial drive loop's own round-cache wrapping a driveStep that itself
159
+ * runs inside a caller's round cache) reuses the outer seed instead of
160
+ * re-reading disk, and only the OUTERMOST call clears the entry — so
161
+ * nesting never re-reads a run mid-round nor drops the cache early out
162
+ * from under an enclosing scope. */
157
163
  function withRoundCache(ctx, fn) {
158
- const seed = (0, run_store_1.loadRunFromCwd)(ctx.runId, ctx.cwd);
159
- roundCache.set(ctx.runId, seed);
164
+ const alreadyActive = roundCache.has(ctx.runId);
165
+ if (!alreadyActive)
166
+ roundCache.set(ctx.runId, (0, run_store_1.loadRunFromCwd)(ctx.runId, ctx.cwd));
160
167
  try {
161
168
  return fn();
162
169
  }
163
170
  finally {
164
- roundCache.delete(ctx.runId);
171
+ if (!alreadyActive)
172
+ roundCache.delete(ctx.runId);
165
173
  }
166
174
  }
167
175
  function resultCachePath(run, task, promptDigest, incremental, delegationDigest) {
@@ -551,7 +559,8 @@ function maybeExpandLoop(run) {
551
559
  const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
552
560
  if (phase.id !== latest.id)
553
561
  continue;
554
- const roundTasks = run.tasks.filter((t) => latest.taskIds.includes(t.id));
562
+ const latestTaskIds = new Set(latest.taskIds);
563
+ const roundTasks = run.tasks.filter((t) => latestTaskIds.has(t.id));
555
564
  if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
556
565
  continue;
557
566
  const round = latest.loopRound || 1;
@@ -560,7 +569,8 @@ function maybeExpandLoop(run) {
560
569
  .sort((a, b) => compareBytes(a.id, b.id))
561
570
  .map((t) => t.result);
562
571
  const roundResults = ordered(roundTasks);
563
- const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopPhases.some((p) => p.taskIds.includes(t.id)));
572
+ const loopTaskIds = new Set(loopPhases.flatMap((p) => p.taskIds));
573
+ const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopTaskIds.has(t.id));
564
574
  const allResults = ordered(allLoopTasks);
565
575
  const ctx = {
566
576
  round,
@@ -597,7 +607,8 @@ function maybeExpandLoop(run) {
597
607
  // Expand: clone the ROUND-1 template tasks into a fresh phase appended
598
608
  // right after the latest round.
599
609
  const nextRound = round + 1;
600
- const templateTasks = run.tasks.filter((t) => origin.taskIds.includes(t.id));
610
+ const originTaskIds = new Set(origin.taskIds);
611
+ const templateTasks = run.tasks.filter((t) => originTaskIds.has(t.id));
601
612
  const { phase: nextPhase, tasks: newTasks } = (0, loop_expansion_1.cloneLoopRoundTasks)(origin, templateTasks, nextRound);
602
613
  const insertAt = run.phases.findIndex((p) => p.id === latest.id);
603
614
  run.phases.splice(insertAt + 1, 0, nextPhase);
@@ -732,8 +743,9 @@ function driveConcurrentRound(ctx, limit) {
732
743
  return [driveStep(ctx)];
733
744
  const phase = (0, dispatch_1.firstRunnablePhase)(run);
734
745
  const width = Math.max(1, Math.floor(limit) || 1);
746
+ const phaseTaskIds = new Set(phase.taskIds);
735
747
  const batch = run.tasks
736
- .filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
748
+ .filter((task) => phaseTaskIds.has(task.id) && (task.status === "pending" || task.status === "running"))
737
749
  .slice(0, width)
738
750
  .map((task) => task.id);
739
751
  const prepared = prepareConcurrentOutcomes(ctx, batch);
@@ -762,13 +774,37 @@ function driveConcurrentRound(ctx, limit) {
762
774
  return steps.length > 0 ? steps : [driveStep(ctx)];
763
775
  });
764
776
  }
765
- /** Drive a run: `--once` advances exactly one step; otherwise run to
766
- * completion, park, or a blocked stop. */
767
- function drive(runId, cwd, options = {}) {
777
+ // A bare Ctrl-C (SIGINT) or an external SIGTERM used to kill the process
778
+ // outright between steps -- no handler was ever installed, so the default
779
+ // kernel action ran: instant termination, no `finally` anywhere gets a
780
+ // chance to run. If that landed mid-`withFileLock` critical section (e.g.
781
+ // the state.json or trust-audit-log lock in run-store.ts/trust-audit.ts),
782
+ // the lock's release `finally` never ran either, leaving a stale `.lock`
783
+ // file that makes the NEXT command retry for ~6s before failing outright
784
+ // (well under the 30s stale-lock steal window).
785
+ //
786
+ // The FIRST fix for this (installing onStopSignal below) assumed the
787
+ // signal would at least be caught BETWEEN rounds, only missing it
788
+ // mid-spawnSync. That assumption was wrong: Node.js does not dispatch a
789
+ // queued POSIX signal to a JS `process.on(signal, ...)` listener until
790
+ // the event loop actually gets a turn, and drive()'s whole multi-round
791
+ // loop -- every round's agent spawn uses spawnSync, and nothing between
792
+ // rounds ever awaits anything -- is ONE continuous synchronous span with
793
+ // no such turn anywhere inside it. A signal landing at any point during
794
+ // that whole span (confirmed live: even a plain CPU-only busy loop with
795
+ // no spawnSync at all drops it the same way) sits queued until the loop
796
+ // finishes on its own, by which point the `finally` below has already
797
+ // removed the listener -- so it is never invoked at all. `drive()` keeps
798
+ // this exact (buggy but byte-identical) shape for full backward
799
+ // compatibility; `driveAsync()` below is the actual fix, for callers
800
+ // that need a live run to really respond to Ctrl-C/SIGTERM.
801
+ const DRIVE_STOP_SIGNALS = ["SIGINT", "SIGTERM"];
802
+ const DRIVE_STOP_EXIT_CODE = { SIGINT: 130, SIGTERM: 143 };
803
+ function buildDriveContext(runId, cwd, options) {
768
804
  const now = options.now || new Date().toISOString();
769
805
  const config = options.agentConfig || (0, agent_config_1.resolveAgentConfig)(options.args || {});
770
806
  const policy = { ...drive_decide_1.DEFAULT_SCHEDULING_POLICY, ...(options.policy || {}) };
771
- const ctx = {
807
+ return {
772
808
  runId,
773
809
  cwd,
774
810
  now,
@@ -779,22 +815,22 @@ function drive(runId, cwd, options = {}) {
779
815
  visitedAppIds: options.visitedAppIds || [],
780
816
  policy,
781
817
  };
782
- const steps = [];
783
- const run0 = loadRun(ctx);
784
- const plannedWorkers = run0.tasks.length;
785
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), policy);
786
- // Phase-boundary progress (brew-style): announce each phase when it
787
- // becomes active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess
788
- // (3/6)`. Describes CW's OWN phases (vendor-neutral); goes to stderr
789
- // via emitProgress so stdout stays clean data. Byte-exact port of the
790
- // old build's src/drive.ts emitPhaseProgress. term.phaseProgressLine
791
- // renders the line; this closure decides WHEN to emit each boundary.
818
+ }
819
+ // Phase-boundary progress (brew-style): announce each phase when it becomes
820
+ // active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess ⇉ (3/6)`.
821
+ // Describes CW's OWN phases (vendor-neutral); goes to stderr via
822
+ // emitProgress so stdout stays clean data. Byte-exact port of the old
823
+ // build's src/drive.ts emitPhaseProgress. term.phaseProgressLine renders
824
+ // the line; the returned closure decides WHEN to emit each boundary.
825
+ // Shared by drive() and driveAsync() so the two never drift apart.
826
+ function createPhaseProgressEmitter() {
792
827
  const announcedPhaseComplete = new Set();
793
828
  let activePhaseId;
794
829
  const titleCase = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
795
- const emitPhaseProgress = (run) => {
830
+ return (run) => {
796
831
  for (const ph of run.phases || []) {
797
- const phaseTasks = run.tasks.filter((task) => ph.taskIds.includes(task.id));
832
+ const phaseTaskIds = new Set(ph.taskIds);
833
+ const phaseTasks = run.tasks.filter((task) => phaseTaskIds.has(task.id));
798
834
  const total = phaseTasks.length;
799
835
  if (total === 0)
800
836
  continue;
@@ -814,38 +850,113 @@ function drive(runId, cwd, options = {}) {
814
850
  return; // only the first not-yet-complete phase is "active"
815
851
  }
816
852
  };
817
- let exhaustedMaxIterations = !options.once;
818
- for (let i = 0; i < maxIter; i++) {
853
+ }
854
+ /** Runs exactly one round -- a single driveStep, or a full concurrent
855
+ * round when width>1 -- and records its steps. Returns false when the
856
+ * round loop should stop (a terminal step, or `--once`'s single-round
857
+ * contract), true to go on to another round. Shared by drive()'s plain
858
+ * loop and driveAsync()'s interruptible one so the two never drift
859
+ * apart. */
860
+ function driveOneRound(ctx, options, steps, emitPhaseProgress) {
861
+ // One round-cache scope for the WHOLE round, not just driveConcurrentRound's
862
+ // own batch: the width check and (for a serial round) driveStep/
863
+ // processSelectedTask's several loadRun(ctx) calls otherwise each
864
+ // re-read+re-parse state.json even though nothing on disk changed
865
+ // between them — reads that only reflect the mutations THIS round's
866
+ // own steps make, which the shared in-memory object already carries.
867
+ const roundSteps = withRoundCache(ctx, () => {
819
868
  const width = (0, drive_decide_1.roundWidth)(loadRun(ctx), options.concurrency);
820
869
  // width>1 (an explicit --concurrency>1, or an auto-width parallel
821
870
  // phase) runs the whole round through driveConcurrentRound — one or
822
- // more steps recorded in deterministic batch order, one flush at
823
- // round end. `--once` still stops after this ONE outer-loop
824
- // iteration even though a round can yield multiple steps.
825
- const roundSteps = width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
826
- for (const stepResult of roundSteps)
827
- steps.push(stepResult);
828
- // Brew-style phase boundaries: after each round, announce a
829
- // newly-active phase and any phase that just finished. Cheap — reuses
830
- // the run we just advanced; goes to stderr so stdout stays clean.
831
- emitPhaseProgress(loadRun(ctx));
832
- const last = roundSteps[roundSteps.length - 1];
833
- if (options.once) {
834
- exhaustedMaxIterations = false;
835
- break;
836
- }
837
- if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
838
- exhaustedMaxIterations = false;
839
- break;
871
+ // more steps recorded in deterministic batch order, one flush at round
872
+ // end. `--once` still stops after this ONE round even though a round
873
+ // can yield multiple steps.
874
+ return width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
875
+ });
876
+ for (const stepResult of roundSteps)
877
+ steps.push(stepResult);
878
+ // Brew-style phase boundaries: after each round, announce a newly-active
879
+ // phase and any phase that just finished. Cheap reuses the run we just
880
+ // advanced; goes to stderr so stdout stays clean.
881
+ emitPhaseProgress(loadRun(ctx));
882
+ const last = roundSteps[roundSteps.length - 1];
883
+ if (options.once)
884
+ return false;
885
+ if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
886
+ return false;
887
+ return true;
888
+ }
889
+ /** One JS EventEmitter listener per SIGINT/SIGTERM, installed for the span
890
+ * of one drive()/driveAsync() call and removed by that caller's own
891
+ * `finally` (this function recurses for nested sub-workflow runs, and
892
+ * many tests call it repeatedly in one process, so nothing must
893
+ * accumulate on `process`'s listener list across calls). The FIRST
894
+ * signal sets a flag the round loop checks at the top of its next
895
+ * iteration -- never mid-round -- so the in-flight round always
896
+ * finishes its own bookkeeping first. A SECOND signal means the caller
897
+ * wants out right now, not a graceful stop, and force-exits immediately
898
+ * with the conventional 128+signum code. */
899
+ function createStopSignalController() {
900
+ let interruptedBy;
901
+ let stopSignalHits = 0;
902
+ const onStopSignal = (signal) => {
903
+ stopSignalHits += 1;
904
+ if (stopSignalHits === 1) {
905
+ interruptedBy = signal;
906
+ emitProgress(`received ${signal} — stopping after the current step (run again with the same run id to resume)`);
907
+ return;
840
908
  }
841
- }
909
+ // A second signal means the caller wants out right now, not a graceful stop.
910
+ emitProgress(`received a second ${signal} — exiting immediately`);
911
+ process.exit(DRIVE_STOP_EXIT_CODE[signal] ?? 1);
912
+ };
913
+ return {
914
+ install: () => {
915
+ for (const signal of DRIVE_STOP_SIGNALS)
916
+ process.on(signal, onStopSignal);
917
+ },
918
+ remove: () => {
919
+ for (const signal of DRIVE_STOP_SIGNALS)
920
+ process.off(signal, onStopSignal);
921
+ },
922
+ getInterruptedBy: () => interruptedBy,
923
+ };
924
+ }
925
+ /** Forces one real turn of the Node.js event loop. `setImmediate` (a
926
+ * genuine macrotask, unlike a bare Promise microtask, which never leaves
927
+ * the current turn) is the only thing that actually lets libuv's poll
928
+ * phase run and dispatch a queued SIGINT/SIGTERM to its JS listener --
929
+ * confirmed live: a real external `kill -INT` sent to a busy synchronous
930
+ * process is dropped for as long as the process never yields, and fires
931
+ * within ~1ms of the very next event-loop turn once it does. */
932
+ function yieldToEventLoop() {
933
+ return new Promise((resolve) => setImmediate(resolve));
934
+ }
935
+ /** Everything drive()/driveAsync() do once their round loop has stopped:
936
+ * fold the interrupted/exhausted-iterations signal into the right
937
+ * "blocked" step, then assemble the DriveResult. Shared so the two
938
+ * loops can never report status differently for the same outcome. */
939
+ function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterationsAtLoopExit, interruptedBy) {
940
+ let exhaustedMaxIterations = exhaustedMaxIterationsAtLoopExit;
842
941
  const run = loadRun(ctx);
843
942
  const completedWorkers = (0, drive_decide_1.countCompleted)(run);
844
943
  const parkedWorkers = (0, drive_decide_1.countParked)(run);
845
944
  const committed = (0, drive_decide_1.hasTerminalCommit)(run);
846
945
  const last = steps[steps.length - 1];
847
- if (exhaustedMaxIterations) {
848
- steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state` }));
946
+ // A signal can land on the very last step (the terminal commit itself) --
947
+ // the run is already fully done, same "complete" check the `once` branch
948
+ // of finalDriveStatus below already uses. Reporting "blocked" here anyway
949
+ // would be a real user-visible lie (e.g. pipeline-cli.ts's `--bundle` gate
950
+ // reads status, so a genuinely finished run would wrongly skip sealing).
951
+ const alreadyComplete = completedWorkers === plannedWorkers && committed;
952
+ if (interruptedBy) {
953
+ exhaustedMaxIterations = false;
954
+ 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` }));
956
+ }
957
+ }
958
+ 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` }));
849
960
  }
850
961
  const statusInputs = {
851
962
  once: Boolean(options.once),
@@ -862,7 +973,7 @@ function drive(runId, cwd, options = {}) {
862
973
  const committedCommit = (run.commits || []).find((c) => c.reason && c.reason.startsWith("agent-delegation-drive"));
863
974
  return {
864
975
  schemaVersion: 1,
865
- runId,
976
+ runId: ctx.runId,
866
977
  workflowId: run.workflow.id,
867
978
  status,
868
979
  steps,
@@ -872,9 +983,92 @@ function drive(runId, cwd, options = {}) {
872
983
  commitId: committedCommit?.id,
873
984
  reportPath: run.paths.report,
874
985
  statePath: run.paths.state,
875
- agentConfigured: agentConfigured(config),
986
+ agentConfigured: agentConfigured(ctx.config),
876
987
  };
877
988
  }
989
+ /** Drive a run: `--once` advances exactly one step; otherwise run to
990
+ * completion, park, or a blocked stop. Fully synchronous, byte-identical
991
+ * to every prior release -- see the file comment above DRIVE_STOP_SIGNALS
992
+ * for why a real SIGINT/SIGTERM sent during a live multi-round call is
993
+ * queued but never actually reaches onStopSignal below; that limitation
994
+ * is deliberately kept here for backward compatibility (recursive
995
+ * sub-workflow runs, and every existing caller/test that depends on this
996
+ * exact synchronous shape) and fixed only in driveAsync(). */
997
+ function drive(runId, cwd, options = {}) {
998
+ 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;
1014
+ }
1015
+ }
1016
+ }
1017
+ finally {
1018
+ stopSignal.remove();
1019
+ }
1020
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1021
+ }
1022
+ /** Same contract and same DriveResult as drive() -- but actually
1023
+ * interruptible. After every round it awaits yieldToEventLoop(), a real
1024
+ * setImmediate-based turn of the event loop, giving Node.js an actual
1025
+ * chance to dispatch a queued SIGINT/SIGTERM to onStopSignal before the
1026
+ * next round's synchronous spawnSync work begins; the loop then sees
1027
+ * the interrupted flag at the top of its next iteration exactly like
1028
+ * drive() already checks it. Shares driveOneRound/createStopSignalController/
1029
+ * finalizeDriveResult with drive(), so the two can only ever differ in
1030
+ * this one respect. Intended for the live, potentially-long-running
1031
+ * entry points (CLI `--drive`, the MCP run.drive.step tool) where a
1032
+ * user's Ctrl-C or a supervisor's SIGTERM must actually be able to stop
1033
+ * the run; internal/recursive/test callers that don't need this keep
1034
+ * calling the plain drive() above, unaffected.
1035
+ *
1036
+ * A signal arriving while a NESTED sub-workflow's own drive() call
1037
+ * (runSubWorkflow, below) is running is still not caught until that
1038
+ * nested call finishes on its own -- an accepted, documented limitation:
1039
+ * the same "cannot interrupt an already in-flight blocking operation,
1040
+ * only checked at explicit boundaries" shape as the pre-existing
1041
+ * cannot-interrupt-mid-spawnSync limit, just one level of recursion
1042
+ * deeper. Fixing that would require threading interruptibility through
1043
+ * the entire recursive call graph (driveStep/processSelectedTask/
1044
+ * runSubWorkflow/handleHop), a materially larger change left for a
1045
+ * future cycle if it proves needed in practice. */
1046
+ async function driveAsync(runId, cwd, options = {}) {
1047
+ 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;
1063
+ }
1064
+ await yieldToEventLoop();
1065
+ }
1066
+ }
1067
+ finally {
1068
+ stopSignal.remove();
1069
+ }
1070
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1071
+ }
878
1072
  function drivePreview(runId, cwd, args = {}) {
879
1073
  const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
880
1074
  const config = (0, agent_config_1.resolveAgentConfig)(args);
@@ -167,7 +167,7 @@ function formatMultiAgentEval(value) {
167
167
  ` replay=${value.paths.replayRunPath}`,
168
168
  "",
169
169
  "Next Action",
170
- ` node scripts/cw.js eval compare ${value.paths.snapshotPath} ${value.paths.replayRunPath}`,
170
+ ` cw eval compare ${value.paths.snapshotPath} ${value.paths.replayRunPath}`,
171
171
  ].join("\n");
172
172
  }
173
173
  if (isSnapshot(value)) {
@@ -201,7 +201,7 @@ function formatMultiAgentEval(value) {
201
201
  " snapshot-ready",
202
202
  "",
203
203
  "Next Action",
204
- ` node scripts/cw.js eval replay ${value.paths.snapshotPath}`,
204
+ ` cw eval replay ${value.paths.snapshotPath}`,
205
205
  ].join("\n");
206
206
  }
207
207
  if (isReport(value)) {
@@ -65,10 +65,10 @@ function buildEvidenceReasoningReport(run, options = {}) {
65
65
  if (persisted && persisted.sourceFingerprint !== currentFingerprint)
66
66
  status = "stale";
67
67
  const nextAction = status === "stale" || status === "absent"
68
- ? `node scripts/cw.js multi-agent reasoning ${run.id} --refresh`
68
+ ? `cw multi-agent reasoning ${run.id} --refresh`
69
69
  : totals.unexplained > 0
70
- ? `node scripts/cw.js multi-agent reasoning ${run.id} --json`
71
- : `node scripts/cw.js multi-agent evidence ${run.id} --json`;
70
+ ? `cw multi-agent reasoning ${run.id} --json`
71
+ : `cw multi-agent evidence ${run.id} --json`;
72
72
  return {
73
73
  schemaVersion: exports.EVIDENCE_REASONING_SCHEMA_VERSION,
74
74
  runId: run.id,
@@ -343,7 +343,7 @@ function refreshEvidenceReasoning(run) {
343
343
  totals: report.totals,
344
344
  entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
345
345
  paths: { reasoningDir: dir, indexPath, reportPath },
346
- nextAction: `node scripts/cw.js multi-agent reasoning ${run.id}`,
346
+ nextAction: `cw multi-agent reasoning ${run.id}`,
347
347
  };
348
348
  (0, fs_atomic_1.writeJson)(indexPath, index);
349
349
  (0, fs_atomic_1.writeJson)(reportPath, { ...report, freshness: { ...report.freshness, status: "valid", persistedFingerprint: report.sourceFingerprint } });
@@ -408,15 +408,34 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
408
408
  const built = buildAgentChildEnv(policy);
409
409
  const childEnv = built.env;
410
410
  forwardedEnvVars = built.forwarded;
411
+ const timeoutMs = resolved.timeoutMs || 600000;
411
412
  const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
412
413
  cwd: request.cwd,
413
414
  env: childEnv,
414
415
  encoding: "utf8",
415
- timeout: resolved.timeoutMs || 600000,
416
+ timeout: timeoutMs,
416
417
  maxBuffer: 32 * 1024 * 1024,
417
418
  shell: false,
419
+ // SIGKILL, not the default SIGTERM: an agent wrapper that ignores
420
+ // SIGTERM would leave this blocking spawnSync waiting forever (no
421
+ // second-stage escalation is possible from inside a sync call), and
422
+ // a timed-out agent's output is discarded by the refusal below
423
+ // anyway — so a hard kill loses nothing and cannot wedge cw.
424
+ killSignal: "SIGKILL",
418
425
  stdio: ["ignore", "pipe", streamStderr ? "inherit" : "pipe"],
419
426
  });
427
+ // A timeout surfaces as child.error with code ETIMEDOUT (status null,
428
+ // signal set). Classify it FIRST: without this, the generic spawnError
429
+ // branch below reported "agent process failed to spawn: ...ETIMEDOUT"
430
+ // and the null-exit-code "timed out or killed" branch was dead code
431
+ // for real timeouts.
432
+ if (child.error && child.error.code === "ETIMEDOUT") {
433
+ const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
434
+ return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
435
+ ...attestation,
436
+ handle: handleOut,
437
+ });
438
+ }
420
439
  outcome = {
421
440
  ...(child.error ? { spawnError: messageOf(child.error) } : {}),
422
441
  exitCode: typeof child.status === "number" ? child.status : null,
@@ -56,7 +56,10 @@ function runContainer(descriptor, policy, request, label, handle, attestation) {
56
56
  }
57
57
  }
58
58
  runArgs.push(handle.ref, command, ...args);
59
- const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
59
+ // An unset timeoutMs must not mean "no timeout" spawnSync would then
60
+ // block forever on a hung container with no kill path. 600000 matches the
61
+ // agent backend's own default fallback (execution-backend/agent.ts).
62
+ const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024 });
60
63
  if (result.error) {
61
64
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, attestation);
62
65
  }
@@ -65,7 +65,10 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
65
65
  cwd: request.cwd,
66
66
  env,
67
67
  encoding: "utf8",
68
- timeout: request.timeoutMs,
68
+ // An unset timeoutMs must not mean "no timeout" — spawnSync would then
69
+ // block forever on a hung child with no kill path. 600000 matches the
70
+ // agent backend's own default fallback (execution-backend/agent.ts).
71
+ timeout: request.timeoutMs || 600000,
69
72
  maxBuffer: 32 * 1024 * 1024,
70
73
  };
71
74
  if (spawnStyle === "shell") {
@@ -67,15 +67,15 @@ function feedbackSummaryCli(args) {
67
67
  const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
68
68
  return (0, error_feedback_1.summarizeFeedback)((0, feedback_operations_1.listFeedback)(run));
69
69
  }
70
+ // collect/task/resolve mutate the run and saveCheckpoint (transitively,
71
+ // in feedback-operations -> error-feedback-io), so they hold the state.json
72
+ // lock across the whole load -> change -> save cycle (lost-update class).
70
73
  function feedbackCollectCli(args) {
71
- const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
72
- return (0, feedback_operations_1.collectFeedback)(run);
74
+ return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.collectFeedback)(run));
73
75
  }
74
76
  function feedbackTaskCli(args) {
75
- const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
76
- return (0, feedback_operations_1.createFeedbackTask)(run, req(args.feedbackId, "feedback id"), args);
77
+ return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.createFeedbackTask)(run, req(args.feedbackId, "feedback id"), args));
77
78
  }
78
79
  function feedbackResolveCli(args) {
79
- const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
80
- return (0, feedback_operations_1.resolveFeedback)(run, req(args.feedbackId, "feedback id"), args);
80
+ return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.resolveFeedback)(run, req(args.feedbackId, "feedback id"), args));
81
81
  }