@deksden-com/dd-flow-cli 0.9.0-beta.22 → 0.9.0-beta.24
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 +17 -0
- package/README.md +9 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/run-cli.js +3 -0
- package/dist/cli.js +0 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +34 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +22 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +30 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +28 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +18 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +62 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +78 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +259 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +93 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +387 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +123 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +435 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +282 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +168 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +157 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +80 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +419 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +598 -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 +119 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +33 -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 +61 -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 +28 -0
- package/dist/services/code-checks.js +25 -11
- package/dist/services/execution-policy.js +61 -0
- package/dist/services/external-work-launch.js +76 -0
- package/dist/services/harness-adapter.js +31 -14
- package/dist/services/runs.js +2 -1
- package/dist/services/stage-context.js +2 -2
- package/dist/services/vnext-execution-profile.js +38 -3
- package/dist/storage/database.js +21 -0
- package/package.json +20 -13
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { processSnapshot } from "./process-snapshot.mjs";
|
|
2
|
+
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inspectDaemonOperation } from "./daemon-operations.mjs";
|
|
5
|
+
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
6
|
+
import { reportedError } from "./operation-errors.mjs";
|
|
7
|
+
import { ObservationClock } from "./observation-clock.mjs";
|
|
8
|
+
import { runtimeProcess } from "./managed-daemon.mjs";
|
|
9
|
+
|
|
10
|
+
/** Explicit same-Session restart retains native stores and the old daemon
|
|
11
|
+
* receipt; it does not turn a terminal directory into a new execution. */
|
|
12
|
+
export async function authorizeRetainedDaemonResume(root, previous, sessionId, config) {
|
|
13
|
+
const sessions = [...(previous.sessions ?? []), ...(previous.root_session_id ? [previous.root_session_id] : [])];
|
|
14
|
+
if (previous.shutdown_state !== "clean" || previous.active_tree === true || typeof sessionId !== "string" || !sessions.some(session => (typeof session === "string" ? session : session.provider_session_id) === sessionId)) throw Object.assign(new Error("terminal daemon restart requires a retained, settled Session identity"), { code: "daemon_state_terminal" });
|
|
15
|
+
for (const key of ["cwd", "projectRoot", "ddFlowHome", "ddFlowBin", "provider", "model", "reasoning", "mode", "variant", "agent", "bin", "noFlow"]) {
|
|
16
|
+
if ((previous.config?.[key] ?? null) !== (config[key] ?? null)) throw Object.assign(new Error(`retained daemon configuration changed: ${key}`), { code: "daemon_config_mismatch" });
|
|
17
|
+
}
|
|
18
|
+
await assertDaemonReplaceable(root);
|
|
19
|
+
await reconcileDriverReplies(root);
|
|
20
|
+
if (!/^[a-zA-Z0-9-]+$/.test(previous.daemon_id ?? "")) throw Object.assign(new Error("original daemon identity is invalid"), { code: "daemon_state_terminal" });
|
|
21
|
+
const directory = path.join(root, "daemon-history"); await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
22
|
+
const file = path.join(directory, `${previous.daemon_id}.json`), bytes = `${JSON.stringify(previous, null, 2)}\n`;
|
|
23
|
+
try { await writeFile(file, bytes, { flag: "wx", mode: 0o600 }); }
|
|
24
|
+
catch (error) { if (error.code !== "EEXIST" || await readFile(file, "utf8") !== bytes) throw error; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A dead bridge alone does not prove its provider tree was stopped. Require
|
|
28
|
+
* the adapter's durable clean-shutdown receipt as well, for every bridge. */
|
|
29
|
+
export async function recoverySettlement(driversRoot) {
|
|
30
|
+
const daemons = [];
|
|
31
|
+
const registries = new Map();
|
|
32
|
+
async function visit(directory) {
|
|
33
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
34
|
+
const daemonRoot = entries.some(entry => entry.name === "daemon.json" && entry.isFile());
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const file = path.join(directory, entry.name);
|
|
37
|
+
if (entry.isSymbolicLink()) throw Object.assign(new Error("driver evidence contains a symbolic link"), { code: "recovery_settlement_unconfirmed" });
|
|
38
|
+
// Native stores contain package/auth symlinks, not adapter daemon
|
|
39
|
+
// receipts. Do not traverse their contents as a daemon inventory.
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
if (daemonRoot && ["gemini", "grok-home", "home", "xdg-data", "xdg-cache", "xdg-config"].includes(entry.name)) continue;
|
|
42
|
+
await visit(file);
|
|
43
|
+
}
|
|
44
|
+
else if (entry.name === "daemon.json") {
|
|
45
|
+
const state = JSON.parse(await readFile(file, "utf8"));
|
|
46
|
+
if (state.shutdown_state !== "clean" || state.active_tree === true) throw Object.assign(new Error("provider tree has no clean shutdown receipt"), { code: "recovery_settlement_unconfirmed" });
|
|
47
|
+
await assertDaemonReplaceable(directory);
|
|
48
|
+
await reconcileDriverReplies(directory);
|
|
49
|
+
if (state.config?.resourceHome && state.resource_process?.id) {
|
|
50
|
+
const key = state.config.resourceHome;
|
|
51
|
+
if (!registries.has(key)) registries.set(key, await runtimeProcess(state.config, "status"));
|
|
52
|
+
const processes = registries.get(key)?.processes;
|
|
53
|
+
if (!Array.isArray(processes) || !processes.some(record => record.id === state.resource_process.id)) throw Object.assign(new Error("managed daemon registration is missing"), { code: "recovery_settlement_unconfirmed" });
|
|
54
|
+
const attemptRoot = path.dirname(path.resolve(driversRoot));
|
|
55
|
+
for (const record of processes.filter(record => record.id === state.resource_process.id || [record.stdout_path, record.stderr_path].some(file => typeof file === "string" && path.resolve(file).startsWith(`${attemptRoot}${path.sep}`)))) {
|
|
56
|
+
if (!["stopped", "failed"].includes(record.state)) throw Object.assign(new Error("a retained managed process has not settled"), { code: "recovery_settlement_unconfirmed", details: { process_id: record.id, state: record.state } });
|
|
57
|
+
if (Number.isInteger(record.pid) && record.pid > 0) {
|
|
58
|
+
try { process.kill(record.pid, 0); }
|
|
59
|
+
catch (error) { if (error.code === "ESRCH") continue; }
|
|
60
|
+
if (typeof record.pid_started_at === "string" && record.pid_started_at.trim()) {
|
|
61
|
+
const observed = (await processSnapshot()).find(item => item.pid === record.pid);
|
|
62
|
+
if (!observed || record.pid_started_at.trim().replace(/\s+/g, " ") !== observed.started) continue;
|
|
63
|
+
}
|
|
64
|
+
throw Object.assign(new Error("a retained managed process is still live or unconfirmed"), { code: "recovery_settlement_unconfirmed", details: { process_id: record.id } });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
daemons.push({ state_file: file, daemon_id: state.daemon_id, shutdown_state: state.shutdown_state, pid: state.pid });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await visit(driversRoot);
|
|
73
|
+
if (!daemons.length) throw Object.assign(new Error("no provider shutdown evidence found"), { code: "recovery_settlement_unconfirmed" });
|
|
74
|
+
return { settled: true, reason: "observed_clean_adapter_shutdown", observed_at: new Date().toISOString(), daemons };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function assertDaemonReplaceable(root) {
|
|
78
|
+
let state;
|
|
79
|
+
try { state = JSON.parse(await readFile(path.join(root, "daemon.json"), "utf8")); }
|
|
80
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
81
|
+
if (!Number.isInteger(state.pid) || state.pid <= 0) throw Object.assign(new Error("original daemon ownership is unknown"), { code: "operation_observation_lost", details: { state_dir: root } });
|
|
82
|
+
const pids = new Set([state.pid, state.provider_pid, ...(state.owned_processes ?? []).map(process => process.pid)].filter(pid => Number.isInteger(pid) && pid > 0));
|
|
83
|
+
for (const pid of pids) {
|
|
84
|
+
try { process.kill(pid, 0); }
|
|
85
|
+
catch (error) { if (error.code === "ESRCH") continue; }
|
|
86
|
+
const retained = state.owned_processes?.filter(record => record.pid === pid && typeof record.started === "string" && record.started.trim());
|
|
87
|
+
if (retained?.length) {
|
|
88
|
+
const observed = (await processSnapshot()).find(record => record.pid === pid);
|
|
89
|
+
if (!observed || retained.every(record => record.started !== observed.started)) continue;
|
|
90
|
+
}
|
|
91
|
+
throw Object.assign(new Error("original daemon/provider is live or its ownership is unconfirmed; do not create a competing bridge"), { code: "operation_observation_lost", details: { state_dir: root, pid } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Observe the original operation; never re-send its parameters. */
|
|
96
|
+
export async function recoverDriverReply(root, id, { timeoutMs = 30_000, pollMs = 250 } = {}) {
|
|
97
|
+
const clock = new ObservationClock({ timeoutMs });
|
|
98
|
+
do {
|
|
99
|
+
let receipt;
|
|
100
|
+
try { receipt = await inspectDaemonOperation(root, id); }
|
|
101
|
+
catch (error) { if (error.code !== "operation_not_found") throw error; }
|
|
102
|
+
if (receipt?.state === "completed") return receipt.result;
|
|
103
|
+
if (receipt?.state === "failed") throw reportedError(receipt.error, "provider operation failed");
|
|
104
|
+
if (clock.sample()) break;
|
|
105
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
106
|
+
} while (true);
|
|
107
|
+
throw Object.assign(new Error("original provider operation has no confirmed reply; reconcile it before starting another Turn"), {
|
|
108
|
+
code: "operation_observation_lost", details: { operation_id: id, state_dir: root }
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Crash recovery barrier. Import late replies into the client ledger before
|
|
113
|
+
* any subsequent productive call, even when a new runner process is used. */
|
|
114
|
+
export async function reconcileDriverReplies(root) {
|
|
115
|
+
const directory = path.join(root, "client-operations");
|
|
116
|
+
let entries;
|
|
117
|
+
try { entries = await readdir(directory); } catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
118
|
+
for (const entry of entries.filter(name => name.endsWith(".json"))) {
|
|
119
|
+
const file = path.join(directory, entry), record = JSON.parse(await readFile(file, "utf8"));
|
|
120
|
+
if (["completed", "failed"].includes(record.state)) continue;
|
|
121
|
+
let receipt;
|
|
122
|
+
try { receipt = await inspectDaemonOperation(root, record.operation_id); }
|
|
123
|
+
catch (error) { if (error.code !== "operation_not_found") throw error; }
|
|
124
|
+
if (!["completed", "failed"].includes(receipt?.state)) throw Object.assign(new Error("a previous driver request has an unknown outcome; no new productive request was sent"), {
|
|
125
|
+
code: "operation_observation_lost", details: { operation_id: record.operation_id, state_dir: root }
|
|
126
|
+
});
|
|
127
|
+
await writeJsonAtomic(file, { ...record, state: receipt.state, result: receipt.result, error: receipt.error, reconciled_at: new Date().toISOString() });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open, readFile, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Cache native filenames, then read only a bounded header and small settings.
|
|
6
|
+
* New child discovery may walk the directory once; heartbeats never replay it. */
|
|
7
|
+
export class DroidMetadataReader {
|
|
8
|
+
constructor(factory) { this.factory = factory; this.files = new Map(); this.cache = new Map(); }
|
|
9
|
+
async locate(id) {
|
|
10
|
+
if (this.files.has(id)) return this.files.get(id);
|
|
11
|
+
const visit = async directory => {
|
|
12
|
+
for (const entry of await readdir(directory, { withFileTypes: true }).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
|
|
13
|
+
const file = path.join(directory, entry.name);
|
|
14
|
+
if (entry.isDirectory()) await visit(file);
|
|
15
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) this.files.set(entry.name.slice(0, -6), file);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
await visit(path.join(this.factory, "sessions"));
|
|
19
|
+
return this.files.get(id) ?? null;
|
|
20
|
+
}
|
|
21
|
+
async read(id) {
|
|
22
|
+
const file = await this.locate(id); if (!file) return null;
|
|
23
|
+
const settingsFile = file.replace(/\.jsonl$/, ".settings.json");
|
|
24
|
+
const info = await stat(settingsFile).catch(error => { if (error.code === "ENOENT") return null; throw error; });
|
|
25
|
+
if (!info || info.size > 1_048_576) return null;
|
|
26
|
+
const identity = `${info.ino}:${info.mtimeMs}:${info.size}`;
|
|
27
|
+
if (this.cache.get(id)?.identity === identity) return this.cache.get(id).value;
|
|
28
|
+
const handle = await open(file, "r"); const buffer = Buffer.alloc(65_536);
|
|
29
|
+
let header;
|
|
30
|
+
try { const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); header = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8").split("\n")[0]); }
|
|
31
|
+
finally { await handle.close(); }
|
|
32
|
+
if (header.type !== "session_start" || header.id !== id) throw Object.assign(new Error("native metadata Session identity differs"), { code: "session_identity_mismatch" });
|
|
33
|
+
let bytes, settings;
|
|
34
|
+
try { bytes = await readFile(settingsFile, "utf8"); settings = JSON.parse(bytes); }
|
|
35
|
+
catch (error) { if (error instanceof SyntaxError || error.code === "ENOENT") return null; throw error; }
|
|
36
|
+
const value = { header, settings, transcript_path: file, source: { path: settingsFile, sha256: createHash("sha256").update(bytes).digest("hex") } };
|
|
37
|
+
this.cache.set(id, { identity, value }); return value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseDroidRoutingLine(line) {
|
|
42
|
+
if (!line.includes("[Model-Router] Model transition | Context: ")) return null;
|
|
43
|
+
let context; try { context = JSON.parse(line.split(" | Context: ").slice(1).join(" | Context: ")); } catch { return null; }
|
|
44
|
+
if (context.slot !== "main" || typeof context.sessionId !== "string" || typeof context.modelId !== "string") return null;
|
|
45
|
+
return { session_id: context.sessionId, model: context.modelId, previous_model: context.previousModelId ?? null,
|
|
46
|
+
reason: typeof context.reason === "string" ? context.reason : null, native_timestamp: /^\[([^\]]+)\]/.exec(line)?.[1] ?? null,
|
|
47
|
+
event_sha256: createHash("sha256").update(line).digest("hex") };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Keep the cursor in daemon state. Bound every read, tolerate incomplete lines
|
|
51
|
+
* and restart from zero after truncation/rotation of this execution's own log. */
|
|
52
|
+
export async function readDroidRoutingLog(file, cursor = {}) {
|
|
53
|
+
let handle;
|
|
54
|
+
try { handle = await open(file, "r"); } catch (error) { if (error.code === "ENOENT") return { cursor, events: [] }; throw error; }
|
|
55
|
+
try {
|
|
56
|
+
const info = await handle.stat();
|
|
57
|
+
const offset = info.ino === cursor.ino && info.size >= (cursor.offset ?? 0) ? cursor.offset ?? 0 : 0;
|
|
58
|
+
const buffer = Buffer.alloc(Math.min(1_048_576, Math.max(0, info.size - offset)));
|
|
59
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
60
|
+
const end = buffer.subarray(0, bytesRead).lastIndexOf(10) + 1;
|
|
61
|
+
const lines = buffer.subarray(0, end).toString("utf8").split("\n");
|
|
62
|
+
// Persist offsets only, never a partial native line that may contain secrets.
|
|
63
|
+
return { cursor: { ino: info.ino, offset: offset + (end || (bytesRead === buffer.length && bytesRead === 1_048_576 ? bytesRead : 0)) }, events: lines.map(parseDroidRoutingLine).filter(Boolean) };
|
|
64
|
+
|
|
65
|
+
} finally { await handle.close(); }
|
|
66
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { commandJson } from "./process-json.mjs";
|
|
3
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
4
|
+
|
|
5
|
+
const leases = new Map();
|
|
6
|
+
|
|
7
|
+
export function assertDaemonOwnership() {
|
|
8
|
+
for (const lease of leases.values()) if (lease.error) throw lease.error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function runtimeProcess(config, action, options = {}) {
|
|
12
|
+
if (!config.resourceHome || !config.ddFlowHome || !config.ddFlowBin) return null;
|
|
13
|
+
const args = ["runtime", "process", action];
|
|
14
|
+
for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
|
|
15
|
+
return await commandJson(config.ddFlowBin, args, {
|
|
16
|
+
cwd: config.cwd,
|
|
17
|
+
env: { ...(config.env ?? {}), DD_FLOW_HOME: config.ddFlowHome, DD_FLOW_RESOURCE_HOME: config.resourceHome }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function registerDaemonProcess(config, input) {
|
|
22
|
+
const result = await runtimeProcess(config, "register", { ...input, "owner-pid": process.pid, "lease-ms": 300_000 });
|
|
23
|
+
return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function confirmDaemonProcess(config, record, child) {
|
|
27
|
+
if (!record) return;
|
|
28
|
+
if (!child?.pid) {
|
|
29
|
+
await finishDaemonProcess(config, record, "failed", "daemon_spawn_missing_pid");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function heartbeatDaemonProcess(config, record) {
|
|
36
|
+
if (!record?.id || !record.lease_token) return;
|
|
37
|
+
let lease = leases.get(record.id);
|
|
38
|
+
if (!lease) {
|
|
39
|
+
lease = { pending: null, error: null, timer: null, lost: false };
|
|
40
|
+
leases.set(record.id, lease);
|
|
41
|
+
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
42
|
+
lease.timer.unref();
|
|
43
|
+
}
|
|
44
|
+
if (lease.lost) throw lease.error;
|
|
45
|
+
if (lease.pending) return await lease.pending;
|
|
46
|
+
lease.pending = (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const result = await runtimeProcess(config, "heartbeat", { id: record.id, "lease-token": record.lease_token, "lease-ms": 300_000 });
|
|
49
|
+
if (result?.ok !== true) {
|
|
50
|
+
lease.lost = true; clearInterval(lease.timer);
|
|
51
|
+
throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
|
|
52
|
+
}
|
|
53
|
+
lease.error = null;
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
|
|
56
|
+
throw lease.error;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
try { await lease.pending; } finally { lease.pending = null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function finishDaemonProcess(config, process, state = "stopped", reason = "daemon_stopped") {
|
|
63
|
+
if (process?.id && process.lease_token) {
|
|
64
|
+
const lease = leases.get(process.id); if (lease) clearInterval(lease.timer);
|
|
65
|
+
const result = await runtimeProcess(config, "finish", { id: process.id, "lease-token": process.lease_token, state, reason });
|
|
66
|
+
if (result?.ok !== true) throw Object.assign(new Error("managed process finish was not accepted"), { code: "process_lease_lost", details: { process_id: process.id } });
|
|
67
|
+
leases.delete(process.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Preserve the startup error and retain the lease/resources if cleanup cannot
|
|
72
|
+
* prove termination. A failed cleanup is not a terminal managed process. */
|
|
73
|
+
export async function cleanupFailedStart(config, record, child, original) {
|
|
74
|
+
try {
|
|
75
|
+
await stopProcessGroup(child);
|
|
76
|
+
await finishDaemonProcess(config, record, "failed", original.code ?? "startup_failed");
|
|
77
|
+
} catch (cleanup) {
|
|
78
|
+
original.details = { ...original.details, cleanup_failed: errorRecord(cleanup), process_id: record?.id ?? null, pid: child?.pid ?? null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function stopProcessGroup(child, graceMs = 1_000) {
|
|
83
|
+
if (!child?.pid) return;
|
|
84
|
+
// Harness bridges are detached on Unix, so their own process group is the
|
|
85
|
+
// smallest reliable unit of cleanup. Windows has no equivalent negative-PID
|
|
86
|
+
// signal operation; there the direct child is the safe best effort.
|
|
87
|
+
const target = process.platform === "win32" ? child.pid : -child.pid;
|
|
88
|
+
const alive = () => { try { process.kill(target, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; if (error.code === "EPERM") return true; throw error; } };
|
|
89
|
+
if (!alive()) return;
|
|
90
|
+
if (child.exitCode !== undefined && child.exitCode !== null) {
|
|
91
|
+
// The leader can exit a few ticks before its helpers. Observe their normal
|
|
92
|
+
// shutdown before failing ownership; never signal a leaderless group.
|
|
93
|
+
const deadline = Date.now() + Math.max(1_000, graceMs);
|
|
94
|
+
while (alive() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
95
|
+
if (!alive()) return;
|
|
96
|
+
throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: { pid: child.pid } });
|
|
97
|
+
}
|
|
98
|
+
process.kill(target, "SIGTERM");
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
100
|
+
if (!alive()) return;
|
|
101
|
+
process.kill(target, "SIGKILL");
|
|
102
|
+
// Grace controls escalation, not the OS acknowledgement budget. A caller
|
|
103
|
+
// testing immediate escalation must still allow the kernel to reap children.
|
|
104
|
+
const deadline = Date.now() + Math.max(1_000, graceMs);
|
|
105
|
+
while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
106
|
+
if (alive()) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete" });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A daemon acknowledgement is not a completed stop until its endpoint closes. */
|
|
110
|
+
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000) {
|
|
111
|
+
const result = await request();
|
|
112
|
+
const deadline = Date.now() + timeoutMs;
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
try { await lstat(socket); }
|
|
115
|
+
catch (error) { if (error.code === "ENOENT") return result; throw error; }
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
117
|
+
}
|
|
118
|
+
throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
|
|
119
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { withRunnerLock } from "./runner-lock.mjs";
|
|
5
|
+
|
|
6
|
+
const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
7
|
+
const fields = ["provider", "model", "reasoning", "mode", "permission_mode"];
|
|
8
|
+
export const observedFields = value => Object.fromEntries(fields.map(key => [key, text(value?.[key])]));
|
|
9
|
+
export const modelObservationFile = journal => path.join(path.dirname(journal), "model-observations.jsonl");
|
|
10
|
+
|
|
11
|
+
/** Routing is allowed. Permission/autonomy changes remain integrity failures.
|
|
12
|
+
* Missing native fields are unknown, never proof that the request was applied. */
|
|
13
|
+
export function checkObservedProfile(requested, observed, { strict = false, required = [] } = {}) {
|
|
14
|
+
const unknown = fields.filter(key => requested?.[key] && !observed?.[key]);
|
|
15
|
+
const changes = fields.filter(key => requested?.[key] && observed?.[key] && requested[key] !== observed[key]);
|
|
16
|
+
const violations = changes.filter(key => strict || ["mode", "permission_mode"].includes(key));
|
|
17
|
+
const missing = required.filter(key => !observed?.[key]);
|
|
18
|
+
if (violations.length || missing.length) throw Object.assign(new Error("native execution profile integrity is not confirmed"), {
|
|
19
|
+
code: "profile_integrity_violation", details: { requested: observedFields(requested), observed: observedFields(observed), violations, missing }
|
|
20
|
+
});
|
|
21
|
+
return { requested: observedFields(requested), observed: observedFields(observed), status: unknown.length ? "incomplete" : changes.length ? "mixed" : "matched", matched: !unknown.length && !changes.length, unknown, changes };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readModelObservations(journal) {
|
|
25
|
+
try { const text = await readFile(modelObservationFile(journal), "utf8"); return text.slice(0, text.lastIndexOf("\n") + 1).split("\n").filter(Boolean).map(JSON.parse); }
|
|
26
|
+
catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Only adapter-verified native facts enter this file. It records changes,
|
|
30
|
+
* not every tool or token; reading it never requires replaying native history. */
|
|
31
|
+
export async function observeModel({ journal, harness, sessionId, parentSessionId = null, requested = {}, observed = {}, source, evidence = "configured", reason = null, nativeTimestamp = null, previousModel = null }) {
|
|
32
|
+
if (!journal || !sessionId) return null;
|
|
33
|
+
if (!source || !["configured", "request", "response", "unavailable"].includes(evidence)) throw new Error("model observation requires a native source and evidence level");
|
|
34
|
+
const file = modelObservationFile(journal), profile = observedFields(observed);
|
|
35
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
|
+
return withRunnerLock(file, async () => {
|
|
37
|
+
const events = await readModelObservations(journal);
|
|
38
|
+
const replay = source?.event_sha256 ? events.find(item => item.session_id === sessionId && item.source?.event_sha256 === source.event_sha256) : null;
|
|
39
|
+
if (replay) return replay;
|
|
40
|
+
const channel = source?.channel ?? evidence;
|
|
41
|
+
const previous = events.findLast(item => item.harness === harness && item.session_id === sessionId && (item.source?.channel ?? item.evidence) === channel);
|
|
42
|
+
const normalizedReason = text(reason)?.slice(0, 200) ?? null;
|
|
43
|
+
if (previous && JSON.stringify(previous.observed) === JSON.stringify(profile) && previous.reason === normalizedReason && previous.evidence === evidence) return previous;
|
|
44
|
+
const previousKnown = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.model);
|
|
45
|
+
const fromModel = text(previousModel) ?? previousKnown?.observed.model ?? null;
|
|
46
|
+
const changed = Boolean(profile.model && fromModel && profile.model !== fromModel);
|
|
47
|
+
const value = { schema_id: "dd-flow/model-observation@1", sequence: events.length + 1,
|
|
48
|
+
harness, session_id: sessionId, parent_session_id: parentSessionId,
|
|
49
|
+
kind: changed ? "model_changed" : !profile.model ? "model_observation_unavailable" : "model_observed",
|
|
50
|
+
previous_model: fromModel, requested: observedFields(requested), observed: profile,
|
|
51
|
+
source, evidence, reason: normalizedReason, native_timestamp: text(nativeTimestamp), observed_at: new Date().toISOString() };
|
|
52
|
+
value.id = createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
53
|
+
const handle = await open(file, "a", 0o600);
|
|
54
|
+
try {
|
|
55
|
+
const bytes = await readFile(file); const committed = bytes.lastIndexOf(10) + 1;
|
|
56
|
+
if (committed < bytes.length) await handle.truncate(committed);
|
|
57
|
+
await handle.write(`${JSON.stringify(value)}\n`); await handle.sync();
|
|
58
|
+
} finally { await handle.close(); }
|
|
59
|
+
const directory = await open(path.dirname(file), "r");
|
|
60
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
61
|
+
return value;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function modelAttribution(events) {
|
|
66
|
+
const sessions = new Map();
|
|
67
|
+
for (const event of events) {
|
|
68
|
+
const key = `${event.harness}:${event.session_id}`;
|
|
69
|
+
const session = sessions.get(key) ?? { harness: event.harness, session_id: event.session_id, parent_session_id: event.parent_session_id, requested: event.requested, observations: [] };
|
|
70
|
+
session.observations.push(event); sessions.set(key, session);
|
|
71
|
+
}
|
|
72
|
+
const models = [...new Set(events.map(event => event.observed?.model).filter(Boolean))];
|
|
73
|
+
const incomplete = !events.length || [...sessions.values()].some(session => session.observations.some(event => event.evidence === "unavailable" || !event.observed?.model));
|
|
74
|
+
return { models, mixed: models.length > 1 || events.some(event => event.observed?.model && event.requested?.model && event.observed.model !== event.requested.model),
|
|
75
|
+
observation_completeness: incomplete ? "incomplete" : "available_native_sources", transitions: events.filter(event => event.kind === "model_changed"),
|
|
76
|
+
sessions: [...sessions.values()], usage_attribution: "session_totals_unattributed_to_model", note: "Native settings/events do not prove undisclosed server routing or per-model token costs." };
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Only observed time counts toward inactivity. A scheduling/clock gap is not
|
|
2
|
+
* evidence of OS sleep, nor of provider progress. Call sample regularly. */
|
|
3
|
+
export class ObservationClock {
|
|
4
|
+
constructor({ timeoutMs, gapMs = 60_000, wall = Date.now, monotonic = () => performance.now(), onGap = () => {} }) {
|
|
5
|
+
if (!(timeoutMs > 0) || !(gapMs > 0)) throw new RangeError("positive observation timeout and gap required");
|
|
6
|
+
Object.assign(this, { timeoutMs, gapMs, wall, monotonic, onGap });
|
|
7
|
+
this.lastWall = wall(); this.lastMono = monotonic(); this.elapsed = 0;
|
|
8
|
+
this.progress = undefined;
|
|
9
|
+
}
|
|
10
|
+
sample(progress) {
|
|
11
|
+
const wall = this.wall(), mono = this.monotonic();
|
|
12
|
+
const delta = mono - this.lastMono, wallDelta = wall - this.lastWall;
|
|
13
|
+
const gap = delta > this.gapMs || wallDelta > this.gapMs || delta < 0 || wallDelta < 0;
|
|
14
|
+
if (gap) {
|
|
15
|
+
this.onGap({ source: "observer_clock", classification: "observation_gap", confirmed_sleep: false,
|
|
16
|
+
started_at: new Date(this.lastWall).toISOString(), observed_at: new Date(wall).toISOString(),
|
|
17
|
+
wall_delta_ms: wallDelta, monotonic_delta_ms: delta });
|
|
18
|
+
this.elapsed = 0;
|
|
19
|
+
} else this.elapsed += delta;
|
|
20
|
+
this.lastWall = wall; this.lastMono = mono;
|
|
21
|
+
if (progress !== undefined && progress !== this.progress) { this.progress = progress; this.elapsed = 0; }
|
|
22
|
+
return this.elapsed >= this.timeoutMs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Node timeout handle: callers can keep their existing clearTimeout cleanup. */
|
|
27
|
+
export function observedTimeout(callback, timeoutMs, options = {}) {
|
|
28
|
+
const clock = new ObservationClock({ timeoutMs, ...options });
|
|
29
|
+
const timer = setInterval(() => {
|
|
30
|
+
if (clock.sample(options.progress?.())) { clearInterval(timer); callback(); }
|
|
31
|
+
}, Math.max(1, Math.min(1_000, timeoutMs / 4)));
|
|
32
|
+
return timer;
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
// The existing runner operation owns its child driver requests, including calls
|
|
4
|
+
// nested in Judge/HITL/fanout. Async context keeps parallel executions separate.
|
|
5
|
+
export const operationContext = new AsyncLocalStorage();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// An observer losing its response does not establish that a provider Turn failed.
|
|
2
|
+
export function isObservationLoss(error) {
|
|
3
|
+
return ["rpc_timeout", "daemon_timeout", "turn_timeout", "operation_observation_lost", "daemon_connection_closed"].includes(error?.code);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function errorRecord(error, depth = 0) {
|
|
7
|
+
return {
|
|
8
|
+
code: typeof error?.code === "string" ? error.code : "operation_failed",
|
|
9
|
+
message: typeof error?.message === "string" ? error.message : String(error),
|
|
10
|
+
...(typeof error?.retryable === "boolean" ? { retryable: error.retryable } : {}),
|
|
11
|
+
...(error?.details !== undefined ? { details: error.details } : {}),
|
|
12
|
+
...(error?.cause && depth < 3 ? { cause: errorRecord(error.cause, depth + 1) } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function reportedError(value, fallback) {
|
|
17
|
+
const record = errorRecord({ ...value, message: value.message ?? fallback });
|
|
18
|
+
return Object.assign(new Error(record.message), record);
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { reportedError } from "./operation-errors.mjs";
|
|
3
|
+
|
|
4
|
+
function failure(message, code) {
|
|
5
|
+
return Object.assign(new Error(message), { code });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function reportedFailure(stdout, stderr, fallback) {
|
|
9
|
+
for (const text of [stdout, stderr]) {
|
|
10
|
+
for (const line of text.trim().split(/\n/).reverse()) {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(line);
|
|
13
|
+
const error = value?.error;
|
|
14
|
+
if (typeof error?.code === "string" && error.code) return reportedError(error, fallback);
|
|
15
|
+
} catch { /* Not a structured CLI error line. */ }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return failure(stderr.trim() || stdout.trim() || fallback, "flow_reconciliation_failed");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function processTarget(executable, args) {
|
|
22
|
+
return /\.[cm]?js$/.test(executable) ? { command: process.execPath, args: [executable, ...args] } : { command: executable, args };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function commandJson(bin, args, { cwd, env = {}, input = null, onProgress = null } = {}) {
|
|
26
|
+
return await new Promise((resolve, reject) => {
|
|
27
|
+
// A canonical runtime may intentionally shadow the ambient `dd-flow`
|
|
28
|
+
// executable with its captured engine. Keep that choice in the supplied
|
|
29
|
+
// environment rather than letting a host-global shim leak into a run.
|
|
30
|
+
const executable = bin === "dd-flow" && typeof env.DD_FLOW_BIN === "string" ? env.DD_FLOW_BIN : bin;
|
|
31
|
+
const target = processTarget(executable, [...args, "--json"]);
|
|
32
|
+
const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env }, stdio: [input === null ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
33
|
+
let stdout = ""; let stderr = ""; let pendingStderr = "";
|
|
34
|
+
child.stdout.setEncoding("utf8").on("data", (data) => { stdout += data; });
|
|
35
|
+
child.stderr.setEncoding("utf8").on("data", (data) => {
|
|
36
|
+
stderr += data; pendingStderr += data;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const end = pendingStderr.indexOf("\n"); if (end < 0) break;
|
|
39
|
+
const line = pendingStderr.slice(0, end); pendingStderr = pendingStderr.slice(end + 1);
|
|
40
|
+
if (typeof onProgress === "function") { try { onProgress(JSON.parse(line)); } catch { /* Ordinary diagnostics stay in stderr evidence. */ } }
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
child.on("error", reject);
|
|
44
|
+
if (input !== null) child.stdin.end(input);
|
|
45
|
+
child.on("close", (status) => {
|
|
46
|
+
if (status !== 0) return reject(reportedFailure(stdout, stderr, `${executable} exited ${status}`));
|
|
47
|
+
try { resolve(JSON.parse(stdout.trim())); }
|
|
48
|
+
catch (error) { reject(failure(`${bin} returned invalid JSON: ${error.message}`, "flow_reconciliation_failed")); }
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function commandText(bin, args, { cwd, env = {} } = {}) {
|
|
54
|
+
return await new Promise((resolve, reject) => {
|
|
55
|
+
const executable = bin === "dd-flow" && typeof env.DD_FLOW_BIN === "string" ? env.DD_FLOW_BIN : bin;
|
|
56
|
+
const target = processTarget(executable, args);
|
|
57
|
+
const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = "";
|
|
58
|
+
child.stdout.setEncoding("utf8").on("data", (data) => { stdout += data; }); child.stderr.setEncoding("utf8").on("data", (data) => { stderr += data; }); child.on("error", reject);
|
|
59
|
+
child.on("close", (status) => status === 0 ? resolve(stdout.trim()) : reject(failure(stderr.trim() || stdout.trim() || `${executable} exited ${status}`, "command_failed")));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
|
|
5
|
+
/** Inspect OS descendants without using process names as ownership. A start-time
|
|
6
|
+
* identity prevents a later unrelated process with a recycled PID being killed. */
|
|
7
|
+
export async function processSnapshot() {
|
|
8
|
+
const { stdout } = await run("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,stat="], { timeout: 5_000 });
|
|
9
|
+
return stdout.trim().split("\n").flatMap(line => { const parts = line.trim().split(/\s+/); return parts.length >= 9 ? [{ pid: Number(parts[0]), ppid: Number(parts[1]), pgid: Number(parts[2]), started: parts.slice(3, 8).join(" "), zombie: parts[8].startsWith("Z") }] : []; });
|
|
10
|
+
}
|