cool-workflow 0.1.97 → 0.1.98

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 (55) 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/capability-core.js +3 -0
  10. package/dist/capability-registry.js +7 -1
  11. package/dist/cli/command-surface.js +4 -0
  12. package/dist/cli/handlers/ledger.js +169 -0
  13. package/dist/drive.js +98 -61
  14. package/dist/execution-backend/agent.js +84 -24
  15. package/dist/ledger.js +313 -0
  16. package/dist/mcp/tool-call.js +36 -0
  17. package/dist/mcp/tool-definitions.js +26 -0
  18. package/dist/orchestrator/lifecycle-operations.js +37 -13
  19. package/dist/orchestrator.js +11 -3
  20. package/dist/remote-source.js +10 -3
  21. package/dist/run-export.js +35 -4
  22. package/dist/version.js +1 -1
  23. package/dist/workbench-host.js +11 -1
  24. package/docs/agent-delegation-drive.7.md +2 -0
  25. package/docs/cli-mcp-parity.7.md +14 -2
  26. package/docs/contract-migration-tooling.7.md +2 -0
  27. package/docs/control-plane-scheduling.7.md +2 -0
  28. package/docs/cross-agent-ledger.7.md +217 -0
  29. package/docs/designs/handoff-ledger.md +145 -0
  30. package/docs/durable-state-and-locking.7.md +2 -0
  31. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  32. package/docs/execution-backends.7.md +2 -0
  33. package/docs/handoff-setup.md +120 -0
  34. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  35. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  36. package/docs/multi-agent-operator-ux.7.md +2 -0
  37. package/docs/node-snapshot-diff-replay.7.md +2 -0
  38. package/docs/observability-cost-accounting.7.md +2 -0
  39. package/docs/project-index.md +16 -5
  40. package/docs/real-execution-backends.7.md +2 -0
  41. package/docs/release-and-migration.7.md +2 -0
  42. package/docs/release-tooling.7.md +2 -0
  43. package/docs/run-registry-control-plane.7.md +2 -0
  44. package/docs/run-retention-reclamation.7.md +2 -0
  45. package/docs/state-explosion-management.7.md +2 -0
  46. package/docs/team-collaboration.7.md +2 -0
  47. package/docs/web-desktop-workbench.7.md +2 -0
  48. package/manifest/plugin.manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/scripts/agents/codex-agent.js +34 -4
  51. package/scripts/canonical-apps.js +4 -4
  52. package/scripts/children/batch-delegate-child.js +30 -10
  53. package/scripts/dogfood-release.js +1 -1
  54. package/scripts/golden-path.js +4 -4
  55. package/scripts/release-flow.js +7 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
3
  "description": "A workflow control plane and run-time you are able to check: it sends out jobs in TypeScript, makes certain of work against facts before it goes through, puts state into fixed records, orders jobs by time, runs jobs again and again, gets a group of agents to do their parts together, and talks MCP. It gives the doing of the work to outside agents — it never runs the models itself.",
