@deksden-com/dd-flow-cli 0.9.0-beta.108 → 0.9.0-beta.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.109
4
+
5
+ ### Patch Changes
6
+
7
+ - Start managed daemons from their confirmed registration without a redundant blocking heartbeat. Keep periodic ownership checks and reject productive work when renewal cannot be confirmed. Recognize a failed daemon start as settled only when the native journal proves no Session was created and the exact daemon/provider processes have terminated with released leases. Accept an identical Work start packet already published by a concurrent starter while retaining conflict detection for changed bindings or bytes.
8
+
3
9
  ## 0.9.0-beta.108
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.108",
4
- "cli_commit": "0df3cd1e7201f27baeeb07f1c4f2b69ed6e2399b",
5
- "built_at": "2026-09-27T04:24:32.044Z",
3
+ "cli_version": "0.9.0-beta.109",
4
+ "cli_commit": "350120c67365b0b0c8f2fc81c7cd73548aae6617",
5
+ "built_at": "2026-09-27T07:05:10.542Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
@@ -2,6 +2,7 @@
2
2
  import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
3
3
  import { cancelSession, createSession, doctor, inspectSession, promptFromFile, promptSession, startSession } from "../lib/dd-codex.mjs";
4
4
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-codex-daemon.mjs";
5
+ import { errorRecord } from "../lib/operation-errors.mjs";
5
6
 
6
7
  function parse(argv) { const positional = []; const options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json", "cancel-tree", "no-flow"].includes(key)) { options[key] = true; continue; } const value = argv[++i]; if (value === undefined) throw new Error(`--${key} requires a value`); options[key] = value; } return { positional, options }; }
7
8
  function common(options) { return { bin: options["codex-bin"], journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, sessionId: options["session-id"], turnId: options["turn-id"], model: options.model, reasoning: options.reasoning, ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], projectRoot: options["project-root"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; }
@@ -22,4 +23,4 @@ try {
22
23
  else if (family === "session" && command === "cancel") result = shared.stateDir ? await callDaemon(shared.stateDir, "session.cancel", shared) : await cancelSession(shared);
23
24
  else throw new Error("usage: dd-codex doctor | daemon start|status|stop | session create|start|prompt|inspect|status|resume|cancel [--json]");
24
25
  process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
25
- } catch (error) { process.stderr.write(`${JSON.stringify({ ok: false, error: 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, cleanupFailedDaemonStart, 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 {}
@@ -281,7 +281,7 @@ export class Runtime {
281
281
  });
282
282
  });
283
283
  void this.providerFinalization.catch(() => {});
284
- try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid }); await heartbeatDaemonProcess(this.config, providerProcess); }
284
+ try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid }); scheduleDaemonProcessHeartbeat(this.config, providerProcess); }
285
285
  catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
286
286
  finally { allowProviderFinalization(); }
287
287
  let readyTimer;
@@ -450,7 +450,7 @@ export class Runtime {
450
450
 
451
451
  export async function serveDaemon(stateDir) {
452
452
  const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
453
- await heartbeatDaemonProcess(state.config, state.resource_process);
453
+ scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
454
454
  const runtime = new Runtime(paths, state); await removeSocket(paths); const server = net.createServer(socket => { let buffer = ""; socket.on("error", () => {}); socket.setEncoding("utf8"); socket.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; let request; try { request = JSON.parse(buffer.slice(0, index)); } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, ok: false, error: errorPayload(error) })}\n`); return; } void durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} })).then(result => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result })}\n`), error => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: false, error: errorPayload(error) })}\n`)); }); }); runtime.server = server;
455
455
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
456
456
  try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
9
9
  import { waitForSettlement } from "./session-settlement.mjs";
10
10
  import { errorRecord } from "./operation-errors.mjs";
11
11
  import { CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
12
- import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, 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";
@@ -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, cleanupFailedDaemonStart, 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";
@@ -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, cleanupFailedDaemonStart, 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,
@@ -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 = "";
@@ -203,7 +203,7 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
203
203
  await confirmDaemonProcess(config, provider, child);
204
204
  state.provider_pid = child.pid;
205
205
  await publish(state);
206
- await heartbeatDaemonProcess(config, provider);
206
+ scheduleDaemonProcessHeartbeat(config, provider);
207
207
  };
208
208
  return await bridge.start();
209
209
  }
@@ -215,15 +215,19 @@ export async function closeManagedBridge(config, state, bridge) {
215
215
  await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
216
216
  }
217
217
 
