@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127

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.
Files changed (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. package/package.json +7 -4
@@ -1,11 +1,13 @@
1
+ import { callDaemonObserved } from './daemon-observation.mjs';
2
+ import { admitNativeRequestBuffer, nativeObservationError } from "./native-streams.mjs";
1
3
  import { createHash, randomUUID } from "node:crypto";
2
4
  import { chmod, lstat, mkdir, open, unlink, realpath } from "node:fs/promises";
3
5
  import { spawn } from "node:child_process";
4
6
  import net from "node:net";
5
7
  import os from "node:os";
6
8
  import { DroidError, DroidRuntime, absolute, droidPaths, droidVersion, prepareDroidHome, readJson, removeDroidAuth, processSnapshot } from "./dd-droid.mjs";
7
- import { durableDaemonDispatch, inspectDaemonOperation } from "./daemon-operations.mjs";
8
- import { observedTimeout } from "./observation-clock.mjs";
9
+ import { durableDaemonDispatch, inspectDaemonOperation, settleDaemonOperation, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
10
+ import { ObservationClock, validateObservationDuration } from "./observation-clock.mjs";
9
11
  import { writeJsonAtomic } from "./runner-events.mjs";
10
12
  import { errorRecord } from "./operation-errors.mjs";
11
13
  import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess } from "./managed-daemon.mjs";
@@ -13,28 +15,22 @@ import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmD
13
15
  const REQUEST = "dd-droid/daemon-request@1", RESPONSE = "dd-droid/daemon-response@1";
14
16
  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; } }
15
17
  export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000, operationId = process.env.DD_EVAL_OPERATION_ID ?? randomUUID()) {
16
- const paths = droidPaths(stateDir), request = { schema_id: REQUEST, id: operationId, operation, params };
17
- return await new Promise((resolve, reject) => {
18
- const client = net.createConnection(paths.socket); let buffer = "", settled = false;
19
- const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value); };
20
- const timer = operation === "session.prompt" ? null : observedTimeout(() => finish(new DroidError("daemon_timeout", `${operation} response was not observed`, { operation_id: operationId })), timeoutMs);
21
- client.setEncoding("utf8"); client.on("connect", () => client.write(`${JSON.stringify(request)}\n`));
22
- client.on("error", error => finish(new DroidError("daemon_not_running", error.message)));
23
- client.on("end", () => finish(new DroidError("daemon_connection_closed", "Droid daemon closed the connection before its reply")));
24
- client.on("close", () => finish(new DroidError("daemon_connection_closed", "Droid daemon connection closed")));
25
- client.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; try { const response = JSON.parse(buffer.slice(0, index)); if (response.schema_id !== RESPONSE || response.id !== operationId) throw new DroidError("daemon_protocol", "Droid daemon returned an unrelated response"); response.ok ? finish(null, response.result) : finish(Object.assign(new Error(response.error.message), response.error)); } catch (error) { finish(error); } });
26
- });
18
+ const request = { schema_id: REQUEST, id: operationId, operation, params };
19
+ return await callDaemonObserved({ stateDir, socket: droidPaths(stateDir).socket, request, responseSchema: RESPONSE, harness: 'droid', timeoutMs });
27
20
  }
28
- function sameConfig(a, b) { return ["cwd", "bin", "model", "reasoning", "mode", "provider", "ddFlowBin", "ddFlowHome", "resourceHome", "projectRoot", "noFlow"].every(key => a?.[key] === b[key]); }
21
+ function sameConfig(a, b) { return ["cwd", "bin", "model", "reasoning", "mode", "provider", "ddFlowBin", "ddFlowHome", "resourceHome", "projectRoot", "noFlow","inactivityMs"].every(key => a?.[key] === b[key]); }
29
22
  export async function startDaemon(options) {
30
23
  const paths = droidPaths(options.stateDir), noFlow = options.noFlow === true || (!options.ddFlowBin && !options.ddFlowHome);
31
24
  const config = { stateDir: paths.dir, home: paths.home, bin: options.bin ?? process.env.DD_DROID_BIN ?? "droid", cwd: absolute(options.cwd, "--cwd"), projectRoot: absolute(options.projectRoot ?? options.cwd, "--project-root"), journal: absolute(options.journal ?? `${paths.dir}/events.jsonl`, "--journal"), entryPath: absolute(options.entryPath, "adapter entry"), authHome: absolute(options.authHome ?? os.homedir(), "auth home"), model: options.model ?? "gpt-5.6-sol", reasoning: options.reasoning ?? "high", mode: options.mode ?? "auto-high", provider: options.provider ?? "openai", noFlow, ddFlowBin: noFlow ? null : absolute(options.ddFlowBin, "--dd-flow-bin"), ddFlowHome: noFlow ? null : absolute(options.ddFlowHome, "--dd-flow-home"), resourceHome: noFlow ? null : process.env.DD_FLOW_RESOURCE_HOME ?? null, inactivityMs: options.timeoutMs ?? 600_000 };
32
25
  await prepareRuntimeOwner(config, paths.dir);
33
26
  config.cwd = await realpath(config.cwd); config.projectRoot = await realpath(config.projectRoot);
34
27
  if (config.mode !== "auto-high") throw new DroidError("droid_mode_unsupported", "The qualified Droid profile requires auto-high");
28
+ config.inactivityMs = options.livenessTimeoutMs ?? options.timeoutMs ?? 600_000;
29
+ validateObservationDuration(config.inactivityMs);
35
30
  await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
31
+ let previous = await readJson(paths.state); assertDaemonRestartable(previous);
36
32
  try { const live = await callDaemon(paths.dir, "daemon.status", {}, 1_000); if (!sameConfig(live.config, config)) throw new DroidError("daemon_config_mismatch", "Droid daemon already uses another configuration"); return { ...live, already_running: true }; } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; }
