@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74

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 (121) hide show
  1. package/CHANGELOG.md +396 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +470 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  26. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  27. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  28. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  29. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  30. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  31. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  32. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  33. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  34. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  35. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  36. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  37. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  38. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  39. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  40. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  41. package/dist/runtime/context.js +3 -1
  42. package/dist/schemas/agent-profile.schema.json +1 -1
  43. package/dist/schemas/code-review-result.schema.json +2 -2
  44. package/dist/schemas/code-work-batch.schema.json +4 -3
  45. package/dist/schemas/code-work-result.schema.json +4 -4
  46. package/dist/schemas/harness-config.schema.json +23 -0
  47. package/dist/schemas/plan-review-result.schema.json +2 -2
  48. package/dist/schemas/run-control-receipt.schema.json +99 -0
  49. package/dist/schemas/run-control-request.schema.json +36 -0
  50. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  51. package/dist/services/cleanup.js +8 -5
  52. package/dist/services/cli-operation-classifier.js +11 -1
  53. package/dist/services/code-checks.js +543 -96
  54. package/dist/services/controller-fanout.js +120 -0
  55. package/dist/services/dashboard.js +9 -0
  56. package/dist/services/engines.js +50 -19
  57. package/dist/services/eval-snapshots.js +639 -60
  58. package/dist/services/execution-policy.js +177 -0
  59. package/dist/services/external-work-launch.js +110 -0
  60. package/dist/services/harness-adapter.js +147 -24
  61. package/dist/services/harness-config.js +68 -0
  62. package/dist/services/hooks.js +413 -168
  63. package/dist/services/lanes.js +1 -1
  64. package/dist/services/lifecycle-command.js +74 -6
  65. package/dist/services/lifecycle-invocations.js +637 -0
  66. package/dist/services/managed-daemon-binding.js +38 -0
  67. package/dist/services/managed-processes.js +138 -22
  68. package/dist/services/merge-queue.js +6 -6
  69. package/dist/services/merge-server.js +75 -29
  70. package/dist/services/migrations.js +1 -1
  71. package/dist/services/native-daemon-history.js +49 -0
  72. package/dist/services/native-session-control.js +39 -0
  73. package/dist/services/projects.js +7 -3
  74. package/dist/services/prompts.js +4 -2
  75. package/dist/services/protocols.js +11 -2
  76. package/dist/services/recovery-observation-budget.js +61 -0
  77. package/dist/services/recovery-snapshot-database.js +107 -0
  78. package/dist/services/run-control-receipt.js +56 -0
  79. package/dist/services/run-control-worker.js +363 -0
  80. package/dist/services/run-control.js +876 -0
  81. package/dist/services/run-controller-adapter.js +211 -0
  82. package/dist/services/run-controller-capture.js +134 -0
  83. package/dist/services/run-controller-process.js +189 -0
  84. package/dist/services/run-controller-recovery.js +222 -0
  85. package/dist/services/run-controller-state.js +31 -0
  86. package/dist/services/run-controller.js +733 -0
  87. package/dist/services/run-engine-bindings.js +20 -62
  88. package/dist/services/run-fork.js +124 -0
  89. package/dist/services/run-observations.js +112 -0
  90. package/dist/services/run-recovery-runtime.js +69 -0
  91. package/dist/services/run-recovery.js +327 -0
  92. package/dist/services/runs.js +280 -40
  93. package/dist/services/runtime-budget.js +362 -0
  94. package/dist/services/runtime-scope-capture.js +50 -0
  95. package/dist/services/runtime-scope-control.js +450 -0
  96. package/dist/services/runtime-scope-resume.js +543 -0
  97. package/dist/services/runtime-scope-stop.js +99 -0
  98. package/dist/services/runtime-scope-worker.js +210 -0
  99. package/dist/services/runtime-service.js +96 -0
  100. package/dist/services/schema-validation.js +12 -12
  101. package/dist/services/stage-context.js +2 -2
  102. package/dist/services/stage-lifecycle.js +32 -10
  103. package/dist/services/stage-pause.js +25 -13
  104. package/dist/services/usage.js +37 -93
  105. package/dist/services/vnext-code-review.js +149 -89
  106. package/dist/services/vnext-code.js +223 -215
  107. package/dist/services/vnext-execution-profile.js +5 -3
  108. package/dist/services/vnext-fanout.js +135 -21
  109. package/dist/services/vnext-merge.js +258 -89
  110. package/dist/services/vnext-plan-review.js +75 -64
  111. package/dist/services/vnext-plan.js +77 -29
  112. package/dist/services/vnext-protocolize.js +21 -14
  113. package/dist/services/vnext-specify.js +26 -16
  114. package/dist/services/work-registry.js +592 -154
  115. package/dist/services/workspace-bootstrap.js +76 -0
  116. package/dist/shared/errors.js +12 -0
  117. package/dist/storage/database.js +341 -31
  118. package/dist/storage/writer-contract.js +77 -0
  119. package/dist/storage/writer-migration.js +102 -0
  120. package/package.json +20 -13
  121. package/tools/repair-paused-run-status.mjs +59 -0