4
- "version": "0.1.97",
4
+ "version": "0.1.98",
5
5
  "author": {
6
6
  "name": "COOLWHITE LLC"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
4
4
  "description": "A workflow control plane and run-time you are able to check: it sends out jobs in TypeScript, makes certain of work against facts before it goes through, puts state into fixed records, orders jobs by time, runs jobs again and again, gets a group of agents to do their parts together, and talks MCP. It gives the doing of the work to outside agents — it never runs the models itself.",
5
5
  "author": {
6
6
  "name": "COOLWHITE LLC"
@@ -3,7 +3,7 @@
3
3
  "id": "architecture-review",
4
4
  "title": "Architecture Review",
5
5
  "summary": "Map a repository architecture, assess risks, verify important findings, and synthesize an evidence-backed verdict.",
6
- "version": "0.1.97",
6
+ "version": "0.1.98",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "architecture-review-fast",
4
4
  "title": "Architecture Review Fast",
5
5
  "summary": "Run a shorter architecture review with parallel map and assess phases for faster first results.",
6
- "version": "0.1.97",
6
+ "version": "0.1.98",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "end-to-end-golden-path",
4
4
  "title": "End-to-End Golden Path",
5
5
  "summary": "Deterministic one-worker workflow app for proving the CW integration chain.",
6
- "version": "0.1.97",
6
+ "version": "0.1.98",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "pr-review-fix-ci",
4
4
  "title": "PR Review Fix CI",
5
5
  "summary": "Review a pull request or branch, inspect CI failures, diagnose actionable issues, optionally patch, verify, and summarize with evidence.",
6
- "version": "0.1.97",
6
+ "version": "0.1.98",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "release-cut",
4
4
  "title": "Release Cut",
5
5
  "summary": "Prepare a release with checklist discipline: version checks, changelog, tests, packaging, release notes, and final verification.",
6
- "version": "0.1.97",
6
+ "version": "0.1.98",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -3,7 +3,7 @@
3
3
  "id": "research-synthesis",
4
4
  "title": "Research Synthesis",
5
5
  "summary": "Split a research question into claims, investigate sources, cross-check evidence, verify claims, and synthesize a concise answer.",
6
- "version": "0.1.97",
6
+ "version": "0.1.98",
7
7
  "author": "COOLWHITE LLC",
8
8
  "inputs": [
9
9
  {
@@ -86,6 +86,7 @@ const telemetry_demo_1 = require("./telemetry-demo");
86
86
  const state_1 = require("./state");
87
87
  const run_export_1 = require("./run-export");
88
88
  const result_normalize_1 = require("./result-normalize");
89
+ const cli_options_1 = require("./orchestrator/cli-options");
89
90
  const node_fs_1 = __importDefault(require("node:fs"));
90
91
  const node_path_1 = __importDefault(require("node:path"));
91
92
  const scheduling_1 = require("./scheduling");
@@ -553,6 +554,7 @@ const DRIVE_RUNTIME_KEYS = [
553
554
  "agent-timeout-ms",
554
555
  "resume",
555
556
  "incremental",
557
+ "concurrency",
556
558
  // Remote-source flags (v0.1.91): materialized into a local checkout in the capability
557
559
  // layer, never passed to plan as inputs (the resolved sourceUrl/sourceCommit ARE inputs).
558
560
  "link",
@@ -592,6 +594,7 @@ function runDrive(runner, args) {
592
594
  once: isTrue(args.once),
593
595
  now: optionalString(args.now),
594
596
  incremental: isTrue(args.incremental),
597
+ concurrency: (0, cli_options_1.numberOption)(args.concurrency),
595
598
  args
596
599
  });
597
600
  }
@@ -467,7 +467,13 @@ const BUILTIN_CAPABILITIES = [
467
467
  { capability: "comment.list", summary: "List append-only comments for a run (optionally one target).", entry: "collaborationCommentList", surface: "both", cli: { path: ["comment", "list"], caseTokens: ["comment"], jsonMode: "flag" }, mcp: { tool: "cw_comment_list", requiredArgs: ["runId"] } },
468
468
  { capability: "handoff", summary: "Record an ownership transfer (from-actor → to-actor) of a run/task.", entry: "collaborationHandoff", surface: "both", cli: { path: ["handoff"], jsonMode: "default" }, mcp: { tool: "cw_handoff", requiredArgs: ["runId", "targetKind|kind", "targetId|target", "to|toActor"] } },
469
469
  { capability: "review.status", summary: "Read the derived per-target review state + collaboration timeline for a run.", entry: "reviewStatus", surface: "both", cli: { path: ["review", "status"], caseTokens: ["review"], jsonMode: "flag" }, mcp: { tool: "cw_review_status", requiredArgs: ["runId"] } },
470
- { capability: "review.policy", summary: "Set the run's review-gate policy (required approvals, authorized roles, self-approval rule).", entry: "reviewPolicy", surface: "both", cli: { path: ["review", "policy"], caseTokens: ["review"], jsonMode: "default" }, mcp: { tool: "cw_review_policy", requiredArgs: ["runId"] } }
470
+ { capability: "review.policy", summary: "Set the run's review-gate policy (required approvals, authorized roles, self-approval rule).", entry: "reviewPolicy", surface: "both", cli: { path: ["review", "policy"], caseTokens: ["review"], jsonMode: "default" }, mcp: { tool: "cw_review_policy", requiredArgs: ["runId"] } },
471
+ // ---- Cross-agent handoff ledger (stage 2, MCP surface + git transport) --
472
+ { capability: "ledger.propose", summary: "Build a verifiable cross-agent change proposal entry (printed as JSON).", entry: "buildLedgerProposal", surface: "both", cli: { path: ["ledger", "propose"], caseTokens: ["ledger", "propose"], jsonMode: "default" }, mcp: { tool: "cw_ledger_propose", requiredArgs: ["from", "to", "title", "rationale"] }, payloadIdentical: false, reason: "Mints a fresh entry each call: createdAt is the wall-clock instant and the id/digest are derived from it, so the output is inherently non-deterministic and a byte-identity probe does not apply. Both surfaces call the same buildLedgerProposal core; round-trip + fail-closed behavior is covered by ledger-verify-smoke." },
473
+ { capability: "ledger.review", summary: "Build a verifiable cross-agent review verdict entry (printed as JSON).", entry: "buildLedgerReview", surface: "both", cli: { path: ["ledger", "review"], caseTokens: ["ledger", "review"], jsonMode: "default" }, mcp: { tool: "cw_ledger_review", requiredArgs: ["from", "to", "target", "verdict"] }, payloadIdentical: false, reason: "Mints a fresh timestamped/digested verdict each call — non-deterministic output, same reasoning as ledger.propose. Both surfaces call the same buildLedgerReview core." },
474
+ { capability: "ledger.verify", summary: "Verify a ledger entry against its content digest (fail-closed on tampering).", entry: "verifyLedgerEntry", surface: "both", cli: { path: ["ledger", "verify"], caseTokens: ["ledger", "verify"], jsonMode: "default" }, mcp: { tool: "cw_ledger_verify", requiredArgs: ["entry"] }, payloadIdentical: false, reason: "The entry arrives by --file/stdin on the CLI and as an `entry` argument over MCP; there is no shared arg-bag the byte-identity probe can feed both. Both surfaces call the same verifyLedgerEntry core; ledger-verify-smoke proves the fail-closed contract." },
475
+ { capability: "ledger.apply", summary: "Verify a proposal entry and return its suggestedDiff for `git apply` (fail-closed: no diff unless the entry verifies as a proposal).", entry: "applyLedgerProposal", surface: "both", cli: { path: ["ledger", "apply"], caseTokens: ["ledger", "apply"], jsonMode: "default" }, mcp: { tool: "cw_ledger_apply", requiredArgs: ["entry"] }, payloadIdentical: false, reason: "The entry arrives by --file/stdin on the CLI and as an `entry` argument over MCP; there is no shared arg-bag the byte-identity probe can feed both. Both surfaces call the same applyLedgerProposal core (a fail-closed wrapper over verifyLedgerEntry); ledger-apply-smoke proves the diff only escapes a verified proposal." },
476
+ { capability: "ledger.list", summary: "Read + verify every entry in one or more shared ledger directories (fail-closed inbox; 2+ dirs union-verify mirrors).", entry: "listLedgerEntries", surface: "both", cli: { path: ["ledger", "list"], caseTokens: ["ledger", "list"], jsonMode: "default" }, mcp: { tool: "cw_ledger_list", requiredArgs: ["dir|dirs"] }, payloadIdentical: false, reason: "Output depends on the on-disk contents of the named ledger directory/directories, which the generic payload probe does not populate. Both surfaces call the same listLedgerEntries/unionLedgerEntries core; ledger-verify-smoke covers the fail-closed inbox and the multi-mirror union." }
471
477
  ];
472
478
  /** The capability registry — the single source of truth, deduplicated by
473
479
  * capability id (last declaration wins). Derived directly from the table above:
@@ -54,6 +54,7 @@ const registry_1 = require("./handlers/registry");
54
54
  const multi_agent_1 = require("./handlers/multi-agent");
55
55
  const run_1 = require("./handlers/run");
56
56
  const collaboration_1 = require("./handlers/collaboration");
57
+ const ledger_1 = require("./handlers/ledger");
57
58
  const blackboard_1 = require("./handlers/blackboard");
58
59
  const eval_1 = require("./handlers/eval");
59
60
  const node_1 = require("./handlers/node");
@@ -427,6 +428,9 @@ async function runCli(argv = process.argv.slice(2)) {
427
428
  case "review":
428
429
  (0, collaboration_1.handleReview)(args, runner);
429
430
  return;
431
+ case "ledger":
432
+ (0, ledger_1.handleLedger)(args, runner);
433
+ return;
430
434
  case "loop": {
431
435
  (0, io_1.printJson)(scheduler.create({ ...args.options, kind: "loop" }));
432
436
  return;
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ // `cw ledger propose|review|verify` — the cross-agent handoff ledger CLI surface.
3
+ // A proposing agent prints a proposal or a review verdict as a verifiable JSON
4
+ // entry; the receiving side verifies it fail-closed before acting. See
5
+ // docs/cross-agent-ledger.7.md and docs/designs/handoff-ledger.md.
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.handleLedger = handleLedger;
41
+ const fs = __importStar(require("fs"));
42
+ const ledger_1 = require("../../ledger");
43
+ const io_1 = require("../io");
44
+ /** Coerce a repeatable/comma-joined list option to a clean string[]. */
45
+ function listOption(value) {
46
+ const parts = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
47
+ return parts.map((p) => String(p).trim()).filter(Boolean);
48
+ }
49
+ function stringOption(value) {
50
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
51
+ }
52
+ function nowIso() {
53
+ return new Date().toISOString();
54
+ }
55
+ function handleLedger(args, _runner) {
56
+ const [subcommand] = args.positionals;
57
+ const opts = args.options;
58
+ switch (subcommand) {
59
+ case "propose": {
60
+ const entry = (0, ledger_1.buildLedgerProposal)({
61
+ from: (0, io_1.required)(stringOption(opts.from), "--from <agent/repo>"),
62
+ to: (0, io_1.required)(stringOption(opts.to), "--to <agent/repo>"),
63
+ title: (0, io_1.required)(stringOption(opts.title), "--title <text>"),
64
+ rationale: (0, io_1.required)(stringOption(opts.rationale), "--rationale <text>"),
65
+ targetFiles: listOption(opts.files),
66
+ // Do NOT trim the diff: it is a unified patch (payload, not a label), and
67
+ // trimming strips the trailing newline `git apply` requires — a trimmed
68
+ // diff is a corrupt patch. Presence is detected with a trimmed test, but
69
+ // the bytes are passed through verbatim (matching the MCP propose path).
70
+ suggestedDiff: typeof opts.diff === "string" && opts.diff.trim() ? opts.diff : undefined,
71
+ createdAt: nowIso()
72
+ });
73
+ (0, io_1.printJson)(entry);
74
+ return;
75
+ }
76
+ case "review": {
77
+ const verdictRaw = (0, io_1.required)(stringOption(opts.verdict), "--verdict <approved|rejected>").toUpperCase();
78
+ if (verdictRaw !== "APPROVED" && verdictRaw !== "REJECTED") {
79
+ throw new Error('--verdict must be "approved" or "rejected".');
80
+ }
81
+ const entry = (0, ledger_1.buildLedgerReview)({
82
+ from: (0, io_1.required)(stringOption(opts.from), "--from <agent/repo>"),
83
+ to: (0, io_1.required)(stringOption(opts.to), "--to <agent/repo>"),
84
+ target: (0, io_1.required)(stringOption(opts.target), "--target <proposal-id|pr-ref>"),
85
+ verdict: verdictRaw,
86
+ findings: listOption(opts.findings),
87
+ createdAt: nowIso()
88
+ });
89
+ (0, io_1.printJson)(entry);
90
+ return;
91
+ }
92
+ case "verify": {
93
+ const file = stringOption(opts.file);
94
+ let text;
95
+ try {
96
+ // --file <path>, else read the entry from stdin (fd 0).
97
+ text = fs.readFileSync(file || 0, "utf8");
98
+ }
99
+ catch (error) {
100
+ throw new Error(`Cannot read ledger entry${file ? ` from ${file}` : " from stdin"}: ${error.message}`);
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(text);
105
+ }
106
+ catch {
107
+ // A non-JSON input is itself a fail-closed refusal, not a crash.
108
+ (0, io_1.printJson)({ ok: false, id: null, kind: null, checks: [{ name: "parse", pass: false, code: "ledger-bad-json" }], failedChecks: [{ name: "parse", code: "ledger-bad-json" }] });
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ const result = (0, ledger_1.verifyLedgerEntry)(parsed);
113
+ (0, io_1.printJson)(result);
114
+ // Fail-closed: a tampered/malformed entry exits non-zero so
115
+ // `cw ledger verify <file> && open-pr` cannot proceed on a lie.
116
+ if (!result.ok)
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ case "apply": {
121
+ const file = stringOption(opts.file);
122
+ let text;
123
+ try {
124
+ // --file <path>, else read the entry from stdin (fd 0), same as verify.
125
+ text = fs.readFileSync(file || 0, "utf8");
126
+ }
127
+ catch (error) {
128
+ throw new Error(`Cannot read ledger entry${file ? ` from ${file}` : " from stdin"}: ${error.message}`);
129
+ }
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(text);
133
+ }
134
+ catch {
135
+ (0, io_1.printJson)({ ok: false, id: null, kind: null, diff: null, failedChecks: [{ name: "parse", code: "ledger-bad-json" }] });
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ const result = (0, ledger_1.applyLedgerProposal)(parsed);
140
+ (0, io_1.printJson)(result);
141
+ // Fail-closed: the diff only comes out (ok:true) when the proposal verifies,
142
+ // so `cw ledger apply <file> | git apply` never feeds git an unverified patch.
143
+ if (!result.ok)
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+ case "list": {
148
+ // `--dir` is repeatable: 2+ dirs union-verify multiple mirrors into one
149
+ // inbox; a single --dir keeps the original single-directory output (POLA).
150
+ const dirs = Array.isArray(opts.dir) ? opts.dir.map(String).filter(Boolean) : [];
151
+ if (dirs.length > 1) {
152
+ const union = (0, ledger_1.unionLedgerEntries)(dirs);
153
+ (0, io_1.printJson)(union);
154
+ if (!union.allOk)
155
+ process.exitCode = 1;
156
+ return;
157
+ }
158
+ const dir = (0, io_1.required)(dirs[0] || stringOption(opts.dir), "--dir <ledger-directory>");
159
+ const result = (0, ledger_1.listLedgerEntries)(dir);
160
+ (0, io_1.printJson)(result);
161
+ // Fail-closed inbox: refuse the whole batch if any entry does not verify.
162
+ if (!result.allOk)
163
+ process.exitCode = 1;
164
+ return;
165
+ }
166
+ default:
167
+ throw new Error("Usage: cw ledger propose|review|verify|apply|list [options]");
168
+ }
169
+ }
package/dist/drive.js CHANGED
@@ -43,6 +43,8 @@ const observability_1 = require("./observability");
43
43
  const loop_expansion_1 = require("./loop-expansion");
44
44
  const telemetry_attestation_1 = require("./telemetry-attestation");
45
45
  const state_1 = require("./state");
46
+ const commit_1 = require("./commit");
47
+ const report_1 = require("./orchestrator/report");
46
48
  const trust_audit_1 = require("./trust-audit");
47
49
  const compare_1 = require("./compare");
48
50
  exports.DRIVE_SCHEMA_VERSION = 1;
@@ -183,7 +185,7 @@ function buildAgentRequest(ctx, run, task, manifest, preparedOutcome) {
183
185
  * fulfill step then settles it through runBackend instead of spawning again,
184
186
  * so the concurrent round and the serial step share EVERY envelope/accept
185
187
  * branch by construction. */
186
- function processSelectedTask(ctx, selected, preparedOutcome) {
188
+ function processSelectedTask(ctx, selected, preparedOutcome, deferPersist = false) {
187
189
  const { runner, runId } = ctx;
188
190
  let run = runner.loadRun(runId);
189
191
  // 1. DISPATCH (only a fresh pending task; a running task is a retry on its scope).
@@ -191,7 +193,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
191
193
  let workerId = selected.workerId;
192
194
  let dispatched = false;
193
195
  if (selected.status === "pending") {
194
- const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent" });
196
+ const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent", ...(deferPersist ? { persistState: false } : {}) });
195
197
  const task = manifest.tasks.find((entry) => entry.id === selected.id) || manifest.tasks[0];
196
198
  if (!task || !task.workerId) {
197
199
  return step("dispatch", "failed", { runId, taskId: selected.id, phase: selected.phase, reason: "dispatch produced no worker scope" });
@@ -216,10 +218,10 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
216
218
  emitProgress(`↺ ${selected.label || selected.id} (${selected.phase}) — accepting cached result`);
217
219
  try {
218
220
  node_fs_1.default.writeFileSync(manifest.resultPath, node_fs_1.default.readFileSync(cachePath, "utf8"), "utf8");
219
- runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {});
221
+ runner.recordWorkerOutput(runId, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
220
222
  }
221
223
  catch (error) {
222
- return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`);
224
+ return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
223
225
  }
224
226
  return step("accept", "ok", {
225
227
  runId,
@@ -233,7 +235,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
233
235
  // CHILD run and bind its report back as this task's result. Leaf work is still
234
236
  // external-agent delegation at every level; CW imports no model SDK here.
235
237
  if (selected.subWorkflow) {
236
- return runSubWorkflow(ctx, run, selected, workerId, manifest);
238
+ return runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist);
237
239
  }
238
240
  emitProgress(`→ ${selected.label || selected.id} (${selected.phase}) — ${dispatched ? "dispatched, " : ""}spawning agent, may take minutes…`);
239
241
  const envelope = (0, execution_backend_1.runBackend)(buildAgentRequest(ctx, run, selected, manifest, preparedOutcome));
@@ -242,16 +244,17 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
242
244
  const reportedUsage = handle?.metadata?.reportedUsage;
243
245
  const usageSignature = handle?.metadata?.usageSignature;
244
246
  if (envelope.status !== "completed") {
245
- return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`);
247
+ return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`, deferPersist);
246
248
  }
247
249
  // 3. ACCEPT — the SEPARATE recordWorkerOutput layer validates + records result.md.
248
250
  // A missing result.md is a failed hop (pre-checked so no terminal side effect);
249
251
  // an invalid result.md throws at validation BEFORE any state mutation.
250
252
  if (!manifest.resultPath || !node_fs_1.default.existsSync(manifest.resultPath)) {
251
- return handleHop(ctx, selected, workerId, "agent produced no result.md");
253
+ return handleHop(ctx, selected, workerId, "agent produced no result.md", deferPersist);
252
254
  }
253
255
  try {
254
256
  runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {
257
+ ...(deferPersist ? { persistState: false } : {}),
255
258
  agentDelegation: {
256
259
  handle: handle,
257
260
  model: reportedModel,
@@ -270,7 +273,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
270
273
  });
271
274
  }
272
275
  catch (error) {
273
- return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`);
276
+ return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
274
277
  }
275
278
  if (cachePath && manifest.resultPath && node_fs_1.default.existsSync(manifest.resultPath)) {
276
279
  writeResultCache(cachePath, node_fs_1.default.readFileSync(manifest.resultPath, "utf8"));
@@ -404,41 +407,61 @@ function writeResultCache(file, content) {
404
407
  * collect-all: a failed/hung/dirty hop never aborts its siblings, every hop
405
408
  * settles and is recorded (failures park via the same retryOrPark). */
406
409
  function driveConcurrentRound(ctx, limit) {
407
- const run = ctx.runner.loadRun(ctx.runId);
408
- const selected = selectDriveTask(run);
409
- const gate = terminalOrConfigStep(ctx, run, selected);
410
- if (gate)
411
- return [gate];
412
- const phase = (0, dispatch_1.firstRunnablePhase)(run);
413
- const width = Math.max(1, Math.floor(limit) || 1);
414
- const batch = run.tasks
415
- .filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
416
- .slice(0, width)
417
- .map((task) => task.id);
418
- // Phase A+B: dispatch every batch task (sequential dispatch mutates state),
419
- // then collect ALL spawn-style child outcomes in one concurrent window. The
420
- // token-budget gate ran at round entry; it is NOT re-checked between accepts —
421
- // the spawns already happened, and refusing to RECORD finished work would
422
- // discard real results (collect-all + never-claw-back). Overshoot is bounded
423
- // by the round width; the next round blocks.
424
- const prepared = prepareConcurrentOutcomes(ctx, batch);
425
- // Phase C: settle + accept in deterministic batch order, regardless of the
426
- // wall-clock order the children finished in.
427
- const steps = [];
428
- for (const taskId of batch) {
429
- const failStep = prepared.failSteps.get(taskId);
430
- if (failStep) {
431
- steps.push(failStep);
432
- continue;
410
+ // The whole round runs inside ONE cached in-memory run object (loadWithCache —
411
+ // reentrant, so a sub-workflow task's recursive drive() call cannot clobber this
412
+ // scope's cache). Every dispatch/accept in the round defers its own state.json
413
+ // write (persistState:false / deferPersist) and mutates that SAME shared object;
414
+ // the round flushes to disk exactly ONCE at the end instead of once per task —
415
+ // was O(N) full-state durable rewrites per round (measured: dominates wall time
416
+ // at scale), now O(1). A crash mid-round loses that round's dispatch/accept
417
+ // bookkeeping (bounded by the round width, i.e. limits.maxConcurrentAgents) and
418
+ // forces a safe-but-wasteful re-dispatch/re-spawn on the next drive never
419
+ // disk corruption or double-counting, since the atomic-rename+fsync write
420
+ // itself is untouched; only the write FREQUENCY changed.
421
+ return ctx.runner.loadWithCache(() => {
422
+ const run = ctx.runner.loadRun(ctx.runId);
423
+ const selected = selectDriveTask(run);
424
+ const gate = terminalOrConfigStep(ctx, run, selected);
425
+ if (gate)
426
+ return [gate];
427
+ const phase = (0, dispatch_1.firstRunnablePhase)(run);
428
+ const width = Math.max(1, Math.floor(limit) || 1);
429
+ const batch = run.tasks
430
+ .filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
431
+ .slice(0, width)
432
+ .map((task) => task.id);
433
+ // Phase A+B: dispatch every batch task (sequential — dispatch mutates state),
434
+ // then collect ALL spawn-style child outcomes in one concurrent window. The
435
+ // token-budget gate ran at round entry; it is NOT re-checked between accepts —
436
+ // the spawns already happened, and refusing to RECORD finished work would
437
+ // discard real results (collect-all + never-claw-back). Overshoot is bounded
438
+ // by the round width; the next round blocks.
439
+ const prepared = prepareConcurrentOutcomes(ctx, batch);
440
+ // Phase C: settle + accept in deterministic batch order, regardless of the
441
+ // wall-clock order the children finished in.
442
+ const steps = [];
443
+ for (const taskId of batch) {
444
+ const failStep = prepared.failSteps.get(taskId);
445
+ if (failStep) {
446
+ steps.push(failStep);
447
+ continue;
448
+ }
449
+ // Re-read per task: a prior accept in this round mutated state (the SAME
450
+ // cached object — no disk round-trip until the round-end flush below).
451
+ const freshRun = ctx.runner.loadRun(ctx.runId);
452
+ const fresh = freshRun.tasks.find((task) => task.id === taskId);
453
+ if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
454
+ continue;
455
+ steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId), true));
433
456
  }
434
- // Re-read per task: a prior accept in this round mutated state.
435
- const freshRun = ctx.runner.loadRun(ctx.runId);
436
- const fresh = freshRun.tasks.find((task) => task.id === taskId);
437
- if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
438
- continue;
439
- steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId)));
440
- }
441
- return steps.length > 0 ? steps : [driveStep(ctx)];
457
+ if (steps.length > 0) {
458
+ const settledRun = ctx.runner.loadRun(ctx.runId);
459
+ (0, commit_1.commitState)(settledRun, `concurrent-round:${batch.length}-tasks`);
460
+ (0, report_1.writeReport)(settledRun);
461
+ (0, state_1.saveCheckpoint)(settledRun);
462
+ }
463
+ return steps.length > 0 ? steps : [driveStep(ctx)];
464
+ });
442
465
  }
443
466
  /** Dispatch each batch task and run every spawn-style agent child concurrently
444
467
  * (one batch delegate child, per-job timeout kill). Returns outcomes keyed by
@@ -457,7 +480,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
457
480
  continue;
458
481
  let workerId = task.workerId;
459
482
  if (task.status === "pending") {
460
- const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent" });
483
+ const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent", persistState: false });
461
484
  const dispatchedTask = manifest.tasks.find((entry) => entry.id === task.id) || manifest.tasks[0];
462
485
  if (!dispatchedTask || !dispatchedTask.workerId) {
463
486
  failSteps.set(taskId, step("dispatch", "failed", { runId, taskId, phase: task.phase, reason: "dispatch produced no worker scope" }));
@@ -499,7 +522,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
499
522
  }
500
523
  /** A failed agent hop: charge one attempt and (reuse v0.1.37 retryOrPark) either
501
524
  * retry on the SAME worker scope next step, or PARK past the retry budget. */
502
- function handleHop(ctx, task, workerId, reason) {
525
+ function handleHop(ctx, task, workerId, reason, deferPersist = false) {
503
526
  const persisted = ctx.runner.showWorker(ctx.runId, workerId).retryCount || 0;
504
527
  const prior = Math.max(ctx.attempts.get(task.id) || 0, persisted);
505
528
  const entry = {
@@ -520,7 +543,8 @@ function handleHop(ctx, task, workerId, reason) {
520
543
  ctx.runner.recordWorkerFailure(ctx.runId, workerId, decided.parkedReason || reason, {
521
544
  code: "agent-delegation-parked",
522
545
  retryable: false,
523
- retryCount: attempts
546
+ retryCount: attempts,
547
+ ...(deferPersist ? { persistState: false } : {})
524
548
  });
525
549
  return step("park", "parked", {
526
550
  runId: ctx.runId,
@@ -532,7 +556,7 @@ function handleHop(ctx, task, workerId, reason) {
532
556
  });
533
557
  }
534
558
  // Retryable: leave the task running (scope reused) for the next step.
535
- (0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason);
559
+ (0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason, deferPersist ? { persist: false } : {});
536
560
  return step("fulfill", "failed", {
537
561
  runId: ctx.runId,
538
562
  taskId: task.id,
@@ -565,17 +589,17 @@ function renderSubInputs(spec, parentInputs) {
565
589
  * own telemetry/audit live in the child run; the parent records ONE honest
566
590
  * `worker.sub-workflow` cross-link (child run id + report digest + child audit
567
591
  * verdict) — nothing is summed or fabricated. */
568
- function runSubWorkflow(ctx, run, selected, workerId, manifest) {
592
+ function runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist = false) {
569
593
  const spec = selected.subWorkflow;
570
594
  const parentApp = run.workflow.id;
571
595
  // Fail-closed BEFORE planning a child (no child state minted when refused).
572
596
  if (ctx.depth + 1 > exports.MAX_SUB_WORKFLOW_DEPTH) {
573
- return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`);
597
+ return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`, deferPersist);
574
598
  }
575
599
  // Include the CURRENT app on the path, so a direct self-cycle (A→A) is caught at
576
600
  // depth 0 — before any child run dir is minted.
577
601
  if ([...ctx.visitedAppIds, parentApp].includes(spec.appId)) {
578
- return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`);
602
+ return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`, deferPersist);
579
603
  }
580
604
  // Deterministic child run id derived from the parent run + task (no clock/random).
581
605
  const childRunId = `sub-${run.id}-${(0, state_1.safeFileName)(selected.id)}`;
@@ -592,7 +616,7 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
592
616
  childRun = ctx.runner.plan(spec.appId, childInputs);
593
617
  }
594
618
  catch (error) {
595
- return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`);
619
+ return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`, deferPersist);
596
620
  }
597
621
  const childResult = drive(ctx.runner, childRun.id, {
598
622
  now: ctx.now,
@@ -603,7 +627,7 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
603
627
  visitedAppIds: [...ctx.visitedAppIds, parentApp]
604
628
  });
605
629
  if (childResult.status !== "complete") {
606
- return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`);
630
+ return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`, deferPersist);
607
631
  }
608
632
  // Bind the child's bytes: the rendered report (default) or the verdict result.
609
633
  const finalChild = ctx.runner.loadRun(childRun.id);
@@ -616,15 +640,15 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
616
640
  childBytes = node_fs_1.default.existsSync(finalChild.paths.report) ? node_fs_1.default.readFileSync(finalChild.paths.report, "utf8") : undefined;
617
641
  }
618
642
  if (childBytes === undefined) {
619
- return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`);
643
+ return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`, deferPersist);
620
644
  }
621
645
  // Accept through the SAME path as any other result (verifier/schema/evidence gate).
622
646
  try {
623
647
  node_fs_1.default.writeFileSync(manifest.resultPath, childBytes, "utf8");
624
- ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, {});
648
+ ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
625
649
  }
626
650
  catch (error) {
627
- return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`);
651
+ return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`, deferPersist);
628
652
  }
629
653
  // Honest cross-link (provenance only — never fails the accepted hop): one
630
654
  // worker.sub-workflow audit event on the parent pins the child run + report digest
@@ -730,6 +754,7 @@ function drive(runner, runId, options = {}) {
730
754
  return; // only the first not-yet-complete phase is "active"
731
755
  }
732
756
  };
757
+ let exhaustedMaxIterations = !options.once;
733
758
  for (let i = 0; i < maxIterations; i++) {
734
759
  const width = concurrency > 1 ? concurrency : autoWidth(runner.loadRun(runId));
735
760
  const roundSteps = width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
@@ -751,27 +776,39 @@ function drive(runner, runId, options = {}) {
751
776
  // reuses the run we just advanced; goes to stderr via emitProgress so stdout is clean.
752
777
  emitPhaseProgress(runner.loadRun(runId));
753
778
  const last = roundSteps[roundSteps.length - 1];
754
- if (options.once)
779
+ if (options.once) {
780
+ exhaustedMaxIterations = false;
755
781
  break;
756
- if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
782
+ }
783
+ if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
784
+ exhaustedMaxIterations = false;
757
785
  break;
786
+ }
758
787
  }
759
788
  const run = runner.loadRun(runId);
760
789
  const completedWorkers = countCompleted(run);
761
790
  const parkedWorkers = countParked(run);
762
791
  const committed = (run.commits || []).find((commit) => commit.reason && commit.reason.startsWith("agent-delegation-drive"));
763
792
  const last = steps[steps.length - 1];
793
+ if (exhaustedMaxIterations) {
794
+ steps.push(step("blocked", "blocked", {
795
+ runId,
796
+ reason: `drive reached max iteration limit (${maxIterations}) before a terminal state`
797
+ }));
798
+ }
764
799
  const status = options.once
765
800
  ? completedWorkers === plannedWorkers && committed
766
801
  ? "complete"
767
802
  : last && (last.status === "parked" || last.status === "blocked")
768
803
  ? last.status
769
804
  : "in-progress"
770
- : parkedWorkers > 0 || (last && last.status === "parked")
771
- ? "parked"
772
- : last && last.status === "blocked"
773
- ? "blocked"
774
- : "complete";
805
+ : exhaustedMaxIterations
806
+ ? "blocked"
807
+ : parkedWorkers > 0 || (last && last.status === "parked")
808
+ ? "parked"
809
+ : last && last.status === "blocked"
810
+ ? "blocked"
811
+ : "complete";
775
812
  return {
776
813
  schemaVersion: 1,
777
814
  runId,