@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +404 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +211 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +224 -216
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +605 -157
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -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,19 +1,79 @@
|
|
|
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
|
+
}
|
|
45
|
+
if (!input.uniqueActiveOperation)
|
|
46
|
+
return insertManagedProcess(context, input);
|
|
47
|
+
if (!input.operationId)
|
|
48
|
+
throw new Error("Unique managed operation requires operationId");
|
|
49
|
+
const db = registry(context);
|
|
50
|
+
db.beginWriteTransaction();
|
|
51
|
+
try {
|
|
52
|
+
const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
|
|
53
|
+
if (existing)
|
|
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
|
+
}
|
|
61
|
+
db.exec("COMMIT");
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
db.exec("ROLLBACK");
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function insertManagedProcess(context, input) {
|
|
10
70
|
const db = registry(context);
|
|
11
71
|
const now = context.now();
|
|
12
72
|
const id = input.id ?? `PROC-${crypto.randomUUID()}`;
|
|
13
73
|
const token = crypto.randomUUID();
|
|
14
74
|
db.run(`INSERT INTO managed_processes
|
|
15
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)
|
|
16
|
-
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
|
|
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) } : {}) })]);
|
|
17
77
|
return requireProcess(db, id);
|
|
18
78
|
}
|
|
19
79
|
export function confirmManagedProcess(context, input) {
|
|
@@ -23,6 +83,10 @@ export function confirmManagedProcess(context, input) {
|
|
|
23
83
|
const metadata = parseMetadata(current.metadata_json);
|
|
24
84
|
if (input.processGroupId)
|
|
25
85
|
metadata.process_group_id = input.processGroupId;
|
|
86
|
+
if (input.ownerPid) {
|
|
87
|
+
metadata.owner_pid = input.ownerPid;
|
|
88
|
+
metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
|
|
89
|
+
}
|
|
26
90
|
const result = db.run(`UPDATE managed_processes
|
|
27
91
|
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
28
92
|
WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
|
|
@@ -36,11 +100,18 @@ export function heartbeatManagedProcess(context, input) {
|
|
|
36
100
|
}
|
|
37
101
|
export function finishManagedProcess(context, input) {
|
|
38
102
|
const db = registry(context);
|
|
103
|
+
const existing = requireProcess(db, input.id);
|
|
104
|
+
if (existing.lease_token === input.leaseToken && existing.state === input.state)
|
|
105
|
+
return true;
|
|
39
106
|
const now = context.now();
|
|
40
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;
|
|
41
108
|
if (updated)
|
|
42
109
|
db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
|
|
43
|
-
|
|
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;
|
|
44
115
|
}
|
|
45
116
|
export function processIsAlive(record) {
|
|
46
117
|
if (!record.pid)
|
|
@@ -48,33 +119,49 @@ export function processIsAlive(record) {
|
|
|
48
119
|
try {
|
|
49
120
|
process.kill(record.pid, 0);
|
|
50
121
|
}
|
|
51
|
-
catch {
|
|
52
|
-
return
|
|
122
|
+
catch (error) {
|
|
123
|
+
return error.code !== "ESRCH";
|
|
53
124
|
}
|
|
54
|
-
|
|
125
|
+
const started = processStartedAt(record.pid);
|
|
126
|
+
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
55
127
|
}
|
|
56
|
-
function processTreeIsAlive(record) {
|
|
128
|
+
export function processTreeIsAlive(record) {
|
|
129
|
+
if (processIsAlive(record))
|
|
130
|
+
return true;
|
|
57
131
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
58
132
|
if (process.platform !== "win32" && typeof group === "number") {
|
|
59
133
|
try {
|
|
60
134
|
process.kill(-group, 0);
|
|
61
135
|
return true;
|
|
62
136
|
}
|
|
63
|
-
catch {
|
|
64
|
-
return
|
|
137
|
+
catch (error) {
|
|
138
|
+
return error.code !== "ESRCH";
|
|
65
139
|
}
|
|
66
140
|
}
|
|
67
|
-
return
|
|
141
|
+
return false;
|
|
68
142
|
}
|
|
69
|
-
|
|
143
|
+
export function managedOwnerIsAlive(record) {
|
|
144
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
145
|
+
if (typeof metadata.owner_pid !== "number")
|
|
146
|
+
return null;
|
|
147
|
+
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Expiry proves that observation stopped, not that the owner died. A live PID
|
|
151
|
+
* remains owned until its result is reconciled; only a dead process can be
|
|
152
|
+
* atomically claimed for cleanup.
|
|
153
|
+
*/
|
|
70
154
|
export function claimExpiredManagedProcesses(context, ownerId) {
|
|
71
155
|
const db = registry(context);
|
|
72
156
|
const now = context.now();
|
|
73
|
-
db.
|
|
157
|
+
db.beginWriteTransaction();
|
|
74
158
|
try {
|
|
75
159
|
const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
|
|
76
160
|
const claimed = [];
|
|
77
161
|
for (const candidate of candidates) {
|
|
162
|
+
const ownerAlive = managedOwnerIsAlive(candidate);
|
|
163
|
+
if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
|
|
164
|
+
continue;
|
|
78
165
|
const token = crypto.randomUUID();
|
|
79
166
|
const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
|
|
80
167
|
if (result.changes === 1)
|
|
@@ -88,9 +175,39 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
88
175
|
throw error;
|
|
89
176
|
}
|
|
90
177
|
}
|
|
178
|
+
/** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
|
|
179
|
+
export function managedLeaseReleased(record) {
|
|
180
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
181
|
+
return record.kind === "workspace-gate" && record.pid === null && record.pid_started_at === null
|
|
182
|
+
&& metadata.process_group_id == null && metadata.role === "check_gate"
|
|
183
|
+
&& ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at);
|
|
184
|
+
}
|
|
91
185
|
export function managedProcessStatus(context) {
|
|
92
186
|
return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
|
|
93
187
|
}
|
|
188
|
+
export async function stopManagedProcess(context, input) {
|
|
189
|
+
const db = registry(context), record = requireProcess(db, input.id);
|
|
190
|
+
if (record.lease_token !== input.leaseToken)
|
|
191
|
+
throw new Error("Managed process lease does not match");
|
|
192
|
+
if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
|
|
193
|
+
return record;
|
|
194
|
+
if (!record.pid || !record.pid_started_at)
|
|
195
|
+
throw Object.assign(new Error("Managed process has no confirmed physical identity; retain its pending launch"), { code: "process_identity_unknown" });
|
|
196
|
+
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]);
|
|
197
|
+
if (processTreeIsAlive(record)) {
|
|
198
|
+
terminateOwnedProcess(record, "SIGTERM");
|
|
199
|
+
await delay(input.graceMs ?? 1_000);
|
|
200
|
+
if (input.force !== false && processTreeIsAlive(record)) {
|
|
201
|
+
terminateOwnedProcess(record, "SIGKILL");
|
|
202
|
+
await delay(250);
|
|
203
|
+
}
|
|
204
|
+
if (processTreeIsAlive(record))
|
|
205
|
+
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 } });
|
|
206
|
+
}
|
|
207
|
+
if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" }))
|
|
208
|
+
throw Object.assign(new Error("Managed process ownership changed before settlement"), { code: "process_lease_lost" });
|
|
209
|
+
return requireProcess(db, record.id);
|
|
210
|
+
}
|
|
94
211
|
/** Reconcile only records the system owns and has atomically claimed. */
|
|
95
212
|
export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
|
|
96
213
|
const claimed = claimExpiredManagedProcesses(context, ownerId);
|
|
@@ -101,18 +218,15 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
|
|
|
101
218
|
outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
|
|
102
219
|
continue;
|
|
103
220
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
terminateOwnedProcess(processRecord, "SIGKILL");
|
|
108
|
-
await delay(Math.min(graceMs, 250));
|
|
221
|
+
try {
|
|
222
|
+
await stopManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, graceMs, reason: "orphan_reconciled" });
|
|
223
|
+
outcomes.push({ id: processRecord.id, outcome: "stopped" });
|
|
109
224
|
}
|
|
110
|
-
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error.code !== "process_tree_not_settled")
|
|
227
|
+
throw error;
|
|
111
228
|
outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
|
|
112
|
-
continue;
|
|
113
229
|
}
|
|
114
|
-
finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
|
|
115
|
-
outcomes.push({ id: processRecord.id, outcome: "stopped" });
|
|
116
230
|
}
|
|
117
231
|
return outcomes;
|
|
118
232
|
}
|
|
@@ -146,11 +260,13 @@ function registry(context) { return getResourceDatabase(resourceHome(context));
|
|
|
146
260
|
function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
|
|
147
261
|
throw new Error(`Managed process is missing: ${id}`); return row; }
|
|
148
262
|
function leaseExpiry(now, leaseMs = defaultLeaseMs) { return new Date(Date.parse(now) + leaseMs).toISOString(); }
|
|
149
|
-
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; }
|
|
263
|
+
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; }
|
|
150
264
|
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
151
265
|
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
152
266
|
function terminateOwnedProcess(record, signal) {
|
|
153
|
-
|
|
267
|
+
// Never signal a group merely because its former leader PID was once ours.
|
|
268
|
+
// PID reuse or a departed leader makes group ownership unprovable.
|
|
269
|
+
if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
|
|
154
270
|
return;
|
|
155
271
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
156
272
|
try {
|
|
@@ -36,7 +36,7 @@ export function claimNextMergeJob(context, input) {
|
|
|
36
36
|
});
|
|
37
37
|
const now = context.now();
|
|
38
38
|
let claimedProtocolId = null;
|
|
39
|
-
context.db.
|
|
39
|
+
context.db.beginWriteTransaction();
|
|
40
40
|
try {
|
|
41
41
|
const job = context.db.get(`SELECT * FROM merge_queue
|
|
42
42
|
WHERE project_id = ? AND status IN ('ready', 'requeued')
|
|
@@ -90,7 +90,7 @@ export function claimMergeJob(context, input) {
|
|
|
90
90
|
workspacePath: input.workspacePath
|
|
91
91
|
});
|
|
92
92
|
const now = context.now();
|
|
93
|
-
context.db.
|
|
93
|
+
context.db.beginWriteTransaction();
|
|
94
94
|
try {
|
|
95
95
|
const job = queueJobByProtocol(context, project.id, input.protocolId);
|
|
96
96
|
if (!job || !["ready", "requeued"].includes(job.status)) {
|
|
@@ -141,7 +141,7 @@ export function claimMergeBundle(context, input) {
|
|
|
141
141
|
workspacePath: input.workspacePath
|
|
142
142
|
});
|
|
143
143
|
const now = context.now();
|
|
144
|
-
context.db.
|
|
144
|
+
context.db.beginWriteTransaction();
|
|
145
145
|
try {
|
|
146
146
|
for (const protocolId of protocolIds) {
|
|
147
147
|
const update = context.db.run(`UPDATE merge_queue
|
|
@@ -381,7 +381,7 @@ export function completeMergeBundle(context, input) {
|
|
|
381
381
|
});
|
|
382
382
|
}
|
|
383
383
|
const now = context.now();
|
|
384
|
-
context.db.
|
|
384
|
+
context.db.beginWriteTransaction();
|
|
385
385
|
try {
|
|
386
386
|
for (const protocolId of protocolIds) {
|
|
387
387
|
const protocol = requireProtocol(context, protocolId, project.id);
|
|
@@ -517,7 +517,7 @@ export function failMergeBundle(context, input) {
|
|
|
517
517
|
}
|
|
518
518
|
const now = context.now();
|
|
519
519
|
const status = input.requeue ? "requeued" : "failed";
|
|
520
|
-
context.db.
|
|
520
|
+
context.db.beginWriteTransaction();
|
|
521
521
|
try {
|
|
522
522
|
for (const protocolId of protocolIds) {
|
|
523
523
|
const protocol = requireProtocol(context, protocolId, project.id);
|
|
@@ -574,7 +574,7 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
574
574
|
throw new AppError("validation", "merge-queue cancel requires --reason", 2);
|
|
575
575
|
}
|
|
576
576
|
const now = context.now();
|
|
577
|
-
context.db.
|
|
577
|
+
context.db.beginWriteTransaction();
|
|
578
578
|
try {
|
|
579
579
|
const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
|
|
580
580
|
if (!job) {
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { runtimeBudgetForRun } from "./runtime-budget.js";
|
|
5
7
|
import { flowCommand } from "./stage-pause.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
|
|
9
|
+
import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
|
|
10
|
+
import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
11
|
+
import { loadAgentProfile, resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
|
|
8
12
|
/** A deterministic dispatcher; only the launched harness Session performs agent work. */
|
|
9
13
|
export async function serveMergeRequests(context, input) {
|
|
10
14
|
const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
|
|
@@ -57,42 +61,90 @@ export async function serveMergeRequests(context, input) {
|
|
|
57
61
|
export function mergeServerStatus(context) { return { ok: true, servers: context.db.all("SELECT server_id, profile_id, status, pid, started_at, heartbeat_at, stopped_at, journal_path FROM merge_servers ORDER BY started_at DESC") }; }
|
|
58
62
|
export function stopMergeServer(context, input) { const changed = context.db.run("UPDATE merge_servers SET status = 'stopping', heartbeat_at = ? WHERE server_id = ? AND status = 'running'", [context.now(), input.serverId]); if (changed.changes !== 1)
|
|
59
63
|
throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
|
|
64
|
+
/** Both dispatchers enter the same project FIFO before creating any provider Session. */
|
|
65
|
+
export function claimQueuedMergeRequest(context, input) {
|
|
66
|
+
context.db.beginWriteTransaction();
|
|
67
|
+
try {
|
|
68
|
+
const request = context.db.get("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE merge_request_id = ?", [input.requestId]);
|
|
69
|
+
if (!request)
|
|
70
|
+
throw new AppError("not_found", "MERGE request was not found", 1);
|
|
71
|
+
const generation = assertRunMutationAllowed(context, request.project_id, request.run_id, input.expectedGeneration);
|
|
72
|
+
const earlier = context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]);
|
|
73
|
+
if (request.status !== "queued" || request.execution_route !== "server" || earlier) {
|
|
74
|
+
context.db.exec("COMMIT");
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const token = crypto.randomUUID();
|
|
78
|
+
context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued'", [input.ownerId, token, new Date(Date.now() + 120_000).toISOString(), context.now(), request.merge_request_id]);
|
|
79
|
+
context.db.exec("COMMIT");
|
|
80
|
+
return { request, token, generation };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
context.db.exec("ROLLBACK");
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
60
87
|
async function dispatch(context, input) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
const run = context.db.get("SELECT index_json, project_root FROM runs WHERE project_id = ? AND id = ?", [input.request.project_id, input.request.run_id]);
|
|
89
|
+
if (!run)
|
|
90
|
+
throw new AppError("not_found", "MERGE RUN was not found", 1, { run_id: input.request.run_id });
|
|
91
|
+
const snapshot = JSON.parse(run.index_json).execution_profile;
|
|
92
|
+
if (snapshot?.settings?.execution) {
|
|
93
|
+
if (!snapshot.agent_profiles)
|
|
94
|
+
throw new AppError("execution_profile_not_frozen", "MERGE dispatch requires frozen RUN agent profiles", 1, { run_id: input.request.run_id });
|
|
95
|
+
const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: snapshot.settings.execution, profiles: snapshot.agent_profiles, stage: "merge" });
|
|
96
|
+
input = { ...input, profile: resolved.coordinator };
|
|
97
|
+
}
|
|
98
|
+
const claim = claimQueuedMergeRequest(context, { requestId: input.request.merge_request_id, ownerId: input.serverId });
|
|
99
|
+
if (!claim)
|
|
65
100
|
return;
|
|
66
|
-
const
|
|
67
|
-
leaseHeartbeat.unref();
|
|
101
|
+
const { generation, token } = claim;
|
|
68
102
|
const stateDir = path.join(input.root, input.request.merge_request_id);
|
|
69
103
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
70
104
|
const promptFile = path.join(stateDir, "launch.md");
|
|
71
105
|
const adapterJournal = path.join(stateDir, "adapter.events.jsonl");
|
|
72
|
-
const
|
|
73
|
-
|
|
106
|
+
const flowExecutable = prepareHarnessFlowExecutable(context, stateDir);
|
|
107
|
+
let runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
|
|
108
|
+
const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${input.request.run_id}:merge:${input.request.merge_request_id}`, role: "merge", project_id: input.request.project_id, run_id: input.request.run_id, work_id: input.request.executor_work_id, operation_id: `${input.request.merge_request_id}:${token}`, state_dir: stateDir, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, input.request.project_id, input.request.run_id) };
|
|
74
109
|
event(input.journal, { type: "launch_intent", server_id: input.serverId, request_id: input.request.merge_request_id, token, profile: input.profile, prompt_file: promptFile, at: context.now() });
|
|
75
110
|
input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
|
|
76
|
-
const executable = context.
|
|
77
|
-
const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root",
|
|
111
|
+
const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
|
|
112
|
+
const common = ["--dd-flow-bin", flowExecutable, "--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
|
|
113
|
+
const retained = { profile: input.profile, executable, owner: runtimeOwner, session_id: null, state_dir: stateDir, create_operation_id: `${input.request.merge_request_id}:${token}:session.create`, prompt_operation_id: `${input.request.merge_request_id}:${token}:session.prompt` };
|
|
114
|
+
const retain = () => { context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify(retained), context.now(), input.request.merge_request_id, input.serverId, token]); };
|
|
115
|
+
retain();
|
|
116
|
+
const leaseHeartbeat = setInterval(() => { if (guarded(context, input.request) || (recoveryGuard(context, input.request.project_id, input.request.run_id)?.generation ?? 0) !== generation)
|
|
117
|
+
return; const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
|
|
118
|
+
leaseHeartbeat.unref();
|
|
78
119
|
try {
|
|
79
|
-
const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env:
|
|
120
|
+
const adapterCall = (args, timeoutMs, progressMessage) => { assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation); return runHarnessAdapter({ executable, args, env: runtimeContext.env, runtimeOwner, operationId: `${input.request.merge_request_id}:${token}:${args[0]}.${args[1]}`, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) }); };
|
|
80
121
|
await adapterCall(["daemon", "start", ...common]);
|
|
81
122
|
const created = await adapterCall(["session", "create", ...common]);
|
|
82
123
|
const sessionId = adapterSessionId(created);
|
|
83
124
|
if (!sessionId)
|
|
84
125
|
throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
|
|
126
|
+
Object.assign(retained, { session_id: sessionId, create: created });
|
|
127
|
+
retain();
|
|
128
|
+
runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(input.profile), stateDir, sessionId, projectRoot: run.project_root, runId: input.request.run_id, generation });
|
|
129
|
+
const stageCommand = managedLifecycleCommand(runtimeContext, `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
|
|
130
|
+
fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
|
|
85
131
|
const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
|
|
132
|
+
Object.assign(retained, { response: prompted });
|
|
133
|
+
retain();
|
|
134
|
+
assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation);
|
|
86
135
|
const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
|
|
87
|
-
context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ profile: input.profile, executable, session_id: sessionId, response: prompted }), context.now(), input.request.merge_request_id]);
|
|
88
136
|
event(input.journal, { type: "adapter_settled", request_id: input.request.merge_request_id, session_id: sessionId, request_status: request?.status ?? null, at: context.now() });
|
|
89
137
|
if (request?.status === "dispatching")
|
|
90
|
-
context.db.run("UPDATE merge_requests SET status = '
|
|
138
|
+
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
|
|
91
139
|
}
|
|
92
140
|
catch (error) {
|
|
141
|
+
if (error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code)) {
|
|
142
|
+
event(input.journal, { type: "recovery_dispatch_blocked", request_id: input.request.merge_request_id, code: error.code, at: context.now() });
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
93
145
|
const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
|
|
94
146
|
if (request?.status === "dispatching")
|
|
95
|
-
context.db.run("UPDATE merge_requests SET status = '
|
|
147
|
+
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
|
|
96
148
|
else if (request?.status === "active")
|
|
97
149
|
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_lost_after_stage_start", error: String(error) }), context.now(), input.request.merge_request_id]);
|
|
98
150
|
throw error;
|
|
@@ -101,23 +153,17 @@ async function dispatch(context, input) {
|
|
|
101
153
|
clearInterval(leaseHeartbeat);
|
|
102
154
|
}
|
|
103
155
|
}
|
|
104
|
-
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
156
|
+
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (guarded(context, request) || selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
105
157
|
return false; selected.add(request.project_id); return true; }).slice(0, limit); }
|
|
106
|
-
function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
|
|
158
|
+
function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id, project_id, run_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
|
|
159
|
+
if (guarded(context, request))
|
|
160
|
+
continue;
|
|
107
161
|
const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
|
|
108
|
-
|
|
109
|
-
context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_requeued" }), context.now(), request.merge_request_id]);
|
|
110
|
-
else
|
|
111
|
-
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_with_started_work", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
|
|
162
|
+
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_outcome_unknown", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
|
|
112
163
|
} }
|
|
113
|
-
function loadProfile(context, id) {
|
|
114
|
-
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
throw new AppError("agent_profile_missing", "Merge-server agent profile is unavailable", 1, { id, file, cause: String(error) });
|
|
118
|
-
} validateSchema({ schemaName: "agent-profile", file }); if (value.id !== id)
|
|
119
|
-
throw new AppError("agent_profile_invalid", "Agent profile id does not match its requested filename", 2, { file, expected: id, actual: value.id ?? null }); return value; }
|
|
164
|
+
function loadProfile(context, id) { return loadAgentProfile(context.ddFlowHome, id); }
|
|
120
165
|
function event(file, value) { fs.appendFileSync(file, `${JSON.stringify(value)}\n`); }
|
|
166
|
+
function guarded(context, request) { const guard = recoveryGuard(context, request.project_id, request.run_id); return Boolean(guard && ["draining", "sealed", "resuming"].includes(guard.status)); }
|
|
121
167
|
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
122
168
|
function processAlive(pid) { if (!pid)
|
|
123
169
|
return false; try {
|
|
@@ -217,7 +217,7 @@ export function applyMigration(context, input) {
|
|
|
217
217
|
chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
|
|
218
218
|
};
|
|
219
219
|
try {
|
|
220
|
-
writeDb.
|
|
220
|
+
writeDb.beginWriteTransaction();
|
|
221
221
|
writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
|
|
222
222
|
writeDb.exec("COMMIT");
|
|
223
223
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome } from "./managed-processes.js";
|
|
6
|
+
import { retainedDaemonOperations } from "./run-control.js";
|
|
7
|
+
import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
|
|
8
|
+
/** A replaced daemon is a settled resource, never another Session to resume. */
|
|
9
|
+
export function historicalDaemonSettlement(context, scopeId, record) {
|
|
10
|
+
const metadata = JSON.parse(record.metadata_json);
|
|
11
|
+
if (!record.kind.endsWith("-daemon") || !metadata.state_dir || !path.isAbsolute(metadata.state_dir))
|
|
12
|
+
throw new AppError("runtime_scope_owner_unknown", "Native history requires an absolute daemon directory", 1);
|
|
13
|
+
const current = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
|
|
14
|
+
if (current.resource_process?.id === record.id)
|
|
15
|
+
return null;
|
|
16
|
+
if (record.state !== "stopped" || record.termination_reason !== "daemon_stopped" || !record.pid || !record.pid_started_at || processTreeIsAlive(record))
|
|
17
|
+
throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no physical completion proof", 1);
|
|
18
|
+
const directory = path.join(metadata.state_dir, "daemon-history");
|
|
19
|
+
const matches = fs.readdirSync(directory).filter(name => /^[a-zA-Z0-9-]+\.json$/.test(name)).map(name => {
|
|
20
|
+
const file = path.join(directory, name);
|
|
21
|
+
if (!fs.lstatSync(file).isFile())
|
|
22
|
+
throw new AppError("runtime_scope_owner_unknown", "Daemon history must contain regular receipts", 1);
|
|
23
|
+
const bytes = fs.readFileSync(file, "utf8");
|
|
24
|
+
return { file, bytes, state: JSON.parse(bytes) };
|
|
25
|
+
}).filter(candidate => candidate.state.resource_process?.id === record.id);
|
|
26
|
+
if (matches.length !== 1)
|
|
27
|
+
throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no unique retained receipt", 1);
|
|
28
|
+
const { file, bytes, state } = matches[0], owner = state.config?.runtime_owner;
|
|
29
|
+
if (!state.daemon_id || path.basename(file) !== `${state.daemon_id}.json` || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.lease_token !== record.lease_token || state.pid !== record.pid)
|
|
30
|
+
throw new AppError("runtime_scope_owner_unknown", "Archived daemon does not match its registration", 1);
|
|
31
|
+
const closed = state.shutdown_state === "clean" && state.active_tree === false
|
|
32
|
+
? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
|
|
33
|
+
const fresh = managedProcessStatus(context).find(item => item.id === record.id);
|
|
34
|
+
if (!closed || !fresh || fresh.lease_token !== record.lease_token || fresh.pid !== record.pid || fresh.pid_started_at !== record.pid_started_at || fresh.metadata_json !== record.metadata_json || fresh.state !== "stopped" || fresh.termination_reason !== "daemon_stopped" || processTreeIsAlive(fresh) || fs.readFileSync(file, "utf8") !== bytes)
|
|
35
|
+
throw new AppError("runtime_scope_outcome_unknown", "Previous daemon lacks stable clean shutdown evidence", 1);
|
|
36
|
+
const providers = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, fresh, state));
|
|
37
|
+
if (state.provider_process && (providers.length !== 1 || providers[0].state !== "stopped" || providers[0].termination_reason !== "daemon_stopped" || !providers[0].operation_id?.startsWith(`${owner.operation_id}:`) || processTreeIsAlive(providers[0])))
|
|
38
|
+
throw new AppError("runtime_scope_outcome_unknown", "Archived provider lacks its exact physical completion proof", 1);
|
|
39
|
+
return { historical: true, process_id: record.id, role: metadata.role, state_dir: metadata.state_dir, daemon_id: state.daemon_id,
|
|
40
|
+
processes: providers.map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) })),
|
|
41
|
+
settled: true, native_settled: true, operation_id: String(closed.operation_id), owner_operation_id: owner.operation_id,
|
|
42
|
+
inventory_version: crypto.createHash("sha256").update(JSON.stringify([bytes, closed])).digest("hex") };
|
|
43
|
+
}
|
|
44
|
+
export function assertHistoricalDaemonSettlement(context, scopeId, proof) {
|
|
45
|
+
const record = managedProcessStatus(context).find(item => item.id === proof.process_id);
|
|
46
|
+
const fresh = record && historicalDaemonSettlement(context, scopeId, record);
|
|
47
|
+
if (!fresh || proof.historical !== true || proof.native_settled !== true || fresh.inventory_version !== proof.inventory_version)
|
|
48
|
+
throw new AppError("runtime_scope_inventory_changed", "Archived daemon settlement changed after observation", 1);
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
3
|
+
export async function cancelControlledNativeSession(input) {
|
|
4
|
+
input.assertCurrent();
|
|
5
|
+
const reply = await runHarnessAdapter({ executable: input.executable,
|
|
6
|
+
args: input.observe ? ["daemon", "operation", "--operation-id", input.operationId, "--state-dir", input.stateDir, "--json"] : ["session", "cancel", "--session-id", input.sessionId, "--state-dir", input.stateDir, "--json"],
|
|
7
|
+
env: input.env, operationId: input.observe ? `${input.operationId}:observe` : input.operationId, timeoutMs: 30_000 });
|
|
8
|
+
if (input.observe && (reply.operation_id !== input.operationId || reply.operation !== "session.cancel" || reply.session_id !== input.sessionId))
|
|
9
|
+
throw new AppError("controller_operation_mismatch", "Cancellation observation belongs to another operation or Session", 1);
|
|
10
|
+
if (input.observe && reply.state === "failed") {
|
|
11
|
+
if (!reply.error || typeof reply.error !== "object" || Array.isArray(reply.error))
|
|
12
|
+
throw new AppError("controller_operation_mismatch", "Cancellation failure has no terminal error", 1);
|
|
13
|
+
input.assertCurrent();
|
|
14
|
+
return { status: "failed", receipt: reply };
|
|
15
|
+
}
|
|
16
|
+
const result = input.observe ? reply.state === "completed" ? reply.result : null : reply;
|
|
17
|
+
// A completed cancel command is an immutable ACK, not proof that the
|
|
18
|
+
// provider tree has stopped. Callers must use a separate fresh inspection.
|
|
19
|
+
if (!input.observe || reply.state === "completed")
|
|
20
|
+
assertNativeCancellationReceipt(result, input.sessionId);
|
|
21
|
+
input.assertCurrent();
|
|
22
|
+
return { status: result ? "completed" : "outcome_unknown", receipt: (result ?? reply) };
|
|
23
|
+
}
|
|
24
|
+
export function assertNativeCancellationReceipt(value, sessionId) {
|
|
25
|
+
const identity = adapterSessionId(value);
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId)
|
|
27
|
+
throw new AppError("controller_operation_mismatch", "Cancellation receipt has no valid result for its target Session", 1);
|
|
28
|
+
}
|
|
29
|
+
/** Native observation is shared by RUN and non-Work EVAL owners. */
|
|
30
|
+
export async function inspectControlledNativeSession(input) {
|
|
31
|
+
input.assertCurrent();
|
|
32
|
+
const receipt = await runHarnessAdapter({ executable: input.executable, args: ["session", "inspect", "--session-id", input.sessionId, "--state-dir", input.stateDir, "--json"], env: input.env, operationId: input.operationId, timeoutMs: 30_000 });
|
|
33
|
+
if (adapterSessionId(receipt) !== input.sessionId)
|
|
34
|
+
throw new AppError("controller_operation_mismatch", "Session observation belongs to another target", 1);
|
|
35
|
+
if (input.harness && receipt.harness !== undefined && receipt.harness !== input.harness)
|
|
36
|
+
throw new AppError("controller_operation_mismatch", "Session observation belongs to another harness", 1);
|
|
37
|
+
input.assertCurrent();
|
|
38
|
+
return receipt;
|
|
39
|
+
}
|