cool-workflow 0.2.4 → 0.2.6

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 (155) 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 +204 -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/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -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());
@@ -79,6 +79,7 @@ const reporter_1 = require("./reporter");
79
79
  const fs_atomic_1 = require("./fs-atomic");
80
80
  const observability_1 = require("./observability");
81
81
  const telemetry_attestation_1 = require("../core/trust/telemetry-attestation");
82
+ const perf_trace_1 = require("./perf-trace");
82
83
  /** Total RECORDED tokens across the run's attested units, reading the
83
84
  * agent-reported usage through normalizeReportedUsage so a hop that
84
85
  * reported snake_case buckets (`input_tokens`/`output_tokens`, the shape
@@ -117,8 +118,9 @@ function tokenBudgetUsage(run) {
117
118
  return undefined;
118
119
  return { spent: recordedTokenTotal(run), budget };
119
120
  }
120
- exports.DRIVE_SCHEMA_VERSION = 1;
121
- exports.MAX_SUB_WORKFLOW_DEPTH = 4;
121
+ var drive_decide_2 = require("../core/pipeline/drive-decide");
122
+ Object.defineProperty(exports, "DRIVE_SCHEMA_VERSION", { enumerable: true, get: function () { return drive_decide_2.DRIVE_SCHEMA_VERSION; } });
123
+ Object.defineProperty(exports, "MAX_SUB_WORKFLOW_DEPTH", { enumerable: true, get: function () { return drive_decide_2.MAX_SUB_WORKFLOW_DEPTH; } });
122
124
  function agentConfigured(config) {
123
125
  return Boolean(config.command || config.endpoint);
124
126
  }
@@ -433,8 +435,8 @@ function processSelectedTask(ctx, selectedId, preparedOutcome, deferPersist = fa
433
435
  }
434
436
  function runSubWorkflow(ctx, run, selected, workerId, manifest, spec, deferPersist = false) {
435
437
  const parentApp = run.workflow.id;
436
- if (ctx.depth + 1 > exports.MAX_SUB_WORKFLOW_DEPTH) {
437
- return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`, deferPersist, deferPersist ? run : undefined);
438
+ if (ctx.depth + 1 > drive_decide_1.MAX_SUB_WORKFLOW_DEPTH) {
439
+ return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${drive_decide_1.MAX_SUB_WORKFLOW_DEPTH})`, deferPersist, deferPersist ? run : undefined);
438
440
  }
439
441
  if ([...ctx.visitedAppIds, parentApp].includes(spec.appId)) {
440
442
  return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`, deferPersist, deferPersist ? run : undefined);
@@ -630,7 +632,7 @@ function maybeExpandLoop(run) {
630
632
  requiresEvidence: t.requiresEvidence,
631
633
  sandboxProfileId: t.sandboxProfileId,
632
634
  },
633
- }, { persist: false, persistNode: (r, node) => void (0, node_store_1.appendRunNode)(r, node) });
635
+ }, { persist: false, persistNode: (r, node) => void (0, node_store_1.appendRunNode)(r, node), pathExists: fs.existsSync });
634
636
  t.stateNodeId = result.outputNodeId;
635
637
  }
636
638
  (0, dispatch_1.updatePhaseStatuses)(run);
@@ -656,89 +658,139 @@ function driveStep(ctx) {
656
658
  return processSelectedTask(ctx, selected.id);
657
659
  }
658
660
  /** 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. */
