cool-workflow 0.2.4 → 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 (132) 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 +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/trust/evidence-grounding.js +13 -1
  27. package/dist/core/util/cli-args.js +22 -0
  28. package/dist/core/version.js +1 -1
  29. package/dist/mcp/dispatch.js +22 -2
  30. package/dist/mcp/server.js +124 -13
  31. package/dist/mcp-server.js +20 -0
  32. package/dist/shell/commit.js +8 -2
  33. package/dist/shell/coordinator-io.js +73 -1
  34. package/dist/shell/drive.js +120 -63
  35. package/dist/shell/error-feedback-io.js +6 -0
  36. package/dist/shell/execution-backend/agent.js +195 -23
  37. package/dist/shell/execution-backend/container.js +44 -10
  38. package/dist/shell/execution-backend/local.js +32 -0
  39. package/dist/shell/fs-atomic.js +93 -12
  40. package/dist/shell/ledger-cli.js +9 -2
  41. package/dist/shell/multi-agent-cli.js +5 -1
  42. package/dist/shell/onramp.js +48 -5
  43. package/dist/shell/pipeline.js +2 -1
  44. package/dist/shell/reclamation-io.js +10 -9
  45. package/dist/shell/run-export.js +52 -4
  46. package/dist/shell/run-store.js +156 -0
  47. package/dist/shell/scheduler-io.js +101 -10
  48. package/dist/shell/telemetry-ledger-io.js +36 -24
  49. package/dist/shell/trust-audit.js +104 -10
  50. package/dist/shell/workbench-host.js +121 -10
  51. package/dist/shell/workbench.js +79 -5
  52. package/dist/shell/worker-isolation.js +1 -1
  53. package/dist/wiring/capability-table/basics.js +5 -0
  54. package/dist/wiring/capability-table/exec-backend.js +40 -22
  55. package/dist/wiring/capability-table/pipeline.js +32 -0
  56. package/dist/wiring/capability-table/registry-core.js +26 -3
  57. package/dist/wiring/capability-table/reporting.js +7 -1
  58. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  59. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  60. package/docs/agent-delegation-drive.7.md +15 -0
  61. package/docs/cli-mcp-parity.7.md +25 -0
  62. package/docs/contract-migration-tooling.7.md +2 -0
  63. package/docs/control-plane-scheduling.7.md +36 -0
  64. package/docs/cross-agent-ledger.7.md +20 -8
  65. package/docs/durable-state-and-locking.7.md +2 -0
  66. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  67. package/docs/execution-backends.7.md +2 -0
  68. package/docs/fix.7.md +4 -4
  69. package/docs/getting-started.md +40 -32
  70. package/docs/index.md +17 -0
  71. package/docs/launch/demo.tape +4 -3
  72. package/docs/mcp-app-surface.7.md +6 -0
  73. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  74. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  75. package/docs/multi-agent-operator-ux.7.md +2 -0
  76. package/docs/node-snapshot-diff-replay.7.md +2 -0
  77. package/docs/observability-cost-accounting.7.md +2 -0
  78. package/docs/project-index.md +39 -9
  79. package/docs/real-execution-backends.7.md +2 -0
  80. package/docs/release-and-migration.7.md +2 -0
  81. package/docs/release-tooling.7.md +2 -0
  82. package/docs/routine.7.md +22 -0
  83. package/docs/run-registry-control-plane.7.md +2 -0
  84. package/docs/run-retention-reclamation.7.md +10 -4
  85. package/docs/state-explosion-management.7.md +2 -0
  86. package/docs/team-collaboration.7.md +2 -0
  87. package/docs/trust-audit-anchor.7.md +2 -0
  88. package/docs/web-desktop-workbench.7.md +24 -2
  89. package/docs/workflow-app-framework.7.md +31 -0
  90. package/manifest/plugin.manifest.json +1 -1
  91. package/manifest/source-context-profiles.json +4 -3
  92. package/package.json +4 -2
  93. package/scripts/agents/agent-adapter-core.js +26 -0
  94. package/scripts/agents/claude-p-agent.js +4 -1
  95. package/scripts/agents/codex-agent.js +4 -0
  96. package/scripts/agents/cw-attest-wrap.js +1 -1
  97. package/scripts/agents/gemini-agent.js +4 -0
  98. package/scripts/agents/opencode-agent.js +5 -0
  99. package/scripts/block-unapproved-tag.js +160 -0
  100. package/scripts/canonical-apps.js +4 -4
  101. package/scripts/children/batch-delegate-child.js +72 -4
  102. package/scripts/children/http-batch-delegate-child.js +132 -0
  103. package/scripts/children/http-delegate-child.js +8 -0
  104. package/scripts/dogfood-release.js +2 -2
  105. package/scripts/fake-date-for-reproduction.js +1 -1
  106. package/scripts/golden-path.js +4 -4
  107. package/scripts/lang-policy-check.js +82 -0
  108. package/scripts/purity-gate.js +16 -3
  109. package/scripts/release-check.js +2 -1
  110. package/scripts/release-flow.js +128 -30
  111. package/scripts/release-gate.js +184 -0
  112. package/scripts/release-oneclick.js +38 -7
  113. package/scripts/release-tags.js +47 -0
  114. package/scripts/verdict-keygen.js +2 -2
  115. package/scripts/verify-bump-reproduction.js +193 -0
  116. package/scripts/verify-verdict-signature.js +1 -1
  117. package/ui/workbench/app.css +41 -9
  118. package/ui/workbench/app.js +160 -27
  119. package/ui/workbench/index.html +2 -2
  120. package/docs/agent-framework.md +0 -177
  121. package/docs/designs/handoff-ledger.md +0 -145
  122. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  123. package/docs/launch/launch-kit.md +0 -195
  124. package/docs/launch/pre-launch-checklist.md +0 -53
  125. package/docs/readme-v0.1.87-full.md +0 -301
  126. package/docs/routines.md +0 -101
  127. package/docs/scheduled-tasks.md +0 -80
  128. package/scripts/agents/claude-p-agent.sh +0 -9
  129. package/scripts/block-unapproved-tag.sh +0 -75
  130. package/scripts/release-gate.sh +0 -94
  131. package/scripts/verify-bump-reproduction.sh +0 -148
  132. package/scripts/verify-container-selfref.js +0 -64
