@deksden-com/dd-flow-cli 0.9.0-beta.37 → 0.9.0-beta.39

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 (72) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +42 -1
  4. package/dist/cli/run-cli.js +145 -17
  5. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  6. package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
  7. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  8. package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
  9. package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
  10. package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
  11. package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
  12. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +73 -20
  13. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
  14. package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
  15. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
  19. package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
  20. package/dist/harness-runtime/lib/process-json.mjs +1 -1
  21. package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
  22. package/dist/schemas/run-control-receipt.schema.json +99 -0
  23. package/dist/schemas/run-control-request.schema.json +36 -0
  24. package/dist/services/cleanup.js +7 -4
  25. package/dist/services/cli-operation-classifier.js +3 -1
  26. package/dist/services/code-checks.js +33 -7
  27. package/dist/services/controller-fanout.js +105 -0
  28. package/dist/services/engines.js +2 -0
  29. package/dist/services/eval-snapshots.js +347 -36
  30. package/dist/services/execution-policy.js +122 -6
  31. package/dist/services/external-work-launch.js +33 -12
  32. package/dist/services/harness-adapter.js +49 -2
  33. package/dist/services/managed-daemon-binding.js +38 -0
  34. package/dist/services/managed-processes.js +54 -7
  35. package/dist/services/merge-server.js +53 -30
  36. package/dist/services/native-daemon-history.js +49 -0
  37. package/dist/services/native-session-control.js +31 -0
  38. package/dist/services/protocols.js +9 -0
  39. package/dist/services/recovery-snapshot-database.js +103 -0
  40. package/dist/services/run-control-receipt.js +56 -0
  41. package/dist/services/run-control-worker.js +297 -0
  42. package/dist/services/run-control.js +752 -0
  43. package/dist/services/run-controller-adapter.js +44 -0
  44. package/dist/services/run-controller-capture.js +117 -0
  45. package/dist/services/run-controller-process.js +178 -0
  46. package/dist/services/run-controller-recovery.js +219 -0
  47. package/dist/services/run-controller-state.js +31 -0
  48. package/dist/services/run-controller.js +538 -0
  49. package/dist/services/run-recovery-runtime.js +69 -0
  50. package/dist/services/run-recovery.js +33 -9
  51. package/dist/services/runs.js +65 -13
  52. package/dist/services/runtime-budget.js +362 -0
  53. package/dist/services/runtime-scope-capture.js +50 -0
  54. package/dist/services/runtime-scope-control.js +431 -0
  55. package/dist/services/runtime-scope-resume.js +543 -0
  56. package/dist/services/runtime-scope-stop.js +95 -0
  57. package/dist/services/runtime-scope-worker.js +165 -0
  58. package/dist/services/runtime-service.js +4 -0
  59. package/dist/services/vnext-code-review.js +1 -1
  60. package/dist/services/vnext-code.js +1 -1
  61. package/dist/services/vnext-execution-profile.js +2 -35
  62. package/dist/services/vnext-fanout.js +23 -7
  63. package/dist/services/vnext-plan-review.js +12 -9
  64. package/dist/services/vnext-plan.js +1 -1
  65. package/dist/services/vnext-protocolize.js +10 -4
  66. package/dist/services/vnext-specify.js +12 -5
  67. package/dist/services/work-registry.js +26 -8
  68. package/dist/services/workspace-bootstrap.js +10 -1
  69. package/dist/storage/database.js +160 -13
  70. package/dist/storage/writer-contract.js +76 -0
  71. package/dist/storage/writer-migration.js +102 -0
  72. package/package.json +1 -1