218
+ export function scheduleDaemonProcessHeartbeat(config, record) {
219
+ if (!record?.id || !record.lease_token) return;
220
+ if (leases.has(record.id)) return;
221
+ const lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
222
+ leases.set(record.id, lease);
223
+ lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
224
+ lease.timer.unref();
225
+ }
226
+
218
227
  export async function heartbeatDaemonProcess(config, record) {
219
228
  if (!record?.id || !record.lease_token) return;
220
- let lease = leases.get(record.id);
221
- if (!lease) {
222
- lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
223
- leases.set(record.id, lease);
224
- lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
225
- lease.timer.unref();
226
- }
229
+ scheduleDaemonProcessHeartbeat(config, record);
230
+ const lease = leases.get(record.id);
227
231
  if (lease.lost) throw lease.error;
228
232
  if (lease.pending) return await lease.pending;
229
233
  lease.pending = (async () => {
@@ -287,14 +287,40 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
287
287
  const closedDaemons = new Map();
288
288
  const nativeVersions = new Map();
289
289
  for (const [directory, { state, records }] of daemonInventories) {
290
+ let failedStart = null;
290
291
  if (!interrupt) {
291
292
  try {
292
293
  const nativeOperations = retainedDaemonOperations(directory);
293
294
  nativeVersions.set(directory, nativeInventoryVersion(directory, nativeOperations));
295
+ // A failed start with no other native request cannot have dispatched a
296
+ // create: productive dispatch always journals its request first.
297
+ const start = nativeOperations.length === 1 ? nativeOperations[0] : null;
298
+ const daemon = records.filter(record => record.kind.endsWith("-daemon"));
299
+ const provider = records.filter(record => record.kind.endsWith("-provider"));
300
+ const owner = state.config?.runtime_owner;
301
+ const launch = start?.launch;
302
+ const controllerStarts = state.shutdown_state === "starting" && controllers.length
303
+ ? context.db.all("SELECT o.operation_id, o.input_json FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND json_extract(o.input_json, '$.state_dir') = ?", [project.id, input.runId, directory]) : [];
304
+ const starts = controllerStarts.filter(operation => {
305
+ const saved = JSON.parse(operation.input_json);
306
+ return saved.owner === owner?.owner_id && saved.executable === directoryAdapters.get(directory) && saved.args?.[0] === "daemon" && saved.args?.[1] === "start" && saved.args.includes("--state-dir") && saved.args[saved.args.indexOf("--state-dir") + 1] === directory;
307
+ });
308
+ if (state.shutdown_state === "starting" && state.sessions?.length === 0 && state.active_tree !== true && !state.active_operation
309
+ && start?.operation === "daemon.start" && start.state === "failed" && start.predecessor_daemon_id === null && start.daemon_id === null
310
+ && typeof state.daemon_id === "string" && controllerStarts.length === 1 && starts.length === 1 && starts[0].operation_id === start.operation_id
311
+ && launch?.runtime_owner === JSON.stringify(owner)
312
+ && daemon.length === 1 && provider.length <= 1 && records.length === daemon.length + provider.length
313
+ && daemon[0].id === state.resource_process?.id && daemon[0].lease_token === state.resource_process.lease_token
314
+ && (state.pid == null || state.pid === daemon[0].pid)
315
+ && (!state.provider_process && state.provider_pid == null && provider.length === 0 || provider.length === 1 && provider[0].id === state.provider_process?.id && provider[0].lease_token === state.provider_process.lease_token && state.provider_pid === provider[0].pid)
316
+ && records.every(record => record.pid && record.pid_started_at && !processTreeIsAlive(record) && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at))) {
317
+ failedStart = { source: "failed_start_without_create", daemon_id: state.daemon_id, operation_id: start.operation_id, session_ids: [], processes: records.map(record => ({ process_id: record.id, pid: record.pid, pid_started_at: record.pid_started_at })) };
318
+ closedDaemons.set(directory, failedStart);
319
+ }
294
320
  for (const operation of nativeOperations)
295
321
  if (!["completed", "failed", "interrupted"].includes(String(operation.state)))
296
322
  unresolved.push({ state_dir: directory, operation_id: operation.operation_id, status: "pending", reason: "native_operation_unsettled" });
297
- if (state.shutdown_state !== "clean" && !records.some(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.pid_started_at && record.lease_token === state.resource_process.lease_token && JSON.parse(record.metadata_json).dispatch_barrier === "run-generation@1"))
323
+ if (state.shutdown_state !== "clean" && !failedStart && !records.some(record => record.kind.endsWith("-daemon") && record.id === state.resource_process?.id && record.pid === state.pid && record.pid_started_at && record.lease_token === state.resource_process.lease_token && JSON.parse(record.metadata_json).dispatch_barrier === "run-generation@1"))
298
324
  unresolved.push({ state_dir: directory, status: "pending", reason: "native_dispatch_barrier_unverified" });
299
325
  }
300
326
  catch {
@@ -304,7 +330,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
304
330
  if (state.shutdown_state !== "clean") {
305
331
  if (state.shutdown_state === "unclean")
306
332
  unresolved.push({ state_dir: directory, status: "pending", reason: "native_shutdown_unclean" });
307
- if (!state.sessions.length)
333
+ if (!state.sessions.length && !failedStart)
308
334
  unresolved.push({ state_dir: directory, status: "pending", reason: "daemon_sessions_unknown" });
309
335
  continue;
310
336
  }
@@ -297,8 +297,14 @@ export function materializePendingWorkStarts(context, projectId, runId) {
297
297
  continue;
298
298
  publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
299
299
  publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
300
- if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'published' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status = 'publishing'", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1)
300
+ if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'published' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status = 'publishing'", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1) {
301
+ const current = context.db.get("SELECT status, prompt_path, start_receipt_json, start_publication_status FROM work_sessions WHERE id = ?", [row.id]);
302
+ if (current?.status === "running" && current.prompt_path === row.prompt_path && current.start_receipt_json === row.start_receipt_json
303
+ && current.start_publication_status === "published" && receiptFileHash(row.prompt_path) === receipt.prompt_sha256
304
+ && receiptFileHash(path.join(path.dirname(row.prompt_path), "context.json")) === receipt.context_sha256)
305
+ continue;
301
306
  throw new AppError("work_start_projection_conflict", "Work start binding changed during packet publication", 1, { work_id: row.work_id });
307
+ }
302
308
  }
303
309
  }
304
310
  function publishStartReceiptFile(file, contents, expectedHash) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.108",
3
+ "version": "0.9.0-beta.109",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {