@deksden-com/dd-flow-cli 0.9.0-beta.107 → 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 +12 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/native-hook-ingress.js +48 -0
- package/dist/cli/run-cli.js +7 -0
- package/dist/cli.js +4 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +2 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +8 -10
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +3 -3
- 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 +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +4 -4
- package/dist/harness-runtime/lib/managed-daemon.mjs +39 -13
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -0
- 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,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.9.0-beta.108
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Route native tool hooks through the prepared hook store and skip storage entirely for unrelated tools. Give ZCode daemon readiness a bounded 30-second window and clean up the exact registered provider process if any managed daemon fails to start, preserving the primary startup error.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.107
|
|
4
16
|
|
|
5
17
|
### 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",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
2
|
+
import { parseCommandArgs } from "../services/lifecycle-command.js";
|
|
3
|
+
/** These ingress commands consume a prepared store; they never prepare it. */
|
|
4
|
+
export function isNativeHookIngress(args) {
|
|
5
|
+
return args[2] === "handle" && ((args[1] === "event" && ["agy", "grok", "droid", "opencode", "zcode"].includes(args[0] ?? ""))
|
|
6
|
+
|| args[0] === "zcode" && args[1] === "hook");
|
|
7
|
+
}
|
|
8
|
+
export async function dispatchNativeHook(args, stdin, env) {
|
|
9
|
+
const parsed = parseCommandArgs(args.slice(3));
|
|
10
|
+
const projectRoot = parsed.options.get("project-root")?.[0];
|
|
11
|
+
if (!isNativeHookIngress(args) || parsed.positional.length || !projectRoot || projectRoot === "__dd_flow_flag__")
|
|
12
|
+
throw new AppError("usage", "Native hook requires --project-root", 2);
|
|
13
|
+
for (const [name, values] of parsed.options)
|
|
14
|
+
if (!["project-root", "json"].includes(name) || values.length !== 1 || name === "json" && values[0] !== "__dd_flow_flag__")
|
|
15
|
+
throw new AppError("usage", `Invalid native hook option: ${name}`, 2);
|
|
16
|
+
const [hooks, { createContext }, { resolveDdFlowHome }] = await Promise.all([
|
|
17
|
+
import("../services/hooks.js"), import("../runtime/context.js"), import("../storage/paths.js")
|
|
18
|
+
]);
|
|
19
|
+
let opened;
|
|
20
|
+
// Reuse the actual envelope validators and shell parser. They return for
|
|
21
|
+
// unrelated tools before their first DB access, without duplicating policy.
|
|
22
|
+
const context = {
|
|
23
|
+
env, ddFlowHome: resolveDdFlowHome(env), now: () => new Date().toISOString(),
|
|
24
|
+
get db() { return (opened ??= createContext(env, "hook")).db; }
|
|
25
|
+
};
|
|
26
|
+
const handler = args[0] === "agy" ? hooks.handleAgyEvent : args[0] === "grok" ? hooks.handleGrokEvent
|
|
27
|
+
: args[0] === "droid" ? hooks.handleDroidEvent : args[0] === "opencode" ? hooks.handleOpenCodeEvent
|
|
28
|
+
: args[1] === "hook" ? hooks.handleZcodeHook : hooks.handleZcodeEvent;
|
|
29
|
+
try {
|
|
30
|
+
return await handler(context, { projectRoot, stdin });
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
opened?.db.close?.();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function runNativeHookIngress(args) {
|
|
37
|
+
try {
|
|
38
|
+
const { readNativeHookInput, hookBudget } = await import("../harness-runtime/lib/native-hook-command.mjs");
|
|
39
|
+
const stdin = await readNativeHookInput(process.stdin, hookBudget());
|
|
40
|
+
process.stdout.write(`${JSON.stringify(await dispatchNativeHook(args, stdin, process.env))}\n`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const record = errorRecord(error);
|
|
45
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: record })}\n`);
|
|
46
|
+
return record.exitCode;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../servic
|
|
|
8
8
|
import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
|
|
9
9
|
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, bindLifecyclePublishedPacket, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, retainObservedLifecycleSemantics, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
|
|
10
10
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
11
|
+
import { dispatchNativeHook, isNativeHookIngress } from "./native-hook-ingress.js";
|
|
11
12
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
12
13
|
import { helpForArgs } from "./help.js";
|
|
13
14
|
import { AppError, isAppError } from "../shared/errors.js";
|
|
@@ -129,6 +130,10 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
129
130
|
return 0;
|
|
130
131
|
}
|
|
131
132
|
const suppliedInvocationId = optionalOption(parseArgs(output.args.slice(2)), "invocation-id");
|
|
133
|
+
if (isNativeHookIngress(output.args)) {
|
|
134
|
+
writeJson(io.stdout, await dispatchNativeHook(output.args, await readStdin(io.stdin), env));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
132
137
|
if (output.args[0] === "run" && ["drive", "control"].includes(output.args[1] ?? "")) {
|
|
133
138
|
const controlArgs = parseArgs(output.args.slice(2));
|
|
134
139
|
requiredOption(controlArgs, "run");
|
|
@@ -411,6 +416,8 @@ function resolveProjectRootBeforeRouting(args, env) {
|
|
|
411
416
|
}
|
|
412
417
|
}
|
|
413
418
|
function databaseModeForCommand(args, classification) {
|
|
419
|
+
if (isNativeHookIngress(args))
|
|
420
|
+
return "hook";
|
|
414
421
|
const [family, command] = args;
|
|
415
422
|
// Managed attempts are issued only from a prepared store. Opening that
|
|
416
423
|
// existing writer must not run migrations before their input is admitted.
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { isNativeHookIngress } from "./cli/native-hook-ingress.js";
|
|
2
3
|
const args = process.argv.slice(2);
|
|
3
4
|
const managedHook = Boolean(process.env.DD_FLOW_CODEX_STATE_DIR) && args.slice(0, 3).join(" ") === "codex hook handle" && !args.includes("--help") && !args.includes("-h");
|
|
4
5
|
const exitCode = managedHook
|
|
5
6
|
? await (await import("./cli/hook-ingress.js")).runHookIngress(args)
|
|
6
|
-
:
|
|
7
|
+
: isNativeHookIngress(args) && !args.includes("--help") && !args.includes("-h")
|
|
8
|
+
? await (await import("./cli/native-hook-ingress.js")).runNativeHookIngress(args)
|
|
9
|
+
: await (await import("./cli/run-cli.js")).runCli(args);
|
|
7
10
|
process.exitCode = exitCode;
|
|
8
|
-
export {};
|
|
@@ -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,
|
|
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 {}
|
|
@@ -206,12 +206,7 @@ export async function startDaemon(options) {
|
|
|
206
206
|
}
|
|
207
207
|
throw new DaemonError("daemon_start_failed", "Antigravity daemon did not become ready", true);
|
|
208
208
|
} catch (error) {
|
|
209
|
-
await
|
|
210
|
-
const persisted = await readState(paths.dir).catch(() => null);
|
|
211
|
-
if (persisted?.provider_process) {
|
|
212
|
-
try { await runtimeProcess(config, "stop", { id: persisted.provider_process.id, "lease-token": persisted.provider_process.lease_token }); }
|
|
213
|
-
catch (cleanup) { error.details = { ...error.details, provider_cleanup_failed: errorPayload(cleanup), provider_process_id: persisted.provider_process.id }; }
|
|
214
|
-
}
|
|
209
|
+
await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), config.daemonId);
|
|
215
210
|
throw error;
|
|
216
211
|
}
|
|
217
212
|
}
|
|
@@ -251,7 +246,10 @@ export class Runtime {
|
|
|
251
246
|
void this.readyPromise.catch(() => {});
|
|
252
247
|
this.providerProcess = await registerDaemonProcess(this.config, { kind: "agy-provider", owner: `agy:${path.basename(this.paths.dir)}`, operation: `agy-provider:${path.basename(this.paths.dir)}`, stdout: this.paths.log, stderr: this.paths.log });
|
|
253
248
|
const providerProcess = this.providerProcess;
|
|
254
|
-
|
|
249
|
+
try { await this.persist({ provider_process: providerProcess }); }
|
|
250
|
+
catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
|
|
251
|
+
try { this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
|
|
252
|
+
catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
|
|
255
253
|
const providerChild = this.child;
|
|
256
254
|
let allowProviderFinalization;
|
|
257
255
|
const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
|
|
@@ -283,7 +281,7 @@ export class Runtime {
|
|
|
283
281
|
});
|
|
284
282
|
});
|
|
285
283
|
void this.providerFinalization.catch(() => {});
|
|
286
|
-
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await
|
|
284
|
+
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid }); scheduleDaemonProcessHeartbeat(this.config, providerProcess); }
|
|
287
285
|
catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
|
|
288
286
|
finally { allowProviderFinalization(); }
|
|
289
287
|
let readyTimer;
|
|
@@ -452,7 +450,7 @@ export class Runtime {
|
|
|
452
450
|
|
|
453
451
|
export async function serveDaemon(stateDir) {
|
|
454
452
|
const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
|
|
455
|
-
|
|
453
|
+
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
456
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;
|
|
457
455
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
|
|
458
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,
|
|
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";
|
|
@@ -90,7 +90,7 @@ export async function startDaemon(options) {
|
|
|
90
90
|
await removeSocket(paths.socket); const resourceProcess = await registerDaemonProcess(config, { kind: "codex-daemon", owner: `codex:${path.basename(paths.dir)}`, operation: `codex-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log }); const state = { schema_id: stateSchema, daemon_id: randomUUID(), pid: null, cwd: config.cwd, journal: config.journal, config, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", sessions: previous?.sessions ?? [], resource_process: resourceProcess }; await writeState(paths.state, state);
|
|
91
91
|
const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [options.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
92
92
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 60_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed" }); }
|
|
93
|
-
catch (error) { await
|
|
93
|
+
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.state), state.daemon_id); throw error; }
|
|
94
94
|
}
|
|
95
95
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
96
96
|
|
|
@@ -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,
|
|
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";
|
|
@@ -171,7 +171,7 @@ export async function startDaemon(options) {
|
|
|
171
171
|
const state = { descendants: previous?.descendants ?? [], schema_id: STATE_SCHEMA, daemon_id: daemonId, pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config: { ...config, daemonId }, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
|
|
172
172
|
await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
173
173
|
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
174
|
-
catch (error) { await
|
|
174
|
+
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), daemonId); throw error; }
|
|
175
175
|
}
|
|
176
176
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
177
177
|
|
|
@@ -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,
|
|
8
|
+
import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AcpBridge,
|
|
@@ -197,7 +197,7 @@ export async function startDaemon(options) {
|
|
|
197
197
|
await log.close();
|
|
198
198
|
try {
|
|
199
199
|
await confirmDaemonProcess(config, resourceProcess, child);
|
|
200
|
-
const deadline = Date.now() +
|
|
200
|
+
const deadline = Date.now() + 30_000;
|
|
201
201
|
while (Date.now() < deadline) {
|
|
202
202
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
203
203
|
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
@@ -205,7 +205,7 @@ export async function startDaemon(options) {
|
|
|
205
205
|
}
|
|
206
206
|
throw new DaemonError("daemon_start_failed", "daemon did not become ready", true);
|
|
207
207
|
} catch (error) {
|
|
208
|
-
await
|
|
208
|
+
await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), state.daemon_id);
|
|
209
209
|
throw error;
|
|
210
210
|
}
|
|
211
211
|
}
|
|
@@ -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 = "";
|
|
@@ -196,14 +196,17 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
|
|
|
196
196
|
state.pid = process.pid;
|
|
197
197
|
await publish(state);
|
|
198
198
|
const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
199
|
-
|
|
200
|
-
await confirmDaemonProcess(config, provider, child);
|
|
201
|
-
await heartbeatDaemonProcess(config, provider);
|
|
202
|
-
state.provider_pid = child.pid;
|
|
199
|
+
try {
|
|
203
200
|
state.provider_process = provider;
|
|
204
201
|
await publish(state);
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
bridge.options.onSpawn = async child => {
|
|
203
|
+
await confirmDaemonProcess(config, provider, child);
|
|
204
|
+
state.provider_pid = child.pid;
|
|
205
|
+
await publish(state);
|
|
206
|
+
scheduleDaemonProcessHeartbeat(config, provider);
|
|
207
|
+
};
|
|
208
|
+
return await bridge.start();
|
|
209
|
+
}
|
|
207
210
|
catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
|
|
208
211
|
}
|
|
209
212
|
|
|
@@ -212,15 +215,19 @@ export async function closeManagedBridge(config, state, bridge) {
|
|
|
212
215
|
await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
|
|
213
216
|
}
|
|
214
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
|
+
|
|
215
227
|
export async function heartbeatDaemonProcess(config, record) {
|
|
216
228
|
if (!record?.id || !record.lease_token) return;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
|
|
220
|
-
leases.set(record.id, lease);
|
|
221
|
-
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
222
|
-
lease.timer.unref();
|
|
223
|
-
}
|
|
229
|
+
scheduleDaemonProcessHeartbeat(config, record);
|
|
230
|
+
const lease = leases.get(record.id);
|
|
224
231
|
if (lease.lost) throw lease.error;
|
|
225
232
|
if (lease.pending) return await lease.pending;
|
|
226
233
|
lease.pending = (async () => {
|
|
@@ -260,6 +267,25 @@ export async function cleanupFailedStart(config, record, child, original) {
|
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
/** The provider has its own process group. A failed daemon start must settle
|
|
271
|
+
* that exact retained process as well as the daemon; PID-only cleanup is unsafe. */
|
|
272
|
+
export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild, original, readState, daemonId) {
|
|
273
|
+
await cleanupFailedStart(config, daemonRecord, daemonChild, original);
|
|
274
|
+
if (!daemonRecord) return;
|
|
275
|
+
try {
|
|
276
|
+
const state = await readState();
|
|
277
|
+
const owner = config.runtime_owner;
|
|
278
|
+
if (!state || state.daemon_id !== daemonId || state.resource_process?.id !== daemonRecord.id || state.resource_process?.lease_token !== daemonRecord.lease_token
|
|
279
|
+
|| (owner && (state.config?.runtime_owner?.owner_id !== owner.owner_id || path.resolve(state.config?.runtime_owner?.state_dir ?? "") !== path.resolve(owner.state_dir)))) {
|
|
280
|
+
throw Object.assign(new Error("Failed daemon state no longer matches the startup owner"), { code: "daemon_cleanup_identity_mismatch" });
|
|
281
|
+
}
|
|
282
|
+
const provider = state.provider_process;
|
|
283
|
+
if (provider) await runtimeProcess(config, "stop", { id: provider.id, "lease-token": provider.lease_token });
|
|
284
|
+
} catch (cleanup) {
|
|
285
|
+
original.details = { ...original.details, provider_cleanup_failed: errorRecord(cleanup) };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
263
289
|
export async function stopProcessGroup(child, graceMs = 1_000) {
|
|
264
290
|
if (!child?.pid) return;
|
|
265
291
|
// Harness bridges are detached on Unix, so their own process group is the
|
|
@@ -1 +1,3 @@
|
|
|
1
1
|
export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
2
|
+
export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { remaining(phase: string): number };
|
|
3
|
+
export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
|
|
@@ -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) {
|