@@ -0,0 +1,95 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { runHarnessAdapter } from "./harness-adapter.js";
6
+ import { managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
7
+ import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
8
+ import { retainedDaemonOperations } from "./run-control.js";
9
+ import { historicalDaemonSettlement } from "./native-daemon-history.js";
10
+ import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
11
+ /** Native daemons own cancellation after the observing CLI disconnects. */
12
+ export async function stopRuntimeScope(context, scopeId, requestId) {
13
+ const fence = fenceRuntimeBudget(context, scopeId, requestId);
14
+ const inventory = runtimeBudgetStatus(context, scopeId).processes;
15
+ const nodes = await Promise.all(inventory.filter(record => record.kind.endsWith("-daemon")).map(async (record) => {
16
+ try {
17
+ const historical = historicalDaemonSettlement(context, scopeId, record);
18
+ if (historical)
19
+ return historical;
20
+ const metadata = JSON.parse(record.metadata_json);
21
+ if (!metadata.state_dir || !path.isAbsolute(metadata.state_dir) || !metadata.adapter_executable || !path.isAbsolute(metadata.adapter_executable))
22
+ throw new AppError("runtime_scope_owner_unknown", "Native stop requires its retained directory and executable", 1);
23
+ const file = path.join(metadata.state_dir, "daemon.json");
24
+ const read = () => JSON.parse(fs.readFileSync(file, "utf8"));
25
+ const state = read();
26
+ const owner = state.config?.runtime_owner;
27
+ if (!state.daemon_id || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.id !== record.id || state.resource_process.lease_token !== record.lease_token || state.pid !== record.pid)
28
+ throw new AppError("runtime_scope_owner_unknown", "Native owner does not match its registration", 1);
29
+ const closed = state.shutdown_state === "clean" && state.active_tree === false && !processTreeIsAlive(record)
30
+ ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
31
+ const operationId = closed ? String(closed.operation_id) : `scope-stop:${crypto.createHash("sha256").update(JSON.stringify([scopeId, record.id, state.daemon_id])).digest("hex")}`;
32
+ const requested = path.join(metadata.state_dir, "operations", crypto.createHash("sha256").update(operationId).digest("hex"), "requested.json");
33
+ const prior = fs.existsSync(requested);
34
+ const current = managedProcessStatus(context).find(item => item.id === record.id);
35
+ if (!current || current.lease_token !== record.lease_token || current.pid !== record.pid || current.pid_started_at !== record.pid_started_at)
36
+ throw new AppError("runtime_scope_owner_changed", "Native registration changed before control", 1);
37
+ if (!prior && !processIsAlive(current))
38
+ throw new AppError("runtime_scope_outcome_unknown", "Dead native owner requires retained shutdown evidence", 1);
39
+ const env = { ...context.env, DD_FLOW_HOME: owner.dd_flow_home, DD_FLOW_BIN: owner.dd_flow_bin, DD_FLOW_RESOURCE_HOME: owner.resource_home };
40
+ delete env.DD_FLOW_CONTROL_TOKEN;
41
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
42
+ const receipt = await runHarnessAdapter({ executable: metadata.adapter_executable,
43
+ args: prior ? ["daemon", "operation", "--operation-id", operationId, "--state-dir", metadata.state_dir, "--json"] : ["daemon", "stop", "--cancel-tree", "--state-dir", metadata.state_dir, "--json"],
44
+ env, operationId: prior ? `${operationId}:observe` : operationId, timeoutMs: 30_000 });
45
+ if (prior && (receipt.operation_id !== operationId || receipt.daemon_id !== state.daemon_id || receipt.operation !== "daemon.stop" || receipt.state !== "completed"))
46
+ throw new AppError("runtime_scope_outcome_unknown", "Native close operation has not completed", 1);
47
+ const result = (prior ? receipt.result : receipt);
48
+ const after = read();
49
+ const finalOwner = managedProcessStatus(context).find(item => item.id === record.id);
50
+ if (!finalOwner || finalOwner.lease_token !== record.lease_token || finalOwner.pid !== record.pid || finalOwner.pid_started_at !== record.pid_started_at || after.resource_process?.id !== record.id || after.resource_process.lease_token !== record.lease_token || JSON.stringify(after.config?.runtime_owner) !== JSON.stringify(owner))
51
+ throw new AppError("runtime_scope_owner_changed", "Native ownership changed during control", 1);
52
+ if (result?.stopped !== true || result.clean !== true || after.daemon_id !== state.daemon_id || after.shutdown_state !== "clean" || after.active_tree !== false || processTreeIsAlive(finalOwner))
53
+ throw new AppError("runtime_scope_not_settled", "Native close has not proved whole-tree settlement", 1);
54
+ const processes = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, finalOwner, after) && item.operation_id?.startsWith(`${owner.operation_id}:`) && !processTreeIsAlive(item)).map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) }));
55
+ return { process_id: record.id, state_dir: metadata.state_dir, settled: true, operation_id: operationId, owner_operation_id: owner.operation_id, processes };
56
+ }
57
+ catch (error) {
58
+ return { process_id: record.id, settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_control_failed", message: String(error) };
59
+ }
60
+ }));
61
+ const covered = new Set(nodes.map(node => node.process_id));
62
+ for (const record of runtimeBudgetStatus(context, scopeId).processes) {
63
+ if (covered.has(record.id))
64
+ continue;
65
+ const metadata = JSON.parse(record.metadata_json);
66
+ // RUN control owns active controllers/checks. Adopt only its already-finished
67
+ // physical registrations here; death alone is not a completed RUN operation.
68
+ if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
69
+ || record.kind === "check" && metadata.role === "check" && record.check_id)
70
+ && record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
71
+ && record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
72
+ const operationId = record.operation_id ?? record.check_id;
73
+ nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
74
+ continue;
75
+ }
76
+ if (scopeLocalProcessRole(record) && record.operation_id && record.pid && record.pid_started_at) {
77
+ try {
78
+ await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token, force: false, graceMs: 500, reason: `scope-stop:${scopeId}` });
79
+ nodes.push({ process_id: record.id, state_dir: "", settled: true, operation_id: record.operation_id, owner_operation_id: record.operation_id });
80
+ }
81
+ catch (error) {
82
+ nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: String(error) });
83
+ }
84
+ continue;
85
+ }
86
+ const binding = JSON.stringify(managedProcessBinding(record, resourceHome(context)));
87
+ const parent = nodes.find(node => node.settled && node.state_dir === metadata.state_dir && "processes" in node && node.processes?.some(item => item.process_id === record.id && item.native_settled && JSON.stringify(item.binding) === binding));
88
+ if (parent && !processTreeIsAlive(record)) {
89
+ nodes.push({ process_id: record.id, state_dir: metadata.state_dir, settled: true, operation_id: parent.operation_id, owner_operation_id: parent.owner_operation_id });
90
+ }
91
+ else
92
+ nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: "Resource needs its own settlement proof" });
93
+ }
94
+ return { scope_id: scopeId, fence, settled: nodes.every(node => node.settled), nodes };
95
+ }
@@ -0,0 +1,165 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { AppError } from "../shared/errors.js";
8
+ import { getResourceDatabase } from "../storage/database.js";
9
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, processIsAlive, processStartedAt, registerManagedProcess, resourceHome } from "./managed-processes.js";
10
+ import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus } from "./runtime-budget.js";
11
+ import { controlRuntimeScopeRuns } from "./runtime-scope-control.js";
12
+ import { prepareRuntimeScopeResume, recordRuntimeScopeResume } from "./runtime-scope-resume.js";
13
+ export async function launchRuntimeScopeResume(context, input) {
14
+ const resume = recordRuntimeScopeResume(context, input);
15
+ if (!resume.current)
16
+ return { scope_id: input.scopeId, resume, current: false, settled: false };
17
+ if (resume.status === "released")
18
+ return { scope_id: input.scopeId, resume, release: resume.release, current: true, dispatch_blocked: resume.dispatch_blocked, settled: false };
19
+ const control = runtimeBudgetStatus(context, input.scopeId).control;
20
+ if (control?.generation !== input.generation)
21
+ throw new AppError("runtime_scope_control_superseded", "Scope resume source changed before owner launch", 1);
22
+ const owner = await launchRuntimeScopeControl(context, input.scopeId, control.request_id, control.requested_mode);
23
+ return { ...owner, resume, dispatch_blocked: true, settled: false };
24
+ }
25
+ const keyFor = (scope, generation) => crypto.createHash("sha256").update(JSON.stringify([scope, generation])).digest("hex");
26
+ function birth() {
27
+ const started = processStartedAt(process.pid);
28
+ if (!started)
29
+ throw new AppError("runtime_scope_owner_unknown", "Scope owner birth identity is unavailable", 1);
30
+ return { pid: process.pid, pid_started_at: started };
31
+ }
32
+ export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest) {
33
+ const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
34
+ let worker;
35
+ db.exec("BEGIN IMMEDIATE");
36
+ try {
37
+ const current = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
38
+ const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
39
+ const existing = prior ? JSON.parse(prior.metadata_json) : null;
40
+ if (existing) {
41
+ const identity = existing.owner ?? existing.launcher;
42
+ if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid < 1 || typeof identity.pid_started_at !== "string" || !identity.pid_started_at || !["starting", "running", "completed", "failed", "superseded"].includes(existing.status))
43
+ throw new AppError("runtime_scope_owner_unknown", "Retained scope owner has no verifiable birth identity", 1);
44
+ }
45
+ if (!current || JSON.parse(current.metadata_json).generation !== control.generation || db.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId])) {
46
+ db.exec("COMMIT");
47
+ return { scope_id: scopeId, control, current: false, settled: false, worker: existing ? publicRuntimeScopeWorker(existing) : null };
48
+ }
49
+ if (existing && (existing.owner ? processIsAlive(existing.owner) : existing.status !== "failed" && processIsAlive(existing.launcher))) {
50
+ db.exec("COMMIT");
51
+ return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
52
+ }
53
+ worker = { scope_id: scopeId, generation: control.generation, request_id: requestId, mode, token: crypto.randomUUID(), status: "starting", launcher: birth() };
54
+ const now = context.now();
55
+ db.run("INSERT INTO managed_resources (resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at) VALUES ('scope-control-worker',?,?,?,?,NULL,?,?,?) ON CONFLICT(resource_kind,resource_key) DO UPDATE SET lease_token = excluded.lease_token, metadata_json = excluded.metadata_json, updated_at = excluded.updated_at", [key, scopeId, worker.token, now, JSON.stringify(worker), now, now]);
56
+ db.exec("COMMIT");
57
+ }
58
+ catch (error) {
59
+ db.exec("ROLLBACK");
60
+ throw error;
61
+ }
62
+ try {
63
+ const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
64
+ if (!fs.existsSync(entry))
65
+ throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching scope control", 1);
66
+ const directory = path.join(resourceHome(context), "scope-controls", key);
67
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
68
+ const log = fs.openSync(path.join(directory, "control.log"), "a", 0o600);
69
+ try {
70
+ const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome(context), DD_FLOW_ENGINE_MODE: "1", DD_FLOW_SCOPE_CONTROL_TOKEN: worker.token };
71
+ delete env.DD_FLOW_CONTROL_TOKEN;
72
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
73
+ const child = spawn(process.execPath, [entry, "runtime", "scope", "serve", "--scope-id", scopeId, "--generation", String(control.generation), "--json"], { env, detached: true, stdio: ["ignore", log, log] });
74
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
75
+ child.unref();
76
+ }
77
+ finally {
78
+ fs.closeSync(log);
79
+ }
80
+ }
81
+ catch (error) {
82
+ db.run("UPDATE managed_resources SET metadata_json = json_set(metadata_json, '$.status', 'failed', '$.error', ?), updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ? AND json_extract(metadata_json, '$.owner') IS NULL", [String(error), context.now(), key, worker.token]);
83
+ }
84
+ const retained = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
85
+ return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(JSON.parse(retained.metadata_json)) };
86
+ }
87
+ export async function serveRuntimeScopeControl(context, scopeId, generation) {
88
+ if (!Number.isSafeInteger(generation) || generation < 1 || !context.env.DD_FLOW_SCOPE_CONTROL_TOKEN)
89
+ throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token and generation", 1);
90
+ const db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, generation), token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
91
+ let worker;
92
+ db.exec("BEGIN IMMEDIATE");
93
+ try {
94
+ const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
95
+ if (!row)
96
+ throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
97
+ worker = JSON.parse(row.metadata_json);
98
+ if (worker.owner || worker.status !== "starting")
99
+ throw new AppError("runtime_scope_already_owned", "Scope control already has a physical owner", 1);
100
+ worker.owner = birth();
101
+ worker.status = "running";
102
+ db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
103
+ db.exec("COMMIT");
104
+ }
105
+ catch (error) {
106
+ db.exec("ROLLBACK");
107
+ throw error;
108
+ }
109
+ let heartbeat;
110
+ try {
111
+ const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
112
+ worker.process_id = record.id;
113
+ worker.process_lease_token = record.lease_token;
114
+ confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
115
+ db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
116
+ let leaseLost = false;
117
+ heartbeat = setInterval(() => { try {
118
+ if (!heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }))
119
+ leaseLost = true;
120
+ }
121
+ catch {
122
+ leaseLost = true;
123
+ } }, 10_000);
124
+ heartbeat.unref();
125
+ for (;;) {
126
+ if (leaseLost)
127
+ throw new AppError("runtime_scope_owner_changed", "Scope worker lost its managed lease", 1);
128
+ const status = runtimeBudgetStatus(context, scopeId);
129
+ const release = status.release;
130
+ if (release?.current === true && release.generation === generation) {
131
+ worker.status = "completed";
132
+ worker.snapshot = release;
133
+ break;
134
+ }
135
+ if (status.control?.generation !== generation || status.fence) {
136
+ worker.status = "superseded";
137
+ break;
138
+ }
139
+ const resume = status.resume;
140
+ worker.snapshot = await prepareRuntimeScopeResume(context, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined) ?? await controlRuntimeScopeRuns(context, scopeId, worker.request_id, worker.mode);
141
+ db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
142
+ await delay(1_000);
143
+ }
144
+ return { scope_id: scopeId, generation, status: worker.status };
145
+ }
146
+ catch (error) {
147
+ if (error instanceof AppError && error.code === "runtime_scope_control_superseded") {
148
+ const release = runtimeBudgetStatus(context, scopeId).release;
149
+ worker.status = release?.current === true && release.generation === generation ? "completed" : "superseded";
150
+ if (worker.status === "completed")
151
+ worker.snapshot = release;
152
+ return { scope_id: scopeId, generation, status: worker.status };
153
+ }
154
+ worker.status = "failed";
155
+ worker.error = String(error);
156
+ throw error;
157
+ }
158
+ finally {
159
+ if (heartbeat)
160
+ clearInterval(heartbeat);
161
+ db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
162
+ if (worker.process_id && worker.process_lease_token)
163
+ finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
164
+ }
165
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import crypto from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
+ import { assertRunMutationAllowed } from "./run-recovery.js";
5
6
  import { requireProjectByRoot } from "./projects.js";
