@deksden-com/dd-flow-cli 0.9.0-beta.108 → 0.9.0-beta.109
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 +6 -0
- package/dist/build-info.json +3 -3
- package/dist/harness-runtime/bin/dd-codex.mjs +2 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/managed-daemon.mjs +12 -8
- package/dist/services/run-control.js +28 -2
- package/dist/services/work-registry.js +7 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.109
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Start managed daemons from their confirmed registration without a redundant blocking heartbeat. Keep periodic ownership checks and reject productive work when renewal cannot be confirmed. Recognize a failed daemon start as settled only when the native journal proves no Session was created and the exact daemon/provider processes have terminated with released leases. Accept an identical Work start packet already published by a concurrent starter while retaining conflict detection for changed bindings or bytes.
|
|
8
|
+
|
|
3
9
|
## 0.9.0-beta.108
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.109",
|
|
4
|
+
"cli_commit": "350120c67365b0b0c8f2fc81c7cd73548aae6617",
|
|
5
|
+
"built_at": "2026-09-27T07:05:10.542Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { cancelSession, createSession, doctor, inspectSession, promptFromFile, promptSession, startSession } from "../lib/dd-codex.mjs";
|
|
4
4
|
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-codex-daemon.mjs";
|
|
5
|
+
import { errorRecord } from "../lib/operation-errors.mjs";
|
|
5
6
|
|
|
6
7
|
function parse(argv) { const positional = []; const options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json", "cancel-tree", "no-flow"].includes(key)) { options[key] = true; continue; } const value = argv[++i]; if (value === undefined) throw new Error(`--${key} requires a value`); options[key] = value; } return { positional, options }; }
|
|
7
8
|
function common(options) { return { bin: options["codex-bin"], journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, sessionId: options["session-id"], turnId: options["turn-id"], model: options.model, reasoning: options.reasoning, ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], projectRoot: options["project-root"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; }
|
|
@@ -22,4 +23,4 @@ try {
|
|
|
22
23
|
else if (family === "session" && command === "cancel") result = shared.stateDir ? await callDaemon(shared.stateDir, "session.cancel", shared) : await cancelSession(shared);
|
|
23
24
|
else throw new Error("usage: dd-codex doctor | daemon start|status|stop | session create|start|prompt|inspect|status|resume|cancel [--json]");
|
|
24
25
|
process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
|
|
25
|
-
} catch (error) { process.stderr.write(`${JSON.stringify({ ok: false, error:
|
|
26
|
+
} catch (error) { const failure = errorRecord(error); process.stderr.write(`${JSON.stringify({ ok: false, error: failure.message, code: failure.code, ...(failure.details ? { details: failure.details } : {}), ...(failure.cause ? { cause: failure.cause } : {}) })}\n`); process.exit(1); }
|
|
@@ -12,7 +12,7 @@ import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { observeModel } from "./model-observations.mjs";
|
|
14
14
|
import { AgyError, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
|
|
15
|
-
import { prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess,
|
|
15
|
+
import { prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
16
16
|
|
|
17
17
|
const REQUEST_SCHEMA = "dd-agy/daemon-request@1", RESPONSE_SCHEMA = "dd-agy/daemon-response@1", STATE_SCHEMA = "dd-agy/daemon-state@1";
|
|
18
18
|
class DaemonError extends AgyError {}
|
|
@@ -281,7 +281,7 @@ export class Runtime {
|
|
|
281
281
|
});
|
|
282
282
|
});
|
|
283
283
|
void this.providerFinalization.catch(() => {});
|
|
284
|
-
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid });
|
|
284
|
+
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid }); scheduleDaemonProcessHeartbeat(this.config, providerProcess); }
|
|
285
285
|
catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
|
|
286
286
|
finally { allowProviderFinalization(); }
|
|
287
287
|
let readyTimer;
|
|
@@ -450,7 +450,7 @@ export class Runtime {
|
|
|
450
450
|
|
|
451
451
|
export async function serveDaemon(stateDir) {
|
|
452
452
|
const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
|
|
453
|
-
|
|
453
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
454
454
|
const runtime = new Runtime(paths, state); await removeSocket(paths); const server = net.createServer(socket => { let buffer = ""; socket.on("error", () => {}); socket.setEncoding("utf8"); socket.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; let request; try { request = JSON.parse(buffer.slice(0, index)); } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, ok: false, error: errorPayload(error) })}\n`); return; } void durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} })).then(result => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result })}\n`), error => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: false, error: errorPayload(error) })}\n`)); }); }); runtime.server = server;
|
|
455
455
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
|
|
456
456
|
try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
|
|
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
10
10
|
import { errorRecord } from "./operation-errors.mjs";
|
|
11
11
|
import { CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
12
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess,
|
|
12
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
13
13
|
|
|
14
14
|
const requestSchema = "dd-codex/daemon-request@1";
|
|
15
15
|
const responseSchema = "dd-codex/daemon-response@1";
|
|
@@ -99,7 +99,7 @@ export async function serveDaemon(stateDir) {
|
|
|
99
99
|
const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_DAEMON_ID: state.daemon_id, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot) }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
100
100
|
const closeBridge = bridge.close.bind(bridge);
|
|
101
101
|
bridge.close = async () => { await closeBridge(); await finishDaemonProcess(state.config, state.provider_process, "stopped", "daemon_stopped"); };
|
|
102
|
-
|
|
102
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
103
103
|
const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.then(() => writeState(paths.state, snapshot)); return persist; };
|
|
104
104
|
const dispatch = async (operation, params) => {
|
|
105
105
|
const assertDispatch = captureDispatchGuard(bridge);
|
|
@@ -8,7 +8,7 @@ import { durableDaemonDispatch, inspectDaemonOperation } from "./daemon-operatio
|
|
|
8
8
|
import { observedTimeout } from "./observation-clock.mjs";
|
|
9
9
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
10
10
|
import { errorRecord } from "./operation-errors.mjs";
|
|
11
|
-
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess,
|
|
11
|
+
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess } from "./managed-daemon.mjs";
|
|
12
12
|
|
|
13
13
|
const REQUEST = "dd-droid/daemon-request@1", RESPONSE = "dd-droid/daemon-response@1";
|
|
14
14
|
async function removeSocket(file) { try { if (!(await lstat(file)).isSocket()) throw new DroidError("unsafe_socket", "Refusing to replace non-socket daemon path"); await unlink(file); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
@@ -118,7 +118,7 @@ export async function serveDaemon(stateDir) {
|
|
|
118
118
|
socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request.id, ok: true, result: receipt })}\n`);
|
|
119
119
|
} catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request?.id, ok: false, error: errorRecord(error) })}\n`); } })();
|
|
120
120
|
}); });
|
|
121
|
-
try { await runtime.start();
|
|
121
|
+
try { await runtime.start(); scheduleDaemonProcessHeartbeat(state.config, state.resource_process); await save({ shutdown_state: "running" }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); }
|
|
122
122
|
catch (error) { let cleanup; try { cleanup = await runtime.closeTree(true); await removeDroidAuth(state.config); } catch (failure) { await save({ shutdown_state: "cleanup_failed", startup_error: errorRecord(error), cleanup_error: errorRecord(failure) }); throw error; } await save({ shutdown_state: "failed", startup_error: errorRecord(error), cleanup }); await finishDaemonProcess(state.config, state.resource_process, "failed", "startup_failed"); throw error; }
|
|
123
123
|
await new Promise(resolve => server.once("close", resolve));
|
|
124
124
|
}
|
|
@@ -16,7 +16,7 @@ import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
|
16
16
|
import { DroidMetadataReader, readDroidRoutingLog } from "./droid-observation.mjs";
|
|
17
17
|
const metadataReaders = new Map();
|
|
18
18
|
function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataReaders.set(factory, new DroidMetadataReader(factory)); return metadataReaders.get(factory); }
|
|
19
|
-
import { confirmDaemonProcess, finishDaemonProcess,
|
|
19
|
+
import { confirmDaemonProcess, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
20
20
|
import { renderAdapterPolicy, lifecycleRetryInstruction } from "./delegation-instructions.mjs";
|
|
21
21
|
|
|
22
22
|
const run = promisify(execFile);
|
|
@@ -113,7 +113,7 @@ export class DroidRuntime {
|
|
|
113
113
|
this.child.stderr.setEncoding("utf8").on("data", chunk => { void this.journal("stderr", { text: chunk.slice(0, 4000) }).catch(() => {}); });
|
|
114
114
|
this.child.on("error", error => this.rejectPending(error));
|
|
115
115
|
this.child.on("close", (code, signal) => { this.closed = true; void this.draining.finally(() => { const error = new DroidError("operation_observation_lost", "Droid process exited before a confirmed reply", { code, signal }); this.rejectPending(error); }); });
|
|
116
|
-
await this.captureProcesses(); await confirmDaemonProcess(this.config, this.providerProcess, this.child);
|
|
116
|
+
await this.captureProcesses(); await confirmDaemonProcess(this.config, this.providerProcess, this.child); scheduleDaemonProcessHeartbeat(this.config, this.providerProcess); await this.save({ provider_pid: this.child.pid, provider_process: this.providerProcess });
|
|
117
117
|
clearInterval(this.processObserver);
|
|
118
118
|
this.processObserver = setInterval(() => { if (!this.processObservation) this.processObservation = this.captureProcesses().catch(error => { this.ownershipError = error; }).finally(() => { this.processObservation = null; }); }, 1_000);
|
|
119
119
|
this.processObserver.unref();
|
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { AcpBridge } from "./dd-zcode.mjs";
|
|
14
14
|
import { cancelSessionWithBridge, createSessionWithBridge, doctor, forkSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge } from "./dd-grok.mjs";
|
|
15
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess,
|
|
15
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
16
16
|
|
|
17
17
|
const REQUEST_SCHEMA = "dd-grok/daemon-request@1";
|
|
18
18
|
const RESPONSE_SCHEMA = "dd-grok/daemon-response@1";
|
|
@@ -382,7 +382,7 @@ export async function serveDaemon(stateDir) {
|
|
|
382
382
|
const rootProviderSessionId = session?.root_provider_session_id ?? sessionId;
|
|
383
383
|
await observeAcpToolCall(state.config, { daemonId: state.daemon_id, rootProviderSessionId, parentProviderSessionId: session?.parent_provider_session_id ?? null, observedProfile: bridge.modelProfiles?.get(sessionId) ?? {} }, message);
|
|
384
384
|
} }); const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "grok-provider", publish: snapshot => writeState(paths.state, snapshot) }); runtime = new Runtime(paths, state, bridge, initialized);
|
|
385
|
-
|
|
385
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
386
386
|
await removeSocket(paths.socket, paths.dir); const server = net.createServer((connection) => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", (chunk) => { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline < 0) return; const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); void (async () => { let request; try { request = JSON.parse(line); if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request"); const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)).catch(error => runtime.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }))); } catch (error) { connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); }); runtime.server = server; await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false }); runtime.ready = true; return await new Promise(() => {});
|
|
387
387
|
}
|
|
388
388
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -11,7 +11,7 @@ import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { OpenCodeClient, OpenCodeError, canonicalHistory, historyDigest, usageSnapshot } from "./dd-opencode.mjs";
|
|
14
|
-
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess,
|
|
14
|
+
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
15
15
|
|
|
16
16
|
const REQUEST_SCHEMA = "dd-opencode/daemon-request@1", RESPONSE_SCHEMA = "dd-opencode/daemon-response@1", STATE_SCHEMA = "dd-opencode/daemon-state@1", ARCHIVE_SCHEMA = "dd-opencode/session-archive@1";
|
|
17
17
|
const OPENCODE_BASELINE = Object.freeze({ version: "1.18.25", api_fingerprint: "dfb7d42a555389f0c662fa2b4a8af1d61633c96710cf54bce3ff2404e2e7d896" });
|
|
@@ -208,8 +208,8 @@ export async function serveDaemon(stateDir) {
|
|
|
208
208
|
providerProcess = await registerDaemonProcess(state.config, { kind: "opencode-provider", owner: `opencode:${path.basename(paths.dir)}`, operation: `opencode-provider:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
209
209
|
child = spawn(target.command, target.args, { cwd: state.config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", "inherit", "inherit"] });
|
|
210
210
|
await confirmDaemonProcess(state.config, providerProcess, child);
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
212
|
+
scheduleDaemonProcessHeartbeat(state.config, providerProcess);
|
|
213
213
|
state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
|
|
214
214
|
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths); const server = net.createServer(connection => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", chunk => { buffer += chunk; const i = buffer.indexOf("\n"); if (i < 0) return; void (async () => { let request; try { request = JSON.parse(buffer.slice(0, i)); const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result?._shutdown) setImmediate(() => runtime.shutdown().then(() => process.exit(0)).catch(error => runtime.persist({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }))); } catch (error) { connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); }); runtime.server = server; await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false, server_port: port }); return await new Promise(() => {});
|
|
215
215
|
} catch (error) {
|
|
@@ -5,7 +5,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
|
|
|
5
5
|
import net from "node:net";
|
|
6
6
|
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess,
|
|
8
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AcpBridge,
|
|
@@ -600,7 +600,7 @@ export async function serveDaemon(stateDir) {
|
|
|
600
600
|
const bridge = new AcpBridge({ ...state.config, env: { ...state.config.env, DD_FLOW_DAEMON_ID: state.daemon_id } });
|
|
601
601
|
const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "zcode-provider", publish: snapshot => writeState(paths.state, snapshot) });
|
|
602
602
|
const runtime = new DaemonRuntime(paths, state, bridge, initialized);
|
|
603
|
-
|
|
603
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
604
604
|
await removeSocket(paths.socket, paths.dir);
|
|
605
605
|
const server = net.createServer((connection) => {
|
|
606
606
|
connection.setEncoding("utf8"); let buffer = "";
|
|
@@ -203,7 +203,7 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
|
|
|
203
203
|
await confirmDaemonProcess(config, provider, child);
|
|
204
204
|
state.provider_pid = child.pid;
|
|
205
205
|
await publish(state);
|
|
206
|
-
|
|
206
|
+
scheduleDaemonProcessHeartbeat(config, provider);
|
|
207
207
|
};
|
|
208
208
|
return await bridge.start();
|
|
209
209
|
}
|
|
@@ -215,15 +215,19 @@ export async function closeManagedBridge(config, state, bridge) {
|
|
|
215
215
|
await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
export function scheduleDaemonProcessHeartbeat(config, record) {
|
|
219
|
+
if (!record?.id || !record.lease_token) return;
|
|
220
|
+
if (leases.has(record.id)) return;
|
|
221
|
+
const lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
|
|
222
|
+
leases.set(record.id, lease);
|
|
223
|
+
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
224
|
+
lease.timer.unref();
|
|
225
|
+
}
|
|
226
|
+
|
|
218
227
|
export async function heartbeatDaemonProcess(config, record) {
|
|
219
228
|
if (!record?.id || !record.lease_token) return;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
|
|
223
|
-
leases.set(record.id, lease);
|
|
224
|
-
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
225
|
-
lease.timer.unref();
|
|
226
|
-
}
|
|
229
|
+
scheduleDaemonProcessHeartbeat(config, record);
|
|
230
|
+
const lease = leases.get(record.id);
|
|
227
231
|
if (lease.lost) throw lease.error;
|
|
228
232
|
if (lease.pending) return await lease.pending;
|
|
229
233
|
lease.pending = (async () => {
|
|
@@ -287,14 +287,40 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
287
287
|
const closedDaemons = new Map();
|
|
288
288
|
const nativeVersions = new Map();
|
|
289
289
|
for (const [directory, { state, records }] of daemonInventories) {
|
|
290
|
+
let failedStart = null;
|
|
290
291
|
if (!interrupt) {
|
|
291
292
|
try {
|
|
292
293
|
const nativeOperations = retainedDaemonOperations(directory);
|
|
293
294
|
nativeVersions.set(directory, nativeInventoryVersion(directory, nativeOperations));
|
|
295
|
+
// A failed start with no other native request cannot have dispatched a
|
|
296
|
+
// create: productive dispatch always journals its request first.
|
|
297
|
+
const start = nativeOperations.length === 1 ? nativeOperations[0] : null;
|
|
298
|
+
const daemon = records.filter(record => record.kind.endsWith("-daemon"));
|
|
299
|
+
const provider = records.filter(record => record.kind.endsWith("-provider"));
|
|
300
|
+
const owner = state.config?.runtime_owner;
|
|
301
|
+
const launch = start?.launch;
|
|
302
|
+
const controllerStarts = state.shutdown_state === "starting" && controllers.length
|
|
303
|
+
? context.db.all("SELECT o.operation_id, o.input_json FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND json_extract(o.input_json, '$.state_dir') = ?", [project.id, input.runId, directory]) : [];
|
|
304
|
+
const starts = controllerStarts.filter(operation => {
|
|
305
|
+
const saved = JSON.parse(operation.input_json);
|
|
306
|
+
return saved.owner === owner?.owner_id && saved.executable === directoryAdapters.get(directory) && saved.args?.[0] === "daemon" && saved.args?.[1] === "start" && saved.args.includes("--state-dir") && saved.args[saved.args.indexOf("--state-dir") + 1] === directory;
|
|
307
|
+
});
|
|
308
|
+
if (state.shutdown_state === "starting" && state.sessions?.length === 0 && state.active_tree !== true && !state.active_operation
|
|
309
|
+
&& start?.operation === "daemon.start" && start.state === "failed" && start.predecessor_daemon_id === null && start.daemon_id === null
|
|
310
|
+
&& typeof state.daemon_id === "string" && controllerStarts.length === 1 && starts.length === 1 && starts[0].operation_id === start.operation_id
|
|
311
|
+
&& launch?.runtime_owner === JSON.stringify(owner)
|
|
312
|
+
&& daemon.length === 1 && provider.length <= 1 && records.length === daemon.length + provider.length
|
|
313
|
+
&& daemon[0].id === state.resource_process?.id && daemon[0].lease_token === state.resource_process.lease_token
|
|
314
|
+
&& (state.pid == null || state.pid === daemon[0].pid)
|
|
315
|
+
&& (!state.provider_process && state.provider_pid == null && provider.length === 0 || provider.length === 1 && provider[0].id === state.provider_process?.id && provider[0].lease_token === state.provider_process.lease_token && state.provider_pid === provider[0].pid)
|
|
316
|
+
&& records.every(record => record.pid && record.pid_started_at && !processTreeIsAlive(record) && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at))) {
|
|
317
|
+
failedStart = { source: "failed_start_without_create", daemon_id: state.daemon_id, operation_id: start.operation_id, session_ids: [], processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })) };
|
|
318
|
+
closedDaemons.set(directory, failedStart);
|
|
319
|
+
}
|
|
294
320
|
for (const operation of nativeOperations)
|
|
295
321
|
if (!["completed", "failed", "interrupted"].includes(String(operation.state)))
|
|
296
322
|
unresolved.push({ state_dir: directory, operation_id: operation.operation_id, status: "pending", reason: "native_operation_unsettled" });
|
|
297
|
-
if (state.shutdown_state !== "clean" && !records.some(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.pid_started_at && record.lease_token === state.resource_process.lease_token && JSON.parse(record.metadata_json).dispatch_barrier === "run-generation@1"))
|
|
323
|
+
if (state.shutdown_state !== "clean" && !failedStart && !records.some(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.pid_started_at && record.lease_token === state.resource_process.lease_token && JSON.parse(record.metadata_json).dispatch_barrier === "run-generation@1"))
|
|
298
324
|
unresolved.push({ state_dir: directory, status: "pending", reason: "native_dispatch_barrier_unverified" });
|
|
299
325
|
}
|
|
300
326
|
catch {
|
|
@@ -304,7 +330,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
304
330
|
if (state.shutdown_state !== "clean") {
|
|
305
331
|
if (state.shutdown_state === "unclean")
|
|
306
332
|
unresolved.push({ state_dir: directory, status: "pending", reason: "native_shutdown_unclean" });
|
|
307
|
-
if (!state.sessions.length)
|
|
333
|
+
if (!state.sessions.length && !failedStart)
|
|
308
334
|
unresolved.push({ state_dir: directory, status: "pending", reason: "daemon_sessions_unknown" });
|
|
309
335
|
continue;
|
|
310
336
|
}
|
|
@@ -297,8 +297,14 @@ export function materializePendingWorkStarts(context, projectId, runId) {
|
|
|
297
297
|
continue;
|
|
298
298
|
publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
|
|
299
299
|
publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
|
|
300
|
-
if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'published' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status = 'publishing'", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1)
|
|
300
|
+
if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'published' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status = 'publishing'", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1) {
|
|
301
|
+
const current = context.db.get("SELECT status, prompt_path, start_receipt_json, start_publication_status FROM work_sessions WHERE id = ?", [row.id]);
|
|
302
|
+
if (current?.status === "running" && current.prompt_path === row.prompt_path && current.start_receipt_json === row.start_receipt_json
|
|
303
|
+
&& current.start_publication_status === "published" && receiptFileHash(row.prompt_path) === receipt.prompt_sha256
|
|
304
|
+
&& receiptFileHash(path.join(path.dirname(row.prompt_path), "context.json")) === receipt.context_sha256)
|
|
305
|
+
continue;
|
|
301
306
|
throw new AppError("work_start_projection_conflict", "Work start binding changed during packet publication", 1, { work_id: row.work_id });
|
|
307
|
+
}
|
|
302
308
|
}
|
|
303
309
|
}
|
|
304
310
|
function publishStartReceiptFile(file, contents, expectedHash) {
|