@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
|
@@ -3,9 +3,57 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
5
|
import { harnessKey } from "./harness-config.js";
|
|
6
|
+
import { vnextStages } from "../domain/stage-catalog.js";
|
|
7
|
+
/** Read once so the frozen policy and its provenance describe the same bytes. */
|
|
8
|
+
export function loadExecutionRoutingInput(file) {
|
|
9
|
+
const source_path = path.resolve(file);
|
|
10
|
+
let bytes;
|
|
11
|
+
let value;
|
|
12
|
+
try {
|
|
13
|
+
bytes = fs.readFileSync(source_path);
|
|
14
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new AppError("execution_profile_invalid", "Execution routing input must be a readable JSON file", 2, { source_path });
|
|
18
|
+
}
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
20
|
+
throw new AppError("execution_profile_invalid", "Execution routing input must be an object", 2, { source_path });
|
|
21
|
+
}
|
|
22
|
+
const input = value;
|
|
23
|
+
if (input.schema_id !== "dd-flow/execution-routing@1"
|
|
24
|
+
|| Object.keys(input).some(key => key !== "schema_id" && key !== "execution")
|
|
25
|
+
|| !isExecutionRouting(input.execution)) {
|
|
26
|
+
throw new AppError("execution_profile_invalid", "Execution routing input does not match dd-flow/execution-routing@1", 2, { source_path });
|
|
27
|
+
}
|
|
28
|
+
return { execution: input.execution, bytes, source_path, source_checksum: crypto.createHash("sha256").update(bytes).digest("hex") };
|
|
29
|
+
}
|
|
30
|
+
/** One routing validator for project defaults and explicit RUN inputs. */
|
|
31
|
+
export function isExecutionRouting(value) {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
33
|
+
return false;
|
|
34
|
+
const { stage_overrides: stages, ...root } = value;
|
|
35
|
+
if (typeof root.agent_profile_id !== "string")
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
validateExecutionOverride(root);
|
|
39
|
+
if (stages !== undefined) {
|
|
40
|
+
if (!stages || typeof stages !== "object" || Array.isArray(stages))
|
|
41
|
+
return false;
|
|
42
|
+
for (const [stage, override] of Object.entries(stages)) {
|
|
43
|
+
if (!vnextStages.some(item => item.id === stage))
|
|
44
|
+
return false;
|
|
45
|
+
validateExecutionOverride(override);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
6
54
|
/** Reads one complete profile. A policy selects profiles; it never field-merges them. */
|
|
7
55
|
export function loadAgentProfile(ddFlowHome, id) {
|
|
8
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id))
|
|
56
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id))
|
|
9
57
|
throw new AppError("agent_profile_invalid", "Agent profile id is invalid", 2, { id });
|
|
10
58
|
const file = path.join(ddFlowHome, "agent-profiles", `${id}.json`);
|
|
11
59
|
let value;
|
|
@@ -19,26 +67,57 @@ export function loadAgentProfile(ddFlowHome, id) {
|
|
|
19
67
|
throw new AppError("agent_profile_invalid", "Agent profile must be an object", 2, { id, file });
|
|
20
68
|
const profile = value;
|
|
21
69
|
if (profile.schema_id !== "dd-flow/agent-profile@1" || profile.id !== id
|
|
70
|
+
|| Object.keys(profile).some(key => !["schema_id", "id", "harness", "provider", "model", "reasoning", "mode", "permission"].includes(key))
|
|
22
71
|
|| !["codex", "zcode", "agy", "opencode", "grok", "droid"].includes(String(profile.harness))
|
|
23
72
|
|| !nonempty(profile.provider) || !nonempty(profile.model) || !nonempty(profile.reasoning) || !nonempty(profile.mode)
|
|
24
73
|
|| (profile.permission !== "allow" && profile.permission !== "deny")) {
|
|
25
74
|
throw new AppError("agent_profile_invalid", "Agent profile does not match dd-flow/agent-profile@1", 2, { id, file });
|
|
26
75
|
}
|
|
27
|
-
|
|
76
|
+
// Only execution settings enter RUN evidence; never copy arbitrary profile fields.
|
|
77
|
+
return { schema_id: profile.schema_id, id, harness: profile.harness, provider: profile.provider, model: profile.model, reasoning: profile.reasoning, mode: profile.mode, permission: profile.permission };
|
|
78
|
+
}
|
|
79
|
+
export function snapshotAgentProfiles(ddFlowHome, policy) {
|
|
80
|
+
if (!isExecutionRouting(policy))
|
|
81
|
+
throw new AppError("execution_profile_invalid", "Execution routing contains an invalid stage, profile or delegation policy", 2);
|
|
82
|
+
const profiles = Object.create(null);
|
|
83
|
+
for (const override of [policy, ...Object.values(policy.stage_overrides ?? {})]) {
|
|
84
|
+
for (const id of [override.agent_profile_id, override.delegation?.agent_profile_id]) {
|
|
85
|
+
if (id !== undefined && !Object.hasOwn(profiles, id))
|
|
86
|
+
profiles[id] = loadAgentProfile(ddFlowHome, id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Validate every declared route now, including stages not reached by this run.
|
|
90
|
+
for (const stage of [undefined, ...Object.keys(policy.stage_overrides ?? {})]) {
|
|
91
|
+
resolveExecutionPolicy({ ddFlowHome, policy, profiles, ...(stage ? { stage } : {}) });
|
|
92
|
+
}
|
|
93
|
+
return profiles;
|
|
28
94
|
}
|
|
29
95
|
/** Resolves a frozen policy with explicit topology. Same profiles do not turn external work into native work. */
|
|
30
96
|
export function resolveExecutionPolicy(input) {
|
|
97
|
+
if (input.work !== undefined)
|
|
98
|
+
validateExecutionOverride(input.work);
|
|
99
|
+
const profile = (id) => {
|
|
100
|
+
if (!input.profiles)
|
|
101
|
+
return loadAgentProfile(input.ddFlowHome, id);
|
|
102
|
+
if (!Object.hasOwn(input.profiles, id))
|
|
103
|
+
throw new AppError("execution_profile_not_frozen", "Profile is not in this RUN's frozen execution snapshot", 1, { id });
|
|
104
|
+
return input.profiles[id];
|
|
105
|
+
};
|
|
31
106
|
const stage = input.stage ? input.policy.stage_overrides?.[input.stage] : undefined;
|
|
32
|
-
const coordinatorId =
|
|
107
|
+
const coordinatorId = stage?.agent_profile_id ?? input.policy.agent_profile_id;
|
|
33
108
|
if (!coordinatorId)
|
|
34
109
|
throw new AppError("execution_profile_missing", "Execution policy needs agent_profile_id before external dispatch", 1, { stage: input.stage ?? null });
|
|
35
|
-
const coordinator =
|
|
110
|
+
const coordinator = profile(coordinatorId);
|
|
36
111
|
const delegation = input.work?.delegation ?? stage?.delegation ?? input.policy.delegation ?? { mode: "native" };
|
|
37
112
|
validateDelegation(delegation);
|
|
38
|
-
|
|
113
|
+
if (input.work?.agent_profile_id && input.work.delegation?.agent_profile_id && input.work.agent_profile_id !== input.work.delegation.agent_profile_id)
|
|
114
|
+
throw new AppError("execution_profile_invalid", "Work selects conflicting worker profiles", 2);
|
|
115
|
+
const worker = profile(input.work?.agent_profile_id ?? delegation.agent_profile_id ?? coordinator.id);
|
|
39
116
|
if (delegation.mode === "native" && harnessKey(worker.harness) !== harnessKey(coordinator.harness)) {
|
|
40
117
|
throw new AppError("native_harness_mismatch", "Native delegation cannot change harness", 2, { coordinator_harness: coordinator.harness, worker_harness: worker.harness });
|
|
41
118
|
}
|
|
119
|
+
if (delegation.mode === "native" && !sameSessionProfile(coordinator, worker))
|
|
120
|
+
throw new AppError("native_profile_override_unqualified", "Native worker profile overrides require qualified adapter support; select external delegation", 2, { coordinator_profile_id: coordinator.id, worker_profile_id: worker.id });
|
|
42
121
|
const resolved = {
|
|
43
122
|
coordinator,
|
|
44
123
|
worker,
|
|
@@ -47,15 +126,52 @@ export function resolveExecutionPolicy(input) {
|
|
|
47
126
|
return { ...resolved, profile_hash: digest(resolved) };
|
|
48
127
|
}
|
|
49
128
|
export function runtimeHarness(profile) { return harnessKey(profile.harness); }
|
|
129
|
+
/** Profile labels are not execution identity; every session-affecting value is. */
|
|
130
|
+
export function sameSessionProfile(left, right) {
|
|
131
|
+
const settings = (profile) => ({ harness: runtimeHarness(profile), provider: profile.provider, model: profile.model, reasoning: profile.reasoning, mode: profile.mode, permission: profile.permission });
|
|
132
|
+
return digest(settings(left)) === digest(settings(right));
|
|
133
|
+
}
|
|
134
|
+
export function assertSameSessionMerge(input) {
|
|
135
|
+
const merge = resolveExecutionPolicy({ ...input, stage: "merge" }).coordinator;
|
|
136
|
+
for (const stage of input.previousStages) {
|
|
137
|
+
const previous = resolveExecutionPolicy({ ...input, stage }).coordinator;
|
|
138
|
+
if (!sameSessionProfile(previous, merge))
|
|
139
|
+
throw new AppError("merge_profile_session_conflict", "Same-session MERGE cannot switch execution profiles; select a compatible profile or server MERGE", 2, { previous_stage: stage, previous_profile_id: previous.id, merge_profile_id: merge.id });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function resolveStageTransition(input) {
|
|
143
|
+
const target = resolveExecutionPolicy(input);
|
|
144
|
+
const previous = input.fromStage ? resolveExecutionPolicy({ ...input, stage: input.fromStage }).coordinator : null;
|
|
145
|
+
const compatible = !previous || sameSessionProfile(previous, target.coordinator);
|
|
146
|
+
if (input.stage === "merge" && input.mergeMode === "same_session") {
|
|
147
|
+
if (!compatible)
|
|
148
|
+
throw new AppError("merge_profile_session_conflict", "Same-session MERGE cannot replace its current coordinator", 2);
|
|
149
|
+
return { ...target, session_mode: "same_session" };
|
|
150
|
+
}
|
|
151
|
+
return { ...target, session_mode: !compatible || (input.stage === "merge" && input.mergeMode === "server") ? "new_session" : input.stageSessionMode };
|
|
152
|
+
}
|
|
50
153
|
function validateDelegation(value) {
|
|
154
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !["mode", "agent_profile_id", "max_parallel"].includes(key)))
|
|
155
|
+
throw new AppError("execution_profile_invalid", "Delegation has unsupported fields", 2);
|
|
51
156
|
if (value.mode !== "native" && value.mode !== "external")
|
|
52
157
|
throw new AppError("execution_profile_invalid", "Delegation mode must be native or external", 2);
|
|
53
|
-
if (value.agent_profile_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value.agent_profile_id))
|
|
158
|
+
if (value.agent_profile_id !== undefined && (typeof value.agent_profile_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value.agent_profile_id)))
|
|
54
159
|
throw new AppError("execution_profile_invalid", "Delegation profile id is invalid", 2);
|
|
55
160
|
if (value.max_parallel !== undefined && (!Number.isInteger(value.max_parallel) || value.max_parallel < 1))
|
|
56
161
|
throw new AppError("execution_profile_invalid", "External max_parallel must be a positive integer", 2);
|
|
57
162
|
if (value.mode === "native" && value.max_parallel !== undefined)
|
|
58
163
|
throw new AppError("execution_profile_invalid", "Native delegation does not accept max_parallel", 2);
|
|
59
164
|
}
|
|
165
|
+
export function validateExecutionOverride(value) {
|
|
166
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !["agent_profile_id", "delegation"].includes(key)))
|
|
167
|
+
throw new AppError("execution_profile_invalid", "Work execution override has unsupported fields", 2);
|
|
168
|
+
const override = value;
|
|
169
|
+
if (override.agent_profile_id === undefined && override.delegation === undefined)
|
|
170
|
+
throw new AppError("execution_profile_invalid", "Work execution override is empty", 2);
|
|
171
|
+
if (override.agent_profile_id !== undefined && (typeof override.agent_profile_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(override.agent_profile_id)))
|
|
172
|
+
throw new AppError("execution_profile_invalid", "Work profile id is invalid", 2);
|
|
173
|
+
if (override.delegation !== undefined)
|
|
174
|
+
validateDelegation(override.delegation);
|
|
175
|
+
}
|
|
60
176
|
function nonempty(value) { return typeof value === "string" && Boolean(value.trim()); }
|
|
61
177
|
function digest(value) { return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
|
@@ -3,11 +3,12 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { ensureWorkLaunchStorage } from "../storage/database.js";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
-
import {
|
|
6
|
+
import { runtimeBudgetForRun } from "./runtime-budget.js";
|
|
7
|
+
import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
|
|
7
8
|
import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
|
|
8
|
-
import { resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
|
|
9
|
+
import { resolveExecutionPolicy, runtimeHarness, validateExecutionOverride } from "./execution-policy.js";
|
|
9
10
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
10
|
-
import { workStartCommand } from "./work-registry.js";
|
|
11
|
+
import { assertWorkLaunchReady, workStartCommand } from "./work-registry.js";
|
|
11
12
|
/**
|
|
12
13
|
* Creates an external provider root for one already-created Work. The launch
|
|
13
14
|
* is not a Work claim: only the worker's trusted `work start` hook binds it.
|
|
@@ -17,18 +18,27 @@ export async function launchExternalWork(context, input) {
|
|
|
17
18
|
const work = context.db.get("SELECT work_id, project_id, run_id, status, payload_json FROM works WHERE work_id = ?", [input.workId]);
|
|
18
19
|
if (!work)
|
|
19
20
|
throw new AppError("not_found", "Work was not found", 1, { work_id: input.workId });
|
|
20
|
-
|
|
21
|
-
throw new AppError("invalid_work_state", "Only created Work can be launched externally", 2, { work_id: work.work_id, status: work.status });
|
|
22
|
-
const run = context.db.get("SELECT run_root, workspace_root, index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]);
|
|
21
|
+
const run = context.db.get("SELECT run_root, project_root, workspace_root, index_json, status FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]);
|
|
23
22
|
if (!run?.run_root)
|
|
24
23
|
throw new AppError("runtime_missing", "Work has no materialized RUN root", 1, { work_id: work.work_id });
|
|
25
24
|
const existing = context.db.get("SELECT operation_id, status, provider_session_id, profile_hash, journal_path FROM work_launches WHERE work_id = ?", [work.work_id]);
|
|
26
25
|
if (existing)
|
|
27
26
|
return { ok: true, work_id: work.work_id, operation_id: existing.operation_id, status: existing.status, provider_session_id: existing.provider_session_id, profile_hash: existing.profile_hash, journal_path: existing.journal_path, idempotent: true };
|
|
27
|
+
assertWorkLaunchReady(context, work.work_id);
|
|
28
|
+
if (run.status !== "running")
|
|
29
|
+
throw new AppError("invalid_run_state", "External Work dispatch requires a running RUN", 2, { run_id: work.run_id, status: run.status });
|
|
28
30
|
const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
29
31
|
const snapshot = JSON.parse(run.index_json);
|
|
32
|
+
if (!snapshot.execution_profile?.agent_profiles)
|
|
33
|
+
throw new AppError("execution_profile_not_frozen", "External dispatch requires a RUN with frozen agent profiles; recreate the RUN with the current engine", 1, { work_id: work.work_id });
|
|
30
34
|
const override = workOverride(work.payload_json);
|
|
31
|
-
const
|
|
35
|
+
const policy = snapshot.execution_profile?.settings?.execution ?? {};
|
|
36
|
+
const stage = snapshot.current_stage;
|
|
37
|
+
if (input.stage !== undefined && input.stage !== stage)
|
|
38
|
+
throw new AppError("execution_stage_mismatch", "Requested stage does not match the RUN's current stage", 2, { requested_stage: input.stage, current_stage: stage ?? null });
|
|
39
|
+
if (!stage && Object.keys(policy.stage_overrides ?? {}).length)
|
|
40
|
+
throw new AppError("execution_stage_missing", "Stage overrides require an authoritative RUN stage", 1, { run_id: work.run_id });
|
|
41
|
+
const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy, profiles: snapshot.execution_profile.agent_profiles, ...(stage ? { stage } : {}), ...(override ? { work: override } : {}) });
|
|
32
42
|
if (resolved.delegation.mode !== "external")
|
|
33
43
|
throw new AppError("external_launch_not_selected", "This Work's frozen policy selects native delegation", 2, { work_id: work.work_id });
|
|
34
44
|
const operationId = `OPR-${crypto.randomUUID()}`;
|
|
@@ -36,19 +46,27 @@ export async function launchExternalWork(context, input) {
|
|
|
36
46
|
const journal = path.join(root, "adapter.events.jsonl");
|
|
37
47
|
fs.mkdirSync(root, { recursive: true });
|
|
38
48
|
const now = context.now();
|
|
39
|
-
|
|
49
|
+
const flowExecutable = prepareHarnessFlowExecutable(context, root);
|
|
50
|
+
const runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
|
|
51
|
+
const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${work.run_id}:work:${work.work_id}`, role: "worker", project_id: work.project_id, run_id: work.run_id, work_id: work.work_id, operation_id: operationId, state_dir: root, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, work.project_id, work.run_id) };
|
|
40
52
|
const executable = resolveHarnessCommand(context.ddFlowHome, runtimeHarness(resolved.worker), "adapter");
|
|
41
|
-
|
|
53
|
+
context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner } }), resolved.profile_hash, root, journal, now, now]);
|
|
54
|
+
const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
|
|
42
55
|
const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
|
|
43
56
|
try {
|
|
44
|
-
const call = (args, timeoutMs) => runHarnessAdapter({ executable, args, env:
|
|
57
|
+
const call = (args, timeoutMs) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: `${operationId}:${args[0]}.${args[1]}`, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
45
58
|
await call(["daemon", "start", ...common]);
|
|
59
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
46
60
|
const created = await call(["session", "create", ...common]);
|
|
47
61
|
const sessionId = adapterSessionId(created);
|
|
48
62
|
if (!sessionId)
|
|
49
63
|
throw new AppError("harness_adapter_invalid", "Harness adapter did not return a typed Session id", 1, { work_id: work.work_id, harness: runtimeHarness(resolved.worker) });
|
|
64
|
+
// Retain the physical root before any subsequent guard or prompt can fail.
|
|
65
|
+
context.db.run("UPDATE work_launches SET provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created }), context.now(), operationId]);
|
|
66
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
67
|
+
assertWorkLaunchReady(context, work.work_id);
|
|
50
68
|
const promptFile = path.join(root, "launch.md");
|
|
51
|
-
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(
|
|
69
|
+
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n`);
|
|
52
70
|
const prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
|
|
53
71
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
54
72
|
context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created, prompt: prompted }), context.now(), operationId]);
|
|
@@ -72,5 +90,8 @@ function workOverride(payload) {
|
|
|
72
90
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
73
91
|
return undefined;
|
|
74
92
|
const execution = value.execution_override;
|
|
75
|
-
|
|
93
|
+
if (execution === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
validateExecutionOverride(execution);
|
|
96
|
+
return execution;
|
|
76
97
|
}
|
|
@@ -1,12 +1,59 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
2
5
|
import { AppError } from "../shared/errors.js";
|
|
3
6
|
const maxAdapterOutputBytes = 1_000_000;
|
|
7
|
+
/** Native hooks need a real executable, not a PATH-dependent shell expression. */
|
|
8
|
+
export function prepareHarnessFlowExecutable(context, stateDir) {
|
|
9
|
+
const configured = context.env.DD_FLOW_BIN;
|
|
10
|
+
if (configured !== undefined) {
|
|
11
|
+
if (!path.isAbsolute(configured) || !fs.existsSync(configured))
|
|
12
|
+
throw new AppError("harness_runtime_missing", "DD_FLOW_BIN must identify an existing absolute executable", 2);
|
|
13
|
+
if (path.basename(configured) === "dd-flow")
|
|
14
|
+
return configured;
|
|
15
|
+
}
|
|
16
|
+
const isolated = path.join(context.ddFlowHome, "bin", "dd-flow");
|
|
17
|
+
if (!configured && fs.existsSync(isolated))
|
|
18
|
+
return isolated;
|
|
19
|
+
const entry = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
20
|
+
if (!configured && !fs.existsSync(entry))
|
|
21
|
+
throw new AppError("harness_runtime_unbuilt", "Build the CLI or supply its pinned DD_FLOW_BIN before launching a harness", 1);
|
|
22
|
+
// The trusted lifecycle parser recognizes this basename, including for a
|
|
23
|
+
// custom pinned executable. Do not broaden that parser to arbitrary scripts.
|
|
24
|
+
const executable = path.join(stateDir, "dd-flow");
|
|
25
|
+
const dispatch = configured
|
|
26
|
+
? `const { spawnSync } = await import("node:child_process");\nconst child = spawnSync(${JSON.stringify(configured)}, process.argv.slice(2), { stdio: "inherit", env: process.env });\nif (child.error) throw child.error;\nif (child.signal) process.kill(process.pid, child.signal);\nprocess.exit(child.status ?? 1);\n`
|
|
27
|
+
: `await import(${JSON.stringify(pathToFileURL(entry).href)});\n`;
|
|
28
|
+
const contents = `#!${process.execPath}\nprocess.env.DD_FLOW_HOME = ${JSON.stringify(context.ddFlowHome)};\n${dispatch}`;
|
|
29
|
+
try {
|
|
30
|
+
fs.writeFileSync(executable, contents, { flag: "wx", mode: 0o700 });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== "EEXIST" || fs.readFileSync(executable, "utf8") !== contents)
|
|
34
|
+
throw new AppError("harness_runtime_binding_conflict", "Launch runtime executable already has different content", 1, { path: executable });
|
|
35
|
+
}
|
|
36
|
+
return executable;
|
|
37
|
+
}
|
|
4
38
|
/** Runs one harness command with the lifecycle guarantees shared by agent services. */
|
|
5
39
|
export function runHarnessAdapter(input) {
|
|
6
40
|
const timeoutMs = input.timeoutMs ?? 120_000;
|
|
41
|
+
if (!input.operationId.trim())
|
|
42
|
+
throw new AppError("operation_id_required", "Harness calls require a scoped operation id", 2);
|
|
43
|
+
// The current adapter transport uses this name; its identity is runtime-owned,
|
|
44
|
+
// never the operation or trusted daemon identity of the calling coordinator.
|
|
45
|
+
const env = { ...input.env, DD_EVAL_OPERATION_ID: input.operationId };
|
|
46
|
+
delete env.DD_FLOW_DAEMON_ID;
|
|
47
|
+
delete env.DD_FLOW_SCOPE_CONTROL_TOKEN;
|
|
48
|
+
delete env.DD_FLOW_RUNTIME_OWNER;
|
|
49
|
+
if (input.runtimeOwner) {
|
|
50
|
+
if (!path.isAbsolute(input.executable) || input.runtimeOwner.adapter_executable !== undefined && input.runtimeOwner.adapter_executable !== input.executable)
|
|
51
|
+
throw new AppError("harness_runtime_binding_conflict", "Owned harness call must use its frozen absolute adapter executable", 1);
|
|
52
|
+
env.DD_FLOW_RUNTIME_OWNER = JSON.stringify({ ...input.runtimeOwner, adapter_executable: input.executable });
|
|
53
|
+
}
|
|
7
54
|
return new Promise((resolve, reject) => {
|
|
8
55
|
const detached = process.platform !== "win32";
|
|
9
|
-
const child = spawn(input.executable, input.args, { env: input.
|
|
56
|
+
const child = spawn(input.executable, input.args, { env, ...(input.cwd ? { cwd: input.cwd } : {}), detached, stdio: ["ignore", "pipe", "pipe"] });
|
|
10
57
|
let stdout = "";
|
|
11
58
|
let stderr = "";
|
|
12
59
|
let outputTooLarge = false;
|
|
@@ -21,7 +68,7 @@ export function runHarnessAdapter(input) {
|
|
|
21
68
|
catch { /* The process already settled. */ } };
|
|
22
69
|
const heartbeat = input.progress && input.progressMessage ? setInterval(() => input.progress?.(`${input.progressMessage}; waiting for adapter completion`), 15_000) : undefined;
|
|
23
70
|
const timer = setTimeout(() => { timedOut = true; terminate("SIGTERM"); killTimer = setTimeout(() => terminate("SIGKILL"), 5_000); }, timeoutMs);
|
|
24
|
-
const evidence = (value) => input.onEvidence?.({ executable: input.executable, args: input.args, ...value, at: new Date().toISOString() });
|
|
71
|
+
const evidence = (value) => input.onEvidence?.({ operation_id: input.operationId, executable: input.executable, args: input.args, ...value, at: new Date().toISOString() });
|
|
25
72
|
const append = (current, chunk) => {
|
|
26
73
|
if (Buffer.byteLength(current) + Buffer.byteLength(chunk) > maxAdapterOutputBytes) {
|
|
27
74
|
outputTooLarge = true;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { managedProcessRuntimeHome, managedProcessStatus } from "./managed-processes.js";
|
|
5
|
+
export function readNativeDaemonState(stateDir) {
|
|
6
|
+
if (!path.isAbsolute(stateDir))
|
|
7
|
+
throw new AppError("daemon_owner_mismatch", "Native state directory must be absolute", 1);
|
|
8
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir, "daemon.json"), "utf8"));
|
|
9
|
+
}
|
|
10
|
+
/** A shared directory or logical owner is not a physical provider binding. */
|
|
11
|
+
export function managedProviderBelongsToDaemon(provider, daemon, state) {
|
|
12
|
+
const child = JSON.parse(provider.metadata_json), parent = JSON.parse(daemon.metadata_json);
|
|
13
|
+
return provider.kind === daemon.kind.replace(/-daemon$/, "-provider")
|
|
14
|
+
&& state.provider_process?.id === provider.id && state.provider_process.lease_token === provider.lease_token
|
|
15
|
+
&& Boolean(provider.pid && provider.pid_started_at && daemon.pid && daemon.pid_started_at)
|
|
16
|
+
&& state.provider_pid === provider.pid && child.owner_pid === daemon.pid && child.owner_pid_started_at === daemon.pid_started_at
|
|
17
|
+
&& provider.owner_id === daemon.owner_id && provider.project_id === daemon.project_id && provider.run_id === daemon.run_id && provider.work_id === daemon.work_id
|
|
18
|
+
&& child.state_dir === parent.state_dir && child.role === parent.role && child.dd_flow_home === parent.dd_flow_home && child.adapter_executable === parent.adapter_executable
|
|
19
|
+
&& child.dispatch_barrier === "run-generation@1" && child.budget?.scope_id === parent.budget?.scope_id;
|
|
20
|
+
}
|
|
21
|
+
/** Identity proof only. Liveness and clean shutdown are separate caller decisions. */
|
|
22
|
+
export function requireManagedDaemonBinding(context, input) {
|
|
23
|
+
const state = readNativeDaemonState(input.stateDir);
|
|
24
|
+
const owner = state.config?.runtime_owner;
|
|
25
|
+
const record = managedProcessStatus(context).find(record => record.id === state.resource_process?.id);
|
|
26
|
+
const metadata = record && JSON.parse(record.metadata_json);
|
|
27
|
+
if (!record || !owner || !record.kind.endsWith("-daemon")
|
|
28
|
+
|| record.lease_token !== state.resource_process?.lease_token
|
|
29
|
+
|| !record.pid || record.pid !== state.pid || !record.pid_started_at
|
|
30
|
+
|| record.owner_id !== owner.owner_id || input.ownerId !== undefined && record.owner_id !== input.ownerId
|
|
31
|
+
|| record.project_id !== input.projectId || owner.project_id !== input.projectId
|
|
32
|
+
|| record.run_id !== input.runId || owner.run_id !== input.runId
|
|
33
|
+
|| metadata?.state_dir !== input.stateDir || owner.state_dir !== input.stateDir
|
|
34
|
+
|| owner.dd_flow_home !== context.ddFlowHome || managedProcessRuntimeHome(record, context) !== context.ddFlowHome) {
|
|
35
|
+
throw new AppError("daemon_owner_mismatch", "Native daemon has no proven physical ownership in this RUN and runtime home", 1);
|
|
36
|
+
}
|
|
37
|
+
return { state, process: record };
|
|
38
|
+
}
|
|
@@ -1,12 +1,47 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { spawnSync } from "node:child_process";
|
|
3
5
|
import net from "node:net";
|
|
4
6
|
import { getResourceDatabase } from "../storage/database.js";
|
|
7
|
+
import { AppError } from "../shared/errors.js";
|
|
8
|
+
export function managedProcessBinding(record, home) {
|
|
9
|
+
return { resource_home: home, process_id: record.id, registration_sha256: crypto.createHash("sha256").update(JSON.stringify([home, record.id, record.kind, record.owner_id, record.lease_token, record.pid, record.pid_started_at, record.project_id, record.run_id, record.work_id, record.check_id, record.operation_id, record.metadata_json])).digest("hex") };
|
|
10
|
+
}
|
|
5
11
|
const defaultLeaseMs = 15 * 60_000;
|
|
6
12
|
export function resourceHome(context) {
|
|
7
13
|
return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
|
|
8
14
|
}
|
|
15
|
+
/** Runtime IDs are home-local; a shared host registry needs this extra scope. */
|
|
16
|
+
export function managedProcessRuntimeHome(record, authority) {
|
|
17
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
18
|
+
if (metadata.dd_flow_home !== undefined)
|
|
19
|
+
return typeof metadata.dd_flow_home === "string" && path.isAbsolute(metadata.dd_flow_home) ? metadata.dd_flow_home : null;
|
|
20
|
+
// Older native registrations can still prove their home from the retained
|
|
21
|
+
// exact daemon owner; PID or project/RUN labels alone never establish it.
|
|
22
|
+
if (typeof metadata.state_dir === "string" && path.isAbsolute(metadata.state_dir)) {
|
|
23
|
+
try {
|
|
24
|
+
const state = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
|
|
25
|
+
const owner = state.config?.runtime_owner;
|
|
26
|
+
if (owner?.owner_id === record.owner_id && owner.project_id === record.project_id && owner.run_id === record.run_id && owner.state_dir === metadata.state_dir && typeof owner.dd_flow_home === "string" && path.isAbsolute(owner.dd_flow_home))
|
|
27
|
+
return owner.dd_flow_home;
|
|
28
|
+
}
|
|
29
|
+
catch { /* No retained native scope proof. */ }
|
|
30
|
+
}
|
|
31
|
+
if (record.kind === "run-controller" && authority.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'") && authority.db.get("SELECT 1 FROM run_controllers WHERE controller_id = ? AND process_id = ? AND process_lease_token = ? AND project_id = ? AND run_id = ?", [record.owner_id, record.id, record.lease_token, record.project_id, record.run_id]))
|
|
32
|
+
return authority.ddFlowHome;
|
|
33
|
+
if (record.kind === "run-control" && authority.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_control_workers'") && authority.db.get("SELECT 1 FROM run_control_workers w JOIN run_controls c ON c.control_id = w.control_id WHERE w.control_id = ? AND w.process_id = ? AND w.process_lease_token = ? AND c.project_id = ? AND c.run_id = ?", [record.owner_id, record.id, record.lease_token, record.project_id, record.run_id]))
|
|
34
|
+
return authority.ddFlowHome;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
9
37
|
export function registerManagedProcess(context, input) {
|
|
38
|
+
const observer = input.kind === "eval-observer";
|
|
39
|
+
const observerBirth = observer && input.pid ? processStartedAt(input.pid) : null;
|
|
40
|
+
if (observer) {
|
|
41
|
+
if (!Number.isSafeInteger(input.pid) || !input.pid || input.pid < 1 || !observerBirth || input.ownerPid !== undefined && input.ownerPid !== input.pid || input.processGroupId !== undefined && input.processGroupId !== input.pid)
|
|
42
|
+
throw new AppError("process_ownership_unknown", "EVAL observer registration requires its live self-owned process identity", 1);
|
|
43
|
+
input = { ...input, ownerPid: input.pid, uniqueActiveOperation: true };
|
|
44
|
+
}
|
|
10
45
|
if (!input.uniqueActiveOperation)
|
|
11
46
|
return insertManagedProcess(context, input);
|
|
12
47
|
if (!input.operationId)
|
|
@@ -16,8 +51,13 @@ export function registerManagedProcess(context, input) {
|
|
|
16
51
|
try {
|
|
17
52
|
const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
|
|
18
53
|
if (existing)
|
|
19
|
-
throw
|
|
20
|
-
|
|
54
|
+
throw new AppError("managed_operation_in_progress", "The operation already owns a managed process; observe it instead of spawning a duplicate", 1, { process_id: existing.id, operation_id: input.operationId });
|
|
55
|
+
let result = insertManagedProcess(context, input);
|
|
56
|
+
if (observer) {
|
|
57
|
+
result = confirmManagedProcess(context, { id: result.id, leaseToken: result.lease_token, pid: input.pid, ...(input.processGroupId ? { processGroupId: input.processGroupId } : {}) });
|
|
58
|
+
if (result.pid_started_at !== observerBirth || !processIsAlive(result))
|
|
59
|
+
throw new AppError("process_ownership_unknown", "EVAL observer changed before registration committed", 1);
|
|
60
|
+
}
|
|
21
61
|
db.exec("COMMIT");
|
|
22
62
|
return result;
|
|
23
63
|
}
|
|
@@ -33,7 +73,7 @@ function insertManagedProcess(context, input) {
|
|
|
33
73
|
const token = crypto.randomUUID();
|
|
34
74
|
db.run(`INSERT INTO managed_processes
|
|
35
75
|
(id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
|
|
36
|
-
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
|
|
76
|
+
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
|
|
37
77
|
return requireProcess(db, id);
|
|
38
78
|
}
|
|
39
79
|
export function confirmManagedProcess(context, input) {
|
|
@@ -67,7 +107,11 @@ export function finishManagedProcess(context, input) {
|
|
|
67
107
|
const updated = db.run("UPDATE managed_processes SET state = ?, termination_reason = ?, finished_at = ?, updated_at = ?, lease_expires_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned')", [input.state, input.reason ?? null, now, now, now, input.id, input.leaseToken]).changes === 1;
|
|
68
108
|
if (updated)
|
|
69
109
|
db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
|
|
70
|
-
|
|
110
|
+
if (updated)
|
|
111
|
+
return true;
|
|
112
|
+
// Another client may have completed the same lease after our initial read.
|
|
113
|
+
const current = requireProcess(db, input.id);
|
|
114
|
+
return current.lease_token === input.leaseToken && current.state === input.state;
|
|
71
115
|
}
|
|
72
116
|
export function processIsAlive(record) {
|
|
73
117
|
if (!record.pid)
|
|
@@ -140,18 +184,21 @@ export async function stopManagedProcess(context, input) {
|
|
|
140
184
|
throw new Error("Managed process lease does not match");
|
|
141
185
|
if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
|
|
142
186
|
return record;
|
|
187
|
+
if (!record.pid || !record.pid_started_at)
|
|
188
|
+
throw Object.assign(new Error("Managed process has no confirmed physical identity; retain its pending launch"), { code: "process_identity_unknown" });
|
|
143
189
|
db.run("UPDATE managed_processes SET state = 'stopping', updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','orphaned')", [context.now(), record.id, record.lease_token]);
|
|
144
190
|
if (processTreeIsAlive(record)) {
|
|
145
191
|
terminateOwnedProcess(record, "SIGTERM");
|
|
146
192
|
await delay(input.graceMs ?? 1_000);
|
|
147
|
-
if (processTreeIsAlive(record)) {
|
|
193
|
+
if (input.force !== false && processTreeIsAlive(record)) {
|
|
148
194
|
terminateOwnedProcess(record, "SIGKILL");
|
|
149
195
|
await delay(250);
|
|
150
196
|
}
|
|
151
197
|
if (processTreeIsAlive(record))
|
|
152
198
|
throw Object.assign(new Error("Owned process tree could not be safely stopped; resources retained"), { code: "process_tree_not_settled", details: { process_id: record.id } });
|
|
153
199
|
}
|
|
154
|
-
finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" })
|
|
200
|
+
if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" }))
|
|
201
|
+
throw Object.assign(new Error("Managed process ownership changed before settlement"), { code: "process_lease_lost" });
|
|
155
202
|
return requireProcess(db, record.id);
|
|
156
203
|
}
|
|
157
204
|
/** Reconcile only records the system owns and has atomically claimed. */
|
|
@@ -206,7 +253,7 @@ function registry(context) { return getResourceDatabase(resourceHome(context));
|
|
|
206
253
|
function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
|
|
207
254
|
throw new Error(`Managed process is missing: ${id}`); return row; }
|
|
208
255
|
function leaseExpiry(now, leaseMs = defaultLeaseMs) { return new Date(Date.parse(now) + leaseMs).toISOString(); }
|
|
209
|
-
function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
256
|
+
export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
210
257
|
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
211
258
|
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
212
259
|
function terminateOwnedProcess(record, signal) {
|