cool-workflow 0.2.1 → 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 (156) 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 +29 -91
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +6 -2
  13. package/dist/core/capability-data.js +271 -0
  14. package/dist/core/capability-table.js +16 -3504
  15. package/dist/core/format/completion.js +68 -0
  16. package/dist/core/format/help.js +50 -8
  17. package/dist/core/format/safe-json.js +73 -0
  18. package/dist/core/format/state-explosion-text.js +1 -1
  19. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  20. package/dist/core/multi-agent/collaboration.js +6 -5
  21. package/dist/core/multi-agent/coordinator.js +9 -8
  22. package/dist/core/multi-agent/runtime.js +4 -4
  23. package/dist/core/multi-agent/topology.js +3 -3
  24. package/dist/core/pipeline/commit-gate.js +2 -1
  25. package/dist/core/pipeline/dispatch.js +18 -4
  26. package/dist/core/pipeline/drive-decide.js +2 -1
  27. package/dist/core/state/migrations.js +16 -1
  28. package/dist/core/state/state-explosion/digest.js +16 -15
  29. package/dist/core/state/state-explosion/graph.js +26 -19
  30. package/dist/core/state/state-explosion/helpers.js +2 -1
  31. package/dist/core/state/state-explosion/report.js +6 -6
  32. package/dist/core/trust/ledger.js +2 -1
  33. package/dist/core/types/execution-backend.js +18 -0
  34. package/dist/core/types/observability.js +7 -0
  35. package/dist/core/util/collate.js +23 -0
  36. package/dist/core/util/numeric-flag.js +40 -0
  37. package/dist/core/version.js +1 -1
  38. package/dist/mcp/server.js +99 -11
  39. package/dist/shell/audit-cli.js +99 -23
  40. package/dist/shell/commit-summary.js +2 -1
  41. package/dist/shell/coordinator-io.js +73 -13
  42. package/dist/shell/dispatch.js +1 -1
  43. package/dist/shell/doctor.js +80 -1
  44. package/dist/shell/drive.js +269 -59
  45. package/dist/shell/eval-text.js +2 -2
  46. package/dist/shell/evidence-reasoning.js +8 -7
  47. package/dist/shell/execution-backend/agent.js +20 -1
  48. package/dist/shell/execution-backend/container.js +4 -1
  49. package/dist/shell/execution-backend/local.js +4 -1
  50. package/dist/shell/execution-backend/registry.js +2 -1
  51. package/dist/shell/execution-backend/types.js +0 -9
  52. package/dist/shell/feedback-cli.js +6 -6
  53. package/dist/shell/fs-atomic.js +232 -30
  54. package/dist/shell/man-cli.js +6 -0
  55. package/dist/shell/metrics-cli.js +2 -1
  56. package/dist/shell/multi-agent-cli.js +367 -323
  57. package/dist/shell/multi-agent-host.js +9 -9
  58. package/dist/shell/multi-agent-operator-ux.js +82 -39
  59. package/dist/shell/node-store.js +10 -4
  60. package/dist/shell/observability.js +13 -12
  61. package/dist/shell/onramp.js +17 -1
  62. package/dist/shell/operator-ux-text.js +24 -23
  63. package/dist/shell/operator-ux.js +22 -20
  64. package/dist/shell/orchestrator.js +49 -38
  65. package/dist/shell/pipeline-cli.js +168 -111
  66. package/dist/shell/reclamation-io.js +16 -5
  67. package/dist/shell/registry-cli.js +19 -17
  68. package/dist/shell/remote-source.js +13 -8
  69. package/dist/shell/report-cli.js +45 -0
  70. package/dist/shell/report.js +4 -2
  71. package/dist/shell/run-registry-io.js +77 -19
  72. package/dist/shell/run-store.js +91 -2
  73. package/dist/shell/scheduling-io.js +12 -13
  74. package/dist/shell/state-cli.js +2 -7
  75. package/dist/shell/state-explosion-cli.js +19 -10
  76. package/dist/shell/topology-io.js +38 -6
  77. package/dist/shell/trust-audit.js +264 -23
  78. package/dist/shell/trust-policy-io.js +1 -1
  79. package/dist/shell/worker-cli.js +41 -29
  80. package/dist/shell/worker-isolation.js +34 -9
  81. package/dist/shell/workflow-app-loader.js +3 -2
  82. package/dist/wiring/capability-table/basics.js +91 -0
  83. package/dist/wiring/capability-table/exec-backend.js +171 -0
  84. package/dist/wiring/capability-table/index.js +42 -0
  85. package/dist/wiring/capability-table/multi-agent.js +630 -0
  86. package/dist/wiring/capability-table/parity.js +467 -0
  87. package/dist/wiring/capability-table/pipeline.js +292 -0
  88. package/dist/wiring/capability-table/registry-core.js +208 -0
  89. package/dist/wiring/capability-table/reporting.js +435 -0
  90. package/dist/wiring/capability-table/scheduling-registry.js +592 -0
  91. package/dist/wiring/capability-table/state.js +181 -0
  92. package/dist/wiring/capability-table/trust-ledger.js +158 -0
  93. package/dist/wiring/capability-table/workflow-apps.js +381 -0
  94. package/docs/agent-delegation-drive.7.md +4 -0
  95. package/docs/candidate-scoring.7.md +1 -1
  96. package/docs/canonical-workflow-apps.7.md +7 -7
  97. package/docs/cli-mcp-parity.7.md +13 -3
  98. package/docs/contract-migration-tooling.7.md +4 -0
  99. package/docs/control-plane-scheduling.7.md +4 -0
  100. package/docs/coordinator-blackboard.7.md +17 -17
  101. package/docs/dogfood-one-real-repo.7.md +11 -11
  102. package/docs/durable-state-and-locking.7.md +24 -0
  103. package/docs/end-to-end-golden-path.7.md +14 -14
  104. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  105. package/docs/execution-backends.7.md +4 -0
  106. package/docs/getting-started.md +37 -37
  107. package/docs/index.md +1 -0
  108. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  109. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  110. package/docs/multi-agent-operator-ux.7.md +23 -19
  111. package/docs/multi-agent-runtime-core.7.md +22 -22
  112. package/docs/multi-agent-topologies.7.md +6 -6
  113. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  114. package/docs/node-snapshot-diff-replay.7.md +4 -0
  115. package/docs/observability-cost-accounting.7.md +4 -0
  116. package/docs/operator-ux.7.md +34 -34
  117. package/docs/pipeline-runner.7.md +4 -4
  118. package/docs/project-index.md +48 -5
  119. package/docs/real-execution-backends.7.md +4 -0
  120. package/docs/release-and-migration.7.md +5 -1
  121. package/docs/release-tooling.7.md +4 -0
  122. package/docs/routines.md +4 -4
  123. package/docs/run-registry-control-plane.7.md +23 -19
  124. package/docs/run-retention-reclamation.7.md +4 -0
  125. package/docs/scheduled-tasks.md +14 -14
  126. package/docs/security-trust-hardening.7.md +43 -13
  127. package/docs/state-explosion-management.7.md +14 -10
  128. package/docs/team-collaboration.7.md +4 -0
  129. package/docs/trust-audit-anchor.7.md +71 -0
  130. package/docs/unix-principles.md +3 -1
  131. package/docs/verifier-gated-commit.7.md +1 -1
  132. package/docs/web-desktop-workbench.7.md +4 -0
  133. package/docs/workflow-app-framework.7.md +13 -13
  134. package/manifest/plugin.manifest.json +1 -1
  135. package/package.json +4 -2
  136. package/scripts/agents/claude-p-agent.js +2 -2
  137. package/scripts/block-unapproved-tag.sh +23 -2
  138. package/scripts/bump-version.js +24 -2
  139. package/scripts/canonical-apps.js +4 -4
  140. package/scripts/children/batch-delegate-child.js +52 -2
  141. package/scripts/dogfood-release.js +1 -1
  142. package/scripts/fake-date-for-reproduction.js +44 -0
  143. package/scripts/golden-path.js +4 -4
  144. package/scripts/purity-baseline.json +71 -0
  145. package/scripts/purity-gate.js +239 -0
  146. package/scripts/release-check.js +8 -1
  147. package/scripts/release-flow.js +57 -1
  148. package/scripts/verdict-keygen.js +83 -0
  149. package/scripts/verify-bump-reproduction.sh +148 -0
  150. package/scripts/verify-verdict-signature.js +61 -0
  151. package/scripts/version-sync-check.js +33 -12
  152. package/skills/cool-workflow/SKILL.md +9 -9
  153. package/skills/cool-workflow/references/commands.md +89 -88
  154. package/ui/workbench/app.css +37 -1
  155. package/ui/workbench/app.js +124 -6
  156. package/workflows/README.md +19 -0