6
7
  import { resolveRun } from "./runs.js";
7
8
  import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
@@ -19,6 +20,7 @@ export async function startRuntimeService(context, input) {
19
20
  if (!run?.run_root || !run.workspace_root)
20
21
  throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
21
22
  input = { ...input, runId: run.id };
23
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
22
24
  const id = `PROC-${crypto.randomUUID()}`;
23
25
  const directory = path.join(run.run_root, "runtime", id);
24
26
  fs.mkdirSync(directory, { recursive: true });
@@ -34,6 +36,8 @@ export async function startRuntimeService(context, input) {
34
36
  const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
35
37
  Object.assign(receipt, { ports: allocation.ports, environment: Object.fromEntries(Object.entries(environment).filter(([key]) => key.startsWith("DD_FLOW_PORT_"))), stop_command: `DD_FLOW_HOME=${quoted(context.ddFlowHome)} DD_FLOW_RESOURCE_HOME=${quoted(resourceHome(context))} dd-flow runtime process stop --id ${record.id} --lease-token ${record.lease_token} --json` });
36
38
  save();
39
+ input.progress?.(`service ${record.id} preparing; receipt: ${receiptPath}`);
40
+ assertRunMutationAllowed(context, project.id, run.id, generation);
37
41
  stdout = fs.openSync(String(receipt.stdout_path), "a");
38
42
  stderr = fs.openSync(String(receipt.stderr_path), "a");
39
43
  const running = runCheck(input.command, run.workspace_root, environment, stdout, stderr, elapsed => input.progress?.(`service ${record.id} alive (${elapsed}s); receipt: ${receiptPath}`), pid => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }));