@@ -0,0 +1,99 @@
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 { managedLeaseReleased, 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
+ if (managedLeaseReleased(record)) {
66
+ nodes.push({ process_id: record.id, settled: true });
67
+ continue;
68
+ }
69
+ const metadata = JSON.parse(record.metadata_json);
70
+ // RUN control owns active controllers/checks. Adopt only its already-finished
71
+ // physical registrations here; death alone is not a completed RUN operation.
72
+ if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
73
+ || record.kind === "check" && metadata.role === "check" && record.check_id)
74
+ && record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
75
+ && record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
76
+ const operationId = record.operation_id ?? record.check_id;
77
+ nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
78
+ continue;
79
+ }
80
+ if (scopeLocalProcessRole(record) && record.operation_id && record.pid && record.pid_started_at) {
81
+ try {
82
+ await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token, force: false, graceMs: 500, reason: `scope-stop:${scopeId}` });
83
+ nodes.push({ process_id: record.id, state_dir: "", settled: true, operation_id: record.operation_id, owner_operation_id: record.operation_id });
84
+ }
85
+ catch (error) {
86
+ nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: String(error) });
87
+ }
88
+ continue;
89
+ }
90
+ const binding = JSON.stringify(managedProcessBinding(record, resourceHome(context)));
91
+ 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));
92
+ if (parent && !processTreeIsAlive(record)) {
93
+ 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 });
94
+ }
95
+ else
96
+ nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: "Resource needs its own settlement proof" });
97
+ }
98
+ return { scope_id: scopeId, fence, settled: nodes.every(node => node.settled), nodes };
99
+ }
@@ -0,0 +1,210 @@
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
+ import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
14
+ export async function launchRuntimeScopeResume(context, input) {
15
+ const resume = recordRuntimeScopeResume(context, input);
16
+ if (!resume.current)
17
+ return { scope_id: input.scopeId, resume, current: false, settled: false };
18
+ if (resume.status === "released")
19
+ return { scope_id: input.scopeId, resume, release: resume.release, current: true, dispatch_blocked: resume.dispatch_blocked, settled: false };
20
+ const control = runtimeBudgetStatus(context, input.scopeId).control;
21
+ if (control?.generation !== input.generation)
22
+ throw new AppError("runtime_scope_control_superseded", "Scope resume source changed before owner launch", 1);
23
+ const owner = await launchRuntimeScopeControl(context, input.scopeId, control.request_id, control.requested_mode, undefined, undefined, input.requestId);
24
+ return { ...owner, resume, dispatch_blocked: true, settled: false };
25
+ }
26
+ const keyFor = (scope, generation) => crypto.createHash("sha256").update(JSON.stringify([scope, generation])).digest("hex");
27
+ function birth() {
28
+ const started = processStartedAt(process.pid);
29
+ if (!started)
30
+ throw new AppError("runtime_scope_owner_unknown", "Scope owner birth identity is unavailable", 1);
31
+ return { pid: process.pid, pid_started_at: started };
32
+ }
33
+ export async function reconcileRuntimeScopeControl(context, scopeId, generation, requestId) {
34
+ const control = runtimeBudgetStatus(context, scopeId).control;
35
+ if (!control || control.generation !== generation)
36
+ throw new AppError("runtime_scope_control_superseded", "Reconciliation requires the current controlled scope generation", 1);
37
+ renewRecoveryObservation({}, requestId);
38
+ return launchRuntimeScopeControl(context, scopeId, control.request_id, control.requested_mode, undefined, requestId);
39
+ }
40
+ export async function launchRuntimeScopeControl(context, scopeId, requestId, mode, manifest, reconcileId, resumeId) {
41
+ const control = requestRuntimeScopeControl(context, scopeId, requestId, mode, manifest), db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, control.generation);
42
+ let worker;
43
+ db.beginWriteTransaction();
44
+ try {
45
+ const current = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
46
+ const prior = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
47
+ const existing = prior ? JSON.parse(prior.metadata_json) : null;
48
+ if (existing) {
49
+ const identity = existing.owner ?? existing.launcher;
50
+ 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))
51
+ throw new AppError("runtime_scope_owner_unknown", "Retained scope owner has no verifiable birth identity", 1);
52
+ }
53
+ 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])) {
54
+ db.exec("COMMIT");
55
+ return { scope_id: scopeId, control, current: false, settled: false, worker: existing ? publicRuntimeScopeWorker(existing) : null };
56
+ }
57
+ if (existing && (existing.owner ? processIsAlive(existing.owner) : existing.status !== "failed" && processIsAlive(existing.launcher))) {
58
+ db.exec("COMMIT");
59
+ return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
60
+ }
61
+ if (existing?.status === "completed" && !reconcileId && !resumeId) {
62
+ db.exec("COMMIT");
63
+ return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
64
+ }
65
+ const renewal = resumeId ? `resume:${resumeId}` : reconcileId;
66
+ const snapshot = renewal ? renewRecoveryObservation(existing?.snapshot ?? {}, renewal) : existing?.snapshot;
67
+ if (existing && recoveryObservationBudget(snapshot).state().remaining_ms === 0) {
68
+ db.exec("COMMIT");
69
+ return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(existing) };
70
+ }
71
+ const cleanupRequest = resumeId ? undefined : reconcileId ?? existing?.reconcile_id;
72
+ worker = { scope_id: scopeId, generation: control.generation, request_id: requestId, mode, token: crypto.randomUUID(), status: "starting", launcher: birth(), ...(snapshot ? { snapshot } : {}), ...(cleanupRequest ? { reconcile_id: cleanupRequest } : {}) };
73
+ const now = context.now();
74
+ 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]);
75
+ db.exec("COMMIT");
76
+ }
77
+ catch (error) {
78
+ db.exec("ROLLBACK");
79
+ throw error;
80
+ }
81
+ try {
82
+ const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
83
+ if (!fs.existsSync(entry))
84
+ throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching scope control", 1);
85
+ const directory = path.join(resourceHome(context), "scope-controls", key);
86
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
87
+ const log = fs.openSync(path.join(directory, "control.log"), "a", 0o600);
88
+ try {
89
+ 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 };
90
+ delete env.DD_FLOW_CONTROL_TOKEN;
91
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
92
+ const child = spawn(process.execPath, [entry, "runtime", "scope", "serve", "--scope-id", scopeId, "--generation", String(control.generation), "--json"], { env, detached: true, stdio: ["ignore", log, log] });
93
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
94
+ child.unref();
95
+ }
96
+ finally {
97
+ fs.closeSync(log);
98
+ }
99
+ }
100
+ catch (error) {
101
+ 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]);
102
+ }
103
+ const retained = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ?", [key]);
104
+ return { scope_id: scopeId, control, current: true, settled: false, worker: publicRuntimeScopeWorker(JSON.parse(retained.metadata_json)) };
105
+ }
106
+ export async function serveRuntimeScopeControl(context, scopeId, generation) {
107
+ if (!Number.isSafeInteger(generation) || generation < 1 || !context.env.DD_FLOW_SCOPE_CONTROL_TOKEN)
108
+ throw new AppError("runtime_scope_owner_unknown", "Scope worker requires its launch token and generation", 1);
109
+ const db = getResourceDatabase(resourceHome(context)), key = keyFor(scopeId, generation), token = context.env.DD_FLOW_SCOPE_CONTROL_TOKEN;
110
+ let worker;
111
+ db.beginWriteTransaction();
112
+ try {
113
+ const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
114
+ if (!row)
115
+ throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
116
+ worker = JSON.parse(row.metadata_json);
117
+ if (worker.owner || worker.status !== "starting")
118
+ throw new AppError("runtime_scope_already_owned", "Scope control already has a physical owner", 1);
119
+ worker.owner = birth();
120
+ worker.status = "running";
121
+ 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]);
122
+ db.exec("COMMIT");
123
+ }
124
+ catch (error) {
125
+ db.exec("ROLLBACK");
126
+ throw error;
127
+ }
128
+ let heartbeat;
129
+ // A fence must never be held indefinitely by an observer that cannot obtain
130
+ // fresh provider evidence. A later operator request continues the retained
131
+ // budget; this owner records why it stopped without claiming settlement.
132
+ const observation = recoveryObservationBudget(worker.snapshot);
133
+ try {
134
+ const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
135
+ worker.process_id = record.id;
136
+ worker.process_lease_token = record.lease_token;
137
+ confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
138
+ 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]);
139
+ let leaseLost = false;
140
+ heartbeat = setInterval(() => { try {
141
+ if (!heartbeatManagedProcess(context, { id: record.id, leaseToken: record.lease_token }))
142
+ leaseLost = true;
143
+ }
144
+ catch {
145
+ leaseLost = true;
146
+ } }, 10_000);
147
+ heartbeat.unref();
148
+ for (;;) {
149
+ if (leaseLost)
150
+ throw new AppError("runtime_scope_owner_changed", "Scope worker lost its managed lease", 1);
151
+ if (observation.exhausted()) {
152
+ worker.status = "failed";
153
+ worker.error = "recovery_observation_budget_exhausted";
154
+ worker.snapshot = {
155
+ ...(worker.snapshot ?? {}),
156
+ status: "recovery_blocked",
157
+ settled: false,
158
+ pending_reasons: ["recovery_observation_budget_exhausted"],
159
+ error: { code: "recovery_observation_budget_exhausted", message: "Scope recovery observation exceeded its 120 second budget" },
160
+ recovery_observation: observation.state(),
161
+ };
162
+ return { scope_id: scopeId, generation, status: "recovery_blocked", settled: false, error: worker.error };
163
+ }
164
+ const status = runtimeBudgetStatus(context, scopeId);
165
+ const release = status.release;
166
+ if (release?.current === true && release.generation === generation) {
167
+ worker.status = "completed";
168
+ worker.snapshot = release;
169
+ break;
170
+ }
171
+ if (status.control?.generation !== generation || status.fence) {
172
+ worker.status = "superseded";
173
+ break;
174
+ }
175
+ const resume = status.resume;
176
+ const outcome = await withRecoveryObservation(observation, async () => (worker.reconcile_id ? null : await prepareRuntimeScopeResume(context, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined))
177
+ ?? await controlRuntimeScopeRuns(context, scopeId, worker.request_id, worker.mode, worker.reconcile_id));
178
+ observation.exhausted();
179
+ worker.snapshot = { ...outcome, recovery_observation: observation.state() };
180
+ 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]);
181
+ if (worker.snapshot.drain?.physical_settled === true) {
182
+ worker.status = "completed";
183
+ break;
184
+ }
185
+ await delay(observation.nextDelay());
186
+ }
187
+ return { scope_id: scopeId, generation, status: worker.status };
188
+ }
189
+ catch (error) {
190
+ if (error instanceof AppError && error.code === "runtime_scope_control_superseded") {
191
+ const release = runtimeBudgetStatus(context, scopeId).release;
192
+ worker.status = release?.current === true && release.generation === generation ? "completed" : "superseded";
193
+ if (worker.status === "completed")
194
+ worker.snapshot = release;
195
+ return { scope_id: scopeId, generation, status: worker.status };
196
+ }
197
+ worker.status = "failed";
198
+ worker.error = String(error);
199
+ throw error;
200
+ }
201
+ finally {
202
+ if (heartbeat)
203
+ clearInterval(heartbeat);
204
+ observation.exhausted();
205
+ worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
206
+ 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]);
207
+ if (worker.process_id && worker.process_lease_token)
208
+ finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
209
+ }
210
+ }
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { assertRunMutationAllowed } from "./run-recovery.js";
6
+ import { requireProjectByRoot } from "./projects.js";
7
+ import { resolveRun } from "./runs.js";
8
+ import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
9
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
10
+ /** Foreground supervisor: the tool may yield while this command owns the service.
11
+ * Its ready receipt is emitted immediately; a separate stop ends this command.
12
+ * Readiness is infrastructure evidence, never a successful scenario verdict. */
13
+ export async function startRuntimeService(context, input) {
14
+ if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0)
15
+ throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
16
+ if (!input.command.trim() || !input.ports.length || input.ports.some(name => !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
17
+ throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
18
+ const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
19
+ const run = resolveRun(context, project.id, input.runId);
20
+ if (!run?.run_root || !run.workspace_root)
21
+ throw new AppError("not_found", "Registered RUN with a materialized workspace is required", 2);
22
+ input = { ...input, runId: run.id };
23
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
24
+ const id = `PROC-${crypto.randomUUID()}`;
25
+ const directory = path.join(run.run_root, "runtime", id);
26
+ fs.mkdirSync(directory, { recursive: true });
27
+ const record = registerManagedProcess(context, { id, kind: "runtime-service", ownerId: `run:${input.runId}`, ownerPid: process.pid, runId: input.runId, projectId: project.id, stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), metadata: { command: input.command } });
28
+ const receiptPath = path.join(directory, "service.json");
29
+ let stdout, stderr;
30
+ let settled = false, ready = false;
31
+ const receipt = { schema_id: "dd-flow/runtime-service@1", process_id: record.id, run_id: input.runId, status: "starting", started_at: context.now(), receipt_path: receiptPath, stdout_path: path.join(directory, "stdout.log"), stderr_path: path.join(directory, "stderr.log") };
32
+ const save = () => { const temporary = `${receiptPath}.${process.pid}.tmp`; fs.writeFileSync(temporary, JSON.stringify(receipt, null, 2)); fs.renameSync(temporary, receiptPath); };
33
+ try {
34
+ const allocation = await reservePorts(context, { ownerId: record.owner_id, processId: record.id, names: input.ports });
35
+ const environment = { ...codeExecutionEnvironment(run.workspace_root), ...Object.fromEntries(Object.entries(allocation.ports).map(([name, port]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(port)])), DD_FLOW_EVIDENCE_DIR: directory, DD_FLOW_CHECK_COMPLETION_FILE: path.join(directory, "completion.json") };
36
+ const quoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
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` });
38
+ save();
39
+ input.progress?.(`service ${record.id} preparing; receipt: ${receiptPath}`);
40
+ assertRunMutationAllowed(context, project.id, run.id, generation);
41
+ stdout = fs.openSync(String(receipt.stdout_path), "a");
42
+ stderr = fs.openSync(String(receipt.stderr_path), "a");
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 }));
44
+ void running.then(() => { settled = true; });
45
+ const deadline = performance.now() + input.timeoutMs;
46
+ while (!settled && performance.now() < deadline) {
47
+ try {
48
+ const response = await fetch(`http://127.0.0.1:${allocation.ports[input.readyPort]}${input.readyPath}`, { signal: AbortSignal.timeout(1_000), redirect: "error" });
49
+ await response.body?.cancel();
50
+ ready = response.ok;
51
+ }
52
+ catch { /* Startup is observed until the declared deadline. */ }
53
+ if (ready)
54
+ break;
55
+ await new Promise(resolve => setTimeout(resolve, 100));
56
+ }
57
+ if (!ready) {
58
+ await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token });
59
+ await running;
60
+ throw new AppError("service_not_ready", "Service did not pass its declared HTTP readiness check; inspect retained logs", 2, { receipt_path: receiptPath });
61
+ }
62
+ Object.assign(receipt, { status: "ready", ready_at: context.now() });
63
+ save();
64
+ input.progress?.(`service ready: ${JSON.stringify(receipt)}; keep this supervisor running, use its exact stop command when finished`);
65
+ const outcome = await running;
66
+ const current = managedProcessStatus(context).find(item => item.id === record.id);
67
+ if (current.state === "stopping") {
68
+ const settleBy = performance.now() + 5_000;
69
+ while (processTreeIsAlive(current) && performance.now() < settleBy)
70
+ await new Promise(resolve => setTimeout(resolve, 25));
71
+ }
72
+ if (processTreeIsAlive(current))
73
+ throw new AppError("process_tree_not_settled", "Service still owns live children; retain resources", 2, { process_id: record.id });
74
+ if (!["stopped", "failed"].includes(current.state))
75
+ finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: current.state === "stopping" || outcome.exitCode === 0 ? "stopped" : "failed", reason: current.state === "stopping" ? "requested_stop" : outcome.error });
76
+ Object.assign(receipt, { status: managedProcessStatus(context).find(item => item.id === record.id).state, finished_at: context.now(), exit_code: outcome.exitCode });
77
+ save();
78
+ if (receipt.status === "failed")
79
+ throw new AppError("service_failed", "Service exited unsuccessfully; inspect retained logs", 2, receipt);
80
+ return receipt;
81
+ }
82
+ catch (error) {
83
+ const current = managedProcessStatus(context).find(item => item.id === record.id);
84
+ if (!processTreeIsAlive(current))
85
+ finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "failed", reason: error instanceof Error ? error.message : String(error) });
86
+ Object.assign(receipt, { status: "failed", error: error instanceof Error ? error.message : String(error), cleanup_pending: processTreeIsAlive(current) });
87
+ save();
88
+ throw error;
89
+ }
90
+ finally {
91
+ if (stdout !== undefined)
92
+ fs.closeSync(stdout);
93
+ if (stderr !== undefined)
94
+ fs.closeSync(stderr);
95
+ }
96
+ }
@@ -6,7 +6,7 @@ import { Ajv } from "ajv/dist/ajv.js";
6
6
  import { Ajv2020 } from "ajv/dist/2020.js";