@@ -7,15 +7,10 @@
7
7
  // milestone). Byte-exact port of the old build's src/drive.ts's
8
8
  // imperative shell around the pure decision core now in
9
9
  // core/pipeline/drive-decide.ts. Sub-workflow nesting and `--incremental`
10
- // are ported; the concurrent-round driver (driveConcurrentRound) is
11
- // scoped down to the serial driver run through a width loop, since no
12
- // case in this milestone's combined gate exercises true concurrent-batch
13
- // recording order (that is `--concurrency`/parallel-phase-specific and is
14
- // authored as its own future conformance case per Open risk 5) — the
15
- // `mode:"parallel"` architecture-review phases still complete correctly
16
- // through the serial per-task loop, just without the wall-clock-parallel
17
- // spawn optimization; this is flagged here rather than silently ported as
18
- // if fully equivalent.
10
+ // are ported; the concurrent-round driver (driveConcurrentRound, below)
11
+ // dispatches and settles a whole round's tasks in one batch (see
12
+ // `--concurrency`/`roundWidth`), pinned by
13
+ // v2/conformance/cases/pipeline-concurrent-round.case.js.
19
14
  //
20
15
  // Evidence: SPEC/pipeline-run.md "Drive loop — src/drive.ts".
21
16
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
@@ -56,6 +51,7 @@ exports.MAX_SUB_WORKFLOW_DEPTH = exports.DRIVE_SCHEMA_VERSION = void 0;
56
51
  exports.maybeExpandLoop = maybeExpandLoop;