@@ -203,7 +203,7 @@ export async function finishVnextCodeReview(context, input) {
203
203
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
204
204
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
205
205
  if (nextAction === "start_merge")
206
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
206
+ advanceFlowRun(context, { settlementStage: "code-review", projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
207
207
  else {
208
208
  await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
209
209
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
@@ -270,7 +270,7 @@ export async function finishVnextCode(context, input) {
270
270
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
271
271
  }
272
272
  else {
273
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
273
+ advanceFlowRun(context, { settlementStage: "code", projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
274
274
  }
275
275
  return {
276
276
  ok: true,
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
+ import { isExecutionRouting } from "./execution-policy.js";
4
5
  const relativePath = path.join(".memory-bank", "dd-flow", "project-execution.json");
5
6
  /** The project owns defaults; a RUN snapshots them once and never re-infers them. */
6
7
  export function loadVnextExecutionProfile(projectRoot) {
@@ -23,43 +24,9 @@ export function loadVnextExecutionProfile(projectRoot) {
23
24
  || !["code_completed", "code_review_completed", "merge_completed"].includes(String(profile.stop_target))
24
25
  || !profile.code_bootstrap || typeof profile.code_bootstrap.command !== "string" || !profile.code_bootstrap.command.trim()
25
26
  || typeof profile.code_bootstrap.policy_ref !== "string" || !profile.code_bootstrap.policy_ref.trim()
26
- || !validRouting(profile.execution, profile.schema_id === "dd-flow/project-execution@3")) {
27
+ || (profile.execution === undefined ? profile.schema_id === "dd-flow/project-execution@3" : !isExecutionRouting(profile.execution))) {
27
28
  throw new AppError("execution_profile_invalid", "Project execution profile does not match its supported dd-flow schema", 1, { file });
28
29
  }
29
30
  return { ...profile, stage_session_mode: profile.stage_session_mode ?? "same_session", code_review_mode: profile.code_review_mode ?? "off" };
30
31
  }
31
32
  export function executionProfilePath(projectRoot) { return path.join(projectRoot, relativePath); }
32
- function validRouting(value, required) {
33
- if (value === undefined)
34
- return !required;
35
- if (!object(value) || !profileId(value.agent_profile_id) || !override(value, true))
36
- return false;
37
- if (value.stage_overrides !== undefined) {
38
- if (!object(value.stage_overrides))
39
- return false;
40
- for (const [stage, item] of Object.entries(value.stage_overrides))
41
- if (!stage.trim() || !object(item) || !override(item, false))
42
- return false;
43
- }
44
- return true;
45
- }
46
- function override(value, root) {
47
- const allowed = root ? new Set(["agent_profile_id", "delegation", "stage_overrides"]) : new Set(["agent_profile_id", "delegation"]);
48
- if (Object.keys(value).some(key => !allowed.has(key)))
49
- return false;
50
- if (!root && value.agent_profile_id === undefined && value.delegation === undefined)
51
- return false;
52
- if (value.agent_profile_id !== undefined && !profileId(value.agent_profile_id))
53
- return false;
54
- if (value.delegation === undefined)
55
- return true;
56
- if (!object(value.delegation) || (value.delegation.mode !== "native" && value.delegation.mode !== "external"))
57
- return false;
58
- if (value.delegation.agent_profile_id !== undefined && !profileId(value.delegation.agent_profile_id))
59
- return false;
60
- if (value.delegation.max_parallel !== undefined && (!Number.isInteger(value.delegation.max_parallel) || Number(value.delegation.max_parallel) < 1))
61
- return false;
62
- return value.delegation.mode !== "native" || value.delegation.max_parallel === undefined;
63
- }
64
- function profileId(value) { return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value); }
65
- function object(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
@@ -4,11 +4,31 @@ import { AppError } from "../shared/errors.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { getFlowRunVariables, appendFlowRunTimelineEvent } from "./runs.js";
7
+ import { resolveExecutionPolicy } from "./execution-policy.js";
7
8
  import { listWorks, failWork, cancelWork } from "./work-registry.js";
8
9
  import { storageSessionId } from "./session-identity.js";
9
10
  import { reconcileUnfinishedChecks } from "./code-checks.js";
10
11
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
11
12
  export const subagentCapacityKey = "runtime.subagent.available_slots";
13
+ /** External concurrency is policy, never evidence of native child qualification. */
14
+ export function getStageFanoutCapacity(context, input) {
15
+ const projectRoot = resolveProjectRoot(input.projectRoot);
16
+ const project = requireProjectByRoot(context, projectRoot);
17
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
18
+ if (!run)
19
+ throw new AppError("not_found", "RUN is unavailable", 1);
20
+ const frozen = JSON.parse(run.index_json).execution_profile;
21
+ if (frozen?.settings.execution) {
22
+ if (!frozen.agent_profiles)
23
+ throw new AppError("execution_profile_not_frozen", "Fan-out needs the RUN's frozen profiles", 1);
24
+ const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: frozen.settings.execution, profiles: frozen.agent_profiles, stage: input.stage });
25
+ if (resolved.delegation.mode === "external")
26
+ return { run_key: null, available_slots: resolved.delegation.max_parallel, source: "external_policy" };
27
+ }
28
+ const variables = getFlowRunVariables(context, { projectRoot, runId: input.runId });
29
+ const available = variables.variables[subagentCapacityKey];
30
+ return { run_key: subagentCapacityKey, available_slots: typeof available === "number" && Number.isInteger(available) && available >= 0 ? available : null, source: "harness_qualification" };
31
+ }
12
32
  export function fanoutState(input) {
13
33
  if (!input.hasWork)
14
34
  return input.dispatch === "none" ? "coordinator_required" : "dispatch_required";
@@ -47,13 +67,12 @@ export function getVnextFanoutStatus(context, input) {
47
67
  return { ok: true, run_id: input.runId, stage: input.stage, orchestration: null };
48
68
  if (descriptor.stage !== input.stage)
49
69
  throw new AppError("runtime_missing", "Stage fan-out descriptor belongs to another stage", 1, { requested_stage: input.stage, descriptor_stage: descriptor.stage });
50
- const variables = getFlowRunVariables(context, { projectRoot, runId: input.runId });
51
- const available = variables.variables[subagentCapacityKey];
70
+ const capacity = getStageFanoutCapacity(context, { projectRoot, runId: input.runId, stage: input.stage });
52
71
  const all = listWorks(context, { parentWorkId: descriptor.parent_work_id, includeResults: false });
53
72
  const ready = listWorks(context, { parentWorkId: descriptor.parent_work_id, ready: true, includeResults: false });
54
73
  const counts = Object.fromEntries(["created", "running", "completed", "failed", "cancelled"].map((status) => [status, all.works.filter((work) => work.status === status).length]));
55
74
  const hasWork = all.works.length > 0;
56
- const capacityKnown = typeof available === "number" && Number.isInteger(available) && available >= 0;
75
+ const capacityKnown = capacity.available_slots !== null;
57
76
  return {
58
77
  ok: true,
59
78
  run_id: input.runId,
@@ -64,10 +83,7 @@ export function getVnextFanoutStatus(context, input) {
64
83
  // not: their coordinator first materializes the Work graph from its
65
84
  // authoritative prompt. An empty graph is therefore not a success.
66
85
  state: fanoutState({ dispatch: descriptor.dispatch, hasWork, capacityRequired: descriptor.capacity_required, capacityKnown, created: counts.created ?? 0, running: counts.running ?? 0 }),
67
- capacity: {
68
- run_key: subagentCapacityKey,
69
- available_slots: capacityKnown ? available : null
70
- },
86
+ capacity,
71
87
  works: { ...counts, ready: ready.works.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: work.start_command })) }
72
88
  }
73
89
  };
@@ -16,7 +16,7 @@ import { vnextStageDirectory } from "../domain/stage-catalog.js";
16
16
  import { writeStageReport } from "./stage-report-renderer.js";
17
17
  import { assertPortableArtifactRef } from "./portable-refs.js";
18
18
  import { applyExternalStageContext } from "./stage-context.js";
19
- import { readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
19
+ import { getStageFanoutCapacity, readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
20
20
  const stage = "plan-review";
21
21
  const stageDir = vnextStageDirectory(stage);
22
22
  const reviewTask = "Independently review the accepted PLAN and decide whether CODE may open.";
@@ -74,7 +74,7 @@ export function startVnextPlanReview(context, input) {
74
74
  const report = reportFor({ run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context) });
75
75
  writeReport(root, report);
76
76
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
77
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "plan_review_off", nextAction: "start_code" });
77
+ advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_off", nextAction: "start_code" });
78
78
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_opened", mode: effective, outcome: "off" });
79
79
  return { ok: true, run_id: run.id, stage, outcome: "review_off", ...(externalContext ? { external_context: externalContext } : {}), plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), reason: "No applicable independent review group." }, next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
80
80
  }
@@ -143,7 +143,7 @@ export function dispatchVnextPlanReview(context, input) {
143
143
  refreshRunWorkProjection(context, project.id, run.id);
144
144
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_dispatched", work_id: parent.work_id, groups: reviews.map((item) => ({ group: item.group, work_id: item.work_id })) });
145
145
  const waves = plannedReviewWaves(groups, capacity.availableSlots);
146
- return { ok: true, run_id: run.id, stage, capacity: { available_slots: capacity.availableSlots }, routing: { group_count: groups.length, planned_waves: waves.length, waves, preference: "Use one wave when semantic compatibility permits; hard dependencies create later waves." }, review_works: reviews, next_action: "launch_ready_reviews" };
146
+ return { ok: true, run_id: run.id, stage, capacity: { available_slots: capacity.availableSlots, source: capacity.source }, routing: { group_count: groups.length, planned_waves: waves.length, waves, preference: "Use one wave when semantic compatibility permits; hard dependencies create later waves." }, review_works: reviews, next_action: "launch_ready_reviews" };
147
147
  }
148
148
  // Capacity is a RUN fact shared by every fan-out stage.
149
149
  const reviewCapacityKey = subagentCapacityKey;
@@ -249,7 +249,7 @@ export function finishVnextPlanReview(context, input) {
249
249
  writeReport(root, report);
250
250
  refreshRunWorkProjection(context, project.id, run.id);
251
251
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
252
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "plan_review_accepted", nextAction: "start_code" });
252
+ advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_accepted", nextAction: "start_code" });
253
253
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_accepted", work_id: parent.work_id, code: registered });
254
254
  return { ok: true, run_id: run.id, stage, outcome: "accepted", code: registered, next_action: "start_code", next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
255
255
  }
@@ -264,7 +264,11 @@ function orchestratorPrompt(context, input) {
264
264
  const decision = path.join(input.root, "decision.json");
265
265
  const revision = currentPlanRevision(input.home, input.run.workspace_root);
266
266
  const workspaceContract = ["<workspace_contract>", `- route: ${input.workspaceRoute.route}`, `- feature branch: ${input.workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${input.workspaceRoute.base_ref ?? "not applicable"}`, `- read/write workspace: ${input.run.workspace_root}`, "The CLI verified this frozen route. All plan and correction writes belong in the named workspace; project root remains only the stable lifecycle identity. Do not create, switch, merge or delete branches/worktrees.", "</workspace_contract>"].join("\n");
267
- return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch reports qualified_capacity_required, stop. The external harness controller qualifies the selected profile outside this RUN and records the resulting integer with ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<qualified-native-child-count>")}. PLAN-REVIEW never launches a capacity probe.`, "After dispatch, launch at most the qualified capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits; do not let finished disposable workers occupy slots before the next wave.", "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
267
+ const capacity = getStageFanoutCapacity(context, { projectRoot: input.projectRoot, runId: input.run.id, stage });
268
+ const reviewerLaunch = capacity.source === "external_policy"
269
+ ? `After dispatch, return at the Work-graph boundary. The shared runtime launches the queued reviewer Works as separate external Sessions, at most ${capacity.available_slots} in parallel under the RUN's frozen profiles. Do not launch native children, create provider roots, run a capacity probe, or record this external limit as native capacity. Reviewers are read-only leaf workers. Continue the semantic decision only after their Work receipts settle; do not substitute a missing result or relaunch a settled reviewer.`
270
+ : "After dispatch, launch at most the qualified capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits; do not let finished disposable workers occupy slots before the next wave.";
271
+ return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch reports qualified_capacity_required, stop. The external harness controller qualifies the selected profile outside this RUN and records the resulting integer with ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<qualified-native-child-count>")}. PLAN-REVIEW never launches a capacity probe.`, reviewerLaunch, "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
268
272
  }
269
273
  function reviewGroups(home, workspaceRoot) {
270
274
  const root = path.join(home, "03-plan");
@@ -328,10 +332,9 @@ export function capacityQualificationResponse(context, input) {
328
332
  return { ok: true, run_id: input.runId, stage, outcome: "qualified_capacity_required", next_action: "qualify_harness_profile_then_record_capacity", next: { record_command: capacityRecordCommand(context, input.runId, input.projectRoot, "<qualified-native-child-count>"), retry_command: dispatchCommand(context, input.runId, input.projectRoot) } };
329
333
  }
330
334
  function ensureReviewCapacity(context, input) {
331
- const variables = getFlowRunVariables(context, { projectRoot: input.projectRoot, runId: input.runId });
332
- const known = variables.variables[reviewCapacityKey];
333
- if (typeof known === "number" && Number.isInteger(known) && known >= 0)
334
- return { pending: false, availableSlots: known };
335
+ const capacity = getStageFanoutCapacity(context, { ...input, stage });
336
+ if (capacity.available_slots !== null)
337
+ return { pending: false, availableSlots: capacity.available_slots, source: capacity.source };
335
338
  return { pending: true, response: capacityQualificationResponse(context, { projectRoot: input.projectRoot, runId: input.runId }) };
336
339
  }
337
340
  function protocolIdsForReview(home) {
@@ -138,7 +138,7 @@ export function finishVnextPlan(context, input) {
138
138
  const reportJson = writeStageReport(root, report).json;
139
139
  validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
140
140
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage: "plan", status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
141
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "planned", nextAction: "start_plan_review" });
141
+ advanceFlowRun(context, { settlementStage: "plan", projectRoot, runId: run.id, status: "running", verdict: "planned", nextAction: "start_plan_review" });
142
142
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_accepted", work_id: work.work_id, protocols, id: workSession.id, next_stage: "plan-review" });
143
143
  return { ok: true, run_id: run.id, protocols, next_action: "start_plan_review", next_command: reviewCommand, next: { kind: "start_stage", stage: "plan-review", command: reviewCommand } };
144
144
  }
@@ -7,6 +7,7 @@ import { previewNextEntityId } from "./ids.js";
7
7
  import { registerProtocol } from "./protocols.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, gitFacts, rebindFlowRunWorkspace } from "./runs.js";
10
+ import { resolveStageTransition } from "./execution-policy.js";
10
11
  import { validateSchema } from "./schema-validation.js";
11
12
  import { assertStageStartHookEvent, hookSessionIdentity } from "./hooks.js";
12
13
  import { bindStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
@@ -21,9 +22,15 @@ const stage = "protocolize";
21
22
  function stageSessionMode(context, run) {
22
23
  const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id]);
23
24
  const value = row ? JSON.parse(row.index_json) : null;
24
- const mode = value?.execution_profile?.settings?.stage_session_mode;
25
+ const profile = value?.execution_profile;
26
+ const mode = profile?.settings.stage_session_mode;
25
27
  if (mode !== "same_session" && mode !== "new_session")
26
28
  throw new AppError("execution_profile_invalid", "RUN is missing its frozen stage session mode", 1, { run_id: run.id });
29
+ if (profile?.settings.execution) {
30
+ if (!profile.agent_profiles)
31
+ throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
32
+ return resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, fromStage: "protocolize", stage: "plan", stageSessionMode: mode, mergeMode: profile.settings.merge_mode }).session_mode;
33
+ }
27
34
  return mode;
