@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,222 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveProjectRoot } from "../storage/paths.js";
7
+ import { requireProjectByRoot } from "./projects.js";
8
+ import { loadExternalStageContext } from "./stage-context.js";
9
+ import { verifyRecoverySnapshot } from "./eval-snapshots.js";
10
+ import { recoveryGuard, resumeRunRecovery } from "./run-recovery.js";
11
+ import { prepareRunRecoveryDispatch } from "./run-recovery-runtime.js";
12
+ import { runtimeHarness } from "./execution-policy.js";
13
+ import { managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive } from "./managed-processes.js";
14
+ import { requireRun, requireController, controllerReceipt, appendEvent, saveState, digest, errorRecord } from "./run-controller-state.js";
15
+ import { controllerAdapter, sessionArguments } from "./run-controller-adapter.js";
16
+ import { spawnController, recordControllerLauncher } from "./run-controller-process.js";
17
+ import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
18
+ import { managedInvocationContext } from "./lifecycle-invocations.js";
19
+ import { runControlStatus } from "./run-control.js";
20
+ import { runtimeBudgetStatus, waitForRuntimeScopeRecovery } from "./runtime-budget.js";
21
+ /** CLI observation is bounded; the physical controller owns the recovery work. */
22
+ export async function resumeRunController(context, input) {
23
+ if (input.waitMs !== undefined && (!Number.isSafeInteger(input.waitMs) || input.waitMs < 0 || input.waitMs > 60_000))
24
+ throw new AppError("usage", "--wait-ms must be between 0 and 60000", 2);
25
+ const intent = recordControllerResume(context, input);
26
+ const manifest = JSON.parse(intent.controller.manifest_json);
27
+ const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
28
+ await spawnController(resourceContext, intent.controller);
29
+ const deadline = performance.now() + (input.waitMs ?? 0);
30
+ const status = () => context.db.get("SELECT status FROM run_controller_operations WHERE operation_id = ?", [intent.operationId]).status;
31
+ while (status() === "accepted" && performance.now() < deadline)
32
+ await delay(Math.min(100, Math.max(1, deadline - performance.now())));
33
+ const observation = runControlStatus(context, { ...input, action: "resume" });
34
+ return { ...observation, controller: controllerReceipt(requireController(context, intent.controller.controller_id)).controller, operation_id: intent.operationId, status: observation.resume_operation.status, ...(intent.retiredProcess ? {} : { reused: true }) };
35
+ }
36
+ /** Retain the logical controller and its receipts; replace only its dead owner. */
37
+ export function recordControllerResume(context, input) {
38
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !input.controlId.trim())
39
+ throw new AppError("usage", "Resume requires request-id and the exact source control", 2);
40
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
41
+ const run = requireRun(context, project.id, input.runId);
42
+ if (!context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'"))
43
+ throw new AppError("controller_attach_required", "RUN has no retained managed controller", 1);
44
+ const request = { action: "control_resume", request_id: input.requestId, control_id: input.controlId };
45
+ const hash = digest(request);
46
+ const findPrior = () => {
47
+ const prior = context.db.get("SELECT o.controller_id, o.operation_id, o.input_hash FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND json_extract(o.input_json, '$.action') = 'control_resume' AND json_extract(o.input_json, '$.request_id') = ?", [project.id, run.id, input.requestId]);
48
+ if (!prior)
49
+ return null;
50
+ if (prior.input_hash !== hash)
51
+ throw new AppError("runtime_request_conflict", "Resume request id has different inputs", 1);
52
+ return { controller: requireController(context, prior.controller_id), operationId: prior.operation_id };
53
+ };
54
+ const prior = findPrior();
55
+ if (prior)
56
+ return prior;
57
+ try {
58
+ const row = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, run.id]);
59
+ if (!row)
60
+ throw new AppError("controller_attach_required", "RUN has no retained resumable controller", 1);
61
+ const operationId = `${row.controller_id}:resume:${input.requestId}`;
62
+ if (!["running", "paused", "waiting_for_user"].includes(run.status))
63
+ throw new AppError("invalid_run_state", "Resume cannot resurrect a terminal RUN", 1, { status: run.status });
64
+ const guard = recoveryGuard(context, project.id, run.id);
65
+ const control = context.db.get("SELECT recovery_id, status FROM run_controls WHERE control_id = ? AND project_id = ? AND run_id = ?", [input.controlId, project.id, run.id]);
66
+ if (!guard || guard.recovery_id !== control?.recovery_id || guard.status !== "sealed" || control.status !== "settled" || !guard.capture_path || JSON.parse(guard.settlement_json ?? "{}").settled !== true)
67
+ throw new AppError("run_control_not_resumable", "Resume requires the current settled control and its immutable recovery capture", 1);
68
+ const manifest = JSON.parse(row.manifest_json), state = JSON.parse(row.state_json);
69
+ if (!manifest.execution_profile?.settings.execution || !manifest.execution_profile.agent_profiles || !path.isAbsolute(manifest.resource_home) || !path.isAbsolute(manifest.flow_executable))
70
+ throw new AppError("controller_manifest_invalid", "Resume requires the original frozen controller manifest", 1);
71
+ if (state.current_session === null || !state.sessions[state.current_session]?.id)
72
+ throw new AppError("recovery_owner_missing", "Resume requires the retained coordinator Session", 1);
73
+ for (const [stage, source] of Object.entries(manifest.contexts))
74
+ loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
75
+ verifyRecoverySnapshot(guard.capture_path, { projectRoot: project.root, runId: run.id, recoveryId: guard.recovery_id, workspaceRoot: run.workspace_root });
76
+ const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
77
+ const previousOwner = managedProcessStatus(resourceContext).find(record => record.id === row.process_id);
78
+ if (!previousOwner || previousOwner.lease_token !== row.process_lease_token || previousOwner.kind !== "run-controller" || previousOwner.owner_id !== row.controller_id || previousOwner.project_id !== project.id || previousOwner.run_id !== run.id || managedProcessRuntimeHome(previousOwner, context) !== context.ddFlowHome || !previousOwner.pid || !previousOwner.pid_started_at || processTreeIsAlive(previousOwner))
79
+ throw new AppError("controller_owner_unsettled", "Resume requires proof that the previous physical controller has exited", 1);
80
+ const token = crypto.randomUUID(), now = context.now();
81
+ context.db.beginWriteTransaction();
82
+ try {
83
+ const duplicate = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
84
+ if (duplicate) {
85
+ if (duplicate.input_hash !== hash)
86
+ throw new AppError("runtime_request_conflict", "Resume request id has different inputs", 1);
87
+ context.db.exec("COMMIT");
88
+ return { controller: requireController(context, row.controller_id), operationId };
89
+ }
90
+ const current = recoveryGuard(context, project.id, run.id);
91
+ if (current?.recovery_id !== guard.recovery_id || current.generation !== guard.generation || current.status !== "sealed" || current.capture_path !== guard.capture_path)
92
+ throw new AppError("run_control_superseded", "Control changed before resume could be accepted", 1);
93
+ if (!["running", "paused", "waiting_for_user"].includes(requireRun(context, project.id, run.id).status))
94
+ throw new AppError("invalid_run_state", "RUN became terminal before resume could be accepted", 1);
95
+ const changed = context.db.run("UPDATE run_controls SET status = 'resuming', updated_at = ? WHERE control_id = ? AND recovery_id = ? AND status = 'settled'", [now, input.controlId, guard.recovery_id]);
96
+ if (changed.changes !== 1)
97
+ throw new AppError("run_control_superseded", "Control changed before resume could be accepted", 1);
98
+ state.recovery = { control_id: input.controlId, recovery_id: guard.recovery_id, operation_id: operationId };
99
+ const claimed = context.db.run("UPDATE run_controllers SET generation = ?, status = 'starting', owner_token = ?, owner_pid = NULL, process_id = NULL, process_lease_token = NULL, state_json = ?, last_error_json = NULL, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND state_json = ? AND manifest_json = ? AND process_id = ?", [guard.generation, token, JSON.stringify(state), now, row.controller_id, row.owner_token, row.state_json, row.manifest_json, row.process_id]);
100
+ if (claimed.changes !== 1)
101
+ throw new AppError("controller_owner_mismatch", "Controller changed before resume could claim its next physical owner", 1);
102
+ 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, guard.generation, hash, JSON.stringify(request), now, now]);
103
+ recordControllerLauncher(context, requireController(context, row.controller_id), previousOwner);
104
+ appendEvent(context, row.controller_id, "resume_requested", { operation_id: operationId, control_id: input.controlId, recovery_id: guard.recovery_id, generation: guard.generation, retired_process_id: previousOwner.id, retained_session_id: state.sessions[state.current_session].id });
105
+ context.db.exec("COMMIT");
106
+ }
107
+ catch (error) {
108
+ context.db.exec("ROLLBACK");
109
+ throw error;
110
+ }
111
+ return { controller: requireController(context, row.controller_id), operationId, retiredProcess: previousOwner };
112
+ }
113
+ catch (error) {
114
+ // A concurrent identical client may commit between our first lookup and
115
+ // validation. Its durable request wins even if our preflight saw the fence.
116
+ const concurrent = findPrior();
117
+ if (concurrent)
118
+ return concurrent;
119
+ throw error;
120
+ }
121
+ }
122
+ export async function resumeControllerSession(context, row, manifest, state, assertPhysicalOwner) {
123
+ const recovery = state.recovery;
124
+ const session = state.current_session === null ? undefined : state.sessions[state.current_session];
125
+ if (!session?.id)
126
+ throw new AppError("recovery_owner_missing", "Recovery lost its retained coordinator Session", 1);
127
+ let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
128
+ const scopeControl = manifest.budget ? runtimeBudgetStatus(executionContext, manifest.budget.scope_id).control : null;
129
+ const assertRecoveryOwner = () => {
130
+ assertPhysicalOwner();
131
+ const guard = recoveryGuard(context, row.project_id, row.run_id);
132
+ if (guard?.generation !== row.generation || guard.recovery_id !== recovery.recovery_id)
133
+ throw new AppError("run_recovery_generation_stale", "A newer control superseded this recovery owner", 1);
134
+ if (!["sealed", "resuming", "recovered"].includes(guard.status) || !context.db.get("SELECT 1 FROM run_controls WHERE control_id = ? AND recovery_id = ? AND status = 'resuming'", [recovery.control_id, recovery.recovery_id]))
135
+ throw new AppError("run_recovery_guarded", "Recovery no longer owns this operator intent", 1);
136
+ };
137
+ assertRecoveryOwner();
138
+ const readDaemon = () => readNativeDaemonState(session.state_dir);
139
+ const { state: retired, process: record } = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id, ownerId: session.owner.owner_id });
140
+ const call = (args, operationId) => controllerAdapter(executionContext, row, manifest, session, args, assertRecoveryOwner, operationId);
141
+ if (processTreeIsAlive(record)) {
142
+ const stopped = await call(["daemon", "stop", "--state-dir", session.state_dir, "--json"]);
143
+ if (stopped.stopped !== true || stopped.clean !== true)
144
+ throw new AppError("recovery_native_shutdown_unproven", "Recovery requires a clean native close before replacing its daemon", 1);
145
+ const deadline = Date.now() + 30_000;
146
+ while (processTreeIsAlive(record) && Date.now() < deadline) {
147
+ await delay(100);
148
+ assertRecoveryOwner();
149
+ }
150
+ }
151
+ if (processTreeIsAlive(record) || readDaemon().shutdown_state !== "clean")
152
+ throw new AppError("recovery_native_shutdown_unproven", "The previous native daemon is not proven clean and closed", 1);
153
+ assertRecoveryOwner();
154
+ session.stopped = false;
155
+ saveState(context, row, state);
156
+ const started = await call(["daemon", "start", "--session-id", session.id, ...sessionArguments(context, manifest, session)]);
157
+ const daemon = readDaemon(), daemonId = daemon.daemon_id ?? daemon.config?.daemonId;
158
+ if (!daemonId || daemonId === (retired.daemon_id ?? retired.config?.daemonId) || started.daemon_id !== daemonId || started.already_running === true)
159
+ throw new AppError("recovery_daemon_stale", "Recovery requires a fresh daemon, not reuse of the retired physical owner", 1);
160
+ executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
161
+ projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
162
+ assertRecoveryOwner();
163
+ const scope = { projectRoot: manifest.request.project_root, runId: row.run_id, recoveryId: recovery.recovery_id };
164
+ resumeRunRecovery(executionContext, { ...scope, daemonId, harness: runtimeHarness(session.profile), nativeSessionId: session.id });
165
+ const dispatch = prepareRunRecoveryDispatch(executionContext, { ...scope, stateDir: session.state_dir, operationId: `${recovery.operation_id}:ack` });
166
+ const inspected = await call(["session", "resume", "--session-id", session.id, ...sessionArguments(context, manifest, session)], dispatch.resume_operation_id);
167
+ if (inspected.settled !== true)
168
+ throw new AppError("recovery_session_unsettled", "Retained native Session is not ready for same-Session recovery", 1);
169
+ assertRecoveryOwner();
170
+ const prepared = { prepared: true, recovery_id: recovery.recovery_id, session_id: session.id, daemon_id: daemonId, process_id: dispatch.process_id, resume_operation_id: dispatch.resume_operation_id };
171
+ context.db.beginWriteTransaction();
172
+ try {
173
+ assertRecoveryOwner();
174
+ const binding = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id, ownerId: session.owner.owner_id });
175
+ if (binding.process.id !== dispatch.process_id || binding.state.daemon_id !== daemonId || binding.process.state !== "running" || !processIsAlive(binding.process))
176
+ throw new AppError("recovery_runtime_mismatch", "Prepared recovery lost its physical Session owner", 1);
177
+ const recorded = context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ? AND controller_id = ? AND generation = ? AND status = 'accepted'", [JSON.stringify(prepared), context.now(), recovery.operation_id, row.controller_id, row.generation]);
178
+ if (recorded.changes !== 1)
179
+ throw new AppError("run_recovery_generation_stale", "Recovery preparation no longer owns its operation", 1);
180
+ appendEvent(context, row.controller_id, "recovery_prepared", prepared);
181
+ context.db.exec("COMMIT");
182
+ }
183
+ catch (error) {
184
+ context.db.exec("ROLLBACK");
185
+ throw error;
186
+ }
187
+ if (scopeControl && manifest.budget) {
188
+ await waitForRuntimeScopeRecovery(executionContext, { scopeId: manifest.budget.scope_id, generation: scopeControl.generation, controllerId: row.controller_id, operationId: recovery.operation_id }, assertRecoveryOwner);
189
+ }
190
+ const file = path.join(row.state_dir, `turn-${String(state.turns + 1).padStart(5, "0")}.md`);
191
+ fs.writeFileSync(file, dispatch.prompt, { flag: "wx", mode: 0o600 });
192
+ state.turns += 1;
193
+ saveState(context, row, state);
194
+ const receipt = await call(["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], dispatch.operation_id);
195
+ assertRecoveryOwner();
196
+ if (recoveryGuard(context, row.project_id, row.run_id)?.status !== "recovered")
197
+ throw new AppError("trusted_recovery_acceptance_required", "Recovery Turn ended without its native acknowledgement", 1);
198
+ context.db.beginWriteTransaction();
199
+ try {
200
+ assertRecoveryOwner();
201
+ context.db.run("UPDATE run_controls SET status = 'resumed', updated_at = ? WHERE control_id = ? AND status = 'resuming'", [context.now(), recovery.control_id]);
202
+ context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [JSON.stringify({ resumed: true, recovery_id: recovery.recovery_id, session_id: session.id, daemon_id: daemonId }), context.now(), recovery.operation_id]);
203
+ state.last_receipt = receipt;
204
+ state.last_continuation = null;
205
+ delete state.recovery;
206
+ saveState(context, row, state);
207
+ appendEvent(context, row.controller_id, "recovery_resumed", { control_id: recovery.control_id, recovery_id: recovery.recovery_id, session_id: session.id, daemon_id: daemonId, generation: row.generation });
208
+ context.db.exec("COMMIT");
209
+ }
210
+ catch (error) {
211
+ context.db.exec("ROLLBACK");
212
+ throw error;
213
+ }
214
+ }
215
+ export function failControllerRecovery(context, row, state, error) {
216
+ if (state.recovery) {
217
+ // Native ACK may already have opened this generation before its Turn lost
218
+ // observation. Retain the accepted evidence but close further dispatch.
219
+ context.db.run("UPDATE run_recovery_guards SET status = 'resuming', updated_at = ? WHERE recovery_id = ? AND status = 'recovered' AND generation = (SELECT MAX(generation) FROM run_recovery_guards WHERE project_id = ? AND run_id = ?) AND EXISTS (SELECT 1 FROM run_controls WHERE control_id = ? AND recovery_id = run_recovery_guards.recovery_id AND status = 'resuming')", [context.now(), state.recovery.recovery_id, row.project_id, row.run_id, state.recovery.control_id]);
220
+ context.db.run("UPDATE run_controller_operations SET status = 'failed', error_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [JSON.stringify(errorRecord(error)), context.now(), state.recovery.operation_id]);
221
+ }
222
+ }
@@ -0,0 +1,31 @@
1
+ import crypto from "node:crypto";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { runtimeHarness } from "./execution-policy.js";
4
+ export function requireRun(context, projectId, runId) {
5
+ const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root, status, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
6
+ if (!run?.run_root)
7
+ throw new AppError("not_found", "Managed RUN has no materialized root", 1, { run_id: runId });
8
+ return run;
9
+ }
10
+ export function requireController(context, id) {
11
+ const row = context.db.get("SELECT * FROM run_controllers WHERE controller_id = ?", [id]);
12
+ if (!row)
13
+ throw new AppError("not_found", "Managed controller was not found", 1, { controller_id: id });
14
+ return row;
15
+ }
16
+ export function controllerReceipt(row) {
17
+ const state = JSON.parse(row.state_json);
18
+ const manifest = JSON.parse(row.manifest_json);
19
+ return { ok: true, schema_id: "dd-flow/run-controller-receipt@1", controller: { controller_id: row.controller_id, request_id: row.request_id, project_id: row.project_id, run_id: row.run_id, generation: row.generation, runtime_budget: manifest.budget ?? null, status: row.status, process_id: row.process_id, owner_pid: row.owner_pid, state_dir: row.state_dir, stage: state.stage, sessions: state.sessions.map(session => ({ session_id: session.id, harness: runtimeHarness(session.profile), profile_id: session.profile.id, state_dir: session.state_dir, journal: session.journal, stopped: session.stopped === true })), error: row.last_error_json ? JSON.parse(row.last_error_json) : null } };
20
+ }
21
+ export function appendEvent(context, id, type, data) { context.db.run("INSERT INTO run_controller_events (controller_id, event_type, data_json, created_at) VALUES (?, ?, ?, ?)", [id, type, JSON.stringify(data), context.now()]); }
22
+ export function saveState(context, row, state) { context.db.run("UPDATE run_controllers SET state_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(state), context.now(), row.controller_id, row.owner_token]); }
23
+ export function digest(value) { return digestBytes(JSON.stringify(value)); }
24
+ export function digestBytes(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
25
+ export function errorRecord(error) {
26
+ return {
27
+ code: error instanceof AppError ? error.code : "controller_failed",
28
+ message: error instanceof Error ? error.message : String(error),
29
+ ...(error instanceof AppError && Object.keys(error.details).length ? { details: error.details } : {})
30
+ };
31
+ }