57
52
  exports.driveStep = driveStep;
58
53
  exports.drive = drive;
54
+ exports.driveAsync = driveAsync;
59
55
  exports.drivePreview = drivePreview;
60
56
  const fs = __importStar(require("node:fs"));
61
57
  const path = __importStar(require("node:path"));
@@ -77,6 +73,7 @@ const agent_config_1 = require("./agent-config");
77
73
  const registry_1 = require("./execution-backend/registry");
78
74
  const agent_1 = require("./execution-backend/agent");
79
75
  const hash_1 = require("../core/hash");
76
+ const collate_1 = require("../core/util/collate");
80
77
  const pipeline_1 = require("./pipeline");
81
78
  const reporter_1 = require("./reporter");
82
79
  const fs_atomic_1 = require("./fs-atomic");
@@ -157,15 +154,22 @@ function loadRun(ctx) {
157
154
  /** Runs `fn` with `runId`'s loadRun calls served from one shared cached
158
155
  * object (seeded fresh from disk), and always clears the cache entry
159
156
  * afterward — even on throw — so a round never leaks its cache into a
160
- * 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. */
161
163
  function withRoundCache(ctx, fn) {
162
- const seed = (0, run_store_1.loadRunFromCwd)(ctx.runId, ctx.cwd);
163
- 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));
164
167
  try {
165
168
  return fn();
166
169
  }
167
170
  finally {
168
- roundCache.delete(ctx.runId);
171
+ if (!alreadyActive)
172
+ roundCache.delete(ctx.runId);
169
173
  }
170
174
  }