661
+ * collect ALL agent child outcomes in one concurrent window: spawn-style
662
+ * (CLI-binary) agents through the batch delegate child, and endpoint-mode
663
+ * agents through the HTTP batch delegate child so `--concurrency N` runs N
664
+ * endpoint delegations in parallel instead of one-at-a-time on the serial
665
+ * path. Returns outcomes keyed by task id; a cache-hit, endpoint-configured
666
+ * agent, or sub-workflow task gets no prepared outcome and settles through
667
+ * the serial accept path inside processSelectedTask (a sub-workflow task is
668
+ * never spawn-eligible in the first place — processSelectedTask always
669
+ * takes its own runSubWorkflow branch for it, so building a spawn job here
670
+ * would only be spawned and thrown away unused). Dispatch failures become
671
+ * recorded fail steps up front, exactly what the serial path would emit. */
666
672
  function prepareConcurrentOutcomes(ctx, batch) {
667
673
  const failSteps = new Map();
668
674
  const jobs = [];
669
675
  const jobTaskIds = [];
670
- for (const taskId of batch) {
671
- const run = loadRun(ctx);
672
- const task = run.tasks.find((candidate) => candidate.id === taskId);
673
- if (!task || (task.status !== "pending" && task.status !== "running"))
674
- continue;
675
- let workerId = task.workerId;
676
- if (task.status === "pending") {
677
- const manifest = (0, dispatch_2.createDispatchManifest)(run, 1, { backendId: task.agentType || "agent" });
678
- const dispatchedTask = manifest.tasks.find((entry) => entry.id === task.id) || manifest.tasks[0];
679
- if (!dispatchedTask || !dispatchedTask.workerId) {
680
- failSteps.set(taskId, (0, drive_decide_1.makeStep)("dispatch", "failed", { runId: ctx.runId, taskId, phase: task.phase, reason: "dispatch produced no worker scope" }));
676
+ const endpointJobs = [];
677
+ const endpointTaskIds = [];
678
+ // Set whenever a task's status flips pending -> running (workerId
679
+ // assigned) this round, regardless of whether it later becomes a real
680
+ // spawn job, an endpoint job, a cache hit, or a sub-workflow task. That
681
+ // dispatch is a real state mutation on the round-cached run object that
682
+ // nothing has written to disk yet — the pre-spawn checkpoint below must
683
+ // flush it even when the batch produces zero spawn/endpoint jobs (e.g. an
684
+ // all-sub-workflow batch), or a crash before the round-end flush loses
685
+ // the dispatch.
686
+ let dispatchedThisRound = false;
687
+ // Dispatch is a short local mutation group. Keep its audit lock only until
688
+ // its events have one durable append; the agent work below stays outside it.
689
+ (0, trust_audit_1.withTrustAuditBatch)(loadRun(ctx), () => {
690
+ for (const taskId of batch) {
691
+ const run = loadRun(ctx);
692
+ const task = run.tasks.find((candidate) => candidate.id === taskId);
693
+ if (!task || (task.status !== "pending" && task.status !== "running"))
681
694
  continue;
695
+ let workerId = task.workerId;
696
+ if (task.status === "pending") {
697
+ const manifest = (0, dispatch_2.createDispatchManifest)(run, 1, { backendId: task.agentType || "agent" });
698
+ const dispatchedTask = manifest.tasks.find((entry) => entry.id === task.id) || manifest.tasks[0];
699
+ if (!dispatchedTask || !dispatchedTask.workerId) {
700
+ failSteps.set(taskId, (0, drive_decide_1.makeStep)("dispatch", "failed", { runId: ctx.runId, taskId, phase: task.phase, reason: "dispatch produced no worker scope" }));
701
+ continue;
702
+ }
703
+ workerId = dispatchedTask.workerId;
704
+ dispatchedThisRound = true;
682
705
  }
683
- workerId = dispatchedTask.workerId;
684
- }
685
- if (!workerId) {
686
- failSteps.set(taskId, (0, drive_decide_1.makeStep)("dispatch", "failed", { runId: ctx.runId, taskId, phase: task.phase, reason: "no worker scope for task" }));
687
- continue;
688
- }
689
- const freshRun = loadRun(ctx);
690
- const manifest = (0, worker_isolation_1.showWorkerManifest)(freshRun, workerId);
691
- const delegationDigest = ctx.incremental
692
- ? (0, drive_decide_1.incrementalDelegationDigest)(task.model || ctx.config.model || "", task.agentType || "agent", manifest.sandboxPolicy?.id || task.sandboxProfileId || "", ctx.config.command || "", ctx.config.args ? (0, agent_1.stripSecretArgs)(ctx.config.args) : [], ctx.config.endpoint || "")
693
- : "";
694
- const cachePath = resultCachePath(freshRun, task, (0, hash_1.sha256)(task.prompt || ""), ctx.incremental, delegationDigest);
695
- if (cachePath && fs.existsSync(cachePath))
696
- continue;
697
- const job = (0, agent_1.prepareAgentSpawn)({
698
- schemaVersion: 1,
699
- runId: ctx.runId,
700
- taskId: task.id,
701
- backendId: task.agentType || "agent",
702
- cwd: freshRun.cwd,
703
- sandboxPolicy: manifest.sandboxPolicy,
704
- manifest: { workerDir: manifest.workerDir, manifestPath: manifest.manifestPath, inputPath: manifest.inputPath, resultPath: manifest.resultPath, prompt: manifest.prompt },
705
- label: task.id,
706
- timeoutMs: ctx.config.timeoutMs,
707
- delegation: { command: ctx.config.command, args: ctx.config.args, endpoint: ctx.config.endpoint, model: task.model || ctx.config.model },
708
- });
709
- if (job) {
710
- const sandboxPolicy = manifest.sandboxPolicy;
711
- if (sandboxPolicy) {
712
- job.env = (0, agent_1.buildAgentChildEnv)(sandboxPolicy).env;
706
+ if (!workerId) {
707
+ failSteps.set(taskId, (0, drive_decide_1.makeStep)("dispatch", "failed", { runId: ctx.runId, taskId, phase: task.phase, reason: "no worker scope for task" }));
708
+ continue;
709
+ }
710
+ const freshRun = loadRun(ctx);
711
+ const manifest = (0, worker_isolation_1.showWorkerManifest)(freshRun, workerId);
712
+ const delegationDigest = ctx.incremental
713
+ ? (0, drive_decide_1.incrementalDelegationDigest)(task.model || ctx.config.model || "", task.agentType || "agent", manifest.sandboxPolicy?.id || task.sandboxProfileId || "", ctx.config.command || "", ctx.config.args ? (0, agent_1.stripSecretArgs)(ctx.config.args) : [], ctx.config.endpoint || "")
714
+ : "";
715
+ const cachePath = resultCachePath(freshRun, task, (0, hash_1.sha256)(task.prompt || ""), ctx.incremental, delegationDigest);
716
+ if (cachePath && fs.existsSync(cachePath))
717
+ continue;
718
+ // A sub-workflow task never goes through the spawn path — processSelectedTask
719
+ // checks task.subWorkflow before it ever looks at a prepared spawn outcome
720
+ // and always takes its own runSubWorkflow branch instead (mirrors that
721
+ // check byte-for-byte). Building a spawn OR endpoint job for it here would
722
+ // just be a real agent child spawned/delegated and thrown away unused.
723
+ const subWorkflow = task.subWorkflow;
724
+ if (subWorkflow)
725
+ continue;
726
+ const request = {
727
+ schemaVersion: 1,
728
+ runId: ctx.runId,
729
+ taskId: task.id,
730
+ backendId: task.agentType || "agent",
731
+ cwd: freshRun.cwd,
732
+ sandboxPolicy: manifest.sandboxPolicy,
733
+ manifest: { workerDir: manifest.workerDir, manifestPath: manifest.manifestPath, inputPath: manifest.inputPath, resultPath: manifest.resultPath, prompt: manifest.prompt },
734
+ label: task.id,
735
+ timeoutMs: ctx.config.timeoutMs,
736
+ delegation: { command: ctx.config.command, args: ctx.config.args, endpoint: ctx.config.endpoint, model: task.model || ctx.config.model },
737
+ };
738
+ const job = (0, agent_1.prepareAgentSpawn)(request);
739
+ if (job) {
740
+ const sandboxPolicy = manifest.sandboxPolicy;
741
+ if (sandboxPolicy) {
742
+ job.env = (0, agent_1.buildAgentChildEnv)(sandboxPolicy).env;
743
+ }
744
+ jobs.push(job);
745
+ jobTaskIds.push(taskId);
746
+ continue;
747
+ }
748
+ // Endpoint-configured agent: no CLI binary to batch-spawn, but its HTTP
749
+ // delegation joins the concurrent window through the endpoint batch child
750
+ // (runEndpointBatchOutcomes) rather than settling one-at-a-time on the
751
+ // serial path. An unconfigured agent (neither command nor endpoint) returns
752
+ // undefined from both prepares here and falls to the serial refusal.
753
+ const endpointJob = (0, agent_1.prepareEndpointJob)(request);
754
+ if (endpointJob) {
755
+ endpointJobs.push(endpointJob);
756
+ endpointTaskIds.push(taskId);
713
757
  }
714
- jobs.push(job);
715
- jobTaskIds.push(taskId);
716
758
  }
759
+ });
760
+ const totalJobs = jobs.length + endpointJobs.length;
761
+ if (totalJobs) {
762
+ emitProgress(`⇉ concurrent round: ${totalJobs} agent${totalJobs > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
717
763
  }
718
- if (jobs.length) {
719
- emitProgress(`⇉ concurrent round: ${jobs.length} agent${jobs.length > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
764
+ if (totalJobs || dispatchedThisRound) {
720
765
  // Every task above that reached "pending" got dispatched (workerId
721
766
  // assigned) in the round-cached run object, but nothing durable was
722
767
  // written for it — unlike the serial path's own dispatch branch, which
723
768
  // 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.
769
+ // settle window opens: a crash mid-spawn OR mid-sub-workflow-recursion
770
+ // (both can run for minutes) then leaves state.json correctly showing
771
+ // these tasks as dispatched, instead of losing the dispatch entirely.
772
+ // Gated on dispatchedThisRound (not just totalJobs) so a batch made
773
+ // entirely of sub-workflow and/or cache-hit tasks — which builds zero
774
+ // spawn/endpoint jobs — still gets this flush before its long-running
775
+ // settle loop starts.
727
776
  (0, run_store_1.saveCheckpoint)(loadRun(ctx));
728
777
  }
729
- const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
730
778
  const outcomes = new Map();
779
+ const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
731
780
  jobTaskIds.forEach((taskId, index) => outcomes.set(taskId, settled[index]));
781
+ const endpointSettled = (0, agent_1.runEndpointBatchOutcomes)(endpointJobs);
782
+ endpointTaskIds.forEach((taskId, index) => outcomes.set(taskId, endpointSettled[index]));
732
783
  return { outcomes, failSteps };
733
784
  }
734
785
  /** One concurrent round inside one cached in-memory run: dispatches every
735
786
  * 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
787
+ * window (CLI-binary agents through the batch child, endpoint agents
788
+ * through the HTTP batch child), then settles + accepts in DETERMINISTIC
789
+ * batch (task-id) order regardless of wall-clock finish order. At round end
790
+ * it flushes once: commitState(run, "concurrent-round:<n>-tasks") +
791
+ * writeReport + saveCheckpoint. Cache-hit tasks get no prepared outcome and
792
+ * settle through the serial path (still inside this one deferred-persist
793
+ * round). If no step was produced (nothing
742
794
  * runnable at round entry — terminal/blocked/token-budget gate) the
743
795
  * round degrades to one plain driveStep. Byte-exact to the old build's
744
796
  * src/drive.ts's driveConcurrentRound. */
@@ -756,28 +808,32 @@ function driveConcurrentRound(ctx, limit) {
756
808
  .filter((task) => phaseTaskIds.has(task.id) && (task.status === "pending" || task.status === "running"))
757
809
  .slice(0, width)
758
810
  .map((task) => task.id);
759
- const prepared = prepareConcurrentOutcomes(ctx, batch);
811
+ const prepared = (0, perf_trace_1.withPerfTraceGroup)("dispatch", () => prepareConcurrentOutcomes(ctx, batch));
760
812
  const steps = [];
761
- for (const taskId of batch) {
762
- const failStep = prepared.failSteps.get(taskId);
763
- if (failStep) {
764
- steps.push(failStep);
765
- continue;
813
+ // Settlement is also local and synchronous. Flush the complete audit
814
+ // group before the existing round checkpoint, never across it.
815
+ (0, perf_trace_1.withPerfTraceGroup)("settlement", () => (0, trust_audit_1.withTrustAuditBatch)(loadRun(ctx), () => {
816
+ for (const taskId of batch) {
817
+ const failStep = prepared.failSteps.get(taskId);
818
+ if (failStep) {
819
+ steps.push(failStep);
820
+ continue;
821
+ }
822
+ // Re-read per task: a prior accept in this round mutated state (the
823
+ // SAME cached object via loadRun's round cache — no disk round-trip
824
+ // until the round-end flush below).
825
+ const freshRun = loadRun(ctx);
826
+ const fresh = freshRun.tasks.find((task) => task.id === taskId);
827
+ if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
828
+ continue;
829
+ steps.push(processSelectedTask(ctx, taskId, prepared.outcomes.get(taskId), true));
766
830
  }
767
- // Re-read per task: a prior accept in this round mutated state (the
768
- // SAME cached object via loadRun's round cache — no disk round-trip
769
- // until the round-end flush below).
770
- const freshRun = loadRun(ctx);
771
- const fresh = freshRun.tasks.find((task) => task.id === taskId);
772
- if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
773
- continue;
774
- steps.push(processSelectedTask(ctx, taskId, prepared.outcomes.get(taskId), true));
775
- }
831
+ }));
776
832
  if (steps.length > 0) {
777
833
  const settledRun = loadRun(ctx);
778
- (0, commit_1.commitState)(settledRun, `concurrent-round:${batch.length}-tasks`);
779
- (0, report_1.writeReport)(settledRun);
780
- (0, run_store_1.saveCheckpoint)(settledRun);
834
+ (0, perf_trace_1.withPerfTraceGroup)("checkpoint", () => (0, commit_1.commitState)(settledRun, `concurrent-round:${batch.length}-tasks`));
835
+ (0, perf_trace_1.withPerfTraceGroup)("report", () => (0, report_1.writeReport)(settledRun));
836
+ (0, perf_trace_1.withPerfTraceGroup)("checkpoint", () => (0, run_store_1.saveCheckpoint)(settledRun));
781
837
  }
782
838
  return steps.length > 0 ? steps : [driveStep(ctx)];
783
839
  });
@@ -872,7 +928,7 @@ function driveOneRound(ctx, options, steps, emitPhaseProgress) {
872
928
  // re-read+re-parse state.json even though nothing on disk changed
873
929
  // between them — reads that only reflect the mutations THIS round's
874
930
  // own steps make, which the shared in-memory object already carries.
875
- const roundSteps = withRoundCache(ctx, () => {
931
+ const roundSteps = (0, perf_trace_1.withPerfTraceGroup)("round", () => withRoundCache(ctx, () => {
876
932
  const width = (0, drive_decide_1.roundWidth)(loadRun(ctx), options.concurrency);
877
933
  // width>1 (an explicit --concurrency>1, or an auto-width parallel
878
934
  // phase) runs the whole round through driveConcurrentRound — one or
@@ -880,7 +936,7 @@ function driveOneRound(ctx, options, steps, emitPhaseProgress) {
880
936
  // end. `--once` still stops after this ONE round even though a round
881
937
  // can yield multiple steps.
882
938
  return width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
883
- });
939
+ }));
884
940
  for (const stepResult of roundSteps)
885
941
  steps.push(stepResult);
886
942
  // Brew-style phase boundaries: after each round, announce a newly-active
@@ -1004,28 +1060,35 @@ function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhau
1004
1060
  * exact synchronous shape) and fixed only in driveAsync(). */
1005
1061
  function drive(runId, cwd, options = {}) {
1006
1062
  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;
1063
+ // Resolve the run dir before the mutex: a missing run throws "Run not found"
1064
+ // here (never a drive.lock side effect on a nonexistent run), via a stat only
1065
+ // so the per-round read accounting is unchanged. Mirrors withRunStateLock's
1066
+ // own probe-before-lock.
1067
+ const runDir = (0, run_store_1.resolveRunDir)(runId, cwd);
1068
+ return (0, run_store_1.withDriveLock)(runDir, runId, () => {
1069
+ const steps = [];
1070
+ const run0 = loadRun(ctx);
1071
+ const plannedWorkers = run0.tasks.length;
1072
+ const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1073
+ const emitPhaseProgress = createPhaseProgressEmitter();
1074
+ let exhaustedMaxIterations = !options.once;
1075
+ const stopSignal = createStopSignalController(runId);
1076
+ stopSignal.install();
1077
+ try {
1078
+ for (let i = 0; i < maxIter; i++) {
1079
+ if (stopSignal.getInterruptedBy())
1080
+ break;
1081
+ if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1082
+ exhaustedMaxIterations = false;
1083
+ break;
1084
+ }
1022
1085
  }
1023
1086
  }
1024
- }
1025
- finally {
1026
- stopSignal.remove();
1027
- }
1028
- return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1087
+ finally {
1088
+ stopSignal.remove();
1089
+ }
1090
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1091
+ });
1029
1092
  }
1030
1093
  /** Same contract and same DriveResult as drive() -- but actually
1031
1094
  * interruptible. After every round it awaits yieldToEventLoop(), a real
@@ -1053,29 +1116,33 @@ function drive(runId, cwd, options = {}) {
1053
1116
  * future cycle if it proves needed in practice. */
1054
1117
  async function driveAsync(runId, cwd, options = {}) {
1055
1118
  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;
1119
+ // Resolve the run dir before the mutex — see drive() above.
1120
+ const runDir = (0, run_store_1.resolveRunDir)(runId, cwd);
1121
+ return (0, run_store_1.withDriveLockAsync)(runDir, runId, async () => {
1122
+ const steps = [];
1123
+ const run0 = loadRun(ctx);
1124
+ const plannedWorkers = run0.tasks.length;
1125
+ const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1126
+ const emitPhaseProgress = createPhaseProgressEmitter();
1127
+ let exhaustedMaxIterations = !options.once;
1128
+ const stopSignal = createStopSignalController(runId);
1129
+ stopSignal.install();
1130
+ try {
1131
+ for (let i = 0; i < maxIter; i++) {
1132
+ if (stopSignal.getInterruptedBy())
1133
+ break;
1134
+ if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1135
+ exhaustedMaxIterations = false;
1136
+ break;
1137
+ }
1138
+ await yieldToEventLoop();
1071
1139
  }
1072
- await yieldToEventLoop();
1073
1140
  }
1074
- }
1075
- finally {
1076
- stopSignal.remove();
1077
- }
1078
- return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1141
+ finally {
1142
+ stopSignal.remove();
1143
+ }
1144
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1145
+ });
1079
1146
  }
1080
1147
  function drivePreview(runId, cwd, args = {}) {
1081
1148
  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,