37
- const previous = await readJson(paths.state);
33
+ previous = await readJson(paths.state); assertDaemonRestartable(previous);
38
34
  if (previous) {
39
35
  const rows = await processSnapshot();
40
36
  if (rows.some(row => !row.zombie && ([previous.pid, previous.provider_pid].includes(row.pid) || previous.owned_processes?.some(old => old.pid === row.pid && old.started === row.started)))) throw new DroidError("invalid_harness_crash", "Previous daemon or provider ownership remains; refusing another owner");
@@ -48,16 +44,16 @@ export async function startDaemon(options) {
48
44
  const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [config.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
49
45
  try {
50
46
  await confirmDaemonProcess(config, resource, child);
51
- const deadline = Date.now() + 30_000;
52
- while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 100)); const saved = await readJson(paths.state); if (saved?.startup_error) throw Object.assign(new Error(saved.startup_error.message), saved.startup_error); try { return await callDaemon(paths.dir, "daemon.status", {}, 1_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } }
47
+ const startupClock = new ObservationClock({ timeoutMs: 30_000 });
48
+ while (!startupClock.sample()) { await new Promise(resolve => setTimeout(resolve, 100)); const saved = await readJson(paths.state); if (saved?.startup_error) throw Object.assign(new Error(saved.startup_error.message), saved.startup_error); try { return await callDaemon(paths.dir, "daemon.status", {}, 1_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } }
53
49
  throw new DroidError("daemon_start_failed", "Droid daemon did not become ready");
54
50
  } catch (error) { await cleanupFailedStart(config, resource, child, error); throw error; }
55
51
  }
56
- export async function stopDaemon(options) { return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, 30_000), droidPaths(options.stateDir).socket, 30_000); }
52
+ export async function stopDaemon(options) { return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, 30_000), droidPaths(options.stateDir).socket, 30_000, { readState: () => readJson(droidPaths(options.stateDir).state) }); }
57
53
  export async function serveDaemon(stateDir) {
58
54
  const paths = droidPaths(stateDir), state = await readJson(paths.state); if (!state?.config) throw new DroidError("daemon_state_missing", "Missing Droid daemon state");
59
55
  let saving = Promise.resolve(), busy = null, stopping = false;
60
- const save = patch => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString() }); const snapshot = structuredClone(state); saving = saving.then(() => writeJsonAtomic(paths.state, snapshot)); return saving; };
56
+ const save = patch => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString() }); const snapshot = structuredClone(state); saving = saving.catch(() => {}).then(() => writeJsonAtomic(paths.state, snapshot)); return saving; };
61
57
  const runtime = new DroidRuntime(state.config, state, save);