171
175
  function resultCachePath(run, task, promptDigest, incremental, delegationDigest) {
@@ -191,7 +195,10 @@ function previousPhaseResultsDigest(run, task) {
191
195
  return undefined;
192
196
  const previousTaskIds = new Set(run.phases.slice(0, phaseIndex).flatMap((p) => p.taskIds));
193
197
  const records = [];
194
- for (const candidate of run.tasks.filter((t) => previousTaskIds.has(t.id)).sort((a, b) => a.id.localeCompare(b.id))) {
198
+ // stableCompare (not a bare localeCompare): this order feeds the sha256
199
+ // digest below, which feeds the incremental cache key — a host-locale-
200
+ // dependent order would silently move the cache key across machines.
201
+ for (const candidate of run.tasks.filter((t) => previousTaskIds.has(t.id)).sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
195
202
  if (candidate.status !== "completed" || !candidate.resultPath || !fs.existsSync(candidate.resultPath)) {
196
203
  records.push(undefined);
197
204
  continue;
@@ -303,7 +310,24 @@ function processSelectedTask(ctx, selectedId, preparedOutcome, deferPersist = fa
303
310
  emitProgress(`↺ ${selected.label || selected.id} (${selected.phase}) — accepting cached result`);
304
311
  try {
305
312
  fs.writeFileSync(manifest.resultPath, fs.readFileSync(cachePath, "utf8"), "utf8");
313
+ // Not gated by requireAttestedTelemetry here: the underlying result was
314
+ // already gated (attested or explicitly overridden) at its FIRST
315
+ // acceptance, before it was cached. Re-blocking a cache hit would only
316
+ // punish the operator for their own earlier, already-audited accept.
317
+ // Still made visible, not silent: when the operator requires attested
318
+ // telemetry, record that this particular accept came from the cache
319
+ // rather than a freshly re-verified hop.
306
320
  (0, worker_isolation_1.recordWorkerOutput)(run, workerId, manifest.resultPath);
321
+ if (ctx.config.requireAttestedTelemetry) {
322
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
323
+ kind: "telemetry.cache-accept",
324
+ decision: "recorded",
325
+ source: "cw-validated",
326
+ workerId,
327
+ taskId: selected.id,
328
+ metadata: { reason: "result-cache hit; original attestation gate applied at first acceptance, not re-verified here" },
329
+ });
330
+ }
307
331
  // Advance the run lifecycle stage on accept, as the old build's
308
332
  // recordWorkerOutput wrapper did (run.loopStage = "observe").
309
333
  run.loopStage = "observe";
@@ -535,7 +559,8 @@ function maybeExpandLoop(run) {
535
559
  const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
536
560
  if (phase.id !== latest.id)
537
561
  continue;
538
- 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));
539
564
  if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
540
565
  continue;
541
566
  const round = latest.loopRound || 1;
@@ -544,7 +569,8 @@ function maybeExpandLoop(run) {
544
569
  .sort((a, b) => compareBytes(a.id, b.id))
545
570
  .map((t) => t.result);
546
571
  const roundResults = ordered(roundTasks);
547
- 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));
548
574
  const allResults = ordered(allLoopTasks);
549
575
  const ctx = {
550
576
  round,
@@ -581,7 +607,8 @@ function maybeExpandLoop(run) {
581
607
  // Expand: clone the ROUND-1 template tasks into a fresh phase appended
582
608
  // right after the latest round.
583
609
  const nextRound = round + 1;
584
- 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));
585
612
  const { phase: nextPhase, tasks: newTasks } = (0, loop_expansion_1.cloneLoopRoundTasks)(origin, templateTasks, nextRound);
586
613
  const insertAt = run.phases.findIndex((p) => p.id === latest.id);
587
614
  run.phases.splice(insertAt + 1, 0, nextPhase);
@@ -716,8 +743,9 @@ function driveConcurrentRound(ctx, limit) {
716
743
  return [driveStep(ctx)];
717
744
  const phase = (0, dispatch_1.firstRunnablePhase)(run);
718
745
  const width = Math.max(1, Math.floor(limit) || 1);
746
+ const phaseTaskIds = new Set(phase.taskIds);
719
747
  const batch = run.tasks
720
- .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"))
721
749
  .slice(0, width)
722
750
  .map((task) => task.id);
723
751
  const prepared = prepareConcurrentOutcomes(ctx, batch);
@@ -746,13 +774,37 @@ function driveConcurrentRound(ctx, limit) {
746
774
  return steps.length > 0 ? steps : [driveStep(ctx)];
747
775
  });
748
776
  }
749
- /** Drive a run: `--once` advances exactly one step; otherwise run to
750
- * completion, park, or a blocked stop. */
751
- 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) {
752
804
  const now = options.now || new Date().toISOString();
753
805
  const config = options.agentConfig || (0, agent_config_1.resolveAgentConfig)(options.args || {});
754
806
  const policy = { ...drive_decide_1.DEFAULT_SCHEDULING_POLICY, ...(options.policy || {}) };
755
- const ctx = {
807
+ return {
756
808
  runId,
757
809
  cwd,
758
810
  now,
@@ -763,22 +815,22 @@ function drive(runId, cwd, options = {}) {
763
815
  visitedAppIds: options.visitedAppIds || [],
764
816
  policy,
765
817
  };
766
- const steps = [];
767
- const run0 = loadRun(ctx);
768
- const plannedWorkers = run0.tasks.length;
769
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), policy);
770
- // Phase-boundary progress (brew-style): announce each phase when it
771
- // becomes active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess
772
- // (3/6)`. Describes CW's OWN phases (vendor-neutral); goes to stderr
773
- // via emitProgress so stdout stays clean data. Byte-exact port of the
774
- // old build's src/drive.ts emitPhaseProgress. term.phaseProgressLine
775
- // 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() {
776
827
  const announcedPhaseComplete = new Set();
777
828
  let activePhaseId;
778
829
  const titleCase = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
779
- const emitPhaseProgress = (run) => {
830
+ return (run) => {
780
831
  for (const ph of run.phases || []) {
781
- 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));
782
834
  const total = phaseTasks.length;
783
835
  if (total === 0)
784
836
  continue;
@@ -798,38 +850,113 @@ function drive(runId, cwd, options = {}) {
798
850
  return; // only the first not-yet-complete phase is "active"
799
851
  }
800
852
  };
801
- let exhaustedMaxIterations = !options.once;
802
- 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, () => {
803
868
  const width = (0, drive_decide_1.roundWidth)(loadRun(ctx), options.concurrency);
804
869
  // width>1 (an explicit --concurrency>1, or an auto-width parallel
805
870
  // phase) runs the whole round through driveConcurrentRound — one or
806
- // more steps recorded in deterministic batch order, one flush at
807
- // round end. `--once` still stops after this ONE outer-loop
808
- // iteration even though a round can yield multiple steps.
809
- const roundSteps = width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
810
- for (const stepResult of roundSteps)
811
- steps.push(stepResult);
812
- // Brew-style phase boundaries: after each round, announce a
813
- // newly-active phase and any phase that just finished. Cheap — reuses
814
- // the run we just advanced; goes to stderr so stdout stays clean.
815
- emitPhaseProgress(loadRun(ctx));
816
- const last = roundSteps[roundSteps.length - 1];
817
- if (options.once) {
818
- exhaustedMaxIterations = false;
819
- break;
820
- }
821
- if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
822
- exhaustedMaxIterations = false;
823
- 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;
824
908
  }
825
- }
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;
826
941
  const run = loadRun(ctx);
827
942
  const completedWorkers = (0, drive_decide_1.countCompleted)(run);
828
943
  const parkedWorkers = (0, drive_decide_1.countParked)(run);
829
944
  const committed = (0, drive_decide_1.hasTerminalCommit)(run);
830
945
  const last = steps[steps.length - 1];
831
- if (exhaustedMaxIterations) {
832
- 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` }));
833
960
  }
834
961
  const statusInputs = {
835
962
  once: Boolean(options.once),
@@ -846,7 +973,7 @@ function drive(runId, cwd, options = {}) {
846
973
  const committedCommit = (run.commits || []).find((c) => c.reason && c.reason.startsWith("agent-delegation-drive"));
847
974
  return {
848
975
  schemaVersion: 1,
849
- runId,
976
+ runId: ctx.runId,
850
977
  workflowId: run.workflow.id,
851
978
  status,
852
979
  steps,
@@ -856,9 +983,92 @@ function drive(runId, cwd, options = {}) {
856
983
  commitId: committedCommit?.id,
857
984
  reportPath: run.paths.report,
858
985
  statePath: run.paths.state,
859
- agentConfigured: agentConfigured(config),
986
+ agentConfigured: agentConfigured(ctx.config),
860
987
  };
861
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
+ }
862
1072
  function drivePreview(runId, cwd, args = {}) {
863
1073
  const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
864
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)) {
@@ -29,6 +29,7 @@ const fs_atomic_1 = require("./fs-atomic");
29
29
  const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
30
30
  const trust_audit_1 = require("./trust-audit");
31
31
  const trust_policy_1 = require("../core/multi-agent/trust-policy");
32
+ const collate_1 = require("../core/util/collate");
32
33
  exports.EVIDENCE_REASONING_SCHEMA_VERSION = 1;
33
34
  function candidatesOf(run) {
34
35
  return (run.candidates || []);
@@ -56,7 +57,7 @@ function buildEvidenceReasoningReport(run, options = {}) {
56
57
  const counterfactuals = deriveCounterfactuals(run, scores);
57
58
  const chains = operator.evidence
58
59
  .map((evidence) => buildChain(run, evidence, { scores, auditEvents, counterfactuals }))
59
- .sort((left, right) => statusRank(left.evidenceStatus) - statusRank(right.evidenceStatus) || left.id.localeCompare(right.id));
60
+ .sort((left, right) => statusRank(left.evidenceStatus) - statusRank(right.evidenceStatus) || (0, collate_1.stableCompare)(left.id, right.id));
60
61
  const totals = summarizeTotals(chains);
61
62
  const currentFingerprint = fingerprintChains(chains);
62
63
  const persisted = options.index;
@@ -64,10 +65,10 @@ function buildEvidenceReasoningReport(run, options = {}) {
64
65
  if (persisted && persisted.sourceFingerprint !== currentFingerprint)
65
66
  status = "stale";
66
67
  const nextAction = status === "stale" || status === "absent"
67
- ? `node scripts/cw.js multi-agent reasoning ${run.id} --refresh`
68
+ ? `cw multi-agent reasoning ${run.id} --refresh`
68
69
  : totals.unexplained > 0
69
- ? `node scripts/cw.js multi-agent reasoning ${run.id} --json`
70
- : `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`;
71
72
  return {
72
73
  schemaVersion: exports.EVIDENCE_REASONING_SCHEMA_VERSION,
73
74
  runId: run.id,
@@ -340,9 +341,9 @@ function refreshEvidenceReasoning(run) {
340
341
  generatedAt: new Date().toISOString(),
341
342
  sourceFingerprint: report.sourceFingerprint,
342
343
  totals: report.totals,
343
- entries: entries.sort((a, b) => a.id.localeCompare(b.id)),
344
+ entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
344
345
  paths: { reasoningDir: dir, indexPath, reportPath },
345
- nextAction: `node scripts/cw.js multi-agent reasoning ${run.id}`,
346
+ nextAction: `cw multi-agent reasoning ${run.id}`,
346
347
  };
347
348
  (0, fs_atomic_1.writeJson)(indexPath, index);
348
349
  (0, fs_atomic_1.writeJson)(reportPath, { ...report, freshness: { ...report.freshness, status: "valid", persistedFingerprint: report.sourceFingerprint } });
@@ -601,5 +602,5 @@ function unique(values) {
601
602
  return Array.from(new Set(values.filter(Boolean))).sort();
602
603
  }
603
604
  function byRef(a, b) {
604
- return a.ref.localeCompare(b.ref);
605
+ return (0, collate_1.stableCompare)(a.ref, b.ref);
605
606
  }
@@ -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") {
@@ -33,6 +33,7 @@ const container_1 = require("./container");
33
33
  const remote_1 = require("./remote");
34
34
  const ci_1 = require("./ci");
35
35
  const agent_1 = require("./agent");
36
+ const collate_1 = require("../../core/util/collate");
36
37
  exports.EXECUTION_BACKEND_SCHEMA_VERSION = 1;
37
38
  exports.DEFAULT_BACKEND_ID = "node";
38
39
  exports.SANDBOX_DIMENSIONS = ["read", "write", "command", "network", "env"];
@@ -159,7 +160,7 @@ function registeredDrivers() {
159
160
  function listBackendDescriptors() {
160
161
  return registeredDrivers()
161
162
  .map((driver) => specDescriptor(driver.spec))
162
- .sort((left, right) => left.id.localeCompare(right.id));
163
+ .sort((left, right) => (0, collate_1.stableCompare)(left.id, right.id));
163
164
  }
164
165
  function backendIds() {
165
166
  return registeredDrivers()
@@ -1,11 +1,2 @@
1
1
  "use strict";
2
- // shell/execution-backend/types.ts — plain data shapes for the driver layer.
3
- //
4
- // MILESTONE 5 (docs/rebuild/PLAN.md build order, step 5). Byte-exact port of the shapes
5
- // in the old build's src/types/execution-backend.ts and the sandbox slice of
6
- // src/types/sandbox.ts that this subsystem needs. Types only — no logic — so
7
- // this file is safe to import from both shell/ (impure) and any future core/
8
- // caller without violating the core/shell purity split.
9
- //
10
- // Evidence: SPEC/execution-backend.md.
11
2
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
  }