7
7
  import { normalizeFlowContract } from "../domain/flow-contract.js";
8
8
  import { AppError } from "../shared/errors.js";
9
- import { findRunHome, readRunEngineBinding } from "./run-engine-bindings.js";
9
+ import { readRunEngineBinding } from "./run-engine-bindings.js";
10
10
  export function captureMemoryBankBaseline(input) {
11
11
  const projectRoot = path.resolve(input.projectRoot);
12
12
  const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
@@ -94,7 +94,7 @@ export function validateSchema(options) {
94
94
  throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
95
95
  }
96
96
  const filePath = path.resolve(options.file);
97
- const data = readJson(filePath, "input file");
97
+ const data = Object.hasOwn(options, "data") ? options.data : readJson(filePath, "input file");
98
98
  const schemaResolution = resolveSchema(options);
99
99
  const schema = readJson(schemaResolution.path, "schema");
100
100
  // Ajv's default export only carries the draft-07 meta-schema. New stage
@@ -117,8 +117,8 @@ export function validateSchema(options) {
117
117
  }
118
118
  const valid = validate(data);
119
119
  const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
120
- const semanticErrors = valid ? validateSemanticSchema(options.schemaName, data) : [];
121
- const allErrors = [...errors, ...semanticErrors];
120
+ const contractErrors = valid ? validateContractInvariants(options.schemaName, data) : [];
121
+ const allErrors = [...errors, ...contractErrors];
122
122
  if (allErrors.length > 0) {
123
123
  throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
124
124
  schema: schemaResolution,
@@ -181,7 +181,7 @@ const historicalSchemaRegistry = {
181
181
  }]
182
182
  };