62
58
  const dispatch = async request => {
63
59
  const params = request.params ?? {}, operation = request.operation;
@@ -71,35 +67,40 @@ export async function serveDaemon(stateDir) {
71
67
  return { ...inspected, cancelled: true, settled: cleanup.settled, cleanup };
72
68
  }
73
69
  if (operation === "daemon.stop") {
74
- if (stopping) throw new DroidError("operation_busy", "Droid shutdown is already in progress");
75
- if (!params.cancelTree && (busy || (runtime.rootId && !(await runtime.inspect(runtime.rootId)).settled))) throw new DroidError("tree_not_settled", "Droid still owns unfinished work");
76
70
  stopping = true;
77
71
  try {
72
+ return await completeDaemonShutdown(paths.dir, state, save, async () => {
73
+ if (!params.cancelTree && (busy || (runtime.rootId && !(await runtime.inspect(runtime.rootId)).settled))) throw new DroidError("tree_not_settled", "Droid still owns unfinished work");
74
+ commitDaemonShutdown(paths.dir);
78
75
  const cleanup = await runtime.closeTree(params.cancelTree === true);
79
76
  if (runtime.rootId) try { await runtime.inspect(runtime.rootId, { ignoreActive: true }); } catch (error) { cleanup.inspection_error = errorRecord(error); }
80
77
  // Native evidence failures remain visible, but cannot strand copied
81
78
  // credentials or the daemon after the owned process tree is gone.
82
- await removeDroidAuth(state.config); const clean = !cleanup.forced && !cleanup.topology_error && !cleanup.inspection_error;
83
- await save({ shutdown_state: clean ? "clean" : "unclean", active_tree: false, cleanup }); await finishDaemonProcess(state.config, state.resource_process);
84
- return { stopped: true, clean, settled: true, cleanup, _shutdown: true };
79
+ const clean = !cleanup.forced && !cleanup.topology_error && !cleanup.inspection_error;
80
+ return { clean, cleanup };
81
+ }, [
82
+ ["auth", () => removeDroidAuth(state.config)],
83
+ ["daemon_resource", () => finishDaemonProcess(state.config, state.resource_process)]
84
+ ]);
85
85
  }
86
- catch (error) { stopping = false; await save({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); throw error; }
86
+ catch (error) { if (error.code === "tree_not_settled" && !runtime.closed) stopping = false; throw error; }
87
87
  }
88
88
  if (busy || stopping) throw new DroidError("operation_busy", "Another Droid operation is active");
89
89
  busy = operation;
90
90
  try {
91
91
  await save({ active_operation: busy });
92
+ assertDaemonDispatch(paths.dir);
92
93
  if (operation === "session.create") { if (params.prompt) throw new DroidError("invalid_create", "create does not execute a prompt"); return await runtime.create(request.id); }
93
94
  if (operation === "session.resume") return await runtime.resume(params.sessionId);
94
- if (["session.prompt", "session.start"].includes(operation)) return await runtime.prompt(params.sessionId, params.prompt, request.id);
95
+ if (["session.prompt", "session.start"].includes(operation)) return await runtime.prompt(params.sessionId, params.prompt, request.id, params.livenessTimeoutMs ?? runtime.config.inactivityMs);
95
96
  throw new DroidError("unknown_operation", `Unknown Droid operation: ${operation}`);
96
97
  } finally { busy = null; await save({ active_operation: null }); }
97
98
  };
98
99
  await removeSocket(paths.socket);
99
- const server = net.createServer(socket => { socket.setEncoding("utf8"); let buffer = "", consumed = false; socket.on("error", () => {}); socket.on("data", chunk => { if (consumed) return; buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
100
+ const server = net.createServer(socket => { socket.setEncoding("utf8"); let buffer = "", consumed = false; socket.on("error", () => {}); socket.on("data", chunk => { if (consumed) return; buffer += chunk; if (!admitNativeRequestBuffer(buffer, socket, RESPONSE)) { consumed = true; return; } const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
100
101
  void (async () => { let request; try { request = JSON.parse(buffer.slice(0, at)); if (request.schema_id !== REQUEST || typeof request.id !== "string" || typeof request.operation !== "string") throw new DroidError("daemon_protocol", "Malformed Droid daemon request"); let result;
101
102
  if (request.operation === "operation.inspect") {
102
- result = await inspectDaemonOperation(paths.dir, request.params?.operationId);
103
+ result = request.params?.settle === true ? await settleDaemonOperation(paths.dir, request.params?.operationId) : await inspectDaemonOperation(paths.dir, request.params?.operationId);
103
104
  if (!["completed", "failed"].includes(result.state)) {
104
105
  const recovered = await runtime.recoverOperation(request.params.operationId);
105
106
  if (recovered) {
@@ -114,10 +115,11 @@ export async function serveDaemon(stateDir) {
114
115
  // durable stop receipt while the Unix pathname is atomically retired.
115
116
  // Unlinking it from the request handler races that final response and
116
117
  // can leave a stop caller observing the socket for its whole timeout.
117
- if (_shutdown) server.close();
118
+ if (_shutdown) retireDaemonEndpoint(server);
118
119
  socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request.id, ok: true, result: receipt })}\n`);
119
- } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request?.id, ok: false, error: errorRecord(error) })}\n`); } })();
120
+ } catch (error) { await retireFailedDaemon(server, state, error, save); socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request?.id, ok: false, error: errorRecord(error) })}\n`); } })();
120
121
  }); });
122
+ trackDaemonConnections(server);
121
123
  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
124
  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
125
  await new Promise(resolve => server.once("close", resolve));
@@ -4,22 +4,23 @@ export { processSnapshot } from "./process-snapshot.mjs";
4
4
  import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { appendFile, chmod, copyFile, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
7
- import { spawn, execFile } from "node:child_process";
8
- import { promisify } from "node:util";
7
+ import { spawn } from "node:child_process";
9
8
  import os from "node:os";
10
9
  import path from "node:path";
11
- import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
10
+ import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
11
+ import { operationProgressReporter } from "./operation-progress.mjs";
12
12
  import { writeJsonAtomic } from "./runner-events.mjs";
13
- import { errorRecord, reportedError } from "./operation-errors.mjs";
13
+ import { errorRecord, reportedError, isObservationLoss } from "./operation-errors.mjs";
14
14
  import { hookBudget, invokeNativeHook } from "./native-hook-command.mjs";
15
15
  import { checkObservedProfile, observeModel } from "./model-observations.mjs";
16
+ import { readNativeLines, streamNativeDiagnostics, nativeObservationError } from "./native-streams.mjs";
17
+ import { commandText } from "./process-json.mjs";
16
18
  import { DroidMetadataReader, readDroidRoutingLog } from "./droid-observation.mjs";
17
19
  const metadataReaders = new Map();
18
20
  function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataReaders.set(factory, new DroidMetadataReader(factory)); return metadataReaders.get(factory); }
19
21
  import { confirmDaemonProcess, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
20
22
  import { renderAdapterPolicy, lifecycleRetryInstruction } from "./delegation-instructions.mjs";
21
23
 
22
- const run = promisify(execFile);
23
24
  export const DROID_CONTRACT = "dd-droid-harness@1";
24
25
  export const DROID_PROTOCOL = "1.201.0";
25
26
  const LATE_RESPONSE_GRACE_MS = 5_000;
@@ -41,7 +42,8 @@ export function droidPaths(stateDir) {
41
42
  export function droidEnvironment(config) {
42
43
  const env = { ...process.env, FACTORY_HOME_OVERRIDE: config.home, FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome, DD_FLOW_BIN: config.ddFlowBin } : {}) };
43
44
  // This adapter deliberately uses the operator's existing Factory login.
44
- for (const key of Object.keys(env)) if (key.endsWith("API_KEY")) delete env[key];
45
+ // Undefined is a tombstone: downstream env overlays must not resurrect keys.
46
+ for (const key of Object.keys(env)) if (key.endsWith("API_KEY")) env[key] = undefined;
45
47
  return env;
46
48
  }
47
49
  export async function prepareDroidHome(config) {
@@ -63,7 +65,7 @@ export async function prepareDroidHome(config) {
63
65
  export async function removeDroidAuth(config) {
64
66
  for (const name of ["auth.encrypted", "auth.v2.key", "auth.v2.file", "auth.json"]) await unlink(path.join(config.home, ".factory", name)).catch(error => { if (error.code !== "ENOENT") throw error; });
65
67
  }
66
- export async function droidVersion(bin) { const target = executable(bin, ["--version"]); return (await run(target.command, target.args, { timeout: 10_000 })).stdout.trim(); }
68
+ export async function droidVersion(bin) { const target = executable(bin, ["--version"]); return await commandText(target.command, target.args, { timeoutMs: 10_000, phase: "native-version" }); }
67
69
 
68
70
  /** The qualified OpenAI profile reports uncached input separately from caches;
69
71
  * output already includes thinking. Keep native counters alongside normalization. */
@@ -101,7 +103,7 @@ export class DroidRuntime {
101
103
  this.config = config; this.state = state; this.paths = droidPaths(config.stateDir); this.persist = persist; this.pending = new Map(); this.lateResponses = new Map(); this.active = null; this.child = null; this.closed = true; this.loaded = false; this.ownedProcesses = state.owned_processes ?? []; this.closing = false; this.workingState = "idle"; this.protocol = null; this.rootId = state.root_session_id ?? null; this.topology = new Map(state.descendants?.map(child => [child.provider_session_id, child]) ?? []); this.draining = Promise.resolve(); this.journalling = Promise.resolve(); this.activity = 0; this.providerProcess = null; this.cancelled = false; this.lastResult = state.last_result ?? null; this.metadata = metadataReader(this.paths.factory); this.fatalError = state.fatal_error ? reportedError(state.fatal_error) : null;
102
104
  }
103
105
  async journal(kind, payload) { const line = JSON.stringify({ schema_id: "dd-droid/event@1", observed_at: new Date().toISOString(), kind, ...payload }); this.journalling = this.journalling.then(() => appendFile(this.config.journal, `${line}\n`, { mode: 0o600 })); return await this.journalling; }
104
- async save(patch = {}) { return await this.persist({ root_session_id: this.rootId, descendants: [...this.topology.values()], working_state: this.workingState, last_activity: this.activity, ...patch }); }
106
+ async save(patch = {}) { return await this.persist({ root_session_id: this.rootId, sessions: this.rootId ? [this.rootId] : [], descendants: [...this.topology.values()], working_state: this.workingState, last_activity: this.activity, ...patch }); }
105
107
  async start() {
106
108
  if (this.child && !this.closed) return;
107
109
  this.closed = false; this.loaded = false; this.closing = false; this.protocol = null; this.buffer = "";
@@ -109,10 +111,14 @@ export class DroidRuntime {
109
111
  const target = executable(this.config.bin, args);
110
112
  this.providerProcess = await registerDaemonProcess(this.config, { kind: "droid-provider", owner: `droid:${this.config.daemonId}`, operation: `droid-provider:${randomUUID()}`, stdout: this.paths.log, stderr: this.paths.log });
111
113
  this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: droidEnvironment(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] });
112
- this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; let at; while ((at = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, at); this.buffer = this.buffer.slice(at + 1); this.draining = this.draining.then(async () => { if (!line.trim()) return; await this.event(JSON.parse(line)); }).catch(async error => { await this.handleObservationError(error); if (!this.fatalError) this.rejectPending(error); }); } });
113
- this.child.stderr.setEncoding("utf8").on("data", chunk => { void this.journal("stderr", { text: chunk.slice(0, 4000) }).catch(() => {}); });
114
+ const observationFailure = async error => {
115
+ if (error.code === "operation_observation_lost") { this.observationError = error; this.rejectPending(error); return; }
116
+ await this.handleObservationError(error); if (!this.fatalError) this.rejectPending(error);
117
+ };
118
+ this.nativeDrain = readNativeLines(this.child.stdout, line => this.draining = this.event(JSON.parse(line)), observationFailure);
119
+ this.diagnostics = streamNativeDiagnostics(this.child.stderr, chunk => this.journal("stderr", { text: chunk }), observationFailure);
114
120
  this.child.on("error", error => this.rejectPending(error));
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); }); });
121
+ this.child.on("close", (code, signal) => { this.closed = true; void Promise.all([this.nativeDrain, this.diagnostics]).finally(() => { const error = new DroidError("operation_observation_lost", "Droid process exited before a confirmed reply", { code, signal }); this.rejectPending(error); }); });
116
122
  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
123
  clearInterval(this.processObserver);
118
124
  this.processObserver = setInterval(() => { if (!this.processObservation) this.processObservation = this.captureProcesses().catch(error => { this.ownershipError = error; }).finally(() => { this.processObservation = null; }); }, 1_000);
@@ -145,22 +151,59 @@ export class DroidRuntime {
145
151
  if (event.method !== "droid.session_notification") return;
146
152
  const { sessionId, notification: n } = event.params ?? {}; if (!n) return;
147
153
  const fingerprint = createHash("sha256").update(JSON.stringify(event.params)).digest("hex");
148
- if (fingerprint !== this.lastEvent) { this.activity += 1; this.lastEvent = fingerprint; }
149
154
  if (sessionId && this.rootId && sessionId !== this.rootId && !this.topology.has(sessionId)) throw new DroidError("session_identity_mismatch", "Unrelated native Session notification");
155
+ const current = this.active;
156
+ const scoped = current && sessionId && (sessionId === this.rootId || this.topology.get(sessionId)?.operation_id === current.operationId)
157
+ && (!n.turnId || n.turnId === current.turnId) && (!n.requestId || n.requestId === current.requestId);
158
+ let productive = ["assistant_text_delta", "assistant_thought_delta"].includes(n.type) && Boolean(n.textDelta ?? n.thoughtDelta)
159
+ || ["agent_turn_completed", "create_message"].includes(n.type);
160
+ if (scoped && ["agent_turn_completed", "create_message"].includes(n.type)) {
161
+ current.lifecycleProgress ??= new Set();
162
+ const key = JSON.stringify([sessionId, n.type, n.requestId ?? null, n.turnId ?? null, n.message?.id ?? null]);
163
+ if (current.lifecycleProgress.has(key)) productive = false;
164
+ else {
165
+ if (current.lifecycleProgress.size >= 1_024) current.lifecycleProgress.delete(current.lifecycleProgress.values().next().value);
166
+ current.lifecycleProgress.add(key);
167
+ }
168
+ }
169
+ if (scoped && ["tool_call_started", "tool_call_update", "tool_call_completed"].includes(n.type)) {
170
+ const toolId = n.toolUseId ?? n.toolCallId ?? n.toolCall?.id;
171
+ current.toolProgress ??= new Set();
172
+ const key = `${sessionId}:${toolId}`, semantic = JSON.stringify({ type: n.type, toolCall: n.toolCall, status: n.status, input: n.input, output: n.output, content: n.content, error: n.error });
173
+ // Keep fixed-size fingerprints, not 1,024 copies of growing tool output.
174
+ const snapshot = createHash("sha256").update(JSON.stringify([key, semantic])).digest("hex");
175
+ if (toolId && !current.toolProgress.has(snapshot)) {
176
+ productive = n.type !== "tool_call_update" || n.status !== undefined || n.output !== undefined || n.content !== undefined || n.toolCall !== undefined || n.error !== undefined;
177
+ if (productive) {
178
+ // ponytail: retain the latest 1,024 snapshots for replay detection,
179
+ // not a lifetime progress quota. Native sequence IDs can extend proof.
180
+ if (current.toolProgress.size >= 1_024) current.toolProgress.delete(current.toolProgress.values().next().value);
181
+ current.toolProgress.add(snapshot);
182
+ }
183
+ }
184
+ }
185
+ if (scoped && productive) {
186
+ this.activity += 1; this.progress = { cursor: this.activity, observed_at: Date.now() };
187
+ const pending = this.pending.get(current.requestId);
188
+ if (pending?.method === "droid.add_user_message") {
189
+ pending.progress = this.progress;
190
+ }
191
+ await current.publish?.("native_progress", { native_request_id: String(current.requestId), session_id: this.rootId, observed_at: new Date(this.progress.observed_at).toISOString() });
192
+ }
150
193
  if (n.type === "droid_working_state_changed" && (!sessionId || sessionId === this.rootId)) this.workingState = n.newState;
151
194
  if (n.type === "child_session_available") {
152
195
  if (!this.rootId || sessionId !== this.rootId || !n.childSessionId || !n.toolUseId) throw new DroidError("droid_child_identity_invalid", "Native child event lacks physical parent or tool identity");
153
- this.topology.set(n.childSessionId, { provider_session_id: n.childSessionId, parent_provider_session_id: sessionId, tool_use_id: n.toolUseId, status: "running", source: "droid.child_session_available" }); await this.save();
196
+ this.topology.set(n.childSessionId, { provider_session_id: n.childSessionId, parent_provider_session_id: sessionId, tool_use_id: n.toolUseId, operation_id: current?.operationId ?? null, status: "running", source: "droid.child_session_available" }); await this.save();
154
197
  }
155
198
  if (n.type === "settings_updated" && this.loaded) await this.observeSettings(sessionId ?? this.rootId, n.settings, { kind: "native.settings_updated", event_sha256: fingerprint });
156
- const current = this.active;
157
199
  if (n.type === "create_message" && n.requestId === current?.requestId) {
158
200
  if (n.message?.id !== current.turnId) throw new DroidError("droid_turn_identity_mismatch", "Droid did not preserve the requested message ID");
159
201
  current.accepted = true; await this.saveBinding(current, { accepted: true });
160
202
  }
161
- if (n.type === "assistant_text_delta" && current) current.text += n.textDelta ?? "";
203
+ if (n.type === "assistant_text_delta" && scoped && sessionId === this.rootId) current.text += n.textDelta ?? "";
162
204
  if (n.type === "agent_turn_completed" && current && n.turnId === current.turnId && (!sessionId || sessionId === this.rootId)) {
163
205
  current.outcome = n; this.lastResult = n; await this.saveBinding(current, { native_outcome: n, assistant_text: current.text }); current.resolve(n);
206
+ if (current.observerReturned && this.active === current) { this.active = null; await this.save({ active_tree: this.workingState !== "idle" }); }
164
207
  }
165
208
  }
166
209
  request(method, params, id = randomUUID(), timeoutMs = 30_000) {
@@ -171,11 +214,11 @@ export class DroidRuntime {
171
214
  pending.timedOut = true;
172
215
  const timeoutError = new DroidError("rpc_timeout", `${method} has no observed reply`, { request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS });
173
216
  void this.journal("request_deadline", { method, request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS });
174
- pending.timer = observedTimeout(() => { if (this.pending.get(id) !== pending) return; this.pending.delete(id); reject(timeoutError); }, LATE_RESPONSE_GRACE_MS);
175
- }, timeoutMs);
217
+ pending.timer = observedTimeout(() => { if (this.pending.get(id) !== pending) return; this.pending.delete(id); reject(timeoutError); }, LATE_RESPONSE_GRACE_MS, { progress: () => pending.progress });
218
+ }, timeoutMs, { progress: () => this.pending.get(id)?.progress });
176
219
  this.pending.set(id, { resolve, reject, timer, method });
177
- try { this.send({ jsonrpc: "2.0", type: "request", factoryApiVersion: "1.0.0", factoryProtocolVersion: DROID_PROTOCOL, id, method, params }, error => { if (error) { clearTimeout(timer); this.pending.delete(id); reject(error); } }); }
178
- catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
220
+ try { this.send({ jsonrpc: "2.0", type: "request", factoryApiVersion: "1.0.0", factoryProtocolVersion: DROID_PROTOCOL, id, method, params }, error => { if (error) { clearTimeout(timer); this.pending.delete(id); reject(nativeObservationError(error)); } }); }
221
+ catch (error) { clearTimeout(timer); this.pending.delete(id); reject(nativeObservationError(error)); }
179
222
  });
180
223
  }
181
224
  send(value, callback) {
@@ -186,7 +229,7 @@ export class DroidRuntime {
186
229
  throw error;
187
230
  }
188
231
  try { stdin.write(`${JSON.stringify(value)}\n`, callback); }
189
- catch (error) { this.rejectPending(error); throw error; }
232
+ catch (cause) { const error = nativeObservationError(cause); this.rejectPending(error); throw error; }
190
233
  }
191
234
  profile(settings) {
192
235
  const model = settings?.modelId ?? settings?.model ?? null;
@@ -244,7 +287,12 @@ export class DroidRuntime {
244
287
  }
245
288
  }
246
289
  async handleObservationError(error) {
247
- if (["profile_integrity_violation", "session_identity_mismatch", "droid_child_identity_invalid", "droid_turn_identity_mismatch", "droid_hook_identity_invalid", "model_observation_storage_failed"].includes(error.code)) {
290
+ if (error.code === "model_observation_storage_failed") {
291
+ this.observationError = Object.assign(new Error("Droid model observation could not be published", { cause: error }), { code: "operation_observation_lost", details: { native_outcome: "unknown", observation_error: errorRecord(error) } });
292
+ this.rejectPending(this.observationError);
293
+ return;
294
+ }
295
+ if (["profile_integrity_violation", "session_identity_mismatch", "droid_child_identity_invalid", "droid_turn_identity_mismatch", "droid_hook_identity_invalid"].includes(error.code)) {
248
296
  if (!this.fatalError) {
249
297
  this.fatalError = error;
250
298
  try { await this.save({ fatal_error: errorRecord(error) }); await this.journal("integrity_failure", { error: errorRecord(error) }); }
@@ -276,7 +324,8 @@ export class DroidRuntime {
276
324
  const source = await readDroidSession(this.paths.factory, id); if (!source || path.resolve(source.header.cwd) !== this.config.cwd) throw new DroidError("session_identity_mismatch", "Resumed Session does not match its controlled workspace");
277
325
  this.loaded = true; this.observedProfile = await this.observeSettings(id, result.settings, "native.load_session"); this.workingState = result.workingState ?? (result.isAgentLoopInProgress ? "unknown" : "idle"); this.cancelled = false; await this.save(); return await this.inspect(id);
278
326
  }
279
- async prompt(id, prompt, operationId) {
327
+ async prompt(id, prompt, operationId, livenessTimeoutMs = this.config.inactivityMs ?? 600_000) {
328
+ validateObservationDuration(livenessTimeoutMs);
280
329
  const assertDispatch = captureDispatchGuard(this);
281
330
  if (this.fatalError) throw this.fatalError;
282
331
  this.requireIdentity(id); if (!prompt) throw new DroidError("prompt_required", "prompt is required"); if (this.active) throw new DroidError("operation_busy", "Another Droid operation is active");
@@ -285,20 +334,31 @@ export class DroidRuntime {
285
334
  const before = await this.inspect(id); if (!before.settled) throw new DroidError("tree_not_settled", "Prior Droid tree has not settled");
286
335
  let resolve, reject; const completed = new Promise((a, b) => { resolve = a; reject = b; }); void completed.catch(() => {});
287
336
  const requestId = randomUUID(), turnId = randomUUID();
288
- const current = { operationId, requestId, turnId, resolve, reject, text: "", saved: { operation: "session.prompt", provider_session_id: id, request_id: requestId, turn_id: turnId, accepted: false } };
337
+ const current = { operationId, requestId, turnId, resolve, reject, text: "", publish: operationProgressReporter(), saved: { operation: "session.prompt", provider_session_id: id, request_id: requestId, turn_id: turnId, accepted: false } };
289
338
  this.active = current; this.cancelled = false; await this.saveBinding(current); await this.save({ active_tree: true });
290
- const clock = new ObservationClock({ timeoutMs: this.config.inactivityMs ?? 600_000, onGap: gap => { void this.journal("observation_gap", { gap }).catch(() => {}); } });
339
+ const clock = new ObservationClock({ timeoutMs: livenessTimeoutMs, onGap: gap => { void this.journal("observation_gap", { gap }).catch(() => {}); } });
291
340
  let checkingTerminal = false;
292
341
  const timer = setInterval(() => {
293
- if (!clock.sample(this.activity) || checkingTerminal) return;
342
+ if (checkingTerminal || !clock.sample(this.progress)) return;
294
343
  checkingTerminal = true;
344
+ const cursor = this.progress?.cursor;
295
345
  void this.recoverOperation(operationId).then(() => {
296
- if (!current.outcome) reject(new DroidError("subject_liveness_timeout", "Droid emitted no native progress within its inactivity window"));
297
- }).catch(error => reject(new DroidError("operation_observation_lost", "Cannot reconcile native outcome before declaring inactivity", { error: errorRecord(error) })));
298
- }, 1_000);
346
+ if (this.active === current && !current.outcome && this.progress?.cursor === cursor && clock.sample(this.progress)) reject(new DroidError("operation_observation_lost", "Droid native outcome remains unknown after its silence window", { operation_id: operationId, native_outcome: "unknown" }));
347
+ }).catch(error => reject(new DroidError("operation_observation_lost", "Cannot reconcile native outcome before declaring inactivity", { error: errorRecord(error) }))).finally(() => { checkingTerminal = false; });
348
+ }, Math.max(10, Math.min(1_000, livenessTimeoutMs / 4)));
349
+ let primary;
299
350
  try {
300
351
  assertDispatch();
352
+ current.dispatched = true;
301
353
  await this.request("droid.add_user_message", { text: prompt, messageId: turnId }, requestId);
354
+ try { await this.journal("native_dispatch", { operation_id: operationId, provider_session_id: id, native_request_id: requestId }); }
355
+ catch (cause) {
356
+ const error = nativeObservationError(cause);
357
+ if (!current.outcome || error.code !== "operation_observation_lost") throw error;
358
+ throw Object.assign(new Error("Droid completed natively but its dispatch journal could not be saved", { cause }), {
359
+ code: "native_outcome_observation_failed", details: { native_outcome: { provider_session_id: id, turn_id: turnId, result: current.outcome }, settled: false, observation_error: errorRecord(error) }
360
+ });
361
+ }
302
362
  const outcome = await completed; await this.draining;
303
363
  try {
304
364
  const receipt = await this.inspect(id, { ignoreActive: true });
@@ -310,8 +370,22 @@ export class DroidRuntime {
310
370
  return { provider_session_id: id, adapter_session_id: id, result: { ...outcome, status: status(outcome.reason) }, assistant_text: current.text, turn_id: turnId, settled: false, settlement_observation_error: errorRecord(error) };
311
371
  }
312
372
  } catch (error) {
313
- await this.journal("prompt_error", { operation_id: operationId, error: errorRecord(error) }); throw error;
314
- } finally { clearInterval(timer); if (this.active === current) this.active = null; await this.save({ active_tree: this.workingState !== "idle" }); }
373
+ if (current.outcome && isObservationLoss(error)) error = Object.assign(new Error("Droid completed natively but its dispatch acknowledgement was lost", { cause: error }), {
374
+ code: "native_outcome_observation_failed", details: { native_outcome: { provider_session_id: id, turn_id: turnId, result: current.outcome }, settled: false, observation_error: errorRecord(error) }
375
+ });
376
+ primary = error;
377
+ try { await this.journal("prompt_error", { operation_id: operationId, error: errorRecord(error) }); }
378
+ catch (publication) { error.cleanup_error ??= errorRecord(publication); }
379
+ throw error;
380
+ } finally {
381
+ clearInterval(timer); current.observerReturned = true;
382
+ if (this.active === current && (current.outcome || !current.dispatched)) this.active = null;
383
+ try { await this.save({ active_tree: Boolean(this.active) || this.workingState !== "idle" }); }
384
+ catch (publication) {
385
+ if (primary) primary.cleanup_error ??= errorRecord(publication);
386
+ else throw Object.assign(new Error("Droid outcome bookkeeping could not be published", { cause: publication }), { code: current.outcome ? "native_outcome_observation_failed" : "operation_observation_lost", details: { native_outcome: current.outcome ?? "unknown", observation_error: errorRecord(publication) } });
387
+ }
388
+ }
315
389
  }
316
390
  async recoverOperation(operationId) {
317
391
  const binding = await readJson(path.join(this.paths.bindings, `${createHash("sha256").update(operationId).digest("hex")}.json`));
@@ -332,6 +406,11 @@ export class DroidRuntime {
332
406
  current.outcome = outcome; this.lastResult = outcome;
333
407
  await this.saveBinding(current, { native_outcome: outcome });
334
408
  current.resolve(outcome);
409
+ if (current.observerReturned) {
410
+ if (this.active === current) this.active = null;
411
+ await this.save({ active_tree: this.workingState !== "idle" });
412
+ return { ...receipt, result: { ...outcome, status: status(outcome.reason) }, assistant_text: binding.assistant_text ?? "", turn_id: binding.turn_id, recovered_from: "native.agent_turn_outcome" };
413
+ }
335
414
  // Let the original dispatch persist its one terminal reply. Settlement
336
415
  // is reconciled separately; do not race a second writer or replay it.
337
416
  return null;
@@ -420,10 +499,17 @@ export class DroidRuntime {
420
499
  try {
421
500
  return await this.flow("event", "handle", { schema_id: "dd-flow/droid-tool-event@1", phase: event === "PreToolUse" ? "before" : "after", event_id: eventId, daemon_id: this.config.daemonId, session: { provider_session_id: id, parent_provider_session_id: parent, directory: this.config.cwd }, tool: payload.tool_name, input: payload.tool_input, profile, transcript_path: native.transcript_path, outcome: { status: payload.tool_response?.is_error ? "error" : "completed" } }, budget.remaining("admission", 15_000));
422
501
  } catch (error) {
502
+ const details = error.details;
503
+ if (event === "PreToolUse" && error.code === "lifecycle_shell_syntax_invalid" && details?.phase === "prepare" && details.effect === "no_effect" && details.recoverable === true && details.disposition === "pre_cli_shell_refusal" && (details.refusal_event_key || details.event_key)) {
504
+ const refusal = errorRecord(error);
505
+ await this.journal("hook_rejected", { error: refusal, event_id: eventId, session_id: id });
506
+ await this.save();
507
+ return { continue: false, stopReason: error.message };
508
+ }
423
509
  // Identity is proven above; only this owned admission can fail its turn.
424
510
  this.fatalError ??= Object.assign(error, { details: { ...error.details, provider_session_id: id, event_id: eventId, effect: "unknown" } });
425
511
  this.rejectPending(this.fatalError);
426
- try { await this.save({ fatal_error: errorRecord(this.fatalError) }); await this.journal("hook_rejected", { error: errorRecord(this.fatalError) }); }
512
+ try { await budget.run(async () => { await this.save({ fatal_error: errorRecord(this.fatalError) }); await this.journal("hook_rejected", { error: errorRecord(this.fatalError) }); }, "failure_persistence"); }
427
513
  catch (storage) { this.fatalError.details.receipt_error = errorRecord(storage); }
428
514
  throw this.fatalError;
429
515
  }