@deksden-com/dd-flow-cli 0.9.0-beta.38 → 0.9.0-beta.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -1
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +42 -1
- package/dist/cli/run-cli.js +145 -17
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -3
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -3
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -3
- package/dist/harness-runtime/bin/dd-zcode.mjs +4 -3
- package/dist/harness-runtime/lib/daemon-operations.mjs +48 -9
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +49 -12
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +57 -16
- package/dist/harness-runtime/lib/dd-codex.mjs +10 -2
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -6
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +8 -5
- package/dist/harness-runtime/lib/managed-daemon.mjs +125 -6
- package/dist/harness-runtime/lib/process-json.mjs +1 -1
- package/dist/harness-runtime/lib/session-settlement.mjs +4 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/services/cleanup.js +7 -4
- package/dist/services/cli-operation-classifier.js +3 -1
- package/dist/services/code-checks.js +33 -7
- package/dist/services/controller-fanout.js +105 -0
- package/dist/services/engines.js +2 -0
- package/dist/services/eval-snapshots.js +347 -36
- package/dist/services/execution-policy.js +122 -6
- package/dist/services/external-work-launch.js +33 -12
- package/dist/services/harness-adapter.js +49 -2
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +54 -7
- package/dist/services/merge-server.js +53 -30
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +31 -0
- package/dist/services/protocols.js +9 -0
- package/dist/services/recovery-snapshot-database.js +103 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +297 -0
- package/dist/services/run-control.js +752 -0
- package/dist/services/run-controller-adapter.js +44 -0
- package/dist/services/run-controller-capture.js +117 -0
- package/dist/services/run-controller-process.js +178 -0
- package/dist/services/run-controller-recovery.js +219 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +538 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +33 -9
- package/dist/services/runs.js +65 -13
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +431 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +95 -0
- package/dist/services/runtime-scope-worker.js +165 -0
- package/dist/services/runtime-service.js +4 -0
- package/dist/services/vnext-code-review.js +1 -1
- package/dist/services/vnext-code.js +1 -1
- package/dist/services/vnext-execution-profile.js +2 -35
- package/dist/services/vnext-fanout.js +23 -7
- package/dist/services/vnext-plan-review.js +12 -9
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/vnext-protocolize.js +10 -4
- package/dist/services/vnext-specify.js +12 -5
- package/dist/services/work-registry.js +26 -8
- package/dist/services/workspace-bootstrap.js +10 -1
- package/dist/storage/database.js +160 -13
- package/dist/storage/writer-contract.js +76 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
4
|
+
import { runtimeHarness } from "./execution-policy.js";
|
|
5
|
+
import { appendEvent, digest, errorRecord } from "./run-controller-state.js";
|
|
6
|
+
export function sessionArguments(context, manifest, session) {
|
|
7
|
+
const profile = session.profile;
|
|
8
|
+
return ["--dd-flow-bin", manifest.flow_executable, "--dd-flow-home", context.ddFlowHome, "--project-root", manifest.request.project_root, "--state-dir", session.state_dir, "--journal", session.journal, "--cwd", session.cwd, "--provider", profile.provider, "--model", profile.model, "--reasoning", profile.reasoning, "--mode", profile.mode, "--permission", profile.permission, ...manifest.adapters[runtimeHarness(profile)].runtime_args, "--json"];
|
|
9
|
+
}
|
|
10
|
+
export function controllerEnvironment(context, manifest, session) {
|
|
11
|
+
const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined };
|
|
12
|
+
for (const key of ["DD_FLOW_CONTROLLER_TOKEN", "DD_FLOW_CONTROL_TOKEN", "DD_FLOW_DAEMON_ID", "DD_FLOW_RUNTIME_OWNER", "DD_FLOW_WORK_ID", "DD_FLOW_SESSION_ID", "CODEX_THREAD_ID"])
|
|
13
|
+
delete env[key];
|
|
14
|
+
if (session?.codex_home)
|
|
15
|
+
env.CODEX_HOME = session.codex_home;
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
18
|
+
export async function controllerAdapter(context, row, manifest, session, args, assertOwner, retainedOperationId) {
|
|
19
|
+
assertOwner?.();
|
|
20
|
+
const operationId = retainedOperationId ?? `${row.controller_id}:${crypto.randomUUID()}`;
|
|
21
|
+
const executable = manifest.adapters[runtimeHarness(session.profile)].executable;
|
|
22
|
+
const input = { executable, args, owner: session.owner.owner_id, state_dir: session.state_dir };
|
|
23
|
+
const now = context.now();
|
|
24
|
+
context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'dispatching', ?, ?)", [operationId, row.controller_id, row.generation, digest(input), JSON.stringify(input), now, now]);
|
|
25
|
+
appendEvent(context, row.controller_id, "operation_requested", { operation_id: operationId, operation: args.slice(0, 2), session_id: session.id || null });
|
|
26
|
+
try {
|
|
27
|
+
assertOwner?.();
|
|
28
|
+
const receipt = await runHarnessAdapter({ executable, args, env: controllerEnvironment(context, manifest, session), operationId, runtimeOwner: session.owner, timeoutMs: args[0] === "session" && args[1] === "prompt" ? 45 * 60_000 : 120_000 });
|
|
29
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
|
|
30
|
+
const target = args[args.indexOf("--session-id") + 1];
|
|
31
|
+
if (args[0] === "session" && args.includes("--session-id") && adapterSessionId(receipt) !== target)
|
|
32
|
+
throw new AppError("session_identity_mismatch", "Managed adapter reply belongs to another or unidentified Session", 1);
|
|
33
|
+
if (receipt.harness !== undefined && receipt.harness !== runtimeHarness(session.profile))
|
|
34
|
+
throw new AppError("profile_integrity_violation", "Managed adapter returned another harness identity", 1);
|
|
35
|
+
context.db.run("UPDATE run_controller_operations SET status = 'completed', updated_at = ? WHERE operation_id = ?", [context.now(), operationId]);
|
|
36
|
+
assertOwner?.();
|
|
37
|
+
appendEvent(context, row.controller_id, "operation_completed", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
|
|
38
|
+
return receipt;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
context.db.run("UPDATE run_controller_operations SET status = CASE WHEN status = 'dispatching' THEN 'outcome_unknown' ELSE status END, error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { createEvalRunSnapshot, verifyControllerBoundarySnapshot } from "./eval-snapshots.js";
|
|
6
|
+
import { observeControllerBoundary } from "./run-control.js";
|
|
7
|
+
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
8
|
+
import { appendEvent, digest, errorRecord, requireController, requireRun } from "./run-controller-state.js";
|
|
9
|
+
import { getFlowRunStatus } from "./runs.js";
|
|
10
|
+
/** Capture is performed by the productive owner, before it releases the next
|
|
11
|
+
* Stage. An observer never races the controller to copy a completed boundary. */
|
|
12
|
+
export async function captureControllerBoundary(context, row, manifest, stage, nextStage, assertPhysicalOwner) {
|
|
13
|
+
const root = manifest.request.capture_root;
|
|
14
|
+
if (!root)
|
|
15
|
+
return;
|
|
16
|
+
const identity = (value) => ({ run_id: row.run_id, stage: value.stage, attempt: value.attempt ?? null, started_at: value.started_at ?? null, completed_at: value.completed_at ?? null });
|
|
17
|
+
const boundaryKey = digest(identity(stage));
|
|
18
|
+
const operationId = `${row.controller_id}:boundary:${boundaryKey}`;
|
|
19
|
+
const output = path.join(root, `${stage.stage}-${boundaryKey}`);
|
|
20
|
+
const scope = { projectRoot: manifest.request.project_root, runId: row.run_id };
|
|
21
|
+
const expected = { controllerId: row.controller_id, operationId, boundaryKey, runId: row.run_id, stageEntry: nextStage };
|
|
22
|
+
const prior = context.db.get("SELECT status,receipt_json FROM run_controller_operations WHERE operation_id = ?", [operationId]);
|
|
23
|
+
if (prior?.status === "completed") {
|
|
24
|
+
try {
|
|
25
|
+
const receipt = JSON.parse(prior.receipt_json ?? "{}");
|
|
26
|
+
if (typeof receipt.manifest_sha256 !== "string")
|
|
27
|
+
throw new AppError("controller_boundary_snapshot_invalid", "Completed capture has no immutable manifest receipt", 1);
|
|
28
|
+
verifyControllerBoundarySnapshot(output, { ...expected, manifestSha256: receipt.manifest_sha256 });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
assertPhysicalOwner();
|
|
33
|
+
context.db.run("UPDATE run_controllers SET status = 'capture_failed', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
38
|
+
try {
|
|
39
|
+
assertPhysicalOwner();
|
|
40
|
+
assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation);
|
|
41
|
+
const now = context.now();
|
|
42
|
+
context.db.run("UPDATE run_controllers SET status = 'capturing', updated_at = ? WHERE controller_id = ? AND owner_token = ?", [now, row.controller_id, row.owner_token]);
|
|
43
|
+
context.db.run("INSERT INTO run_controller_operations (operation_id,controller_id,generation,input_hash,input_json,status,created_at,updated_at) VALUES (?,?,?,?,?,'capturing',?,?) ON CONFLICT(operation_id) DO UPDATE SET generation = excluded.generation, status = 'capturing', error_json = NULL, updated_at = excluded.updated_at", [operationId, row.controller_id, row.generation, boundaryKey, JSON.stringify({ action: "boundary_capture", ...identity(stage), stage_entry: nextStage, output }), now, now]);
|
|
44
|
+
appendEvent(context, row.controller_id, "boundary_capture_requested", { operation_id: operationId, boundary_key: boundaryKey, stage: stage.stage, stage_entry: nextStage });
|
|
45
|
+
context.db.exec("COMMIT");
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
context.db.exec("ROLLBACK");
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const assertCurrent = () => {
|
|
52
|
+
assertPhysicalOwner();
|
|
53
|
+
const guard = recoveryGuard(context, row.project_id, row.run_id);
|
|
54
|
+
if ((guard?.generation ?? 0) !== row.generation || ["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
|
|
55
|
+
throw new AppError("run_recovery_guarded", "Recovery or operator control superseded boundary capture", 1);
|
|
56
|
+
if (requireController(context, row.controller_id).status !== "capturing" || context.db.get("SELECT status FROM run_controller_operations WHERE operation_id = ?", [operationId])?.status !== "capturing")
|
|
57
|
+
throw new AppError("controller_capture_owner_lost", "Controller no longer owns boundary capture", 1);
|
|
58
|
+
const index = JSON.parse(requireRun(context, row.project_id, row.run_id).index_json);
|
|
59
|
+
const current = index.stage_runs.find(item => item.stage === stage.stage);
|
|
60
|
+
if (!current || !["done", "skipped"].includes(current.status) || digest(identity(current)) !== boundaryKey)
|
|
61
|
+
throw new AppError("controller_boundary_changed", "Stage identity changed after its capture fence", 1);
|
|
62
|
+
const continuation = getFlowRunStatus(context, scope).continuation;
|
|
63
|
+
if (nextStage ? continuation.kind !== "start_stage" || continuation.stage !== nextStage : continuation.kind !== "terminal")
|
|
64
|
+
throw new AppError("controller_boundary_changed", "Stage successor changed after its capture fence", 1);
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
let receipt;
|
|
68
|
+
if (fs.existsSync(output)) {
|
|
69
|
+
assertCurrent();
|
|
70
|
+
receipt = verifyControllerBoundarySnapshot(output, expected);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
let lastPending = "";
|
|
74
|
+
for (;;) {
|
|
75
|
+
assertCurrent();
|
|
76
|
+
let verify = () => { throw new AppError("controller_boundary_unsettled", "Boundary has no stable writer observation", 1); };
|
|
77
|
+
const observation = await observeControllerBoundary(context, { ...scope, controllerId: row.controller_id, operationId, assertCurrent, retainVerification: value => { verify = value; } });
|
|
78
|
+
if (observation.settled === true) {
|
|
79
|
+
try {
|
|
80
|
+
createEvalRunSnapshot(context, { ...scope, output, ...(nextStage ? { stageEntry: nextStage } : { candidate: true }), boundary: { controllerId: row.controller_id, operationId, boundaryKey, observation, verify } });
|
|
81
|
+
receipt = verifyControllerBoundarySnapshot(output, expected);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!(error instanceof AppError) || !["snapshot_source_changed", "owned_inventory_changed"].includes(error.code))
|
|
86
|
+
throw error;
|
|
87
|
+
observation.settled = false;
|
|
88
|
+
observation.pending_reasons = [error.code];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const pending = digest(observation.pending_reasons);
|
|
92
|
+
if (pending !== lastPending) {
|
|
93
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(observation), context.now(), operationId]);
|
|
94
|
+
appendEvent(context, row.controller_id, "boundary_capture_pending", { operation_id: operationId, pending_reasons: observation.pending_reasons });
|
|
95
|
+
lastPending = pending;
|
|
96
|
+
}
|
|
97
|
+
await delay(1000);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
101
|
+
try {
|
|
102
|
+
assertCurrent();
|
|
103
|
+
context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
|
|
104
|
+
appendEvent(context, row.controller_id, "boundary_captured", { ...identity(stage), boundary_key: boundaryKey, operation_id: operationId, ...receipt });
|
|
105
|
+
context.db.run("UPDATE run_controllers SET status = 'running', updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
|
|
106
|
+
context.db.exec("COMMIT");
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
context.db.exec("ROLLBACK");
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
context.db.run("UPDATE run_controller_operations SET status = 'failed', error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { getResourceDatabase } from "../storage/database.js";
|
|
8
|
+
import { finishManagedProcess, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processStartedAt, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
9
|
+
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
10
|
+
import { requireRun, requireController, controllerReceipt, appendEvent, digest, errorRecord } from "./run-controller-state.js";
|
|
11
|
+
const launchId = (row) => `${row.controller_id}:owner-launch:${digest(row.owner_token)}`;
|
|
12
|
+
const processOperation = (context, row) => `controller:${digest({ database: context.db.path, project: row.project_id, run: row.run_id })}`;
|
|
13
|
+
const birth = () => ({ pid: process.pid, pid_started_at: processStartedAt(process.pid) });
|
|
14
|
+
const dead = (value) => Boolean(value && Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.pid_started_at === "string" && value.pid_started_at && !processIsAlive(value));
|
|
15
|
+
/** Called inside the RUN intent transaction, before the client can disappear. */
|
|
16
|
+
export function recordControllerLauncher(context, row, retired) {
|
|
17
|
+
const request = { action: "controller_owner_launch", owner_id: launchId(row) };
|
|
18
|
+
const receipt = { ...birth(), registration: "controller_self@1", ...(retired ? { retired_process: { id: retired.id, lease_token: retired.lease_token, pid: retired.pid, pid_started_at: retired.pid_started_at } } : {}) };
|
|
19
|
+
const now = context.now();
|
|
20
|
+
context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'completed', ?, ?, ?)", [launchId(row), row.controller_id, row.generation, digest(request), JSON.stringify(request), JSON.stringify(receipt), now, now]);
|
|
21
|
+
}
|
|
22
|
+
function launchRecord(context, row) {
|
|
23
|
+
const record = context.db.get("SELECT receipt_json FROM run_controller_operations WHERE operation_id = ?", [launchId(row)]);
|
|
24
|
+
if (!record)
|
|
25
|
+
return null;
|
|
26
|
+
const value = JSON.parse(record.receipt_json);
|
|
27
|
+
return value?.registration === "controller_self@1" ? { ...record, value } : null;
|
|
28
|
+
}
|
|
29
|
+
function assertLaunchAllowed(context, row) {
|
|
30
|
+
const run = requireRun(context, row.project_id, row.run_id);
|
|
31
|
+
if (!["running", "paused", "waiting_for_user"].includes(run.status))
|
|
32
|
+
throw new AppError("invalid_run_state", "A terminal RUN cannot start a physical controller", 1);
|
|
33
|
+
const state = JSON.parse(row.state_json);
|
|
34
|
+
if (!state.recovery) {
|
|
35
|
+
assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const guard = recoveryGuard(context, row.project_id, row.run_id);
|
|
39
|
+
if (guard?.generation !== row.generation || guard.recovery_id !== state.recovery.recovery_id || !["sealed", "resuming"].includes(guard.status)
|
|
40
|
+
|| !context.db.get("SELECT 1 FROM run_controls WHERE control_id = ? AND recovery_id = ? AND status = 'resuming'", [state.recovery.control_id, state.recovery.recovery_id]))
|
|
41
|
+
throw new AppError("run_control_superseded", "Controller launch no longer owns the accepted recovery", 1);
|
|
42
|
+
}
|
|
43
|
+
function retire(context, row, identity) {
|
|
44
|
+
const record = managedProcessStatus(context).find(item => item.id === identity.id);
|
|
45
|
+
if (!record || record.kind !== "run-controller" || record.owner_id !== row.controller_id || record.project_id !== row.project_id || record.run_id !== row.run_id
|
|
46
|
+
|| managedProcessRuntimeHome(record, context) !== context.ddFlowHome || record.lease_token !== identity.lease_token || record.pid !== identity.pid || record.pid_started_at !== identity.pid_started_at || !dead(identity) || processTreeIsAlive(record)
|
|
47
|
+
|| !finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: "controller_launch_owner_retired" }))
|
|
48
|
+
throw new AppError("controller_owner_unsettled", "Prior controller identity is not proven dead and retired", 1);
|
|
49
|
+
}
|
|
50
|
+
/** Retry only before productive admission, with exact prior birth and journal CAS. */
|
|
51
|
+
function prepareSpawn(context, row) {
|
|
52
|
+
const launch = launchRecord(context, row);
|
|
53
|
+
if (!launch || launch.value.dispatch_ready || !["starting", "running", "recovery_required"].includes(row.status))
|
|
54
|
+
return null;
|
|
55
|
+
const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: JSON.parse(row.manifest_json).resource_home } };
|
|
56
|
+
const ours = launch.value.pid === process.pid && launch.value.pid_started_at === processStartedAt(process.pid);
|
|
57
|
+
let token = row.owner_token;
|
|
58
|
+
if (!ours) {
|
|
59
|
+
if (row.owner_pid) {
|
|
60
|
+
if (launch.value.owner?.pid !== row.owner_pid || !dead(launch.value.owner))
|
|
61
|
+
return null;
|
|
62
|
+
const records = managedProcessStatus(resourceContext).filter(item => item.operation_id === processOperation(context, row));
|
|
63
|
+
const owned = records.filter(item => { const metadata = JSON.parse(item.metadata_json); return metadata.owner_pid === row.owner_pid && metadata.owner_pid_started_at === launch.value.owner.pid_started_at; });
|
|
64
|
+
if (owned.length > 1 || row.process_id && owned[0]?.id !== row.process_id)
|
|
65
|
+
return null;
|
|
66
|
+
const record = owned[0];
|
|
67
|
+
if (records.some(item => item.id !== record?.id && (item.state !== "stopped" || !item.pid_started_at || processTreeIsAlive(item))))
|
|
68
|
+
return null;
|
|
69
|
+
if (record) {
|
|
70
|
+
if (record.kind !== "run-controller" || record.owner_id !== row.controller_id || record.project_id !== row.project_id || record.run_id !== row.run_id || managedProcessRuntimeHome(record, context) !== context.ddFlowHome || row.process_lease_token && record.lease_token !== row.process_lease_token || record.pid !== null && record.pid !== row.owner_pid || processTreeIsAlive(record))
|
|
71
|
+
return null;
|
|
72
|
+
if (record.pid === null) {
|
|
73
|
+
const db = getResourceDatabase(JSON.parse(row.manifest_json).resource_home);
|
|
74
|
+
if (db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ? WHERE id = ? AND lease_token = ? AND pid IS NULL", [row.owner_pid, launch.value.owner.pid_started_at, record.id, record.lease_token]).changes !== 1)
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
retire(resourceContext, row, { id: record.id, lease_token: record.lease_token, ...launch.value.owner });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
if (row.process_id || row.process_lease_token || !dead(launch.value))
|
|
82
|
+
return null;
|
|
83
|
+
if (launch.value.child && !dead(launch.value.child))
|
|
84
|
+
return null;
|
|
85
|
+
if (managedProcessStatus(resourceContext).some(item => item.operation_id === processOperation(context, row) && item.id !== launch.value.retired_process?.id && (item.state !== "stopped" || !item.pid_started_at || processTreeIsAlive(item))))
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
token = crypto.randomUUID();
|
|
89
|
+
}
|
|
90
|
+
else if (row.owner_pid || launch.value.spawn_started)
|
|
91
|
+
return null;
|
|
92
|
+
if (launch.value.retired_process)
|
|
93
|
+
retire(resourceContext, row, launch.value.retired_process);
|
|
94
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
95
|
+
try {
|
|
96
|
+
assertLaunchAllowed(context, row);
|
|
97
|
+
const changed = context.db.run(`UPDATE run_controllers SET owner_token = ?, owner_pid = NULL, process_id = NULL, process_lease_token = NULL, status = 'starting', last_error_json = NULL, updated_at = ?
|
|
98
|
+
WHERE controller_id = ? AND owner_token = ? AND owner_pid IS ? AND process_id IS ? AND status = ? AND state_json = ?
|
|
99
|
+
AND (SELECT receipt_json FROM run_controller_operations WHERE operation_id = ?) = ?`, [token, context.now(), row.controller_id, row.owner_token, row.owner_pid, row.process_id, row.status, row.state_json, launchId(row), launch.receipt_json]);
|
|
100
|
+
if (changed.changes !== 1) {
|
|
101
|
+
context.db.exec("ROLLBACK");
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const current = requireController(context, row.controller_id);
|
|
105
|
+
if (!ours) {
|
|
106
|
+
recordControllerLauncher(context, current);
|
|
107
|
+
appendEvent(context, row.controller_id, "launch_owner_reconciled", { prior_launch: launchId(row), generation: row.generation, prior_process_id: row.process_id });
|
|
108
|
+
}
|
|
109
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(receipt_json, '$.spawn_started', json('true')), updated_at = ? WHERE operation_id = ?", [context.now(), launchId(current)]);
|
|
110
|
+
context.db.exec("COMMIT");
|
|
111
|
+
return current;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
context.db.exec("ROLLBACK");
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** The physical child records its own birth before resource registration. */
|
|
119
|
+
export function claimControllerProcess(context, row) {
|
|
120
|
+
const launch = launchRecord(context, row);
|
|
121
|
+
if (!launch && (!row.process_id || !row.process_lease_token))
|
|
122
|
+
throw new AppError("controller_owner_mismatch", "Managed registration is not authorized", 1);
|
|
123
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
124
|
+
try {
|
|
125
|
+
const claimed = context.db.run("UPDATE run_controllers SET owner_pid = ?, status = 'running', updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [process.pid, context.now(), row.controller_id, row.owner_token]);
|
|
126
|
+
if (claimed.changes !== 1)
|
|
127
|
+
throw new AppError("controller_already_owned", "Managed owner has already claimed this launch", 1);
|
|
128
|
+
if (launch)
|
|
129
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(receipt_json, '$.owner', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify(birth()), context.now(), launchId(row)]);
|
|
130
|
+
context.db.exec("COMMIT");
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
context.db.exec("ROLLBACK");
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (!row.process_id) {
|
|
137
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
138
|
+
const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
|
|
139
|
+
const log = path.join(row.state_dir, "controller.log");
|
|
140
|
+
const record = registerManagedProcess(resourceContext, { kind: "run-controller", ownerId: row.controller_id, projectId: row.project_id, runId: row.run_id, operationId: processOperation(context, row), uniqueActiveOperation: true, ownerPid: process.pid, stdoutPath: log, stderrPath: log, metadata: { controller_id: row.controller_id, state_dir: row.state_dir, role: "controller", budget: manifest.budget } });
|
|
141
|
+
row.process_id = record.id;
|
|
142
|
+
row.process_lease_token = record.lease_token;
|
|
143
|
+
if (context.db.run("UPDATE run_controllers SET process_id = ?, process_lease_token = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND owner_pid = ? AND status = 'running'", [record.id, record.lease_token, context.now(), row.controller_id, row.owner_token, process.pid]).changes !== 1)
|
|
144
|
+
throw new AppError("controller_owner_mismatch", "Managed owner changed during registration", 1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function markControllerDispatchReady(context, row) {
|
|
148
|
+
context.db.run("UPDATE run_controller_operations SET receipt_json = json_set(receipt_json, '$.dispatch_ready', json('true')), updated_at = ? WHERE operation_id = ?", [context.now(), launchId(row)]);
|
|
149
|
+
}
|
|
150
|
+
export async function spawnController(context, row) {
|
|
151
|
+
const admitted = prepareSpawn(context, row);
|
|
152
|
+
if (!admitted)
|
|
153
|
+
return controllerReceipt(requireController(context, row.controller_id));
|
|
154
|
+
row = admitted;
|
|
155
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
156
|
+
const run = requireRun(context, row.project_id, row.run_id);
|
|
157
|
+
const controllerId = row.controller_id, root = row.state_dir, token = row.owner_token;
|
|
158
|
+
const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
159
|
+
try {
|
|
160
|
+
if (!fs.existsSync(entry))
|
|
161
|
+
throw new AppError("harness_runtime_unbuilt", "Build the CLI before launching its detached controller", 1);
|
|
162
|
+
const output = fs.openSync(path.join(root, "controller.log"), "a", 0o600);
|
|
163
|
+
try {
|
|
164
|
+
const child = spawn(process.execPath, [entry, "run", "drive", "serve", "--run", run.id, "--project-root", run.project_root, "--controller-id", controllerId, "--json"], { cwd: run.workspace_root, detached: true, stdio: ["ignore", output, output], env: { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_CONTROLLER_TOKEN: token } });
|
|
165
|
+
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
|
166
|
+
context.db.run("UPDATE run_controller_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(), launchId(row)]);
|
|
167
|
+
child.unref();
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
fs.closeSync(output);
|
|
171
|
+
}
|
|
172
|
+
return controllerReceipt(requireController(context, controllerId));
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
context.db.run("UPDATE run_controllers SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(errorRecord(error)), context.now(), controllerId, token]);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
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 { runControlStatus } from "./run-control.js";
|
|
19
|
+
import { runtimeBudgetStatus, waitForRuntimeScopeRecovery } from "./runtime-budget.js";
|
|
20
|
+
/** CLI observation is bounded; the physical controller owns the recovery work. */
|
|
21
|
+
export async function resumeRunController(context, input) {
|
|
22
|
+
if (input.waitMs !== undefined && (!Number.isSafeInteger(input.waitMs) || input.waitMs < 0 || input.waitMs > 60_000))
|
|
23
|
+
throw new AppError("usage", "--wait-ms must be between 0 and 60000", 2);
|
|
24
|
+
const intent = recordControllerResume(context, input);
|
|
25
|
+
const manifest = JSON.parse(intent.controller.manifest_json);
|
|
26
|
+
const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
|
|
27
|
+
await spawnController(resourceContext, intent.controller);
|
|
28
|
+
const deadline = performance.now() + (input.waitMs ?? 0);
|
|
29
|
+
const status = () => context.db.get("SELECT status FROM run_controller_operations WHERE operation_id = ?", [intent.operationId]).status;
|
|
30
|
+
while (status() === "accepted" && performance.now() < deadline)
|
|
31
|
+
await delay(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
32
|
+
const observation = runControlStatus(context, { ...input, action: "resume" });
|
|
33
|
+
return { ...observation, controller: controllerReceipt(requireController(context, intent.controller.controller_id)).controller, operation_id: intent.operationId, status: observation.resume_operation.status, ...(intent.retiredProcess ? {} : { reused: true }) };
|
|
34
|
+
}
|
|
35
|
+
/** Retain the logical controller and its receipts; replace only its dead owner. */
|
|
36
|
+
export function recordControllerResume(context, input) {
|
|
37
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !input.controlId.trim())
|
|
38
|
+
throw new AppError("usage", "Resume requires request-id and the exact source control", 2);
|
|
39
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
40
|
+
const run = requireRun(context, project.id, input.runId);
|
|
41
|
+
if (!context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'"))
|
|
42
|
+
throw new AppError("controller_attach_required", "RUN has no retained managed controller", 1);
|
|
43
|
+
const request = { action: "control_resume", request_id: input.requestId, control_id: input.controlId };
|
|
44
|
+
const hash = digest(request);
|
|
45
|
+
const findPrior = () => {
|
|
46
|
+
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]);
|
|
47
|
+
if (!prior)
|
|
48
|
+
return null;
|
|
49
|
+
if (prior.input_hash !== hash)
|
|
50
|
+
throw new AppError("runtime_request_conflict", "Resume request id has different inputs", 1);
|
|
51
|
+
return { controller: requireController(context, prior.controller_id), operationId: prior.operation_id };
|
|
52
|
+
};
|
|
53
|
+
const prior = findPrior();
|
|
54
|
+
if (prior)
|
|
55
|
+
return prior;
|
|
56
|
+
try {
|
|
57
|
+
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]);
|
|
58
|
+
if (!row)
|
|
59
|
+
throw new AppError("controller_attach_required", "RUN has no retained resumable controller", 1);
|
|
60
|
+
const operationId = `${row.controller_id}:resume:${input.requestId}`;
|
|
61
|
+
if (!["running", "paused", "waiting_for_user"].includes(run.status))
|
|
62
|
+
throw new AppError("invalid_run_state", "Resume cannot resurrect a terminal RUN", 1, { status: run.status });
|
|
63
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
64
|
+
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]);
|
|
65
|
+
if (!guard || guard.recovery_id !== control?.recovery_id || guard.status !== "sealed" || control.status !== "settled" || !guard.capture_path || JSON.parse(guard.settlement_json ?? "{}").settled !== true)
|
|
66
|
+
throw new AppError("run_control_not_resumable", "Resume requires the current settled control and its immutable recovery capture", 1);
|
|
67
|
+
const manifest = JSON.parse(row.manifest_json), state = JSON.parse(row.state_json);
|
|
68
|
+
if (!manifest.execution_profile?.settings.execution || !manifest.execution_profile.agent_profiles || !path.isAbsolute(manifest.resource_home) || !path.isAbsolute(manifest.flow_executable))
|
|
69
|
+
throw new AppError("controller_manifest_invalid", "Resume requires the original frozen controller manifest", 1);
|
|
70
|
+
if (state.current_session === null || !state.sessions[state.current_session]?.id)
|
|
71
|
+
throw new AppError("recovery_owner_missing", "Resume requires the retained coordinator Session", 1);
|
|
72
|
+
for (const [stage, source] of Object.entries(manifest.contexts))
|
|
73
|
+
loadExternalStageContext({ file: source.file, expectedSha256: source.sha256, stage });
|
|
74
|
+
verifyRecoverySnapshot(guard.capture_path, { projectRoot: project.root, runId: run.id, recoveryId: guard.recovery_id, workspaceRoot: run.workspace_root });
|
|
75
|
+
const resourceContext = { ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
|
|
76
|
+
const previousOwner = managedProcessStatus(resourceContext).find(record => record.id === row.process_id);
|
|
77
|
+
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))
|
|
78
|
+
throw new AppError("controller_owner_unsettled", "Resume requires proof that the previous physical controller has exited", 1);
|
|
79
|
+
const token = crypto.randomUUID(), now = context.now();
|
|
80
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
81
|
+
try {
|
|
82
|
+
const duplicate = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
|
|
83
|
+
if (duplicate) {
|
|
84
|
+
if (duplicate.input_hash !== hash)
|
|
85
|
+
throw new AppError("runtime_request_conflict", "Resume request id has different inputs", 1);
|
|
86
|
+
context.db.exec("COMMIT");
|
|
87
|
+
return { controller: requireController(context, row.controller_id), operationId };
|
|
88
|
+
}
|
|
89
|
+
const current = recoveryGuard(context, project.id, run.id);
|
|
90
|
+
if (current?.recovery_id !== guard.recovery_id || current.generation !== guard.generation || current.status !== "sealed" || current.capture_path !== guard.capture_path)
|
|
91
|
+
throw new AppError("run_control_superseded", "Control changed before resume could be accepted", 1);
|
|
92
|
+
if (!["running", "paused", "waiting_for_user"].includes(requireRun(context, project.id, run.id).status))
|
|
93
|
+
throw new AppError("invalid_run_state", "RUN became terminal before resume could be accepted", 1);
|
|
94
|
+
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]);
|
|
95
|
+
if (changed.changes !== 1)
|
|
96
|
+
throw new AppError("run_control_superseded", "Control changed before resume could be accepted", 1);
|
|
97
|
+
state.recovery = { control_id: input.controlId, recovery_id: guard.recovery_id, operation_id: operationId };
|
|
98
|
+
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]);
|
|
99
|
+
if (claimed.changes !== 1)
|
|
100
|
+
throw new AppError("controller_owner_mismatch", "Controller changed before resume could claim its next physical owner", 1);
|
|
101
|
+
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]);
|
|
102
|
+
recordControllerLauncher(context, requireController(context, row.controller_id), previousOwner);
|
|
103
|
+
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 });
|
|
104
|
+
context.db.exec("COMMIT");
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
context.db.exec("ROLLBACK");
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return { controller: requireController(context, row.controller_id), operationId, retiredProcess: previousOwner };
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// A concurrent identical client may commit between our first lookup and
|
|
114
|
+
// validation. Its durable request wins even if our preflight saw the fence.
|
|
115
|
+
const concurrent = findPrior();
|
|
116
|
+
if (concurrent)
|
|
117
|
+
return concurrent;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function resumeControllerSession(context, row, manifest, state, assertPhysicalOwner) {
|
|
122
|
+
const recovery = state.recovery;
|
|
123
|
+
const session = state.current_session === null ? undefined : state.sessions[state.current_session];
|
|
124
|
+
if (!session?.id)
|
|
125
|
+
throw new AppError("recovery_owner_missing", "Recovery lost its retained coordinator Session", 1);
|
|
126
|
+
const executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
|
|
127
|
+
const scopeControl = manifest.budget ? runtimeBudgetStatus(executionContext, manifest.budget.scope_id).control : null;
|
|
128
|
+
const assertRecoveryOwner = () => {
|
|
129
|
+
assertPhysicalOwner();
|
|
130
|
+
const guard = recoveryGuard(context, row.project_id, row.run_id);
|
|
131
|
+
if (guard?.generation !== row.generation || guard.recovery_id !== recovery.recovery_id)
|
|
132
|
+
throw new AppError("run_recovery_generation_stale", "A newer control superseded this recovery owner", 1);
|
|
133
|
+
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]))
|
|
134
|
+
throw new AppError("run_recovery_guarded", "Recovery no longer owns this operator intent", 1);
|
|
135
|
+
};
|
|
136
|
+
assertRecoveryOwner();
|
|
137
|
+
const readDaemon = () => readNativeDaemonState(session.state_dir);
|
|
138
|
+
const { state: retired, process: record } = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id, ownerId: session.owner.owner_id });
|
|
139
|
+
const call = (args, operationId) => controllerAdapter(executionContext, row, manifest, session, args, assertRecoveryOwner, operationId);
|
|
140
|
+
if (processTreeIsAlive(record)) {
|
|
141
|
+
const stopped = await call(["daemon", "stop", "--state-dir", session.state_dir, "--json"]);
|
|
142
|
+
if (stopped.stopped !== true || stopped.clean !== true)
|
|
143
|
+
throw new AppError("recovery_native_shutdown_unproven", "Recovery requires a clean native close before replacing its daemon", 1);
|
|
144
|
+
const deadline = Date.now() + 30_000;
|
|
145
|
+
while (processTreeIsAlive(record) && Date.now() < deadline) {
|
|
146
|
+
await delay(100);
|
|
147
|
+
assertRecoveryOwner();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (processTreeIsAlive(record) || readDaemon().shutdown_state !== "clean")
|
|
151
|
+
throw new AppError("recovery_native_shutdown_unproven", "The previous native daemon is not proven clean and closed", 1);
|
|
152
|
+
assertRecoveryOwner();
|
|
153
|
+
session.stopped = false;
|
|
154
|
+
saveState(context, row, state);
|
|
155
|
+
const started = await call(["daemon", "start", "--session-id", session.id, ...sessionArguments(context, manifest, session)]);
|
|
156
|
+
const daemon = readDaemon(), daemonId = daemon.daemon_id ?? daemon.config?.daemonId;
|
|
157
|
+
if (!daemonId || daemonId === (retired.daemon_id ?? retired.config?.daemonId) || started.daemon_id !== daemonId || started.already_running === true)
|
|
158
|
+
throw new AppError("recovery_daemon_stale", "Recovery requires a fresh daemon, not reuse of the retired physical owner", 1);
|
|
159
|
+
assertRecoveryOwner();
|
|
160
|
+
const scope = { projectRoot: manifest.request.project_root, runId: row.run_id, recoveryId: recovery.recovery_id };
|
|
161
|
+
resumeRunRecovery(executionContext, { ...scope, daemonId, harness: runtimeHarness(session.profile), nativeSessionId: session.id });
|
|
162
|
+
const dispatch = prepareRunRecoveryDispatch(executionContext, { ...scope, stateDir: session.state_dir, operationId: `${recovery.operation_id}:ack` });
|
|
163
|
+
const inspected = await call(["session", "resume", "--session-id", session.id, ...sessionArguments(context, manifest, session)], dispatch.resume_operation_id);
|
|
164
|
+
if (inspected.settled !== true)
|
|
165
|
+
throw new AppError("recovery_session_unsettled", "Retained native Session is not ready for same-Session recovery", 1);
|
|
166
|
+
assertRecoveryOwner();
|
|
167
|
+
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 };
|
|
168
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
169
|
+
try {
|
|
170
|
+
assertRecoveryOwner();
|
|
171
|
+
const binding = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id, ownerId: session.owner.owner_id });
|
|
172
|
+
if (binding.process.id !== dispatch.process_id || binding.state.daemon_id !== daemonId || binding.process.state !== "running" || !processIsAlive(binding.process))
|
|
173
|
+
throw new AppError("recovery_runtime_mismatch", "Prepared recovery lost its physical Session owner", 1);
|
|
174
|
+
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]);
|
|
175
|
+
if (recorded.changes !== 1)
|
|
176
|
+
throw new AppError("run_recovery_generation_stale", "Recovery preparation no longer owns its operation", 1);
|
|
177
|
+
appendEvent(context, row.controller_id, "recovery_prepared", prepared);
|
|
178
|
+
context.db.exec("COMMIT");
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
context.db.exec("ROLLBACK");
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (scopeControl && manifest.budget) {
|
|
185
|
+
await waitForRuntimeScopeRecovery(executionContext, { scopeId: manifest.budget.scope_id, generation: scopeControl.generation, controllerId: row.controller_id, operationId: recovery.operation_id }, assertRecoveryOwner);
|
|
186
|
+
}
|
|
187
|
+
const file = path.join(row.state_dir, `turn-${String(state.turns + 1).padStart(5, "0")}.md`);
|
|
188
|
+
fs.writeFileSync(file, dispatch.prompt, { flag: "wx", mode: 0o600 });
|
|
189
|
+
state.turns += 1;
|
|
190
|
+
saveState(context, row, state);
|
|
191
|
+
const receipt = await call(["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], dispatch.operation_id);
|
|
192
|
+
assertRecoveryOwner();
|
|
193
|
+
if (recoveryGuard(context, row.project_id, row.run_id)?.status !== "recovered")
|
|
194
|
+
throw new AppError("trusted_recovery_acceptance_required", "Recovery Turn ended without its native acknowledgement", 1);
|
|
195
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
196
|
+
try {
|
|
197
|
+
assertRecoveryOwner();
|
|
198
|
+
context.db.run("UPDATE run_controls SET status = 'resumed', updated_at = ? WHERE control_id = ? AND status = 'resuming'", [context.now(), recovery.control_id]);
|
|
199
|
+
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]);
|
|
200
|
+
state.last_receipt = receipt;
|
|
201
|
+
state.last_continuation = null;
|
|
202
|
+
delete state.recovery;
|
|
203
|
+
saveState(context, row, state);
|
|
204
|
+
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 });
|
|
205
|
+
context.db.exec("COMMIT");
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
context.db.exec("ROLLBACK");
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export function failControllerRecovery(context, row, state, error) {
|
|
213
|
+
if (state.recovery) {
|
|
214
|
+
// Native ACK may already have opened this generation before its Turn lost
|
|
215
|
+
// observation. Retain the accepted evidence but close further dispatch.
|
|
216
|
+
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]);
|
|
217
|
+
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]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -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, 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
|
+
}
|