@@ -199,7 +199,13 @@ function commitState(run, input) {
199
199
  // NOT prematurely move the run off "interpret".
200
200
  if (gate.verifierGated)
201
201
  run.loopStage = "checkpoint";
202
- (0, fs_atomic_1.writeJson)(snapshotPath, { commit, run });
202
+ // Only store the commit's own (small, bounded) record. The whole run
203
+ // (all tasks, nodes, workers, feedback, and run.commits itself) used to
204
+ // be embedded here too, but no reader anywhere ever parses a `run` key
205
+ // back out of a commits/<id>.json file — see docs/run-retention-reclamation.7.md.
206
+ // Embedding the growing run made total commit-dir bytes grow like N^2
207
+ // in the number of commits.
208
+ (0, fs_atomic_1.writeJson)(snapshotPath, { commit });
203
209
  run.commits.push(commit);
204
210
  return commit;
205
211
  }
@@ -214,7 +220,7 @@ function recordCommitNode(run, commit, options, gate) {
214
220
  artifacts: [{ id: "snapshot", kind: "json", path: commit.snapshotPath }],
215
221
  evidence: commit.evidence || verifierNode.evidence,
216
222
  metadata: { ...(options.metadata || {}), reason: options.reason, commitId: commit.id, verifierGated: true, checkpoint: false, verifierNodeId: verifierNode.id, candidateId: gate.candidateId, selectionId: gate.selectionId, selectionNodeId: gate.selectionNodeId },
217
- }, { persist: false, persistNode: node_store_1.writeRunNode });
223
+ }, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
218
224
  if (gate.selectionNodeId && commitResult.outputNodeId)
219
225
  linkAdditionalParent(run, gate.selectionNodeId, commitResult.outputNodeId);
220
226
  return commitResult.outputNodeId;
