@deksden-com/dd-flow-cli 0.9.0-beta.22 → 0.9.0-beta.23
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 +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/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 +249 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +90 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +382 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +123 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +434 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +279 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +167 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +153 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +80 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +420 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +593 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +119 -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/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +24 -0
- 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 +8 -2
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { observedTimeout } from "./observation-clock.mjs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
AcpBridge,
|
|
12
|
+
cancelSessionWithBridge,
|
|
13
|
+
cancelChildWithBridge,
|
|
14
|
+
createSessionWithBridge,
|
|
15
|
+
doctor,
|
|
16
|
+
forkSessionWithBridge,
|
|
17
|
+
inspectSessionWithBridge,
|
|
18
|
+
promptSessionWithBridge,
|
|
19
|
+
} from "./dd-zcode.mjs";
|
|
20
|
+
|
|
21
|
+
const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
|
|
22
|
+
const RESPONSE_SCHEMA = "dd-zcode/daemon-response@1";
|
|
23
|
+
const STATE_SCHEMA = "dd-zcode/daemon-state@1";
|
|
24
|
+
// An agent can spend several minutes in a single tool operation or provider
|
|
25
|
+
// turn without emitting an ACP update. This is an idle deadline, not a
|
|
26
|
+
// wall-clock cap: every observed update resets it.
|
|
27
|
+
export const DEFAULT_LIVENESS_TIMEOUT_MS = 600_000;
|
|
28
|
+
|
|
29
|
+
class DaemonError extends Error {
|
|
30
|
+
constructor(code, message, retryable = false, details) {
|
|
31
|
+
super(message); this.code = code; this.retryable = retryable; this.details = details;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function absolute(value, label) {
|
|
36
|
+
if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`);
|
|
37
|
+
return path.resolve(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function locations(stateDir) {
|
|
41
|
+
const dir = absolute(stateDir, "--state-dir");
|
|
42
|
+
const localSocket = path.join(dir, "daemon.sock");
|
|
43
|
+
const socket = Buffer.byteLength(localSocket) < 100
|
|
44
|
+
? localSocket
|
|
45
|
+
: `/tmp/dd-zcode-${createHash("sha256").update(dir).digest("hex").slice(0, 24)}.sock`;
|
|
46
|
+
return { dir, socket, state: path.join(dir, "daemon.json"), log: path.join(dir, "daemon.log") };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readState(stateDir) {
|
|
50
|
+
const file = locations(stateDir).state;
|
|
51
|
+
try { return JSON.parse(await readFile(file, "utf8")); }
|
|
52
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeState(file, value) {
|
|
56
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
57
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
58
|
+
await rename(temporary, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function removeSocket(socket, dir) {
|
|
62
|
+
if (socket !== locations(dir).socket) throw new DaemonError("unsafe_socket", "daemon socket does not match the exact state directory");
|
|
63
|
+
try {
|
|
64
|
+
const info = await lstat(socket);
|
|
65
|
+
if (!info.isSocket()) throw new DaemonError("unsafe_socket", "refusing to remove a non-socket daemon path");
|
|
66
|
+
await unlink(socket);
|
|
67
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errorPayload(error) {
|
|
71
|
+
return {
|
|
72
|
+
code: error.code ?? "operation_failed",
|
|
73
|
+
message: error.message ?? String(error),
|
|
74
|
+
retryable: error.retryable === true,
|
|
75
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
80
|
+
const { socket } = locations(stateDir);
|
|
81
|
+
const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
82
|
+
return await new Promise((resolve, reject) => {
|
|
83
|
+
const client = net.createConnection(socket);
|
|
84
|
+
let buffer = ""; let settled = false;
|
|
85
|
+
// A prompt has its own sliding ACP liveness deadline. Do not add an
|
|
86
|
+
// independent wall-clock deadline here: it can cut off an actively
|
|
87
|
+
// producing Turn merely because it is long.
|
|
88
|
+
const timer = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
89
|
+
? observedTimeout(() => { client.destroy(); reject(new DaemonError("daemon_timeout", `${operation} timed out`, true)); }, timeoutMs)
|
|
90
|
+
: null;
|
|
91
|
+
const finish = (error, value) => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true; if (timer) clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value);
|
|
94
|
+
};
|
|
95
|
+
client.setEncoding("utf8");
|
|
96
|
+
client.on("connect", () => client.write(`${JSON.stringify(request)}\n`));
|
|
97
|
+
client.on("data", (chunk) => {
|
|
98
|
+
buffer += chunk;
|
|
99
|
+
const newline = buffer.indexOf("\n");
|
|
100
|
+
if (newline < 0) return;
|
|
101
|
+
try {
|
|
102
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
103
|
+
if (response.schema_id !== RESPONSE_SCHEMA || response.id !== request.id) return finish(new DaemonError("daemon_protocol_mismatch", "invalid daemon response"));
|
|
104
|
+
if (!response.ok) return finish(new DaemonError(response.error?.code ?? "operation_failed", response.error?.message ?? "daemon operation failed", response.error?.retryable, response.error?.details));
|
|
105
|
+
finish(null, response.result);
|
|
106
|
+
} catch (error) { finish(new DaemonError("daemon_protocol_mismatch", error.message)); }
|
|
107
|
+
});
|
|
108
|
+
client.on("error", (error) => finish(new DaemonError("daemon_not_running", error.message, true)));
|
|
109
|
+
client.on("end", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
|
|
110
|
+
client.on("close", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sameConfig(state, config) {
|
|
115
|
+
return ["cwd", "journal", "bin", "zcodePath", "ddFlowBin", "ddFlowHome", "projectRoot", "resourceHome", "livenessTimeoutMs"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function startDaemon(options) {
|
|
119
|
+
const paths = locations(options.stateDir);
|
|
120
|
+
const hasFlowContext = Boolean(options.ddFlowHome || options.projectRoot || options.ddFlowBin);
|
|
121
|
+
if (hasFlowContext && (!options.ddFlowHome || !options.projectRoot)) {
|
|
122
|
+
throw new DaemonError("flow_context_incomplete", "--dd-flow-home and --project-root must be provided together for ZCode lifecycle forwarding");
|
|
123
|
+
}
|
|
124
|
+
const config = {
|
|
125
|
+
cwd: absolute(options.cwd, "--cwd"),
|
|
126
|
+
journal: absolute(options.journal, "--journal"),
|
|
127
|
+
bin: options.bin ?? null,
|
|
128
|
+
zcodePath: options.zcodePath ?? null,
|
|
129
|
+
// The worker itself invokes `dd-flow` from PATH. Use that same executable
|
|
130
|
+
// for the trusted event bridge unless a pinned executable was supplied.
|
|
131
|
+
ddFlowBin: hasFlowContext ? (options.ddFlowBin ? absolute(options.ddFlowBin, "--dd-flow-bin") : "dd-flow") : null,
|
|
132
|
+
ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null,
|
|
133
|
+
projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : null,
|
|
134
|
+
resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null,
|
|
135
|
+
env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
|
|
136
|
+
livenessTimeoutMs: options.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS,
|
|
137
|
+
};
|
|
138
|
+
await mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
139
|
+
await chmod(paths.dir, 0o700);
|
|
140
|
+
try {
|
|
141
|
+
const status = await callDaemon(paths.dir, "daemon.status", {}, 1000);
|
|
142
|
+
if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration");
|
|
143
|
+
return { ...status, already_running: true };
|
|
144
|
+
} catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
145
|
+
|
|
146
|
+
const previous = await readState(paths.dir);
|
|
147
|
+
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
148
|
+
if (previous?.shutdown_state === "running" && previous.active_tree) {
|
|
149
|
+
throw new DaemonError("invalid_harness_crash", "the previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
|
|
150
|
+
}
|
|
151
|
+
await removeSocket(paths.socket, paths.dir);
|
|
152
|
+
const versions = await doctor({ bin: config.bin, zcodePath: config.zcodePath });
|
|
153
|
+
const resourceProcess = await registerDaemonProcess(config, {
|
|
154
|
+
kind: "zcode-daemon", owner: `zcode:${path.basename(paths.dir)}`, operation: `zcode-daemon:${path.basename(paths.dir)}`,
|
|
155
|
+
stdout: paths.log, stderr: paths.log,
|
|
156
|
+
});
|
|
157
|
+
const state = {
|
|
158
|
+
schema_id: STATE_SCHEMA,
|
|
159
|
+
daemon_id: randomUUID(),
|
|
160
|
+
pid: null,
|
|
161
|
+
socket: paths.socket,
|
|
162
|
+
cwd: config.cwd,
|
|
163
|
+
journal: config.journal,
|
|
164
|
+
started_at: new Date().toISOString(),
|
|
165
|
+
shutdown_state: "starting",
|
|
166
|
+
active_tree: false,
|
|
167
|
+
recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start",
|
|
168
|
+
versions: versions.versions,
|
|
169
|
+
config,
|
|
170
|
+
sessions: previous?.sessions ?? [],
|
|
171
|
+
resource_process: resourceProcess,
|
|
172
|
+
};
|
|
173
|
+
await writeState(paths.state, state);
|
|
174
|
+
const log = await open(paths.log, "a", 0o600);
|
|
175
|
+
const child = spawn(process.execPath, [absolute(options.entryPath, "dd-zcode entry"), "daemon", "serve", "--state-dir", paths.dir], {
|
|
176
|
+
cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd],
|
|
177
|
+
});
|
|
178
|
+
child.unref();
|
|
179
|
+
await log.close();
|
|
180
|
+
try {
|
|
181
|
+
await confirmDaemonProcess(config, resourceProcess, child);
|
|
182
|
+
const deadline = Date.now() + 15_000;
|
|
183
|
+
while (Date.now() < deadline) {
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
185
|
+
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
186
|
+
catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
187
|
+
}
|
|
188
|
+
throw new DaemonError("daemon_start_failed", "daemon did not become ready", true);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await cleanupFailedStart(config, resourceProcess, child, error);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function stopDaemon(options) {
|
|
196
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
197
|
+
const { socket } = locations(options.stateDir);
|
|
198
|
+
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class DaemonRuntime {
|
|
202
|
+
constructor(paths, state, bridge, initialized) {
|
|
203
|
+
this.paths = paths; this.state = state; this.bridge = bridge; this.initialized = initialized;
|
|
204
|
+
this.sessions = new Map((state.sessions ?? []).map((item) => [item.provider_session_id, item]));
|
|
205
|
+
this.liveAdapters = new Set();
|
|
206
|
+
this.activeProductive = null; this.server = null; this.persisting = Promise.resolve();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
persist(patch = {}) {
|
|
210
|
+
Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] });
|
|
211
|
+
const snapshot = structuredClone(this.state);
|
|
212
|
+
this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot));
|
|
213
|
+
const resourceProcess = this.state.resource_process;
|
|
214
|
+
if (resourceProcess?.id && resourceProcess.lease_token) {
|
|
215
|
+
this.persisting = this.persisting.then(async () => {
|
|
216
|
+
try { await heartbeatDaemonProcess(this.state.config, resourceProcess); }
|
|
217
|
+
catch { /* A crashed client is recovered through the durable lease. */ }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return this.persisting;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
options(params) {
|
|
224
|
+
const known = this.sessions.get(params.sessionId);
|
|
225
|
+
const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
|
|
226
|
+
const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
|
|
227
|
+
return { ...this.state.config, ...params, adapterSessionId: adapter, daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter), assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); } };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
track(result, topology = result?.evidence?.subagents) {
|
|
231
|
+
if (result?.provider_session_id) { const existing = this.sessions.get(result.provider_session_id); this.sessions.set(result.provider_session_id, {
|
|
232
|
+
provider_session_id: result.provider_session_id,
|
|
233
|
+
adapter_session_id: result.adapter_session_id ?? result.provider_session_id,
|
|
234
|
+
parent_provider_session_id: result.parent_provider_session_id ?? null,
|
|
235
|
+
topology: topology ?? null,
|
|
236
|
+
completed_turns: existing?.completed_turns ?? 0,
|
|
237
|
+
}); }
|
|
238
|
+
if (result?.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
|
|
239
|
+
for (const child of topology?.running ?? []) {
|
|
240
|
+
const adapter = child.childSessionId ?? child.adapterSessionId ?? child.sessionId;
|
|
241
|
+
if (typeof adapter === "string" && adapter) this.liveAdapters.add(adapter);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
treeRunning(result) {
|
|
246
|
+
return result?.read?.projection?.status === "running" || (result?.subagents?.running ?? result?.evidence?.subagents?.running ?? result?.after?.running ?? []).length > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async productive(name, task) {
|
|
250
|
+
if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
|
|
251
|
+
this.activeProductive = name;
|
|
252
|
+
this.activeGeneration = this.controlGeneration ?? 0;
|
|
253
|
+
await this.persist({ active_tree: true, active_operation: name });
|
|
254
|
+
try {
|
|
255
|
+
const result = await task();
|
|
256
|
+
this.track(result);
|
|
257
|
+
const running = this.treeRunning(result);
|
|
258
|
+
await this.persist({ active_tree: running, active_operation: null });
|
|
259
|
+
return result;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
await this.persist({ active_operation: null });
|
|
262
|
+
throw error;
|
|
263
|
+
} finally { this.activeProductive = null; this.activeGeneration = null; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async requireSettled(params) {
|
|
267
|
+
const known = this.sessions.get(params.sessionId);
|
|
268
|
+
// A freshly created ZCode Session has no prior Turn and is not yet exposed
|
|
269
|
+
// by the provider's subagent index. Its first prompt is therefore safe.
|
|
270
|
+
if (known && (known.completed_turns ?? 0) === 0) return null;
|
|
271
|
+
const observed = await inspectSessionWithBridge(this.bridge, this.options(params));
|
|
272
|
+
const runningChildren = observed.subagents?.running ?? [];
|
|
273
|
+
if (this.treeRunning(observed)) {
|
|
274
|
+
throw new DaemonError("tree_not_settled", "the prior ZCode agent tree is not fully settled", false, { session_id: params.sessionId, running_children: runningChildren.map((child) => child.sessionId ?? child.childSessionId ?? null) });
|
|
275
|
+
}
|
|
276
|
+
this.track(observed, observed.subagents);
|
|
277
|
+
return observed;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async dispatch(operation, params) {
|
|
281
|
+
if (operation === "daemon.status") return {
|
|
282
|
+
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
283
|
+
cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
|
|
284
|
+
started_at: this.state.started_at, updated_at: this.state.updated_at,
|
|
285
|
+
shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status,
|
|
286
|
+
active_tree: this.state.active_tree, active_operation: this.activeProductive,
|
|
287
|
+
sessions: [...this.sessions.values()], config: this.state.config,
|
|
288
|
+
};
|
|
289
|
+
if (operation === "session.create") return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized));
|
|
290
|
+
if (operation === "session.prompt") {
|
|
291
|
+
const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, this.options(params)); });
|
|
292
|
+
const session = this.sessions.get(result.provider_session_id); if (session) session.completed_turns = (session.completed_turns ?? 0) + 1;
|
|
293
|
+
await this.persist(); return result;
|
|
294
|
+
}
|
|
295
|
+
if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
|
|
296
|
+
if (operation === "session.inspect") {
|
|
297
|
+
const adapter = params.adapterSessionId ?? params.sessionId;
|
|
298
|
+
if (!this.liveAdapters.has(adapter) && this.activeProductive) {
|
|
299
|
+
throw new DaemonError(
|
|
300
|
+
"child_inspection_unavailable",
|
|
301
|
+
"cannot inspect an unregistered child while a daemon-owned turn is running; inspection would resume and mutate it",
|
|
302
|
+
true,
|
|
303
|
+
{ session_id: params.sessionId },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const result = await inspectSessionWithBridge(this.bridge, this.options(params));
|
|
307
|
+
// A child can be inspected through its parent's live bridge, but is not
|
|
308
|
+
// an independently daemon-owned root Session. Do not promote it into
|
|
309
|
+
// `sessions`: daemon shutdown would then treat the child as a second
|
|
310
|
+
// root and could reject an otherwise settled parent tree.
|
|
311
|
+
if (this.sessions.has(result.provider_session_id)) this.track(result, result.subagents);
|
|
312
|
+
else if (result.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
|
|
313
|
+
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
if (operation === "session.cancel") {
|
|
317
|
+
if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon");
|
|
318
|
+
this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
319
|
+
const result = await cancelSessionWithBridge(this.bridge, this.options(params));
|
|
320
|
+
this.track(result, result.after);
|
|
321
|
+
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
if (operation === "session.cancel-child") {
|
|
325
|
+
const result = await cancelChildWithBridge(this.bridge, this.options(params));
|
|
326
|
+
this.track(result, result.after);
|
|
327
|
+
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
if (operation === "daemon.stop") return await this.prepareStop(params.cancelTree === true);
|
|
331
|
+
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async prepareStop(cancel) {
|
|
335
|
+
if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
336
|
+
const unsettled = [];
|
|
337
|
+
for (const session of this.sessions.values()) {
|
|
338
|
+
const options = this.options({ sessionId: session.provider_session_id, adapterSessionId: session.adapter_session_id });
|
|
339
|
+
let observed;
|
|
340
|
+
try { observed = await inspectSessionWithBridge(this.bridge, options); }
|
|
341
|
+
catch (error) { if (!cancel) throw error; }
|
|
342
|
+
if (!observed || this.treeRunning(observed) || (cancel && this.activeProductive)) {
|
|
343
|
+
if (!cancel) unsettled.push(session.provider_session_id);
|
|
344
|
+
else {
|
|
345
|
+
let cancelled;
|
|
346
|
+
try { cancelled = await cancelSessionWithBridge(this.bridge, options); }
|
|
347
|
+
catch (error) { unsettled.push(session.provider_session_id); continue; }
|
|
348
|
+
// Cancellation acceptance is not settlement. Re-read the same
|
|
349
|
+
// controlled tree before letting the daemon close its endpoint.
|
|
350
|
+
try { observed = await inspectSessionWithBridge(this.bridge, options); }
|
|
351
|
+
catch (error) { unsettled.push(session.provider_session_id); continue; }
|
|
352
|
+
if (!cancelled.settled || this.treeRunning(observed)) unsettled.push(session.provider_session_id);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
|
|
357
|
+
const deadline = Date.now() + 5000;
|
|
358
|
+
while (this.activeProductive && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
359
|
+
if (this.activeProductive) throw new DaemonError("tree_not_settled", "productive operation did not settle after cancellation");
|
|
360
|
+
await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null });
|
|
361
|
+
return { daemon_id: this.state.daemon_id, stopped: true, clean: true, _shutdown: true };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async shutdown() {
|
|
365
|
+
await this.bridge.close();
|
|
366
|
+
await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
|
|
367
|
+
const resourceProcess = this.state.resource_process;
|
|
368
|
+
if (resourceProcess?.id && resourceProcess.lease_token) {
|
|
369
|
+
try { await finishDaemonProcess(this.state.config, resourceProcess, "stopped", "daemon_stopped"); }
|
|
370
|
+
catch { /* Keep the state; the next reconciler owns stale processes. */ }
|
|
371
|
+
}
|
|
372
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
373
|
+
await removeSocket(this.paths.socket, this.paths.dir);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function serveDaemon(stateDir) {
|
|
378
|
+
const paths = locations(stateDir);
|
|
379
|
+
const state = await readState(paths.dir);
|
|
380
|
+
if (!state || state.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing or incompatible");
|
|
381
|
+
const bridge = new AcpBridge(state.config);
|
|
382
|
+
const initialized = await bridge.start();
|
|
383
|
+
const runtime = new DaemonRuntime(paths, state, bridge, initialized);
|
|
384
|
+
await removeSocket(paths.socket, paths.dir);
|
|
385
|
+
const server = net.createServer((connection) => {
|
|
386
|
+
connection.setEncoding("utf8"); let buffer = "";
|
|
387
|
+
connection.on("data", (chunk) => {
|
|
388
|
+
buffer += chunk;
|
|
389
|
+
const newline = buffer.indexOf("\n");
|
|
390
|
+
if (newline < 0) return;
|
|
391
|
+
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
392
|
+
void (async () => {
|
|
393
|
+
let request;
|
|
394
|
+
try {
|
|
395
|
+
request = JSON.parse(line);
|
|
396
|
+
if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
|
|
397
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}));
|
|
398
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
399
|
+
if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
|
|
400
|
+
} catch (error) {
|
|
401
|
+
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
runtime.server = server;
|
|
407
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); });
|
|
408
|
+
await chmod(paths.socket, 0o600);
|
|
409
|
+
await runtime.persist({ shutdown_state: "running", active_tree: false });
|
|
410
|
+
let shuttingDown = false;
|
|
411
|
+
const onSignal = () => {
|
|
412
|
+
if (shuttingDown) return;
|
|
413
|
+
shuttingDown = true;
|
|
414
|
+
void runtime.shutdown().catch(async () => { await runtime.persist({ shutdown_state: "running" }); }).finally(() => process.exit(1));
|
|
415
|
+
};
|
|
416
|
+
process.on("SIGTERM", onSignal);
|
|
417
|
+
process.on("SIGINT", onSignal);
|
|
418
|
+
return await new Promise(() => {});
|
|
419
|
+
}
|
|
420
|
+
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|