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

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 (123) hide show
  1. package/CHANGELOG.md +404 -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 +471 -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/delegation-instructions.d.mts +10 -0
  26. package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
  27. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  28. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  29. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  30. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  31. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  32. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  33. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  34. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  35. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  36. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  37. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  38. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  39. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  40. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  41. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  42. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  43. package/dist/runtime/context.js +3 -1
  44. package/dist/schemas/agent-profile.schema.json +1 -1
  45. package/dist/schemas/code-review-result.schema.json +2 -2
  46. package/dist/schemas/code-work-batch.schema.json +4 -3
  47. package/dist/schemas/code-work-result.schema.json +4 -4
  48. package/dist/schemas/harness-config.schema.json +23 -0
  49. package/dist/schemas/plan-review-result.schema.json +2 -2
  50. package/dist/schemas/run-control-receipt.schema.json +99 -0
  51. package/dist/schemas/run-control-request.schema.json +36 -0
  52. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  53. package/dist/services/cleanup.js +8 -5
  54. package/dist/services/cli-operation-classifier.js +11 -1
  55. package/dist/services/code-checks.js +543 -96
  56. package/dist/services/controller-fanout.js +116 -0
  57. package/dist/services/dashboard.js +9 -0
  58. package/dist/services/engines.js +50 -19
  59. package/dist/services/eval-snapshots.js +639 -60
  60. package/dist/services/execution-policy.js +177 -0
  61. package/dist/services/external-work-launch.js +110 -0
  62. package/dist/services/harness-adapter.js +147 -24
  63. package/dist/services/harness-config.js +68 -0
  64. package/dist/services/hooks.js +413 -168
  65. package/dist/services/lanes.js +1 -1
  66. package/dist/services/lifecycle-command.js +74 -6
  67. package/dist/services/lifecycle-invocations.js +637 -0
  68. package/dist/services/managed-daemon-binding.js +38 -0
  69. package/dist/services/managed-processes.js +138 -22
  70. package/dist/services/merge-queue.js +6 -6
  71. package/dist/services/merge-server.js +75 -29
  72. package/dist/services/migrations.js +1 -1
  73. package/dist/services/native-daemon-history.js +49 -0
  74. package/dist/services/native-session-control.js +39 -0
  75. package/dist/services/projects.js +7 -3
  76. package/dist/services/prompts.js +4 -2
  77. package/dist/services/protocols.js +11 -2
  78. package/dist/services/recovery-observation-budget.js +61 -0
  79. package/dist/services/recovery-snapshot-database.js +107 -0
  80. package/dist/services/run-control-receipt.js +56 -0
  81. package/dist/services/run-control-worker.js +363 -0
  82. package/dist/services/run-control.js +876 -0
  83. package/dist/services/run-controller-adapter.js +211 -0
  84. package/dist/services/run-controller-capture.js +134 -0
  85. package/dist/services/run-controller-process.js +189 -0
  86. package/dist/services/run-controller-recovery.js +222 -0
  87. package/dist/services/run-controller-state.js +31 -0
  88. package/dist/services/run-controller.js +733 -0
  89. package/dist/services/run-engine-bindings.js +20 -62
  90. package/dist/services/run-fork.js +124 -0
  91. package/dist/services/run-observations.js +112 -0
  92. package/dist/services/run-recovery-runtime.js +69 -0
  93. package/dist/services/run-recovery.js +327 -0
  94. package/dist/services/runs.js +280 -40
  95. package/dist/services/runtime-budget.js +362 -0
  96. package/dist/services/runtime-scope-capture.js +50 -0
  97. package/dist/services/runtime-scope-control.js +450 -0
  98. package/dist/services/runtime-scope-resume.js +543 -0
  99. package/dist/services/runtime-scope-stop.js +99 -0
  100. package/dist/services/runtime-scope-worker.js +210 -0
  101. package/dist/services/runtime-service.js +96 -0
  102. package/dist/services/schema-validation.js +12 -12
  103. package/dist/services/stage-context.js +2 -2
  104. package/dist/services/stage-lifecycle.js +32 -10
  105. package/dist/services/stage-pause.js +25 -13
  106. package/dist/services/usage.js +37 -93
  107. package/dist/services/vnext-code-review.js +149 -89
  108. package/dist/services/vnext-code.js +224 -216
  109. package/dist/services/vnext-execution-profile.js +5 -3
  110. package/dist/services/vnext-fanout.js +135 -21
  111. package/dist/services/vnext-merge.js +258 -89
  112. package/dist/services/vnext-plan-review.js +75 -64
  113. package/dist/services/vnext-plan.js +77 -29
  114. package/dist/services/vnext-protocolize.js +21 -14
  115. package/dist/services/vnext-specify.js +26 -16
  116. package/dist/services/work-registry.js +605 -157
  117. package/dist/services/workspace-bootstrap.js +76 -0
  118. package/dist/shared/errors.js +12 -0
  119. package/dist/storage/database.js +341 -31
  120. package/dist/storage/writer-contract.js +77 -0
  121. package/dist/storage/writer-migration.js +102 -0
  122. package/package.json +20 -13
  123. package/tools/repair-paused-run-status.mjs +59 -0