@@ -158,6 +158,75 @@ function dirtySetsFor(state) {
158
158
  function markBlackboardDirty(state, kind, id) {
159
159
  dirtySetsFor(state)[kind].add(id);
160
160
  }
161
+ // Dirty-id tracking for state.messages, PARALLEL to the 5-kind tracking
162
+ // above (not folded in: messages share ONE ordered log file, not a
163
+ // recordPath-per-id file). The only push site is postBlackboardMessage
164
+ // (`state.messages.push(message)`), so the dirty ids at persist time are
165
+ // always exactly the trailing ids of state.messages since the last
166
+ // persist. Kept off the serialized state for the same reason as
167
+ // blackboardDirtySets: a WeakMap, never a field on BlackboardState.
168
+ const blackboardMessagesDirty = new WeakMap();
169
+ function dirtyMessageIdsFor(state) {
170
+ let ids = blackboardMessagesDirty.get(state);
171
+ if (!ids) {
172
+ ids = new Set();
173
+ blackboardMessagesDirty.set(state, ids);
174
+ }
175
+ return ids;
176
+ }
177
+ function markBlackboardMessageDirty(state, id) {
178
+ dirtyMessageIdsFor(state).add(id);
179
+ }
180
+ // Writes messages.jsonl for a persist call, or skips it entirely when
181
+ // nothing changed (the common case: 7 of the 8 persistBlackboardState call
182
+ // sites never touch state.messages).
183
+ //
184
+ // Fast path (the common case for the ONE call site that does change
185
+ // messages, postBlackboardMessage): the dirty ids are exactly the trailing
186
+ // `k` entries of state.messages (the sole push site always appends). Sort
187
+ // only that small batch, check it does not need to interleave with the
188
+ // already-on-disk tail, and APPEND it — no read, no resort, no rewrite of
189
+ // the earlier messages. This turns posting M messages from O(M^2) written
190
+ // bytes into O(M) total.
191
+ //
192
+ // Fallback (a caller-supplied custom --id ties createdAt with an earlier
193
+ // message and sorts BEFORE it, so a plain append would leave the file out
194
+ // of order): resort the whole array and rewrite the whole file, byte-
195
+ // identical to the code path this replaces.
196
+ //
197
+ // Either way state.messages is left fully sorted after this call, exactly
198
+ // as the old unconditional `.sort()` left it — buildBlackboardGraph (which
199
+ // iterates state.messages in raw array order) sees the same order as
200
+ // before.
201
+ function persistBlackboardMessages(run, state) {
202
+ const dirty = dirtyMessageIdsFor(state);
203
+ if (dirty.size === 0)
204
+ return;
205
+ const file = messagesPath(run);
206
+ const total = state.messages.length;
207
+ const k = dirty.size;
208
+ const priorCount = total - k;
209
+ const batch = priorCount >= 0 ? state.messages.slice(priorCount).sort(cb.compareRecords) : [];
210
+ const batchMatchesDirty = batch.length === dirty.size && batch.every((message) => dirty.has(message.id));
211
+ const priorTail = priorCount > 0 ? state.messages[priorCount - 1] : undefined;
212
+ const canAppend = batchMatchesDirty && (!priorTail || cb.compareRecords(priorTail, batch[0]) <= 0);
213
+ if (canAppend) {
214
+ state.messages.splice(priorCount, k, ...batch);
215
+ const lines = batch.map((message) => `${JSON.stringify(message)}\n`).join("");
216
+ const currentBytes = fs.existsSync(file) ? fs.statSync(file).size : 0;
217
+ // Same torn-tail guard as trust-audit's events.jsonl (shared helper):
218
+ // a crash mid-append can leave the log without its final "\n"; put the
219
+ // new lines on their own clean line rather than merging with a torn
220
+ // tail.
221
+ const leadingNewline = currentBytes > 0 && !(0, fs_atomic_1.logEndsWithNewline)(file, currentBytes) ? "\n" : "";
222
+ (0, fs_atomic_1.durableAppendFileSync)(file, leadingNewline + lines);
223
+ }
224
+ else {
225
+ // Same bytes the old unconditional code path always wrote.
226
+ (0, fs_atomic_1.writeTextDurable)(file, state.messages.sort(cb.compareRecords).map((message) => JSON.stringify(message)).join("\n") + (state.messages.length ? "\n" : ""));
227
+ }
228
+ dirty.clear();
229
+ }
161
230
  function linkMultiAgent(run, blackboardId, topicIds, input) {
162
231
  const groupId = input.agentGroupId ?? input.groupId;
163
232
  const roleId = input.agentRoleId ?? input.roleId;
@@ -218,7 +287,9 @@ function persistBlackboardState(run) {
218
287
  decisions: state.decisions.map(cb.indexRow),
219
288
  messages: state.messages.map((message) => ({ id: message.id, blackboardId: message.blackboardId, topicId: message.topicId, createdAt: message.createdAt, status: message.status, author: message.author, evidenceRefs: message.linkedEvidenceRefs, artifactRefIds: message.linkedArtifactRefIds })),
220
289
  });
221
- fs.writeFileSync(messagesPath(run), state.messages.sort(cb.compareRecords).map((message) => JSON.stringify(message)).join("\n") + (state.messages.length ? "\n" : ""), "utf8");
290
+ // messages.jsonl: see persistBlackboardMessages above for the
291
+ // skip-if-unchanged / append-if-safe / full-rewrite-fallback split.
292
+ persistBlackboardMessages(run, state);
222
293
  const dirty = dirtySetsFor(state);
223
294
  for (const id of dirty.topics) {
224
295
  const record = state.topics.find((entry) => entry.id === id);
@@ -308,6 +379,7 @@ function postBlackboardMessage(run, input) {
308
379
  requireArtifactRefs(run, input.artifactRefIds || []);
309
380
  const message = cb.buildMessage(run.id, board, topic, input, id, now(), trust_policy_io_1.hashText, sourceForActorLocal);
310
381
  state.messages.push(message);
382
+ markBlackboardMessageDirty(state, message.id);
311
383
  topic.messageIds = cb.unique([...topic.messageIds, message.id]);
312
384
  board.messageCount = state.messages.filter((entry) => entry.blackboardId === board.id).length;
313
385
  cb.touch(topic, now());
@@ -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,32 +738,53 @@ function prepareConcurrentOutcomes(ctx, batch) {
713
738
  }
714
739
  jobs.push(job);
715
740
  jobTaskIds.push(taskId);
741
+ continue;
716
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);
752
+ }
753
+ }
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…`);
717
757
  }
718
- if (jobs.length) {
719
- emitProgress(`⇉ concurrent round: ${jobs.length} agent${jobs.length > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
758
+ if (totalJobs || dispatchedThisRound) {
720
759
  // Every task above that reached "pending" got dispatched (workerId
721
760
  // assigned) in the round-cached run object, but nothing durable was
722
761
  // written for it — unlike the serial path's own dispatch branch, which
723
762
  // always checkpoints immediately. Flush now, BEFORE the batch's long
724
- // spawn window opens: a crash mid-spawn (which can run for minutes)
725
- // then leaves state.json correctly showing these tasks as dispatched,
726
- // instead of losing the dispatch entirely.
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.
727
770
  (0, run_store_1.saveCheckpoint)(loadRun(ctx));
728
771
  }
729
- const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
730
772
  const outcomes = new Map();
773
+ const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
731
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]));
732
777
  return { outcomes, failSteps };