28
35
  }
29
36
  export function isVnextProtocolizeRun(context, input) {
@@ -77,7 +84,6 @@ export function startVnextProtocolize(context, input) {
77
84
  const handoff = specifyHandoff(path.join(requireRunHome(run), "01-specify", "work-context.json"), work.work_id);
78
85
  prepareVnextProtocolize(context, { projectRoot, runId: run.id, workId: work.work_id, handoff });
79
86
  }
80
- const handoff = JSON.parse(fs.readFileSync(contextPath, "utf8"));
81
87
  const frozenContext = JSON.parse(fs.readFileSync(contextPath, "utf8"));
82
88
  const previous = context.db.get("SELECT ws.session_id, s.stop_reason FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, work.work_id]);
83
89
  const activeWorkSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
@@ -91,7 +97,7 @@ export function startVnextProtocolize(context, input) {
91
97
  return false;
92
98
  }
93
99
  })();
94
- const mode = handoff.system?.handoff?.stage_handoff.effective;
100
+ const mode = frozenContext.system?.handoff?.stage_handoff.effective;
95
101
  const promptPath = path.join(protocolizeRoot, "prompt.md");
96
102
  const externalContext = applyExternalStageContext({ stageRoot: protocolizeRoot, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
97
103
  const sessionId = hookSessionIdentity(context, project.id, input.hookEventId).sessionId;
@@ -165,7 +171,7 @@ export function finishVnextProtocolize(context, input) {
165
171
  validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
166
172
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html" });
167
173
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "protocol_documents_materialized", work_id: work.work_id, protocol_ids: protocolIds, stage });
168
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
174
+ advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
169
175
  refreshRunWorkProjection(context, project.id, run.id);
170
176
  const nextCommand = `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`;
171
177
  return { ok: true, outcome: "protocolized", run_id: run.id, protocol_ids: protocolIds, workspace_root: workspaceRoot, git_policy: resolvedPolicy, next_action: "await_plan", next_command: nextCommand, next: { kind: "start_stage", stage: "plan", command: nextCommand, cwd: workspaceRoot, session_mode: resolvedPolicy.stage_session_mode }, artifacts: { result: resultFile, report_json: reportJson, report_markdown: reportMarkdown, report_html: reportHtml, protocols: protocolIds.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "summary.md")) } };