@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,363 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { AppError } from "../shared/errors.js";
9
+ import { resolveProjectRoot } from "../storage/paths.js";
10
+ import { requireProjectByRoot } from "./projects.js";
11
+ import { getResourceDatabase } from "../storage/database.js";
12
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessRuntimeHome, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess, resourceHome } from "./managed-processes.js";
13
+ import { interruptControlledSessions, observeControlledSessions, recordRunControlIntent, runControlStatus } from "./run-control.js";
14
+ import { recoveryGuard, recordRecoveryCapture, sealRunRecovery } from "./run-recovery.js";
15
+ import { createEvalRunSnapshot, verifyRecoverySnapshot } from "./eval-snapshots.js";
16
+ import { recoveryObservationBudget, renewRecoveryObservation, withRecoveryObservation } from "./recovery-observation-budget.js";
17
+ export async function reconcileRunControl(context, input) {
18
+ const status = runControlStatus(context, input);
19
+ const control = status.control;
20
+ if (!control?.current || !["requested", "settled"].includes(control.status))
21
+ throw new AppError("run_control_state_changed", "Reconciliation requires the current draining/sealed control", 1);
22
+ renewRecoveryObservation({}, input.requestId); // Validate before claiming anything.
23
+ if (control.status === "requested")
24
+ await launchControlWorker(context, { ...input, reconcileId: input.requestId });
25
+ return runControlStatus(context, input);
26
+ }
27
+ /** The caller publishes intent and launches a control owner; it never owns drain. */
28
+ export async function requestRunControl(context, input) {
29
+ if (input.waitMs !== undefined && (!Number.isSafeInteger(input.waitMs) || input.waitMs < 0 || input.waitMs > 60_000))
30
+ throw new AppError("usage", "--wait-ms must be between 0 and 60000", 2);
31
+ const receipt = recordRunControlIntent(context, input);
32
+ const projectReceipt = () => {
33
+ const observation = runControlStatus(context, { ...input, controlId: typeof receipt.control_id === "string" ? receipt.control_id : null });
34
+ return { ...observation, status: observation.control?.status ?? receipt.status, action: input.action, ...(receipt.reused === true ? { reused: true } : {}) };
35
+ };
36
+ if (!receipt.control_id || receipt.status !== "requested")
37
+ return projectReceipt();
38
+ const scope = { projectRoot: input.projectRoot, runId: input.runId, controlId: String(receipt.control_id), ...(input.reconcileId ? { reconcileId: input.reconcileId } : {}) };
39
+ await launchControlWorker(context, scope);
40
+ const deadline = performance.now() + (input.waitMs ?? 0);
41
+ while (performance.now() < deadline) {
42
+ const worker = context.db.get("SELECT status FROM run_control_workers WHERE control_id = ?", [scope.controlId]);
43
+ const current = context.db.get("SELECT status FROM run_controls WHERE control_id = ?", [scope.controlId]);
44
+ if (current?.status !== "requested" || ["failed", "completed", "superseded"].includes(worker?.status ?? ""))
45
+ break;
46
+ await delay(Math.min(100, Math.max(1, deadline - performance.now())));
47
+ }
48
+ return projectReceipt();
49
+ }
50
+ async function launchControlWorker(context, input) {
51
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
52
+ const run = context.db.get("SELECT run_root, workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
53
+ if (!run?.run_root)
54
+ throw new AppError("run_control_unmaterialized", "Control intent is fenced, but the RUN has no directory for its owner", 1);
55
+ const token = crypto.randomUUID(), directory = path.join(run.run_root, "controls", input.controlId), now = context.now();
56
+ const home = resourceHome(context);
57
+ // Reclaim only a proven dead launcher or admitted worker. Lease expiry
58
+ // never proves death or authorizes a second physical owner.
59
+ let claimed = false;
60
+ context.db.beginWriteTransaction();
61
+ try {
62
+ claimed = context.db.run("INSERT OR IGNORE INTO run_control_workers (control_id, owner_token, status, resource_home, state_dir, created_at, updated_at) VALUES (?, ?, 'starting', ?, ?, ?, ?)", [input.controlId, token, home, directory, now, now]).changes === 1;
63
+ if (claimed) {
64
+ recordControlLauncher(context, input.controlId, token);
65
+ if (input.reconcileId)
66
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ? WHERE control_id = ?", [JSON.stringify(renewRecoveryObservation({}, input.reconcileId)), input.controlId]);
67
+ }
68
+ context.db.exec("COMMIT");
69
+ }
70
+ catch (error) {
71
+ context.db.exec("ROLLBACK");
72
+ throw error;
73
+ }
74
+ if (!claimed && !reclaimControlWorker(context, input, project.id, token, home))
75
+ return;
76
+ try {
77
+ const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
78
+ if (!fs.existsSync(entry))
79
+ throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached control owner", 1);
80
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
81
+ const log = path.join(directory, "control.log");
82
+ const output = fs.openSync(log, "a", 0o600);
83
+ try {
84
+ const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: home, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_CONTROL_TOKEN: token };
85
+ delete env.DD_FLOW_CONTROLLER_TOKEN;
86
+ delete env.DD_FLOW_SCOPE_CONTROL_TOKEN;
87
+ const child = spawn(process.execPath, [entry, "run", "control", "serve", "--run", input.runId, "--project-root", project.root, "--control-id", input.controlId, "--json"], { cwd: run.workspace_root, detached: true, stdio: ["ignore", output, output], env });
88
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
89
+ context.db.run("UPDATE run_control_operations SET receipt_json = json_set(receipt_json, '$.child', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify({ pid: child.pid, pid_started_at: child.pid ? processStartedAt(child.pid) : null }), context.now(), launchOperationId(input.controlId, token)]);
90
+ child.unref();
91
+ }
92
+ finally {
93
+ fs.closeSync(output);
94
+ }
95
+ // The detached worker is the durable owner of the stop. Do not let the
96
+ // caller tear down its RUN/home before that owner has either registered
97
+ // its managed-process lease or recorded a startup failure.
98
+ await waitForControlWorkerAdmission(context, input.controlId, token);
99
+ }
100
+ catch (error) {
101
+ context.db.run("UPDATE run_control_workers SET status = 'failed', error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(failure(error)), context.now(), input.controlId, token]);
102
+ // The durable intent remains accepted even if its worker could not start.
103
+ }
104
+ }
105
+ async function waitForControlWorkerAdmission(context, controlId, ownerToken, timeoutMs = 5_000) {
106
+ const deadline = Date.now() + timeoutMs;
107
+ for (;;) {
108
+ const worker = context.db.get("SELECT status, process_id, owner_token FROM run_control_workers WHERE control_id = ?", [controlId]);
109
+ if (!worker || worker.owner_token !== ownerToken)
110
+ return;
111
+ // A running worker without a resource lease is still in the vulnerable
112
+ // registration gap; keep the parent alive until that gap closes.
113
+ if (worker.process_id || ["failed", "completed", "superseded"].includes(worker.status))
114
+ return;
115
+ if (Date.now() >= deadline)
116
+ throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease", 1, { control_id: controlId, timeout_ms: timeoutMs });
117
+ await delay(25);
118
+ }
119
+ }
120
+ const launchOperationId = (controlId, token) => `${controlId}:worker-launch:${crypto.createHash("sha256").update(token).digest("hex")}`;
121
+ function recordControlLauncher(context, controlId, token) {
122
+ const now = context.now();
123
+ context.db.run("INSERT INTO run_control_operations (operation_id, control_id, target_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, 'completed', ?, ?, ?)", [launchOperationId(controlId, token), controlId, JSON.stringify({ kind: "control_worker_launch_claim" }), JSON.stringify({ pid: process.pid, pid_started_at: processStartedAt(process.pid), registration: "worker_self@1" }), now, now]);
124
+ }
125
+ function reclaimControlWorker(context, input, projectId, token, home) {
126
+ const previous = context.db.get("SELECT * FROM run_control_workers WHERE control_id = ?", [input.controlId]);
127
+ if (!previous || !["starting", "running", "failed"].includes(previous.status) || previous.resource_home !== home)
128
+ return false;
129
+ const saved = JSON.parse(previous.snapshot_json ?? "{}");
130
+ const requested = input.reconcileId ? renewRecoveryObservation(saved, input.reconcileId) : saved;
131
+ if (recoveryObservationBudget(requested).state().remaining_ms === 0)
132
+ return false;
133
+ const registry = fs.existsSync(path.join(home, "runtime.sqlite")) ? getResourceDatabase(home) : null;
134
+ let record = previous.process_id ? registry?.get("SELECT * FROM managed_processes WHERE id = ?", [previous.process_id]) : null;
135
+ const operationId = launchOperationId(input.controlId, previous.owner_token);
136
+ const launch = context.db.get("SELECT receipt_json FROM run_control_operations WHERE operation_id = ?", [operationId]);
137
+ const launchIdentity = JSON.parse(launch?.receipt_json ?? "null");
138
+ if (previous.owner_pid && launchIdentity?.registration === "worker_self@1") {
139
+ const claim = context.db.get("SELECT receipt_json FROM run_control_operations WHERE operation_id = ?", [`${operationId}:worker-claim`]);
140
+ const identity = JSON.parse(claim?.receipt_json ?? "null");
141
+ if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid < 1 || identity.pid !== previous.owner_pid || typeof identity.pid_started_at !== "string" || !identity.pid_started_at || processIsAlive(identity))
142
+ return false;
143
+ const records = registry?.all("SELECT * FROM managed_processes WHERE operation_id = ?", [`control:${input.controlId}`]) ?? [];
144
+ const owned = records.filter(item => {
145
+ const metadata = JSON.parse(item.metadata_json);
146
+ return metadata.owner_pid === identity.pid && metadata.owner_pid_started_at === identity.pid_started_at;
147
+ });
148
+ if (owned.length > 1 || previous.process_id && owned[0]?.id !== previous.process_id)
149
+ return false;
150
+ record = owned[0] ?? null;
151
+ if (record && (record.kind !== "run-control" || record.owner_id !== input.controlId || record.project_id !== projectId || record.run_id !== input.runId || managedProcessRuntimeHome(record, context) !== context.ddFlowHome || previous.process_lease_token && record.lease_token !== previous.process_lease_token || record.pid !== null && record.pid !== identity.pid || processTreeIsAlive(record)))
152
+ return false;
153
+ if (records.some(item => item.id !== record?.id && (item.state !== "stopped" || !item.pid_started_at || processTreeIsAlive(item))))
154
+ return false;
155
+ if (record && record.pid === null) {
156
+ // Registration describes this very worker, not an as-yet-unidentified
157
+ // spawned child. Retain its prior RUN-store birth evidence on retirement.
158
+ if (registry.run("UPDATE managed_processes SET pid = ?, pid_started_at = ? WHERE id = ? AND lease_token = ? AND pid IS NULL", [identity.pid, identity.pid_started_at, record.id, record.lease_token]).changes !== 1)
159
+ return false;
160
+ record.pid = identity.pid;
161
+ record.pid_started_at = identity.pid_started_at;
162
+ }
163
+ }
164
+ else if (previous.process_id) {
165
+ if (!record || !previous.owner_pid || !previous.process_lease_token || record.lease_token !== previous.process_lease_token || record.kind !== "run-control" || record.owner_id !== input.controlId || record.project_id !== projectId || record.run_id !== input.runId || record.pid !== previous.owner_pid || !record.pid_started_at || managedProcessRuntimeHome(record, context) !== context.ddFlowHome || processTreeIsAlive(record))
166
+ return false;
167
+ }
168
+ else {
169
+ // No worker has claimed RUN authority. Prove launcher death and reject
170
+ // unresolved registrations, including a legacy crash before linkage.
171
+ if (previous.owner_pid || previous.process_lease_token)
172
+ return false;
173
+ const identity = launchIdentity;
174
+ if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid < 1 || typeof identity.pid_started_at !== "string" || !identity.pid_started_at || processIsAlive(identity))
175
+ return false;
176
+ if (identity.child && (!Number.isSafeInteger(identity.child.pid) || identity.child.pid < 1 || identity.child.pid_started_at !== null && typeof identity.child.pid_started_at !== "string" || processIsAlive(identity.child)))
177
+ return false;
178
+ if (registry?.all("SELECT * FROM managed_processes WHERE operation_id = ?", [`control:${input.controlId}`]).some(item => item.state !== "stopped" || !item.pid || !item.pid_started_at || processTreeIsAlive(item)))
179
+ return false;
180
+ }
181
+ // The resource store and RUN store are separate: retirement is idempotent,
182
+ // and a crash before the RUN CAS leaves the same dead owner reconcilable.
183
+ if (record && !finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: "control_owner_crash_reconciled" }))
184
+ return false;
185
+ context.db.beginWriteTransaction();
186
+ try {
187
+ const changed = context.db.run(`UPDATE run_control_workers SET owner_token = ?, owner_pid = NULL, status = 'starting', process_id = NULL, process_lease_token = NULL, updated_at = ?
188
+ WHERE control_id = ? AND owner_token = ? AND process_id IS ? AND owner_pid IS ? AND status = ?
189
+ AND (SELECT receipt_json FROM run_control_operations WHERE operation_id = ?) IS ?
190
+ AND EXISTS (SELECT 1 FROM run_controls c JOIN run_recovery_guards g ON g.recovery_id = c.recovery_id
191
+ WHERE c.control_id = run_control_workers.control_id AND c.status = 'requested' AND g.status IN ('draining','sealed')
192
+ AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [token, context.now(), input.controlId, previous.owner_token, previous.process_id, previous.owner_pid, previous.status, operationId, launch?.receipt_json ?? null]);
193
+ if (changed.changes !== 1) {
194
+ context.db.exec("ROLLBACK");
195
+ return false;
196
+ }
197
+ if (input.reconcileId) {
198
+ const snapshot = renewRecoveryObservation(JSON.parse(previous.snapshot_json ?? "{}"), input.reconcileId);
199
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = NULL WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), input.controlId, token]);
200
+ }
201
+ const now = context.now();
202
+ context.db.run("INSERT INTO run_control_operations (operation_id, control_id, target_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, 'completed', ?, ?, ?)", [`${launchOperationId(input.controlId, previous.owner_token)}:reconciled`, input.controlId, JSON.stringify({ kind: "control_worker_reconciliation", process_id: record?.id ?? null, resource_home: home }), JSON.stringify({ process_id: record?.id ?? null, pid: record?.pid ?? null, pid_started_at: record?.pid_started_at ?? null, prior_status: previous.status, snapshot: JSON.parse(previous.snapshot_json ?? "null"), error: JSON.parse(previous.error_json ?? "null") }), now, now]);
203
+ recordControlLauncher(context, input.controlId, token);
204
+ context.db.exec("COMMIT");
205
+ return true;
206
+ }
207
+ catch (error) {
208
+ context.db.exec("ROLLBACK");
209
+ throw error;
210
+ }
211
+ }
212
+ export async function serveRunControl(context, input) {
213
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
214
+ const intent = context.db.get("SELECT * FROM run_controls WHERE control_id = ? AND project_id = ? AND run_id = ?", [input.controlId, project.id, input.runId]);
215
+ const worker = context.db.get("SELECT * FROM run_control_workers WHERE control_id = ?", [input.controlId]);
216
+ if (!intent || !worker || worker.owner_token !== context.env.DD_FLOW_CONTROL_TOKEN)
217
+ throw new AppError("run_control_owner_mismatch", "Control serve does not own this request", 1);
218
+ const launch = context.db.get("SELECT receipt_json FROM run_control_operations WHERE operation_id = ?", [launchOperationId(input.controlId, worker.owner_token)]);
219
+ const selfRegister = JSON.parse(launch?.receipt_json ?? "null")?.registration === "worker_self@1";
220
+ if ((!worker.process_id || !worker.process_lease_token) && !selfRegister)
221
+ throw new AppError("run_control_owner_mismatch", "Control registration is not authorized", 1);
222
+ // Physical identity is committed with the sole RUN claim, before the worker
223
+ // registers itself in the separate resource store or performs any dispatch.
224
+ context.db.beginWriteTransaction();
225
+ try {
226
+ const claimed = context.db.run("UPDATE run_control_workers SET status = 'running', owner_pid = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [process.pid, context.now(), input.controlId, worker.owner_token]);
227
+ if (claimed.changes !== 1)
228
+ throw new AppError("run_control_already_owned", "A control owner has already claimed this request", 1);
229
+ const now = context.now();
230
+ context.db.run("INSERT INTO run_control_operations (operation_id, control_id, target_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, 'completed', ?, ?, ?)", [`${launchOperationId(input.controlId, worker.owner_token)}:worker-claim`, input.controlId, JSON.stringify({ kind: "control_worker_self_claim" }), JSON.stringify({ pid: process.pid, pid_started_at: processStartedAt(process.pid) }), now, now]);
231
+ context.db.exec("COMMIT");
232
+ }
233
+ catch (error) {
234
+ context.db.exec("ROLLBACK");
235
+ throw error;
236
+ }
237
+ const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: worker.resource_home } };
238
+ let leaseLost = false;
239
+ const heartbeat = setInterval(() => {
240
+ try {
241
+ if (!heartbeatManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token }))
242
+ leaseLost = true;
243
+ }
244
+ catch {
245
+ leaseLost = true;
246
+ }
247
+ }, 10_000);
248
+ heartbeat.unref();
249
+ try {
250
+ if (!worker.process_id) {
251
+ const log = path.join(worker.state_dir, "control.log");
252
+ const record = registerManagedProcess(resourceContext, { kind: "run-control", ownerId: input.controlId, projectId: project.id, runId: input.runId, operationId: `control:${input.controlId}`, uniqueActiveOperation: true, ownerPid: process.pid, stdoutPath: log, stderrPath: log, metadata: { control_id: input.controlId } });
253
+ worker.process_id = record.id;
254
+ worker.process_lease_token = record.lease_token;
255
+ context.db.run("UPDATE run_control_workers SET process_id = ?, process_lease_token = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND owner_pid = ? AND status = 'running'", [record.id, record.lease_token, context.now(), input.controlId, worker.owner_token, process.pid]);
256
+ }
257
+ if (!worker.process_lease_token)
258
+ throw new AppError("run_control_owner_mismatch", "Control owner has no resource lease", 1);
259
+ confirmManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
260
+ let priorObservation = null;
261
+ try {
262
+ priorObservation = worker.snapshot_json ? JSON.parse(worker.snapshot_json) : null;
263
+ }
264
+ catch { /* A malformed prior snapshot cannot grant a new budget. */
265
+ priorObservation = { recovery_observation: { remaining_ms: 0 } };
266
+ }
267
+ const observation = recoveryObservationBudget(priorObservation);
268
+ for (;;) {
269
+ if (leaseLost)
270
+ throw new AppError("run_control_lease_lost", "Control worker lost its physical ownership lease", 1);
271
+ const current = context.db.get("SELECT * FROM run_controls WHERE control_id = ?", [input.controlId]);
272
+ const guard = recoveryGuard(context, project.id, input.runId);
273
+ if (current.status !== "requested" || guard?.recovery_id !== intent.recovery_id || !["draining", "sealed"].includes(guard.status)) {
274
+ const status = current.status === "settled" ? "completed" : "superseded";
275
+ context.db.run("UPDATE run_control_workers SET status = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [status, context.now(), input.controlId, worker.owner_token]);
276
+ return { ok: true, control_id: input.controlId, status };
277
+ }
278
+ if (observation.exhausted()) {
279
+ const latest = context.db.get("SELECT snapshot_json FROM run_control_workers WHERE control_id = ?", [input.controlId]);
280
+ let retained = {};
281
+ try {
282
+ retained = JSON.parse(latest?.snapshot_json ?? "{}") ?? {};
283
+ }
284
+ catch { /* Retain a blocked status even when the old projection is malformed. */ }
285
+ const snapshot = { ...retained, status: "recovery_blocked", settled: false, pending_reasons: ["recovery_observation_budget_exhausted"],
286
+ error: { code: "recovery_observation_budget_exhausted", message: "Control settlement has no confirmed result within the active observation budget" }, recovery_observation: observation.state() };
287
+ context.db.run("UPDATE run_control_workers SET status = 'failed', snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(snapshot.error), context.now(), input.controlId, worker.owner_token]);
288
+ return { ok: true, control_id: input.controlId, ...snapshot };
289
+ }
290
+ if (guard.status === "sealed") {
291
+ // Capture retries are local evidence work. Never dispatch another
292
+ // native interrupt/inspect merely because publication previously failed.
293
+ const settlement = JSON.parse(guard.settlement_json ?? "{}");
294
+ try {
295
+ if (settlement.settled !== true)
296
+ throw new AppError("run_control_settlement_missing", "Sealed control has no settled tree receipt", 1);
297
+ const root = path.resolve(context.env.DD_FLOW_RECOVERY_HOME ?? path.join(os.homedir(), ".dd-flow-recovery"));
298
+ const capture = guard.capture_path ?? path.join(root, crypto.createHash("sha256").update(context.ddFlowHome).digest("hex"), project.id, input.runId, guard.recovery_id);
299
+ const scope = { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id };
300
+ if (!fs.existsSync(capture))
301
+ createEvalRunSnapshot(context, { ...scope, output: capture });
302
+ if (!guard.capture_path)
303
+ recordRecoveryCapture(context, { ...scope, capturePath: capture });
304
+ else {
305
+ const run = context.db.get("SELECT workspace_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
306
+ verifyRecoverySnapshot(capture, { ...scope, workspaceRoot: run.workspace_root });
307
+ }
308
+ const applied = context.db.run(`UPDATE run_controls SET status = 'settled', updated_at = ?
309
+ WHERE control_id = ? AND status = 'requested' AND recovery_id = ?
310
+ AND EXISTS (SELECT 1 FROM run_control_workers w WHERE w.control_id = run_controls.control_id AND w.owner_token = ? AND w.status = 'running')
311
+ AND EXISTS (SELECT 1 FROM run_recovery_guards g WHERE g.recovery_id = run_controls.recovery_id AND g.status = 'sealed' AND g.capture_path = ? AND g.generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = g.project_id AND run_id = g.run_id))`, [context.now(), input.controlId, guard.recovery_id, worker.owner_token, capture]);
312
+ if (applied.changes !== 1)
313
+ throw new AppError("run_control_state_changed", "Control changed before its capture could be acknowledged", 1);
314
+ const snapshot = { ...settlement, status: "settled", capture_path: capture, recovery_observation: observation.state() };
315
+ context.db.run("UPDATE run_control_workers SET status = 'completed', snapshot_json = ?, error_json = NULL, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
316
+ return { ok: true, control_id: input.controlId, ...snapshot };
317
+ }
318
+ catch (error) {
319
+ observation.exhausted();
320
+ const snapshot = { ...settlement, status: "requested", capture: { status: "pending", error: failure(error) }, pending_reasons: ["recovery_capture_required"], recovery_observation: observation.state() };
321
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
322
+ await delay(observation.nextDelay());
323
+ continue;
324
+ }
325
+ }
326
+ const request = JSON.parse(current.request_json);
327
+ const interrupt = current.action === "stop" || request.escalate === "interrupt" && Date.now() >= Date.parse(current.created_at) + (request.grace_ms ?? 30_000);
328
+ let snapshot;
329
+ try {
330
+ snapshot = await withRecoveryObservation(observation, async () => {
331
+ const cancellation = interrupt ? await interruptControlledSessions(context, input) : null;
332
+ return { ...await observeControlledSessions(context, input), ...(cancellation ? { cancellation } : {}) };
333
+ });
334
+ }
335
+ catch (error) {
336
+ snapshot = { ok: true, control_id: input.controlId, status: "requested", settled: false, pending_reasons: ["control_observation_failed"], error: failure(error) };
337
+ }
338
+ observation.exhausted();
339
+ snapshot = { ...snapshot, recovery_observation: observation.state() };
340
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify(snapshot), context.now(), input.controlId, worker.owner_token]);
341
+ if (snapshot.settled === true) {
342
+ try {
343
+ sealRunRecovery(context, { projectRoot: project.root, runId: input.runId, recoveryId: guard.recovery_id, settlement: JSON.stringify(snapshot) });
344
+ continue;
345
+ }
346
+ catch (error) {
347
+ context.db.run("UPDATE run_control_workers SET snapshot_json = ?, error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'running'", [JSON.stringify({ ...snapshot, pending_reasons: ["recovery_seal_required"], error: failure(error) }), JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
348
+ }
349
+ }
350
+ await delay(observation.nextDelay());
351
+ }
352
+ }
353
+ catch (error) {
354
+ context.db.run("UPDATE run_control_workers SET status = 'failed', error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ?", [JSON.stringify(failure(error)), context.now(), input.controlId, worker.owner_token]);
355
+ throw error;
356
+ }
357
+ finally {
358
+ clearInterval(heartbeat);
359
+ if (worker.process_id && worker.process_lease_token)
360
+ finishManagedProcess(resourceContext, { id: worker.process_id, leaseToken: worker.process_lease_token, state: "stopped", reason: "control_owner_exited" });
361
+ }
362
+ }
363
+ function failure(error) { return { code: error instanceof AppError ? error.code : "run_control_failed", message: error instanceof Error ? error.message : String(error) }; }