733
778
  }
734
779
  /** One concurrent round inside one cached in-memory run: dispatches every
735
780
  * batch task, spawns all spawn-style agent children in one concurrent
736
- * window, then settles + accepts in DETERMINISTIC batch (task-id) order
737
- * regardless of wall-clock finish order. At round end it flushes once:
738
- * commitState(run, "concurrent-round:<n>-tasks") + writeReport +
739
- * saveCheckpoint. Cache-hit tasks and endpoint-only agents get no
740
- * prepared outcome and settle through the serial path (still inside
741
- * 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
742
788
  * runnable at round entry — terminal/blocked/token-budget gate) the
743
789
  * round degrades to one plain driveStep. Byte-exact to the old build's
744
790
  * src/drive.ts's driveConcurrentRound. */
@@ -1004,28 +1050,35 @@ function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhau
1004
1050
  * exact synchronous shape) and fixed only in driveAsync(). */
1005
1051
  function drive(runId, cwd, options = {}) {
1006
1052
  const ctx = buildDriveContext(runId, cwd, options);
1007
- const steps = [];
1008
- const run0 = loadRun(ctx);
1009
- const plannedWorkers = run0.tasks.length;
1010
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1011
- const emitPhaseProgress = createPhaseProgressEmitter();
1012
- let exhaustedMaxIterations = !options.once;
1013
- const stopSignal = createStopSignalController(runId);
1014
- stopSignal.install();
1015
- try {
1016
- for (let i = 0; i < maxIter; i++) {
1017
- if (stopSignal.getInterruptedBy())
1018
- break;
1019
- if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1020
- exhaustedMaxIterations = false;
1021
- 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
+ }
1022
1075
  }
1023
1076
  }
1024
- }
1025
- finally {
1026
- stopSignal.remove();
1027
- }
1028
- 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
+ });
1029
1082
  }
1030
1083
  /** Same contract and same DriveResult as drive() -- but actually
1031
1084
  * interruptible. After every round it awaits yieldToEventLoop(), a real
@@ -1053,29 +1106,33 @@ function drive(runId, cwd, options = {}) {
1053
1106
  * future cycle if it proves needed in practice. */
1054
1107
  async function driveAsync(runId, cwd, options = {}) {
1055
1108
  const ctx = buildDriveContext(runId, cwd, options);
1056
- const steps = [];
1057
- const run0 = loadRun(ctx);
1058
- const plannedWorkers = run0.tasks.length;
1059
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1060
- const emitPhaseProgress = createPhaseProgressEmitter();
1061
- let exhaustedMaxIterations = !options.once;
1062
- const stopSignal = createStopSignalController(runId);
1063
- stopSignal.install();
1064
- try {
1065
- for (let i = 0; i < maxIter; i++) {
1066
- if (stopSignal.getInterruptedBy())
1067
- break;
1068
- if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1069
- exhaustedMaxIterations = false;
1070
- 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();
1071
1129
  }
1072
- await yieldToEventLoop();
1073
1130
  }
1074
- }
1075
- finally {
1076
- stopSignal.remove();
1077
- }
1078
- 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
+ });
1079
1136
  }
1080
1137
  function drivePreview(runId, cwd, args = {}) {
1081
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,