183
183
  function resolveRunBoundSchema(options) {
184
- if (!options.projectRoot || options.schemaDir)
184
+ if (!options.runRoot || options.schemaDir)
185
185
  return null;
186
186
  const data = readJson(path.resolve(options.file), "input file");
187
187
  const root = asRecord(data);
@@ -190,13 +190,13 @@ function resolveRunBoundSchema(options) {
190
190
  const runId = options.runId ?? inferredRunId;
191
191
  if (typeof runId !== "string")
192
192
  return null;
193
- const projectRoot = path.resolve(options.projectRoot);
194
- const located = findRunHome(options.ddFlowHome ?? process.env.DD_FLOW_HOME ?? path.join(path.dirname(projectRoot), ".dd-flow"), projectRoot, runId);
195
- if (!located || !isInside(located.run_home, path.resolve(options.file)))
196
- return null;
197
- const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
193
+ const runRoot = path.resolve(options.runRoot);
194
+ const bindingPath = path.join(runRoot, "engine-binding.json");
195
+ const binding = readRunEngineBinding(bindingPath);
198
196
  if (!binding)
199
- return null;
197
+ throw new AppError("run_engine_binding_missing", "RUN engine binding is missing", 1, { path: bindingPath });
198
+ if (binding.run_id !== runId)
199
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding belongs to another RUN", 1, { expected_run_id: runId, actual_run_id: binding.run_id, path: bindingPath });
200
200
  const manifestPath = path.join(binding.engine.snapshot_root, "engine.json");
201
201
  const manifest = readJson(manifestPath, "bound engine manifest");
202
202
  const integrity = asRecord(objectValue(manifest, "integrity"));
@@ -284,7 +284,7 @@ function formatAjvError(error) {
284
284
  keyword: error.keyword
285
285
  };
286
286
  }
287
- function validateSemanticSchema(schemaName, data) {
287
+ function validateContractInvariants(schemaName, data) {
288
288
  const root = asRecord(data);
289
289
  if (!root) {
290
290
  return [];
@@ -76,8 +76,8 @@ export function renderExternalStageContext(input) {
76
76
  ].join("\n");
77
77
  }
78
78
  function validateContext(value, expectedStage) {
79
- if (value.schema_id !== "dd-eval/stage-context@1")
80
- throw new AppError("validation", "Stage context must use dd-eval/stage-context@1", 2);
79
+ if (value.schema_id !== "dd-flow/stage-context@1" && value.schema_id !== "dd-eval/stage-context@1")
80
+ throw new AppError("validation", "Stage context must use dd-flow/stage-context@1", 2);
81
81
  if (value.stage !== expectedStage)
82
82
  throw new AppError("validation", "Stage context does not match --stage", 2, { expected_stage: expectedStage, context_stage: value.stage });
83
83
  if (!value.objective?.trim())
@@ -8,7 +8,7 @@ import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from
8
8
  import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
9
9
  import { validateSchema } from "./schema-validation.js";
10
10
  import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
11
- import { usageForRun } from "./usage.js";
11
+ import { recalculateRunUsage, usageForRun } from "./usage.js";
12
12
  import { boundCanonicalPlan } from "./plan-runtime.js";
13
13
  import { AppError } from "../shared/errors.js";
14
14
  import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
@@ -18,6 +18,17 @@ import { registerProject } from "./projects.js";
18
18
  import { registerProtocol } from "./protocols.js";
19
19
  import { previewNextEntityId } from "./ids.js";
20
20
  import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
21
+ import { assertRunMutationAllowed } from "./run-recovery.js";
22
+ function stageUsageProjection(context, view, stage, startedAt) {
23
+ if (!startedAt)
24
+ return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
25
+ const value = recalculateRunUsage(context, { projectId: view.run.project_id, runId: view.run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
26
+ const totals = value.totals, coverage = value.coverage;
27
+ const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
28
+ const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
29
+ const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
30
+ return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
31
+ }
21
32
  const stagePromptSections = [
22
33
  "stage_identity",
23
34
  "authoritative_runtime_facts",
@@ -53,7 +64,9 @@ function bootstrapStageRun(context, projectRoot, input) {
53
64
  if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
54
65
  throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
55
66
  }
56
- const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
67
+ if (!started.run.run_root)
68
+ throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
69
+ const intakeDir = path.join(started.run.run_root, "intake");
57
70
  fs.mkdirSync(intakeDir, { recursive: true });
58
71
  fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
59
72
  }
@@ -117,6 +130,7 @@ export function startStage(context, input) {
117
130
  if (!runId)
118
131
  throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
119
132
  const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
133
+ assertRunMutationAllowed(context, before.run.project_id, before.run.id);
120
134
  const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
121
135
  const attached = runView(attachFlowRunStage(context, {
122
136
  projectRoot,
@@ -216,7 +230,7 @@ export function startStage(context, input) {
216
230
  })),
217
231
  worker_prompt_markdown: prompt
218
232
  });
219
- validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
233
+ validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, runRoot: runHome, ddFlowHome: context.ddFlowHome });
220
234
  atomicWrite(path.join(stageRoot, "stage-start.json"), {
221
235
  schema_id: "dd-flow/stage-start@2",
222
236
  run_id: attached.run.id,
@@ -261,6 +275,7 @@ export function finishStage(context, input) {
261
275
  throw new AppError("usage", "stage finish requires a RUN id", 2);
262
276
  const runId = input.runId;
263
277
  const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
278
+ assertRunMutationAllowed(context, view.run.project_id, view.run.id);
264
279
  const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
265
280
  if (!existing) {
266
281
  throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
@@ -273,7 +288,7 @@ export function finishStage(context, input) {
273
288
  const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
274
289
  const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
275
290
  try {
276
- validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, ddFlowHome: context.ddFlowHome });
291
+ validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
277
292
  const semantic = readSemanticFile(semanticFile, runHome);
278
293
  const status = stringValue(semantic.status) ?? "done";
279
294
  if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
@@ -288,12 +303,13 @@ export function finishStage(context, input) {
288
303
  const planFinish = input.stage === "plan"
289
304
  ? preparePlanFinish(context, projectRoot, view, stageRoot)
290
305
  : undefined;
306
+ const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
291
307
  const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
292
308
  const dataPath = path.join(stageRoot, "stage-report.json");
293
309
  const reportPath = path.join(stageRoot, "stage-report.md");
294
310
  const htmlPath = path.join(stageRoot, "stage-report.html");
295
311
  atomicWrite(dataPath, report);
296
- validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
312
+ validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
297
313
  atomicWrite(reportPath, renderMarkdown(report));
298
314
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
299
315
  if (view.run.subject.type === "protocol") {
@@ -308,7 +324,8 @@ export function finishStage(context, input) {
308
324
  stageReport: htmlPath,
309
325
  data: dataPath,
310
326
  dataSchemaId: String(report.schema_id),
311
- report: reportPath
327
+ report: reportPath,
328
+ usage: stageUsage
312
329
  });
313
330
  finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
314
331
  appendAudit(context, {
@@ -410,7 +427,12 @@ function nextActionForProtocolStage(stage) {
410
427
  return `continue_${stage}`;
411
428
  }
412
429
  function runHomePath(run) {
413
- return run.run_home_path ?? path.dirname(run.run_index_path);
430
+ if (!run.run_root)
431
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
432
+ return run.run_root;
433
+ }
434
+ function runStatePath(run) {
435
+ return path.join(runHomePath(run), "run.json");
414
436
  }
415
437
  export function defaultStageDir(stage, flowKind) {
416
438
  const normalized = stage === "implementation" ? "code" : stage;
@@ -702,7 +724,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
702
724
  if (stage === "code" || stage === "implementation") {
703
725
  return {
704
726
  schema_id: "dd-flow/code-stage-report@2",
705
- run: { run_id: view.run.id, run_state: view.run.run_index_path },
727
+ run: { run_id: view.run.id, run_state: runStatePath(view.run) },
706
728
  stage: { name: stage, dir: path.basename(stageRoot), status },
707
729
  project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
708
730
  subject: { id: view.run.subject.id, title: view.run.subject.id },
@@ -712,7 +734,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
712
734
  summary: result,
713
735
  next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
714
736
  },
715
- breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }],
737
+ breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }],
716
738
  implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
717
739
  acceptance_scenarios: acceptance.map((summary, index) => ({
718
740
  id: `SCN-${stage}-${index + 1}`,
@@ -763,7 +785,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
763
785
  memory_bank_scope: "changed_files_and_links_only",
764
786
  status: "passed"
765
787
  },
766
- breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }]
788
+ breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }]
767
789
  };
768
790
  }
769
791
  function preparePlanFinish(context, projectRoot, view, stageRoot) {