@@ -0,0 +1,733 @@
1
+ import { assertLifecycleOutcomes, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
8
+ import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
9
+ import { AppError } from "../shared/errors.js";
10
+ import { parseRuntimeBudget } from "./runtime-budget.js";
11
+ import { requireProjectByRoot } from "./projects.js";
12
+ import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
13
+ import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
14
+ import { loadExternalStageContext } from "./stage-context.js";
15
+ import { flowCommand } from "./stage-pause.js";
16
+ import { resolveStageTransition, sameSessionProfile, runtimeHarness } from "./execution-policy.js";
17
+ import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
18
+ import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
19
+ import { confirmManagedProcess, heartbeatManagedProcess, finishManagedProcess, processTreeIsAlive, resourceHome } from "./managed-processes.js";
20
+ import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
21
+ import { initCodexHome } from "./hooks.js";
22
+ import { launchExternalWork } from "./external-work-launch.js";
23
+ import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFanout } from "./controller-fanout.js";
24
+ import { claimQueuedMergeRequest } from "./merge-server.js";
25
+ import { prepareVnextCodeWorkspace } from "./vnext-code.js";
26
+ import { requireRun, requireController, controllerReceipt, appendEvent, saveState, digest, digestBytes, errorRecord } from "./run-controller-state.js";
27
+ import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady } from "./run-controller-process.js";
28
+ import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
29
+ import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
30
+ import { captureControllerBoundary } from "./run-controller-capture.js";
31
+ import { requestRunControl } from "./run-control-worker.js";
32
+ export { resumeRunController } from "./run-controller-recovery.js";
33
+ /** Persist intent before spawning. A repeated client observes the same owner. */
34
+ export async function launchRunController(context, input) {
35
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
36
+ throw new AppError("usage", "Managed launch needs a request id and context SHA-256", 2);
37
+ if (input.stopAfter !== undefined && !/^[a-z][a-z-]*$/.test(input.stopAfter))
38
+ throw new AppError("usage", "Invalid stop-after stage", 2);
39
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
40
+ const run = requireRun(context, project.id, input.runId);
41
+ const captureRoot = input.captureRoot === undefined ? undefined : canonicalPath(input.captureRoot, false);
42
+ if (captureRoot && [context.ddFlowHome, project.root, run.workspace_root].some(root => { const relative = path.relative(canonicalPath(root, false), captureRoot); return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }))
43
+ throw new AppError("snapshot_output_inside_runtime", "Controller captures require a root outside the runtime, project and workspace", 2);
44
+ if (captureRoot && fs.existsSync(captureRoot) && !fs.statSync(captureRoot).isDirectory())
45
+ throw new AppError("usage", "Controller capture root must be a directory", 2);
46
+ const budget = parseRuntimeBudget(context.env.DD_FLOW_RUNTIME_BUDGET);
47
+ const request = { project_root: project.root, run_id: run.id, context_sha256: input.contextSha256, stop_after: input.stopAfter ?? null, ...(captureRoot ? { capture_root: captureRoot } : {}), ...(budget ? { budget_sha256: digest(budget) } : {}) };
48
+ const requestHash = digest(request);
49
+ ensureRunControllerStorage(context.db);
50
+ const existing = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
51
+ if (existing) {
52
+ if (existing.request_hash !== requestHash)
53
+ throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
54
+ return { ...await spawnController(context, existing), reused: true };
55
+ }
56
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
57
+ if (run.status !== "running")
58
+ throw new AppError("invalid_run_state", "Managed launch requires a running logical RUN", 1, { status: run.status });
59
+ const index = JSON.parse(run.index_json);
60
+ const execution = index.execution_profile;
61
+ if (!execution?.settings.execution || !execution.agent_profiles)
62
+ throw new AppError("execution_profile_not_frozen", "Managed launch requires the RUN's frozen execution profiles", 1);
63
+ const contexts = readContexts(input.contextFile, input.contextSha256);
64
+ const next = getFlowRunStatus(context, { projectRoot: project.root, runId: run.id }).continuation;
65
+ if (next.kind !== "start_stage" || !next.stage)
66
+ throw new AppError("controller_attach_required", "Managed launch cannot replace an attached or unfinished coordinator; reconcile its existing owner", 1, { continuation: next });
67
+ if (!contexts[next.stage])
68
+ throw new AppError("controller_context_missing", "Managed context omits the first stage", 2, { stage: next.stage });
69
+ const adapters = {};
70
+ for (const profile of Object.values(execution.agent_profiles)) {
71
+ const harness = runtimeHarness(profile);
72
+ adapters[harness] = { executable: resolveHarnessCommand(context.ddFlowHome, harness, "adapter"), runtime_args: harnessRuntimeArguments(context.ddFlowHome, harness) };
73
+ }
74
+ const controllerId = `DRV-${crypto.randomUUID()}`;
75
+ const root = path.join(run.run_root, "controllers", controllerId);
76
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
77
+ const flowExecutable = prepareHarnessFlowExecutable(context, root);
78
+ const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
79
+ if (!fs.existsSync(entry))
80
+ throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
81
+ const frozenContexts = {};
82
+ for (const [stage, value] of Object.entries(contexts)) {
83
+ const file = path.join(root, `${stage}.context.json`);
84
+ fs.writeFileSync(file, value.bytes, { flag: "wx", mode: 0o600 });
85
+ frozenContexts[stage] = { file, sha256: value.sha256 };
86
+ }
87
+ const manifest = { schema_id: "dd-flow/run-controller-manifest@1", request, execution_profile: execution, contexts: frozenContexts, adapters, flow_executable: flowExecutable, resource_home: resourceHome(context), budget };
88
+ const token = crypto.randomUUID();
89
+ const now = context.now();
90
+ const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
91
+ context.db.beginWriteTransaction();
92
+ try {
93
+ const duplicate = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
94
+ if (duplicate) {
95
+ if (duplicate.request_hash !== requestHash)
96
+ throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
97
+ context.db.exec("COMMIT");
98
+ return { ...controllerReceipt(duplicate), reused: true };
99
+ }
100
+ assertRunMutationAllowed(context, project.id, run.id, generation);
101
+ const owner = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, run.id]);
102
+ if (owner)
103
+ throw new AppError("controller_already_owned", "RUN already has an unresolved managed owner; observe or reconcile it", 1, { controller_id: owner.controller_id });
104
+ if (context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' LIMIT 1", [project.id, run.id]))
105
+ throw new AppError("controller_attach_required", "A WorkSession acquired the RUN before managed launch", 1);
106
+ context.db.run("INSERT INTO run_controllers (controller_id, project_id, run_id, request_id, request_hash, generation, status, owner_token, manifest_json, state_json, state_dir, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?)", [controllerId, project.id, run.id, input.requestId, requestHash, generation, token, JSON.stringify(manifest), JSON.stringify(state), root, now, now]);
107
+ recordControllerLauncher(context, requireController(context, controllerId));
108
+ appendEvent(context, controllerId, "launch_requested", { request_id: input.requestId, generation, request_hash: requestHash });
109
+ context.db.exec("COMMIT");
110
+ }
111
+ catch (error) {
112
+ context.db.exec("ROLLBACK");
113
+ throw error;
114
+ }
115
+ return await spawnController(context, requireController(context, controllerId));
116
+ }
117
+ /**
118
+ * A registry write can be temporarily unavailable while another runtime client
119
+ * owns SQLite's writer lock. That is not evidence that this controller lost
120
+ * its lease. Productive dispatch still requires a successful recheck before
121
+ * proceeding, while an explicit negative renewal remains a hard ownership
122
+ * loss.
123
+ */
124
+ export function controllerLeaseMonitor(heartbeat) {
125
+ let lost = false;
126
+ let unconfirmed = null;
127
+ const lostError = () => new AppError("controller_lease_lost", "Managed owner lease could not be confirmed", 1);
128
+ return {
129
+ tick() {
130
+ try {
131
+ if (!heartbeat())
132
+ lost = true;
133
+ else
134
+ unconfirmed = null;
135
+ }
136
+ catch (error) {
137
+ unconfirmed = error;
138
+ }
139
+ },
140
+ isConfirmed() { return !lost && unconfirmed === null; },
141
+ assertConfirmed() {
142
+ if (lost)
143
+ throw lostError();
144
+ if (unconfirmed === null)
145
+ return;
146
+ try {
147
+ if (!heartbeat()) {
148
+ lost = true;
149
+ throw lostError();
150
+ }
151
+ unconfirmed = null;
152
+ }
153
+ catch (error) {
154
+ if (error instanceof AppError)
155
+ throw error;
156
+ throw new AppError("controller_lease_unconfirmed", "Managed owner lease could not be refreshed", 1, { cause: error instanceof Error ? error.message : String(error) });
157
+ }
158
+ }
159
+ };
160
+ }
161
+ /** No initialization, provider calls or lease takeover on the observation path. */
162
+ export function runControllerStatus(context, input) {
163
+ if (input.after !== undefined && (!Number.isSafeInteger(input.after) || input.after < 0))
164
+ throw new AppError("usage", "--after must be a non-negative cursor", 2);
165
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
166
+ const run = requireRun(context, project.id, input.runId);
167
+ if (!context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'"))
168
+ return { ok: true, run_id: run.id, controller: null, events: [], next_cursor: input.after ?? 0 };
169
+ const row = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, controller_id DESC LIMIT 1", [project.id, run.id]);
170
+ if (!row)
171
+ return { ok: true, run_id: run.id, controller: null, events: [], next_cursor: input.after ?? 0 };
172
+ const events = context.db.all("SELECT sequence, event_type, data_json, created_at FROM run_controller_events WHERE controller_id = ? AND sequence > ? ORDER BY sequence LIMIT 100", [row.controller_id, input.after ?? 0]);
173
+ return { ...controllerReceipt(row), events: events.map(event => ({ sequence: event.sequence, type: event.event_type, data: JSON.parse(event.data_json), at: event.created_at })), next_cursor: events.at(-1)?.sequence ?? input.after ?? 0 };
174
+ }
175
+ export async function serveRunController(context, input) {
176
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
177
+ const row = requireController(context, input.controllerId);
178
+ if (row.project_id !== project.id || row.run_id !== input.runId || row.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN)
179
+ throw new AppError("controller_owner_mismatch", "Managed serve does not own this launch", 1);
180
+ claimControllerProcess(context, row);
181
+ if (!row.process_id || !row.process_lease_token)
182
+ throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
183
+ confirmManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
184
+ const lease = controllerLeaseMonitor(() => heartbeatManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token }));
185
+ const heartbeat = setInterval(() => {
186
+ try {
187
+ lease.tick();
188
+ if (lease.isConfirmed()) {
189
+ const guard = recoveryGuard(context, row.project_id, row.run_id);
190
+ if ((guard?.generation ?? 0) === row.generation && !["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
191
+ context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching'", [new Date(Date.now() + 120_000).toISOString(), context.now(), row.project_id, row.run_id, row.controller_id]);
192
+ }
193
+ }
194
+ catch { /* The lease monitor distinguishes renewal uncertainty from loss. */ }
195
+ }, 30_000);
196
+ heartbeat.unref();
197
+ const manifest = JSON.parse(row.manifest_json);
198
+ const state = JSON.parse(row.state_json);
199
+ const assertPhysicalOwner = () => {
200
+ lease.assertConfirmed();
201
+ const current = requireController(context, row.controller_id);
202
+ if (current.owner_pid !== process.pid || current.owner_token !== row.owner_token || !["running", "capturing", "waiting_for_user", "waiting_for_context", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(current.status))
203
+ throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
204
+ };
205
+ const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
206
+ let primaryFailure;
207
+ const retainSecondary = (phase, error) => {
208
+ process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
209
+ };
210
+ try {
211
+ context.db.exec("SAVEPOINT controller_ready_status");
212
+ try {
213
+ markControllerDispatchReady(context, row);
214
+ if (!state.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
215
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
216
+ context.db.exec("RELEASE controller_ready_status");
217
+ }
218
+ catch (error) {
219
+ context.db.exec("ROLLBACK TO controller_ready_status; RELEASE controller_ready_status");
220
+ throw error;
221
+ }
222
+ appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
223
+ if (state.recovery)
224
+ await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
225
+ await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner);
226
+ return controllerReceipt(requireController(context, row.controller_id));
227
+ }
228
+ catch (error) {
229
+ primaryFailure = error;
230
+ try {
231
+ try {
232
+ failControllerRecovery(context, row, state, error);
233
+ }
234
+ catch (secondary) {
235
+ retainSecondary("fail_controller_recovery", secondary);
236
+ }
237
+ if ((error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code) || (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) !== row.generation)
238
+ && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
239
+ const guard = recoveryGuard(context, row.project_id, row.run_id);
240
+ const control = guard && ["draining", "sealed"].includes(guard.status) && context.db.get("SELECT control_id, action FROM run_controls WHERE project_id = ? AND run_id = ? AND recovery_id = ? AND status IN ('requested','settled')", [row.project_id, row.run_id, guard.recovery_id]);
241
+ if (control) {
242
+ // Control is a later state transition, not evidence that the causal
243
+ // failure which fenced admission disappeared.
244
+ context.db.run("UPDATE run_controllers SET status = CASE WHEN last_error_json IS NULL THEN 'control_requested' ELSE status END, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
245
+ appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
246
+ return controllerReceipt(requireController(context, row.controller_id));
247
+ }
248
+ }
249
+ context.db.exec("SAVEPOINT controller_failure_status");
250
+ try {
251
+ // A failed capture must not reopen admission when this process exits.
252
+ const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
253
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
254
+ if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
255
+ setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
256
+ }
257
+ appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
258
+ context.db.exec("RELEASE controller_failure_status");
259
+ }
260
+ catch (failure) {
261
+ retainSecondary("persist_controller_failure", failure);
262
+ try {
263
+ context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
264
+ }
265
+ catch (rollback) {
266
+ retainSecondary("rollback_controller_failure", rollback);
267
+ }
268
+ }
269
+ }
270
+ catch (secondary) {
271
+ retainSecondary("controller_failure_boundary", secondary);
272
+ }
273
+ throw error;
274
+ }
275
+ finally {
276
+ clearInterval(heartbeat);
277
+ finishOwner();
278
+ }
279
+ function finishOwner() {
280
+ try {
281
+ finishManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, state: "stopped", reason: "controller_owner_exited" });
282
+ }
283
+ catch (cleanup) {
284
+ if (!primaryFailure)
285
+ throw cleanup;
286
+ retainSecondary("finish_controller_process", cleanup);
287
+ }
288
+ }
289
+ }
290
+ /** Public HITL input: persist raw bytes, never deliver a turn from this client. */
291
+ export function answerRunController(context, input) {
292
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !input.pauseId.trim())
293
+ throw new AppError("usage", "Answer requires request-id and pause-id", 2);
294
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
295
+ const row = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, input.runId]);
296
+ if (!row)
297
+ throw new AppError("not_found", "RUN has no managed controller for this answer", 1);
298
+ const bytes = fs.readFileSync(path.resolve(input.answerFile));
299
+ if (!bytes.toString("utf8").trim())
300
+ throw new AppError("validation", "HITL answer must not be empty", 2);
301
+ const operationId = `${row.controller_id}:answer:${input.requestId}`;
302
+ const answerHash = digestBytes(bytes);
303
+ const hash = digest({ pause_id: input.pauseId, answer_sha256: answerHash });
304
+ context.db.beginWriteTransaction();
305
+ try {
306
+ const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
307
+ if (prior) {
308
+ if (prior.input_hash !== hash)
309
+ throw new AppError("runtime_request_conflict", "HITL answer request id has different raw input", 1);
310
+ context.db.exec("COMMIT");
311
+ return { ok: true, operation_id: operationId, status: prior.status, reused: true };
312
+ }
313
+ const currentOwner = requireController(context, row.controller_id);
314
+ if (["completed", "cancelled", "superseded"].includes(currentOwner.status))
315
+ throw new AppError("invalid_run_state", "The answer's controller is no longer current", 1);
316
+ const competing = context.db.get("SELECT operation_id FROM run_controller_operations WHERE controller_id = ? AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? LIMIT 1", [row.controller_id, input.pauseId]);
317
+ if (competing)
318
+ throw new AppError("runtime_request_conflict", "This HITL pause already has an accepted answer", 1, { operation_id: competing.operation_id });
319
+ const run = requireRun(context, project.id, input.runId);
320
+ if (["done", "failed", "cancelled"].includes(run.status))
321
+ throw new AppError("invalid_run_state", "A terminal RUN cannot accept a new answer", 1);
322
+ const index = JSON.parse(run.index_json);
323
+ const stage = index.stage_runs.find(stage => stage.pause?.id === input.pauseId && ["paused", "waiting_for_user"].includes(stage.status));
324
+ if (!stage?.pause?.work_id)
325
+ throw new AppError("hitl_pause_mismatch", "Answer does not match the RUN's current HITL pause", 1);
326
+ const file = path.join(row.state_dir, `answer-${digest(operationId)}.md`);
327
+ try {
328
+ fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
329
+ }
330
+ catch (error) {
331
+ if (error.code !== "EEXIST")
332
+ throw error;
333
+ // A crash before COMMIT may leave the immutable answer file behind.
334
+ if (!fs.readFileSync(file).equals(bytes))
335
+ throw new AppError("runtime_request_conflict", "Persisted answer bytes differ from this retry", 1);
336
+ }
337
+ const now = context.now();
338
+ const generation = recoveryGuard(context, project.id, run.id)?.generation ?? row.generation;
339
+ context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, generation, hash, JSON.stringify({ action: "user_answer", pause_id: input.pauseId, stage: stage.stage, work_id: stage.pause.work_id, file, sha256: answerHash }), now, now]);
340
+ appendEvent(context, row.controller_id, "answer_accepted", { operation_id: operationId, pause_id: input.pauseId, answer_sha256: answerHash });
341
+ context.db.exec("COMMIT");
342
+ return { ok: true, operation_id: operationId, status: "accepted" };
343
+ }
344
+ catch (error) {
345
+ context.db.exec("ROLLBACK");
346
+ throw error;
347
+ }
348
+ }
349
+ /** Publish a missing stage slice without giving the submitting client dispatch rights. */
350
+ export function supplyRunControllerContext(context, input) {
351
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-z][a-z-]*$/.test(input.stage) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
352
+ throw new AppError("usage", "Context submission requires request id, stage and SHA-256", 2);
353
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
354
+ const operationId = `${input.controllerId}:context:${input.requestId}`;
355
+ const hash = digest({ stage: input.stage, sha256: input.contextSha256 });
356
+ context.db.beginWriteTransaction();
357
+ try {
358
+ const row = requireController(context, input.controllerId);
359
+ if (row.project_id !== project.id || row.run_id !== input.runId)
360
+ throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
361
+ const prior = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
362
+ if (prior) {
363
+ if (prior.input_hash !== hash)
364
+ throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
365
+ context.db.exec("COMMIT");
366
+ return { ok: true, operation_id: operationId, status: prior.status, reused: true };
367
+ }
368
+ assertRunMutationAllowed(context, project.id, input.runId, row.generation);
369
+ if (!["starting", "running", "waiting_for_context", "waiting_for_user", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(row.status))
370
+ throw new AppError("invalid_run_state", "Controller cannot accept a new context in its current state", 1);
371
+ const manifest = JSON.parse(row.manifest_json);
372
+ if (manifest.contexts[input.stage])
373
+ throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
374
+ loadExternalStageContext({ file: input.contextFile, expectedSha256: input.contextSha256, stage: input.stage });
375
+ const bytes = fs.readFileSync(input.contextFile);
376
+ if (digestBytes(bytes) !== input.contextSha256)
377
+ throw new AppError("context_checksum_mismatch", "Context changed before publication", 2);
378
+ const file = path.join(row.state_dir, `${input.stage}.context.json`);
379
+ try {
380
+ fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
381
+ }
382
+ catch (error) {
383
+ if (error.code !== "EEXIST")
384
+ throw error;
385
+ if (!fs.readFileSync(file).equals(bytes))
386
+ throw new AppError("stage_context_replacement", "Unpublished context bytes differ from this request", 1);
387
+ }
388
+ manifest.contexts[input.stage] = { file, sha256: input.contextSha256 };
389
+ const now = context.now();
390
+ context.db.run("UPDATE run_controllers SET manifest_json = ?, updated_at = ? WHERE controller_id = ?", [JSON.stringify(manifest), now, row.controller_id]);
391
+ context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, row.generation, hash, JSON.stringify({ action: "stage_context", stage: input.stage, file, sha256: input.contextSha256 }), now, now]);
392
+ appendEvent(context, row.controller_id, "context_accepted", { operation_id: operationId, stage: input.stage, context_sha256: input.contextSha256 });
393
+ context.db.exec("COMMIT");
394
+ return { ok: true, operation_id: operationId, status: "accepted" };
395
+ }
396
+ catch (error) {
397
+ context.db.exec("ROLLBACK");
398
+ throw error;
399
+ }
400
+ }
401
+ async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner) {
402
+ assertOwnership();
403
+ const observer = { ...context, db: getDatabase(context.ddFlowHome, "read_existing") };
404
+ const checkOutcomes = () => assertLifecycleOutcomes(observer, { projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
405
+ const assertOwner = () => { if (fatal)
406
+ throw fatal; checkOutcomes(); assertOwnership(); };
407
+ const policy = manifest.execution_profile.settings.execution;
408
+ const profiles = manifest.execution_profile.agent_profiles;
409
+ let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined, DD_FLOW_CODE_READINESS_OWNER: row.controller_id } };
410
+ const bindInvocationScope = (session) => {
411
+ executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
412
+ projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
413
+ };
414
+ let lastCapture = null;
415
+ let fatal;
416
+ let stopping;
417
+ const cancellation = new AbortController();
418
+ const stopAfterFailure = (error) => {
419
+ if (stopping)
420
+ return stopping;
421
+ fatal = error;
422
+ // Persist the cause before fencing/cancellation; later control is not an
423
+ // operator pause and cannot replace this execution failure.
424
+ stopping = (async () => {
425
+ try {
426
+ context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
427
+ }
428
+ catch (persistence) {
429
+ process.stderr.write(`${JSON.stringify({ primary: errorRecord(error), persistence: errorRecord(persistence) })}\n`);
430
+ }
431
+ await requestRunControl(executionContext, { projectRoot: manifest.request.project_root, runId: row.run_id, requestId: `fatal:${row.controller_id}:${row.generation}`, action: "stop", waitMs: 0 });
432
+ })().catch(cleanup => {
433
+ // The caller still receives the primary failure if control cannot start.
434
+ try {
435
+ appendEvent(context, row.controller_id, "failure_control_error", { primary: errorRecord(error), cleanup: errorRecord(cleanup) });
436
+ }
437
+ catch {
438
+ process.stderr.write(`${JSON.stringify({ primary: errorRecord(error), cleanup: errorRecord(cleanup) })}\n`);
439
+ }
440
+ }).finally(() => cancellation.abort());
441
+ return stopping;
442
+ };
443
+ const setStatus = (status) => {
444
+ const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
445
+ if (changed.changes)
446
+ appendEvent(context, row.controller_id, "status_changed", { status, stage: state.stage });
447
+ };
448
+ const call = (session, args, productive = true) => controllerAdapter(executionContext, row, manifest, session, args, productive ? assertOwner : undefined, undefined, productive ? cancellation.signal : undefined);
449
+ const confirmStopped = async (stateDir, receipt) => {
450
+ const fail = () => new AppError("controller_native_shutdown_unproven", "The managed daemon is not proven clean and closed", 1, { state_dir: stateDir, receipt });
451
+ if (receipt.stopped !== true || receipt.clean !== true)
452
+ throw fail();
453
+ const binding = requireManagedDaemonBinding(executionContext, { stateDir, projectId: row.project_id, runId: row.run_id });
454
+ const deadline = performance.now() + 30_000;
455
+ while (processTreeIsAlive(binding.process) && performance.now() < deadline)
456
+ await delay(100);
457
+ const current = readNativeDaemonState(stateDir);
458
+ if (processTreeIsAlive(binding.process) || current.shutdown_state !== "clean"
459
+ || current.pid !== binding.process.pid || (current.daemon_id ?? current.config?.daemonId) !== (binding.state.daemon_id ?? binding.state.config?.daemonId)
460
+ || current.resource_process?.id !== binding.process.id || current.resource_process?.lease_token !== binding.process.lease_token)
461
+ throw fail();
462
+ };
463
+ const stopSession = async (session) => {
464
+ if (session.stopped)
465
+ return;
466
+ const receipt = await call(session, ["daemon", "stop", "--state-dir", session.state_dir, "--json"], false);
467
+ await confirmStopped(session.state_dir, receipt);
468
+ session.stopped = true;
469
+ saveState(context, row, state);
470
+ appendEvent(context, row.controller_id, "session_stopped", { session_id: session.id || null, state_dir: session.state_dir, receipt });
471
+ };
472
+ const settle = async () => {
473
+ const results = await Promise.allSettled(state.sessions.map(stopSession));
474
+ const failure = results.find((result) => result.status === "rejected");
475
+ if (failure)
476
+ throw failure.reason;
477
+ };
478
+ const prompt = async (session, text, operationId) => {
479
+ const file = path.join(row.state_dir, `turn-${String(state.turns + 1).padStart(5, "0")}.md`);
480
+ fs.writeFileSync(file, `${text}\n`, { flag: "wx", mode: 0o600 });
481
+ state.turns += 1;
482
+ saveState(context, row, state);
483
+ let receipt;
484
+ try {
485
+ const adapter = controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId, cancellation.signal);
486
+ try {
487
+ receipt = await adapter;
488
+ checkOutcomes();
489
+ if (fatal)
490
+ throw fatal;
491
+ }
492
+ catch (error) {
493
+ if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
494
+ throw error;
495
+ await stopAfterFailure(error);
496
+ cancellation.abort();
497
+ await adapter.catch(() => { });
498
+ throw fatal ?? error;
499
+ }
500
+ }
501
+ finally { /* Adapter settlement is joined before returning. */ }
502
+ state.last_receipt = receipt;
503
+ saveState(context, row, state);
504
+ return receipt;
505
+ };
506
+ const monitor = setInterval(() => {
507
+ if (fatal)
508
+ return;
509
+ try {
510
+ checkOutcomes();
511
+ }
512
+ catch (error) {
513
+ void stopAfterFailure(error);
514
+ }
515
+ }, 1000);
516
+ monitor.unref();
517
+ try {
518
+ for (;;) {
519
+ assertOwner();
520
+ if (state.current_session !== null && !state.sessions[state.current_session].stopped)
521
+ bindInvocationScope(state.sessions[state.current_session]);
522
+ const run = requireRun(context, row.project_id, row.run_id);
523
+ const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
524
+ const next = view.continuation;
525
+ const completed = state.stage && view.index.stage_runs.find(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status));
526
+ if (completed && manifest.request.capture_root && ["start_stage", "terminal"].includes(next.kind)) {
527
+ const key = JSON.stringify([completed.stage, completed.attempt, completed.started_at, completed.completed_at]);
528
+ if (key !== lastCapture) {
529
+ await captureControllerBoundary(executionContext, row, manifest, completed, next.kind === "start_stage" ? next.stage : null, assertPhysicalOwner);
530
+ lastCapture = key;
531
+ }
532
+ }
533
+ if (completed && manifest.request.stop_after === state.stage) {
534
+ await settle();
535
+ setStatus("stop_target_reached");
536
+ appendEvent(context, row.controller_id, "stop_target_reached", { stage: state.stage, attempt: completed.attempt ?? null });
537
+ return;
538
+ }
539
+ if (next.kind === "terminal") {
540
+ await settle();
541
+ setStatus(run.status === "cancelled" ? "cancelled" : "completed");
542
+ appendEvent(context, row.controller_id, "terminal", { reason: next.reason });
543
+ return;
544
+ }
545
+ if (next.kind === "paused") {
546
+ setStatus("waiting_for_user");
547
+ const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
548
+ if (!pause || state.current_session === null)
549
+ throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
550
+ const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
551
+ if (!answer) {
552
+ await delay(1000);
553
+ continue;
554
+ }
555
+ const input = JSON.parse(answer.input_json);
556
+ if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
557
+ throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
558
+ assertOwner();
559
+ // The accepted answer is logical intent; only its generation-bound
560
+ // adapter operation represents a physical dispatch that stop must drain.
561
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`);
562
+ const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
563
+ context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
564
+ state.last_continuation = null;
565
+ saveState(context, row, state);
566
+ setStatus("running");
567
+ continue;
568
+ }
569
+ if (next.kind === "blocked" || next.kind === "wait" || !next.stage)
570
+ throw new AppError("controller_continuation_blocked", "RUN has no legal managed continuation", 1, { continuation: next });
571
+ if (next.kind === "start_stage") {
572
+ // Only context slices can be added; profiles and launch inputs stay frozen.
573
+ manifest.contexts = JSON.parse(requireController(context, row.controller_id).manifest_json).contexts;
574
+ const stageContext = manifest.contexts[next.stage];
575
+ if (!stageContext) {
576
+ if (requireController(context, row.controller_id).status !== "waiting_for_context")
577
+ appendEvent(context, row.controller_id, "context_required", { stage: next.stage, attempt: next.attempt ?? null });
578
+ setStatus("waiting_for_context");
579
+ await delay(1000);
580
+ continue;
581
+ }
582
+ loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
583
+ const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
584
+ if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
585
+ const request = context.db.get("SELECT merge_request_id, status FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, merge_request_id DESC LIMIT 1", [row.project_id, row.run_id]);
586
+ if (!request || request.status !== "queued")
587
+ throw new AppError("controller_attach_required", "Managed MERGE needs an unclaimed queued request; reconcile an existing owner", 1, { request });
588
+ const claim = claimQueuedMergeRequest(context, { requestId: request.merge_request_id, ownerId: row.controller_id, expectedGeneration: row.generation });
589
+ if (!claim) {
590
+ setStatus("waiting_for_merge");
591
+ await delay(1000);
592
+ continue;
593
+ }
594
+ state.merge_claim = { request_id: claim.request.merge_request_id, token: claim.token, target_workspace: claim.request.target_workspace, work_id: claim.request.executor_work_id };
595
+ saveState(context, row, state);
596
+ appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
597
+ }
598
+ let session = state.current_session === null ? null : state.sessions[state.current_session];
599
+ if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator)) {
600
+ if (session)
601
+ await stopSession(session);
602
+ assertOwner();
603
+ const directory = path.join(row.state_dir, `session-${state.sessions.length + 1}`);
604
+ fs.mkdirSync(directory, { mode: 0o700 });
605
+ const owner = { schema_id: "dd-flow/runtime-owner@1", owner_id: row.controller_id, role: "subject", project_id: row.project_id, run_id: row.run_id, operation_id: `${row.controller_id}:session:${state.sessions.length + 1}`, state_dir: directory, dd_flow_home: context.ddFlowHome, dd_flow_bin: manifest.flow_executable, resource_home: manifest.resource_home, budget: manifest.budget };
606
+ if (next.stage === "merge" && state.merge_claim) {
607
+ owner.role = "merge";
608
+ owner.work_id = state.merge_claim.work_id;
609
+ }
610
+ session = { id: "", profile: transition.coordinator, state_dir: directory, cwd: next.stage === "merge" && state.merge_claim ? state.merge_claim.target_workspace : run.workspace_root, journal: path.join(directory, "adapter.events.jsonl"), owner };
611
+ if (runtimeHarness(session.profile) === "codex-desktop") {
612
+ session.codex_home = path.join(directory, "codex-home");
613
+ initCodexHome(context, { projectRoot: run.project_root, profile: `controller-${digest(directory).slice(0, 16)}`, targetHome: session.codex_home, ...(context.env.CODEX_HOME ? { sourceHome: context.env.CODEX_HOME } : {}) });
614
+ }
615
+ state.sessions.push(session);
616
+ state.current_session = state.sessions.length - 1;
617
+ saveState(context, row, state);
618
+ await call(session, ["daemon", "start", ...sessionArguments(context, manifest, session)]);
619
+ const created = await call(session, ["session", "create", ...sessionArguments(context, manifest, session)]);
620
+ const sessionId = adapterSessionId(created);
621
+ if (!sessionId)
622
+ throw new AppError("harness_adapter_invalid", "Managed adapter did not return its Session id", 1);
623
+ session.id = sessionId;
624
+ saveState(context, row, state);
625
+ bindInvocationScope(session);
626
+ if (next.stage === "merge" && state.merge_claim)
627
+ context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify({ controller_id: row.controller_id, profile: session.profile, session_id: session.id, state_dir: session.state_dir, create: created }), context.now(), state.merge_claim.request_id, row.controller_id, state.merge_claim.token]);
628
+ appendEvent(context, row.controller_id, "session_created", { stage: next.stage, session_id: sessionId, harness: runtimeHarness(session.profile), profile: session.profile });
629
+ }
630
+ state.native_baseline = controllerNativeChildren(state.last_receipt ?? {}, session.id).map(child => child.session_id);
631
+ state.stage = next.stage;
632
+ state.last_continuation = null;
633
+ saveState(context, row, state);
634
+ setStatus("running");
635
+ if (next.stage === "code" || next.stage === "code-review") {
636
+ // A CODE_REVIEW fork has source files but intentionally no installed
637
+ // dependencies. Its repair Work executes checks just like CODE Work.
638
+ const readiness = await prepareVnextCodeWorkspace(executionContext, { projectRoot: run.project_root, runId: run.id, ownerId: row.controller_id, stage: next.stage });
639
+ assertOwner();
640
+ appendEvent(context, row.controller_id, "code_workspace_prepared", { stage: next.stage, readiness_status: readiness.status ?? null });
641
+ }
642
+ const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
643
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage start ${run.id} --stage ${next.stage} --project-root ${JSON.stringify(run.project_root)} --context-file ${JSON.stringify(stageContext.file)} --context-sha256 ${stageContext.sha256} --require-session-binding --response-file ${JSON.stringify(responseFile)} --json --progress-jsonl`);
644
+ appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
645
+ await prompt(session, controllerStageEntryPrompt(next.stage, command, responseFile));
646
+ continue;
647
+ }
648
+ if (state.current_session === null)
649
+ throw new AppError("controller_attach_required", "An unfinished Stage has no managed coordinator", 1);
650
+ const session = state.sessions[state.current_session];
651
+ const baseline = new Set(state.native_baseline ?? []);
652
+ const children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
653
+ const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles });
654
+ assertOwner();
655
+ if (fanout.kind === "none")
656
+ throw new AppError("incomplete_subject_turn", "Managed Turn ended without a completed Stage or declared continuation", 1, { stage: next.stage });
657
+ if (fanout.kind === "wait") {
658
+ setStatus(fanout.reason === "qualified_capacity_required" ? "waiting_for_capacity" : "waiting_for_children");
659
+ await delay(1000);
660
+ state.last_receipt = await call(session, ["session", "inspect", "--session-id", session.id, "--state-dir", session.state_dir, "--json"], false);
661
+ saveState(context, row, state);
662
+ continue;
663
+ }
664
+ setStatus("running");
665
+ if (fanout.kind === "external") {
666
+ const results = await Promise.allSettled(fanout.works.map(async (work) => {
667
+ try {
668
+ assertOwner();
669
+ const receipt = await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage });
670
+ if (fatal)
671
+ throw fatal;
672
+ appendEvent(context, row.controller_id, "external_work_observed", { work_id: work.work_id, receipt });
673
+ const launch = context.db.get("SELECT state_dir, profile_json FROM work_launches WHERE work_id = ?", [work.work_id]);
674
+ const status = context.db.get("SELECT status FROM works WHERE work_id = ?", [work.work_id])?.status;
675
+ if (!launch || !["completed", "failed", "cancelled"].includes(status ?? ""))
676
+ throw new AppError("external_work_unsettled", "External launch returned without its terminal Work receipt; observe it rather than relaunching", 1, { work_id: work.work_id, status });
677
+ const resolved = JSON.parse(launch.profile_json);
678
+ const executable = manifest.adapters[runtimeHarness(resolved.worker)].executable;
679
+ const stopped = await runHarnessAdapter({ executable, args: ["daemon", "stop", "--state-dir", launch.state_dir, "--json"], env: controllerEnvironment(context, manifest), operationId: `${row.controller_id}:${work.work_id}:settle` });
680
+ await confirmStopped(launch.state_dir, stopped);
681
+ appendEvent(context, row.controller_id, "external_work_settled", { work_id: work.work_id, receipt: stopped });
682
+ }
683
+ catch (error) {
684
+ await stopAfterFailure(error);
685
+ throw error;
686
+ }
687
+ }));
688
+ const rejected = results.find((result) => result.status === "rejected");
689
+ if (rejected)
690
+ throw fatal ?? rejected.reason;
691
+ state.last_continuation = null;
692
+ saveState(context, row, state);
693
+ continue;
694
+ }
695
+ if (state.last_continuation === fanout.fingerprint)
696
+ throw new AppError("fanout_stage_nonprogressing", "The coordinator repeated an unchanged Work-graph continuation", 1, { stage: next.stage });
697
+ state.last_continuation = fanout.fingerprint;
698
+ saveState(context, row, state);
699
+ await prompt(session, fanout.prompt);
700
+ }
701
+ }
702
+ catch (error) {
703
+ if (!fatal && error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale", "controller_owner_mismatch"].includes(error.code))
704
+ throw error;
705
+ await stopAfterFailure(fatal ?? error);
706
+ throw fatal ?? error;
707
+ }
708
+ finally {
709
+ clearInterval(monitor);
710
+ if (stopping)
711
+ await stopping;
712
+ observer.db.close?.();
713
+ }
714
+ }
715
+ function readContexts(file, expected) {
716
+ const bytes = fs.readFileSync(path.resolve(file));
717
+ if (digestBytes(bytes) !== expected)
718
+ throw new AppError("context_checksum_mismatch", "Managed context package SHA-256 differs", 2);
719
+ const value = JSON.parse(bytes.toString("utf8"));
720
+ if (value.schema_id !== "dd-flow/managed-context@1" || !value.stages || typeof value.stages !== "object" || Array.isArray(value.stages) || Object.keys(value).some(key => !["schema_id", "stages"].includes(key)))
721
+ throw new AppError("validation", "Expected dd-flow/managed-context@1 with stage file/hash references", 2);
722
+ const result = {};
723
+ for (const [stage, source] of Object.entries(value.stages)) {
724
+ if (!/^[a-z][a-z-]*$/.test(stage) || !source || typeof source !== "object" || Object.keys(source).some(key => !["file", "sha256"].includes(key)) || typeof source.file !== "string" || !path.isAbsolute(source.file) || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256))
725
+ throw new AppError("validation", "Invalid managed stage context reference", 2);
726
+ loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
727
+ const content = fs.readFileSync(source.file);
728
+ if (digestBytes(content) !== source.sha256)
729
+ throw new AppError("context_checksum_mismatch", "Stage context changed while freezing the launch", 2);
730
+ result[stage] = { bytes: content, sha256: source.sha256 };
731
+ }
732
+ return result;
733
+ }