@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 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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.107",
4
- "cli_commit": "b1e4fba26b8d9dea7b9b4f6de05aec6be3e21dc2",
5
- "built_at": "2026-09-27T03:02:31.051Z",
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
+ }
@@ -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
- : await (await import("./cli/run-cli.js")).runCli(args);
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: error.message, ...(error.code ? { code: error.code } : {}) })}\n`); process.exit(1); }
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, runtimeProcess, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
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 cleanupFailedStart(config, resourceProcess, child, error);
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
- this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] });
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 heartbeatDaemonProcess(this.config, providerProcess); await this.persist({ provider_process: providerProcess, 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); }
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
- await heartbeatDaemonProcess(state.config, state.resource_process);
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, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
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 cleanupFailedStart(config, resourceProcess, child, error); throw error; }
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
- await heartbeatDaemonProcess(state.config, state.resource_process);
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, heartbeatDaemonProcess, registerDaemonProcess } from "./managed-daemon.mjs";
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(); await heartbeatDaemonProcess(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); }
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, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
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); await heartbeatDaemonProcess(this.config, this.providerProcess); await this.save({ provider_pid: this.child.pid, provider_process: this.providerProcess });
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, cleanupFailedStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
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 cleanupFailedStart(config, resourceProcess, child, error); throw error; }
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
- await heartbeatDaemonProcess(state.config, state.resource_process);
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, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
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
- await heartbeatDaemonProcess(state.config, state.resource_process);
212
- await heartbeatDaemonProcess(state.config, providerProcess);
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, cleanupFailedStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
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() + 15_000;
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 cleanupFailedStart(config, resourceProcess, child, error);
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
- await heartbeatDaemonProcess(state.config, state.resource_process);
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
- bridge.options.onSpawn = async child => {
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
- try { return await bridge.start(); }
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
- let lease = leases.get(record.id);
218
- if (!lease) {
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.107",
3
+ "version": "0.9.0-beta.109",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {