@deksden-com/dd-flow-cli 0.9.0-beta.145 → 0.9.0-beta.149

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 (101) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/build-info.json +7 -7
  3. package/dist/cli/command-inputs.js +6 -2
  4. package/dist/cli/command-metadata.js +18 -2
  5. package/dist/cli/diagnostic-invocation.js +46 -0
  6. package/dist/cli/diagnostic.js +120 -0
  7. package/dist/cli/help.js +35 -0
  8. package/dist/cli/memory-flow-admission.js +4 -2
  9. package/dist/cli/memory-init.js +5 -2
  10. package/dist/cli/run-cli.js +16 -15
  11. package/dist/cli.js +6 -0
  12. package/dist/domain/diagnostic-aspects.js +103 -0
  13. package/dist/domain/diagnostic-flows.js +8 -0
  14. package/dist/harness-runtime/lib/agy-native-inspection.mjs +216 -0
  15. package/dist/harness-runtime/lib/agy-native_pb.js +117 -0
  16. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +84 -10
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +306 -34
  19. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +27 -13
  20. package/dist/harness-runtime/lib/dd-codex.mjs +4 -0
  21. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +4 -3
  22. package/dist/harness-runtime/lib/dd-droid.mjs +70 -28
  23. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -3
  24. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +66 -20
  25. package/dist/harness-runtime/lib/dd-opencode.mjs +30 -3
  26. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +7 -6
  27. package/dist/harness-runtime/lib/driver-recovery.mjs +9 -8
  28. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -1
  29. package/dist/harness-runtime/lib/managed-daemon.mjs +11 -3
  30. package/dist/harness-runtime/lib/native-children.d.mts +8 -1
  31. package/dist/harness-runtime/lib/native-children.mjs +114 -8
  32. package/dist/harness-runtime/lib/native-request-proof.mjs +1 -1
  33. package/dist/harness-runtime/lib/observation-clock.d.mts +2 -2
  34. package/dist/harness-runtime/lib/observation-clock.mjs +7 -4
  35. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +1 -1
  36. package/dist/harness-runtime/lib/provider-overload-policy.mjs +1 -1
  37. package/dist/harness-runtime/lib/tool-observations.mjs +1 -1
  38. package/dist/schemas/diagnostic-stage-result.schema.json +354 -0
  39. package/dist/schemas/diagnostic-work-result.schema.json +296 -0
  40. package/dist/schemas/execution-bundle.schema.json +7 -1
  41. package/dist/schemas/project-flow-pack-manifest.schema.json +1 -1
  42. package/dist/services/check-environment.js +21 -0
  43. package/dist/services/check-runner-evidence.js +267 -15
  44. package/dist/services/code-checks.js +23 -15
  45. package/dist/services/controller-execution.js +10 -4
  46. package/dist/services/controller-fanout.js +13 -6
  47. package/dist/services/controller-memory-worker.js +3 -2
  48. package/dist/services/dashboard.js +2 -2
  49. package/dist/services/descriptor-run-settlement.js +5 -3
  50. package/dist/services/descriptor-run-start.js +22 -3
  51. package/dist/services/descriptor-stage-coordinator.js +37 -4
  52. package/dist/services/descriptor-stage-settlement.js +2 -1
  53. package/dist/services/diagnostic-evidence.js +73 -0
  54. package/dist/services/diagnostic-report-publication.js +72 -0
  55. package/dist/services/diagnostic-report.js +67 -0
  56. package/dist/services/diagnostic-run-bundle.js +74 -0
  57. package/dist/services/diagnostic-snapshot.js +159 -0
  58. package/dist/services/diagnostic-stage-handlers.js +108 -0
  59. package/dist/services/diagnostic-stage-results.js +132 -0
  60. package/dist/services/diagnostic-start.js +84 -0
  61. package/dist/services/diagnostic-work-batch.js +34 -0
  62. package/dist/services/diagnostic-work-collection.js +97 -0
  63. package/dist/services/diagnostic-work-registration.js +101 -0
  64. package/dist/services/diagnostic-work-results.js +93 -0
  65. package/dist/services/eval-snapshots.js +85 -29
  66. package/dist/services/execution-bundles.js +19 -7
  67. package/dist/services/flow-catalog.js +6 -1
  68. package/dist/services/frozen-engine.js +37 -2
  69. package/dist/services/hybrid-contracts.js +2 -0
  70. package/dist/services/installed-flow-source.js +25 -3
  71. package/dist/services/lifecycle-invocations.js +147 -34
  72. package/dist/services/memory-init-contexts.js +16 -0
  73. package/dist/services/memory-init-controller-start.js +12 -4
  74. package/dist/services/memory-init-lint-capability.js +5 -5
  75. package/dist/services/memory-init-native-capacity.js +4 -4
  76. package/dist/services/memory-init-preflight.js +10 -16
  77. package/dist/services/memory-init-worktrunk-capability.js +3 -7
  78. package/dist/services/memory-layer.js +4 -3
  79. package/dist/services/memory-tool-probe.js +25 -0
  80. package/dist/services/memory-verification-checks.js +15 -4
  81. package/dist/services/native-daemon-history.js +2 -1
  82. package/dist/services/provider-capacity.js +43 -38
  83. package/dist/services/release-cache.js +8 -0
  84. package/dist/services/run-control.js +2 -1
  85. package/dist/services/run-controller-adapter.js +2 -1
  86. package/dist/services/run-controller-process.js +2 -1
  87. package/dist/services/run-controller-recovery.js +2 -1
  88. package/dist/services/run-controller.js +85 -15
  89. package/dist/services/run-fork.js +1 -1
  90. package/dist/services/runs.js +6 -1
  91. package/dist/services/runtime-scope-resume.js +12 -6
  92. package/dist/services/runtime-scope-stop.js +3 -2
  93. package/dist/services/runtime-service.js +2 -1
  94. package/dist/services/stage-handlers.js +16 -3
  95. package/dist/services/stage-pause.js +4 -1
  96. package/dist/services/version-status.js +1 -1
  97. package/dist/services/vnext-code.js +2 -2
  98. package/dist/services/vnext-fanout.js +31 -1
  99. package/dist/services/work-registry.js +37 -6
  100. package/dist/services/workspace-bootstrap.js +2 -1
  101. package/package.json +6 -3
@@ -7,12 +7,13 @@ import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mj
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
9
  import net from "node:net";
10
- import { durableDaemonDispatch, completeDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
10
+ import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
11
11
  import { ObservationClock } from "./observation-clock.mjs";
12
12
  import { parseCapacityOperation } from "./provider-overload-policy.mjs";
13
13
  import path from "node:path";
14
14
  import { spawn } from "node:child_process";
15
15
  import { waitForSettlement } from "./session-settlement.mjs";
16
+ import { nativeExecutionReceipt } from "./native-children.mjs";
16
17
  import { errorRecord } from "./operation-errors.mjs";
17
18
  import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, codexReadPhase, readCodexHistory, retainedCodexTurn, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
18
19
  import { assertDaemonOwnership, assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
@@ -77,7 +78,7 @@ export async function inspectCodexTree(bridge, options, { remember = () => {}, a
77
78
  options = { ...options, readPhase: options.readPhase ?? codexReadPhase(bridge, options) };
78
79
  const sessions = new Set([options.sessionId]);
79
80
  const observed = await observeCodexSessions(bridge, options, sessions, active, remember);
80
- return { ...observed.observations[0], settled: !observed.active && !observed.sessions.length, unsettled_sessions: observed.sessions, tree_session_ids: [...sessions], tree_observations: observed.observations.map(item => ({ provider_session_id: item.provider_session_id, status: item.status, settled: item.settled })) };
81
+ return nativeExecutionReceipt({ ...observed.observations[0], settled: !observed.active && !observed.sessions.length, unsettled_sessions: observed.sessions, tree_session_ids: [...sessions], tree_observations: observed.observations.map(item => ({ provider_session_id: item.provider_session_id, status: item.status, settled: item.settled })) }, { daemonId: options.daemonId });
81
82
  }
82
83
 
83
84
  export async function cancelCodexTree(bridge, options, { remember = () => {}, active = () => null, timeoutMs = 5_000 } = {}) {
@@ -120,32 +121,44 @@ export async function startDaemon(options) {
120
121
  }
121
122
  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, { readState: () => readState(locations(options.stateDir).state) }); }
122
123
 
124
+ export async function prepareCodexStop({ bridge, state, sessions, active, save, stateDir, cancelTree }) {
125
+ if (cancelTree) cancelPendingDispatch(bridge);
126
+ if (active() && !cancelTree) throw Object.assign(new Error("productive operation is running"), { code: "tree_not_settled" });
127
+ let revision;
128
+ await waitForSettlement({
129
+ knownSessions: () => [...sessions],
130
+ observe: () => { revision = state.native_execution_revision; return observeCodexSessions(bridge, state.config, sessions, active); },
131
+ ...(cancelTree ? { cancel: async sessionId => { try { await cancelSessionWithBridge(bridge, { ...state.config, sessionId }); } catch (error) { if (error.code !== "turn_not_active") throw error; } } } : {})
132
+ });
133
+ if (active() || state.native_execution_revision !== revision) throw Object.assign(new Error("native activity changed during settlement observation"), { code: "tree_not_settled" });
134
+ commitDaemonShutdown(stateDir);
135
+ await save({ active_tree: false });
136
+ return { clean: true, settled: true, native_settled: true, native_settlement: { confirmed: true, revision } };
137
+ }
138
+
123
139
  export async function serveDaemon(stateDir) {
124
140
  const paths = locations(stateDir); const state = await readState(paths.state); if (!state || state.schema_id !== stateSchema) throw Object.assign(new Error("daemon state missing"), { code: "daemon_state_missing" });
125
141
  const bridge = new CodexBridge({ ...state.config, env: { ...codexBridgeEnv(state.config, state.daemon_id), CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
126
142
  scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
127
143
  const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.catch(() => {}).then(() => writeState(paths.state, snapshot)); return persist; };
144
+ bridge.options.onNativeActivity = () => {
145
+ state.native_execution_revision = (state.native_execution_revision ?? 0) + 1;
146
+ if (state.active_tree !== true) void save({ active_tree: true }).catch(error => { bridge.rejectAll(error); });
147
+ };
128
148
  const dispatch = async (operation, params, operationId) => {
129
149
  const assertDispatch = captureDispatchGuard(bridge);
130
150
  if (operation === "hook.verify") {
131
151
  if (params.daemonId !== state.daemon_id) throw Object.assign(new Error("Hook daemon identity differs"), { code: "native_hook_unproven" });
132
152
  return { ...bridge.verifyNativeHook(params), daemonId: state.daemon_id, ddFlowHome: state.config.ddFlowHome, projectRoot: state.config.projectRoot };
133
153
  }
134
- if (operation === "daemon.status") return { daemon_id: state.daemon_id, pid: process.pid, cwd: state.cwd, journal: state.journal, config: state.config, sessions: [...sessions], active_operation: active, shutdown_state: state.shutdown_state };
154
+ if (operation === "daemon.status") return { daemon_id: state.daemon_id, pid: process.pid, cwd: state.cwd, journal: state.journal, config: state.config, sessions: [...sessions], active_operation: active, active_tree: state.active_tree ?? null, shutdown_state: state.shutdown_state };
135
155
  // The runner may need to retire an orphaned daemon after its caller was
136
156
  // interrupted. `daemon.stop` is the one control operation that remains
137
157
  // available while a request is marked active; callers must first prove the
138
158
  // provider Session itself is idle.
139
159
  if (operation === "daemon.stop") {
140
160
  return await completeDaemonShutdown(paths.dir, state, save, async () => {
141
- if (params.cancelTree) cancelPendingDispatch(bridge);
142
- if (active && !params.cancelTree) throw Object.assign(new Error("productive operation is running"), { code: "tree_not_settled" });
143
- await waitForSettlement({
144
- knownSessions: () => [...sessions],
145
- observe: () => observeCodexSessions(bridge, state.config, sessions, () => active),
146
- ...(params.cancelTree ? { cancel: async sessionId => { try { await cancelSessionWithBridge(bridge, { ...state.config, sessionId }); } catch (error) { if (error.code !== "turn_not_active") throw error; } } } : {})
147
- });
148
- return { clean: true };
161
+ return await prepareCodexStop({ bridge, state, sessions, active: () => active, save, stateDir: paths.dir, cancelTree: params.cancelTree });
149
162
  }, [
150
163
  ["provider", () => bridge.close()],
151
164
  ["provider_resource", () => finishDaemonProcess(state.config, state.provider_process)],
@@ -154,7 +167,7 @@ export async function serveDaemon(stateDir) {
154
167
  }
155
168
  if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(paths.dir);
156
169
  if (active && !["session.inspect", "session.cancel"].includes(operation)) throw Object.assign(new Error(`${active} is in progress`), { code: "operation_busy", retryable: true });
157
- if (operation === "session.create") { active = operation; try { await save({ active_operation: active }); const result = await createSessionWithBridge(bridge, { ...state.config, ...params }); sessions.add(result.provider_session_id); return result; } finally { active = null; await save({ active_operation: null }); } }
170
+ if (operation === "session.create") { active = operation; try { await save({ active_operation: active, active_tree: true }); const result = await createSessionWithBridge(bridge, { ...state.config, ...params }); sessions.add(result.provider_session_id); return result; } finally { active = null; await save({ active_operation: null }); } }
158
171
  if (operation === "session.prompt") {
159
172
  parseCapacityOperation(operationId);
160
173
  // Admission can wait for a permit. Observe again after it returns, not
@@ -163,6 +176,7 @@ export async function serveDaemon(stateDir) {
163
176
  // journal entries and must not both pass the idle check while inspecting.
164
177
  active = operation;
165
178
  try {
179
+ await save({ active_operation: active, active_tree: true });
166
180
  const tree = await inspectCodexTree(bridge, { ...state.config, ...params });
167
181
  assertDispatch();
168
182
  if (tree.settled !== true) throw Object.assign(new Error("native Session tree changed while waiting for admission"), { code: "tree_not_settled", details: { provider_session_id: params.sessionId, unsettled_sessions: tree.unsettled_sessions } });
@@ -173,7 +187,7 @@ export async function serveDaemon(stateDir) {
173
187
  }
174
188
  finally { active = null; await save({ active_operation: null }); }
175
189
  }
176
- if (operation === "session.start") { sessions.add(params.sessionId); active = operation; try { await save({ active_operation: active }); return await startSessionWithBridge(bridge, { ...state.config, ...params, assertDispatch, operationId }); } finally { active = null; await save({ active_operation: null }); } }
190
+ if (operation === "session.start") { sessions.add(params.sessionId); active = operation; try { await save({ active_operation: active, active_tree: true }); return await startSessionWithBridge(bridge, { ...state.config, ...params, assertDispatch, operationId }); } finally { active = null; await save({ active_operation: null }); } }
177
191
  if (operation === "session.inspect" || operation === "session.resume") {
178
192
  const owned = new Set(sessions);
179
193
  for (const id of owned) for (const child of bridge.directChildren?.(id) ?? []) owned.add(child.provider_session_id);
@@ -180,6 +180,10 @@ export class CodexBridge {
180
180
 
181
181
  receive(line) {
182
182
  let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
183
+ // Read-induced idle/status notifications are not new execution. Native
184
+ // turn/item events and newly active threads invalidate an idle proof.
185
+ if (/^(turn|item)\//.test(message.method ?? "")
186
+ || message.method === "thread/status/changed" && message.params?.status?.type === "active") this.options.onNativeActivity?.();
183
187
  this.observeItemPhase(message);
184
188
  try { this.observeTopology(message); }
185
189
  catch (error) { this.topologyError = error; this.rejectAll(error); return; }
@@ -66,7 +66,7 @@ export async function serveDaemon(stateDir) {
66
66
  runtime.requireIdentity(params.sessionId); const cleanup = await runtime.closeTree(true); let inspected;
67
67
  try { inspected = await runtime.inspect(params.sessionId, { ignoreActive: true }); }
68
68
  catch (error) { inspected = { harness: "droid-cli", provider_session_id: params.sessionId, descendants: [...runtime.topology.values()], inspection_error: errorRecord(error) }; }
69
- return { ...inspected, cancelled: true, settled: cleanup.settled, cleanup };
69
+ return { ...inspected, cancelled: true, settled: inspected.settled === true && cleanup.native_settled === true, native_settled: inspected.settled === true && cleanup.native_settled === true, physical_stopped: cleanup.physical_stopped, cleanup };
70
70
  }
71
71
  if (operation === "daemon.stop") {
72
72
  stopping = true;
@@ -78,9 +78,10 @@ export async function serveDaemon(stateDir) {
78
78
  if (runtime.rootId) try { await runtime.inspect(runtime.rootId, { ignoreActive: true }); } catch (error) { cleanup.inspection_error = errorRecord(error); }
79
79
  // Native evidence failures remain visible, but cannot strand copied
80
80
  // credentials or the daemon after the owned process tree is gone.
81
- const clean = !cleanup.forced && !cleanup.topology_error && !cleanup.inspection_error;
82
- return { clean, cleanup };
81
+ const clean = cleanup.native_settled === true && !cleanup.inspection_error;
82
+ return { clean, settled: cleanup.native_settled === true, native_settled: cleanup.native_settled === true, physical_stopped: cleanup.physical_stopped, cleanup };
83
83
  }, [
84
+ ["provider", async () => { if (!runtime.closed) throw new DroidError("provider_process_stop_incomplete", "Droid provider has not closed"); }],
84
85
  ["auth", () => removeDroidAuth(state.config)],
85
86
  ["daemon_resource", () => finishDaemonProcess(state.config, state.resource_process)]
86
87
  ]);
@@ -22,7 +22,7 @@ function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataRe
22
22
  import { confirmDaemonProcess, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
23
23
  import { renderAdapterPolicy, lifecycleRetryInstruction } from "./delegation-instructions.mjs";
24
24
  import { classifyProviderError, safeProviderError } from "./provider-overload-policy.mjs";
25
- import { settledNativeTree } from "./native-children.mjs";
25
+ import { settledNativeTree, nativeExecutionReceipt } from "./native-children.mjs";
26
26
 
27
27
  export const DROID_CONTRACT = "dd-droid-harness@1";
28
28
  export const DROID_PROTOCOL = "1.201.0";
@@ -256,7 +256,7 @@ export class DroidRuntime {
256
256
  }
257
257
  async observeSettings(id, settings, source, extra = {}) {
258
258
  const observed = this.profile(settings);
259
- const event = await observeModel({ journal: this.config.journal, harness: "droid-cli", sessionId: id, parentSessionId: id === this.rootId ? null : this.rootId,
259
+ const event = await observeModel({ journal: this.config.journal, harness: "droid-cli", sessionId: id, parentSessionId: id === this.rootId ? null : this.topology.get(id)?.parent_provider_session_id ?? this.rootId,
260
260
  requested: this.config, observed, source, ...extra });
261
261
  if (id === this.rootId) this.observedProfile = observed;
262
262
  this.modelEventIds ??= new Map();
@@ -267,8 +267,9 @@ export class DroidRuntime {
267
267
  return observed;
268
268
  }
269
269
  validateMetadata(id, native) {
270
- if (!native || path.resolve(native.header.cwd) !== this.config.cwd || native.header.id !== id || (id !== this.rootId && native.header.callingSessionId !== this.rootId)) throw new DroidError("session_identity_mismatch", "Native model observation is outside the owned Session tree");
271
- if (id !== this.rootId && !this.topology.has(id)) this.topology.set(id, { provider_session_id: id, parent_provider_session_id: this.rootId, tool_use_id: native.header.callingToolUseId, status: "unknown", source: "native.metadata" });
270
+ const parent = native?.header.callingSessionId;
271
+ if (!native || path.resolve(native.header.cwd) !== this.config.cwd || native.header.id !== id || (id !== this.rootId && (id === parent || parent !== this.rootId && !this.topology.has(parent) || this.topology.has(id) && this.topology.get(id).parent_provider_session_id !== parent))) throw new DroidError("session_identity_mismatch", "Native model observation is outside the owned Session tree");
272
+ if (id !== this.rootId && !this.topology.has(id)) this.topology.set(id, { provider_session_id: id, parent_provider_session_id: parent, tool_use_id: native.header.callingToolUseId, status: "unknown", source: "native.metadata" });
272
273
  }
273
274
  async observeModels() {
274
275
  if (!this.rootId || !this.loaded) return;
@@ -276,9 +277,12 @@ export class DroidRuntime {
276
277
  const info = await stat(catalog).catch(error => { if (error.code === "ENOENT") return null; throw error; });
277
278
  if (info && info.mtimeMs !== this.modelCatalogMtime) {
278
279
  const value = await readJson(catalog);
279
- for (const task of value?.invocations ?? []) if (task.parentSessionId === this.rootId && task.childSessionId) {
280
- const native = await this.metadata.read(task.childSessionId);
281
- if (native) this.validateMetadata(task.childSessionId, native);
280
+ const tasks = Array.isArray(value?.invocations) ? value.invocations : [];
281
+ for (let size = -1; size !== this.topology.size;) { size = this.topology.size;
282
+ for (const task of tasks) if (task?.childSessionId && (task.parentSessionId === this.rootId || this.topology.has(task.parentSessionId))) {
283
+ const native = await this.metadata.read(task.childSessionId);
284
+ if (native) this.validateMetadata(task.childSessionId, native);
285
+ }
282
286
  }
283
287
  this.modelCatalogMtime = info.mtimeMs;
284
288
  }
@@ -286,7 +290,7 @@ export class DroidRuntime {
286
290
  const routing = await readDroidRoutingLog(log, this.state.model_log_cursor);
287
291
  for (const transition of routing.events) {
288
292
  const native = await this.metadata.read(transition.session_id);
289
- if (!native || (transition.session_id !== this.rootId && native.header.callingSessionId !== this.rootId)) continue;
293
+ if (!native || (transition.session_id !== this.rootId && native.header.callingSessionId !== this.rootId && !this.topology.has(native.header.callingSessionId))) continue;
290
294
  this.validateMetadata(transition.session_id, native);
291
295
  await this.observeSettings(transition.session_id, { ...native.settings, modelId: transition.model, model: transition.model },
292
296
  { path: log, channel: "native_router", event_sha256: transition.event_sha256 }, { reason: transition.reason, nativeTimestamp: transition.native_timestamp, previousModel: transition.previous_model });
@@ -439,39 +443,72 @@ export class DroidRuntime {
439
443
  return { ...receipt, result: { ...outcome, status: status(outcome.reason) }, assistant_text: binding.assistant_text ?? "", turn_id: binding.turn_id, recovered_from: "native.agent_turn_outcome" };
440
444
  }
441
445
  async refreshTopology() {
442
- let catalog;
443
- try { catalog = await readJson(path.join(this.paths.factory, "task-invocations.json")); }
444
- catch (error) { if (!(error instanceof SyntaxError)) throw error; this.topologyIncomplete = true; return [...this.topology.values()]; }
446
+ let catalog, missing = false;
447
+ try { catalog = JSON.parse(await readFile(path.join(this.paths.factory, "task-invocations.json"), "utf8")); }
448
+ catch (error) { if (error.code === "ENOENT") missing = true; else { if (!(error instanceof SyntaxError)) throw error; this.topologyIncomplete = true; return [...this.topology.values()]; } }
445
449
  this.topologyIncomplete = false;
446
- const invocations = catalog?.invocations ?? [];
447
450
  const root = this.rootId ? await readDroidSession(this.paths.factory, this.rootId) : null;
448
- const rootTools = new Map((root?.records ?? []).flatMap(record => record.message?.content ?? []).filter(block => block.type === "tool_use").map(block => [block.id, block]));
451
+ const taskTools = native => new Map((native?.records ?? []).flatMap(record => record.message?.content ?? []).filter(block => block.type === "tool_use" && block.name === "Task").map(block => [block.id, block]));
452
+ const validCatalog = catalog && !Array.isArray(catalog) && Array.isArray(catalog.invocations);
453
+ if (!validCatalog) {
454
+ const noChildren = !this.topology.size && !taskTools(root).size;
455
+ const noDispatch = !this.active && !this.lastResult && !this.state.active_tree && (!root || root.complete && root.records.every(record => record.type === "session_start"));
456
+ const completeNoTask = root?.complete && terminal(root.outcomes.at(-1)?.reason) && root.tool_calls.completeness === "complete";
457
+ this.topologyIncomplete = !(missing && noChildren && (noDispatch || completeNoTask));
458
+ return [...this.topology.values()];
459
+ }
460
+ const invocations = catalog.invocations, owned = new Set([this.rootId, ...this.topology.keys()]);
461
+ // Resolve ancestry before validating records so malformed foreign entries
462
+ // do not block this tree, while malformed reachable children do.
463
+ for (let size = -1; size !== owned.size;) { size = owned.size; for (const task of invocations) if (owned.has(task?.parentSessionId) && task?.childSessionId) owned.add(task.childSessionId); }
464
+ const parents = new Map([[this.rootId, root]]);
465
+ for (const id of owned) if (id !== this.rootId) parents.set(id, await readDroidSession(this.paths.factory, id));
466
+ for (const [parentId, native] of parents) {
467
+ if (!native?.complete) this.topologyIncomplete = true;
468
+ for (const toolId of taskTools(native).keys()) if (!invocations.some(task => task?.parentSessionId === parentId && task?.parentToolUseId === toolId)) this.topologyIncomplete = true;
469
+ }
449
470
  const groups = new Map();
450
471
  for (const task of invocations) {
451
- if (task.parentSessionId !== this.rootId || !task.childSessionId || !task.parentToolUseId) continue;
472
+ if (!task || typeof task !== "object") { this.topologyIncomplete = true; continue; }
473
+ if (!owned.has(task.parentSessionId) && !owned.has(task.childSessionId) && ![...parents.values()].some(native => taskTools(native).has(task.parentToolUseId))) continue;
474
+ if (![task.parentSessionId, task.childSessionId, task.parentToolUseId, task.taskInvocationId, task.status].every(value => typeof value === "string" && value.length)) { this.topologyIncomplete = true; continue; }
452
475
  const tasks = groups.get(task.childSessionId) ?? []; tasks.push(task); groups.set(task.childSessionId, tasks);
453
476
  }
477
+ for (const id of this.topology.keys()) if (!groups.has(id)) this.topologyIncomplete = true;
454
478
  for (const [id, tasks] of groups) {
455
- const native = await readDroidSession(this.paths.factory, id);
479
+ const native = parents.get(id), parentId = tasks[0].parentSessionId, rootTools = taskTools(parents.get(parentId));
456
480
  if (!native || !native.settings) { this.topologyIncomplete = true; continue; }
457
- if (native.header.callingSessionId !== this.rootId) throw new DroidError("droid_child_identity_invalid", "Task invocation disagrees with physical child parent");
481
+ if (id === this.rootId || id === parentId || tasks.some(task => task.parentSessionId !== parentId) || native.header.callingSessionId !== parentId || path.resolve(native.header.cwd) !== this.config.cwd) throw new DroidError("droid_child_identity_invalid", "Task invocation disagrees with physical child parent/workspace");
482
+ if (new Set(tasks.map(task => task.taskInvocationId)).size !== tasks.length) this.topologyIncomplete = true;
458
483
  const creationTool = native.header.callingToolUseId;
459
484
  for (const task of tasks) {
485
+ if (!rootTools.has(task.parentToolUseId)) {
486
+ const block = (parents.get(parentId)?.records ?? []).flatMap(record => record.message?.content ?? []).find(block => block.type === "tool_use" && block.id === task.parentToolUseId);
487
+ if (block) throw new DroidError("droid_child_identity_invalid", "Task invocation is bound to a non-Task tool");
488
+ this.topologyIncomplete = true; continue;
489
+ }
460
490
  if (task.parentToolUseId === creationTool) continue;
461
491
  const tool = rootTools.get(task.parentToolUseId);
462
492
  if (tool?.name !== "Task" || tool.input?.resume !== id) throw new DroidError("droid_child_identity_invalid", "Repeated child invocation has no matching native Task resume evidence");
463
493
  }
464
494
  await this.observeSettings(id, native.settings, { path: native.transcript_path, sha256: native.source_sha256 });
465
- const prior = this.topology.get(id);
466
495
  // Task resume adds a Turn to the same physical child, not another
467
496
  // Session. Its transcript retains the original creation edge. Work
468
497
  // reassignment is checked by dd-flow's physical Session binding.
469
- const ordered = tasks.toSorted((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
470
- const latest = ordered.at(-1); const running = tasks.some(task => !terminal(task.status));
471
- const closed = running && prior?.source === "adapter.tree_closed";
498
+ const order = [...rootTools.keys()];
499
+ const ordered = tasks.toSorted((a, b) => order.indexOf(a.parentToolUseId) - order.indexOf(b.parentToolUseId));
500
+ const latest = ordered.at(-1); const running = !terminal(latest.status);
472
501
  const outcome = native.outcomes.at(-1), nativeTurn = outcome && terminal(outcome.reason) && typeof outcome.turnId === "string" ? outcome.turnId : null;
473
- const evidence = !running && nativeTurn ? { terminal_turn_id: nativeTurn, native_source: "droid.agent_turn_outcome", native_turn_items: { observed: native.complete && native.tool_calls.completeness === "complete", possible_effects: native.tool_calls.total > 0, pending: native.tool_calls.pending > 0 }, ...(outcome.error && typeof outcome.error === "object" ? { provider_error: safeProviderError(outcome.error) } : {}) } : {};
474
- this.topology.set(id, { provider_session_id: id, parent_provider_session_id: this.rootId, tool_use_id: creationTool, latest_tool_use_id: latest.parentToolUseId, task_id: latest.taskInvocationId, task_invocation_ids: ordered.map(task => task.taskInvocationId), status: closed ? prior.status : running ? "running" : status(latest.status), source: closed ? prior.source : "droid.task_invocations", transcript_path: native.transcript_path, ...evidence });
502
+ const parentResult = (parents.get(parentId)?.records ?? []).flatMap(record => record.message?.content ?? []).some(block => block.type === "tool_result" && block.tool_use_id === latest.parentToolUseId);
503
+ const exactTurn = nativeTurn && (tasks.length === 1 || latest.turnId === nativeTurn);
504
+ // A resumed Task's previous terminal Turn is not its current outcome.
505
+ // The matching native Task result proves completion without attributing
506
+ // a previous error/Turn ID to this invocation.
507
+ const evidence = !running && nativeTurn && (tasks.length === 1 || parentResult || exactTurn) ? {
508
+ ...(exactTurn ? { terminal_turn_id: nativeTurn } : {}), native_source: "droid.agent_turn_outcome",
509
+ native_turn_items: { observed: native.complete && native.tool_calls.completeness === "complete", possible_effects: native.tool_calls.total > 0, pending: native.tool_calls.pending > 0 },
510
+ ...(exactTurn && outcome.error && typeof outcome.error === "object" ? { provider_error: safeProviderError(outcome.error) } : {}) } : {};
511
+ this.topology.set(id, { provider_session_id: id, parent_provider_session_id: parentId, tool_use_id: creationTool, latest_tool_use_id: latest.parentToolUseId, task_id: latest.taskInvocationId, task_invocation_ids: ordered.map(task => task.taskInvocationId), status: running ? "running" : status(latest.status), source: "droid.task_invocations", transcript_path: native.transcript_path, ...evidence });
475
512
  }
476
513
  return [...this.topology.values()];
477
514
  }
@@ -491,9 +528,10 @@ export class DroidRuntime {
491
528
  async inspect(id, { ignoreActive = false, ingestUsage = true } = {}) {
492
529
  this.requireIdentity(id); await this.captureProcesses(); const children = await this.refreshTopology(); const source = await readDroidSession(this.paths.factory, id);
493
530
  const usage = []; for (const sessionId of [id, ...children.map(child => child.provider_session_id)]) usage.push(await this.snapshotUsage(sessionId, { ingest: ingestUsage }));
494
- const settled = !this.topologyIncomplete && (!this.active || ignoreActive) && (this.closed || this.workingState === "idle") && children.every(child => terminal(child.status));
495
- const rootSettled = (!this.active || ignoreActive) && (this.closed || this.workingState === "idle") && terminal((source?.outcomes?.at(-1) ?? this.lastResult)?.reason);
496
- return { harness: "droid-cli", runtime_family: "droid", session: { harness_id: "droid-cli", session_id: id }, provider_session_id: id, adapter_session_id: id, cwd: this.config.cwd, observed_profile: source?.settings ? await this.observeSettings(id, source.settings, { path: source.transcript_path, sha256: source.source_sha256 }) : this.observedProfile, observed_runtime: { droid: this.config.version, factory_protocol: this.protocol ?? DROID_PROTOCOL, dd_harness_contract: DROID_CONTRACT }, status: settled ? "idle" : "running", settled, ...settledNativeTree(id, children, settled, rootSettled && !this.topologyIncomplete), terminal_turn_id: source?.outcomes?.at(-1)?.turnId ?? this.lastResult?.turnId, descendants: children, transcript_path: source?.transcript_path ?? null, usage, result: this.lastResult, provider_pid: this.closed ? null : this.child?.pid ?? null };
531
+ const settled = !this.topologyIncomplete && (!this.active || ignoreActive) && (this.closed || this.workingState === "idle") && source?.tool_calls.pending === 0 && children.every(child => terminal(child.status) && child.native_turn_items?.observed === true && child.native_turn_items.pending === false);
532
+ const rootSettled = source?.complete && source.tool_calls.completeness === "complete" && source.tool_calls.pending === 0 && (!this.active || ignoreActive) && (this.closed || this.workingState === "idle") && terminal((source?.outcomes?.at(-1) ?? this.lastResult)?.reason);
533
+ return nativeExecutionReceipt({ harness: "droid-cli", runtime_family: "droid", session: { harness_id: "droid-cli", session_id: id }, provider_session_id: id, adapter_session_id: id, cwd: this.config.cwd, observed_profile: source?.settings ? await this.observeSettings(id, source.settings, { path: source.transcript_path, sha256: source.source_sha256 }) : this.observedProfile, observed_runtime: { droid: this.config.version, factory_protocol: this.protocol ?? DROID_PROTOCOL, dd_harness_contract: DROID_CONTRACT }, status: settled ? "idle" : "running", settled, ...settledNativeTree(id, children, settled, rootSettled && !this.topologyIncomplete), terminal_turn_id: source?.outcomes?.at(-1)?.turnId ?? this.lastResult?.turnId, descendants: children, transcript_path: source?.transcript_path ?? null, usage, result: this.lastResult, provider_pid: this.closed ? null : this.child?.pid ?? null,
534
+ ...(this.topologyIncomplete ? { native_settlement: { confirmed: false, state: "unproven", reason: "native_task_inventory_incomplete" } } : {}) }, { daemonId: this.config.daemonId });
497
535
  }
498
536
  async observeHook(payload, eventId, deadlineAt = null) {
499
537
  try { return await this.observeHookChecked(payload, eventId, hookBudget(23_000, deadlineAt)); }
@@ -508,7 +546,8 @@ export class DroidRuntime {
508
546
  const foreign = message => new DroidError("droid_hook_identity_invalid", message, { scope: "foreign_hook", provider_session_id: id });
509
547
  if (!native || path.resolve(payload.cwd) !== this.config.cwd || await realpath(payload.transcript_path) !== await realpath(native.transcript_path)) throw foreign("Hook is outside its controlled native Session/workspace");
510
548
  const parent = native.header.callingSessionId ?? null;
511
- if (parent && parent !== this.rootId) throw foreign("Hook child belongs to another root");
549
+ if (parent && parent !== this.rootId && !this.topology.has(parent)) throw foreign("Hook child belongs to another root");
550
+ if (parent && (id === parent || this.topology.has(id) && this.topology.get(id).parent_provider_session_id !== parent)) throw foreign("Hook child ancestry changed");
512
551
  if (!parent && this.rootId && id !== this.rootId) throw foreign("Hook came from an unrelated root");
513
552
  if (path.resolve(native.header.cwd) !== this.config.cwd) throw new DroidError("droid_hook_identity_invalid", "Owned native Session workspace changed");
514
553
  if (parent && !terminal(this.topology.get(id)?.status)) this.topology.set(id, { ...(this.topology.get(id) ?? {}), provider_session_id: id, parent_provider_session_id: parent, tool_use_id: native.header.callingToolUseId, status: "running", source: "droid.session_start" });
@@ -576,8 +615,11 @@ export class DroidRuntime {
576
615
  let topologyError = null;
577
616
  try { await this.refreshTopology(); }
578
617
  catch (error) { topologyError = errorRecord(error); await this.journal("closed_tree_topology_error", { error: topologyError }); }
579
- for (const [id, child] of this.topology) if (!terminal(child.status)) this.topology.set(id, { ...child, status: "cancelled", source: "adapter.tree_closed", native_status: child.status });
580
- const cleanup = { settled: true, forced, observed_processes: this.ownedProcesses.length, ...(topologyError ? { topology_error: topologyError } : {}) };
618
+ const root = this.rootId ? await readDroidSession(this.paths.factory, this.rootId) : null;
619
+ const nativeSettled = !forced && !topologyError && !this.topologyIncomplete
620
+ && (!this.rootId || root?.complete && (terminal(root.outcomes.at(-1)?.reason) || !this.active && !this.lastResult && root.records.every(record => record.type === "session_start")) && root.tool_calls.pending === 0)
621
+ && [...this.topology.values()].every(child => terminal(child.status) && child.native_turn_items?.observed === true && child.native_turn_items.pending === false);
622
+ const cleanup = { settled: true, physical_stopped: true, native_settled: nativeSettled, forced, observed_processes: this.ownedProcesses.length, ...(topologyError ? { topology_error: topologyError } : {}) };
581
623
  await this.save({ active_tree: false, cleanup });
582
624
  return cleanup;
583
625
  }
@@ -21,7 +21,7 @@ import path from "node:path";
21
21
 
22
22
  import { AcpBridge } from "./dd-zcode.mjs";
23
23
  import { safeProviderError } from "./provider-overload-policy.mjs";
24
- import { settledNativeTree } from "./native-children.mjs";
24
+ import { settledNativeTree, nativeExecutionReceipt } from "./native-children.mjs";
25
25
  import { observeModel, readModelObservations } from "./model-observations.mjs";
26
26
  import { cancelSessionWithBridge, createSessionWithBridge, doctor, forkSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge } from "./dd-grok.mjs";
27
27
  import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
@@ -465,7 +465,7 @@ export class Runtime {
465
465
  if (operation === "session.create") return await this.productive(operation, async () => { const result = await createSessionWithBridge(this.bridge, { ...this.options(params), onSessionCreated: async (session) => { this.track(session); this.loadedSessionId = session.provider_session_id; await this.persist(); } }, this.initialized); this.loadedSessionId = result.provider_session_id; return result; }, null, operationId);
466
466
  if (operation === "session.prompt") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await promptSessionWithBridge(this.bridge, { ...this.options(params), assertDispatch, operationId }); this.loadedSessionId = params.sessionId; return result; }, params.sessionId, operationId); }
467
467
  if (operation === "session.fork") { await this.requireSettled(); return await this.productive(operation, async () => { const result = await forkSessionWithBridge(this.bridge, this.options(params)); this.loadedSessionId = params.sessionId; return result; }); }
468
- if (operation === "session.inspect" || operation === "session.resume") { const before = new Map(this.descendants); const result = await inspectSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); this.track(result, before); await this.persist(); const settled = !this.active && !this.treeRunning(result); const descendants = this.topology(result.provider_session_id); const terminal = this.sessions.get(result.provider_session_id)?.native_root_receipt; return { ...result, settled, descendants, ...settledNativeTree(result.provider_session_id, descendants, settled, !this.active && !this.rootRunning(result)), ...(terminal?.kind === "failed" && terminal.request_id !== undefined ? { terminal_turn_id: String(terminal.request_id) } : {}) }; }
468
+ if (operation === "session.inspect" || operation === "session.resume") { const before = new Map(this.descendants); const result = await inspectSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); this.track(result, before); await this.persist(); const settled = !this.active && !this.treeRunning(result); const descendants = this.topology(result.provider_session_id); const terminal = this.sessions.get(result.provider_session_id)?.native_root_receipt; return nativeExecutionReceipt({ ...result, settled, descendants, ...settledNativeTree(result.provider_session_id, descendants, settled, !this.active && !this.rootRunning(result)), ...(terminal?.kind === "failed" && terminal.request_id !== undefined ? { terminal_turn_id: String(terminal.request_id) } : {}) }, { daemonId: this.state.daemon_id, generation: this.controlGeneration ?? 0 }); }
469
469
  if (operation === "session.cancel") { if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon"); cancelPendingDispatch(this); const result = await cancelSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); await this.persist({ active_tree: Boolean(this.active) || !result.settled }); return result; }
470
470
  if (operation === "session.archive") { await this.requireSettled(); return await this.productive(operation, async () => { let session = this.sessions.get(params.sessionId); if (!session?.cwd) { const result = await inspectSessionWithBridge(this.bridge, this.options({ ...params, liveSession: true })); this.track(result); session = this.sessions.get(result.provider_session_id); } return await exportSessionArchive(this.paths, session.provider_session_id, absolute(session.cwd, "Grok Build session cwd"), params.archiveDir, this.state.versions); }); }
471
471
  if (operation === "daemon.stop") {
@@ -476,7 +476,7 @@ export class Runtime {
476
476
  observe: async () => ({ sessions: await this.refreshTree(), active: Boolean(this.active), active_operation: this.active }),
477
477
  ...(params.cancelTree ? { cancel: id => cancelSessionWithBridge(this.bridge, this.options({ sessionId: id, liveSession: true })) } : {})
478
478
  });
479
- return { clean: true };
479
+ return { clean: true, settled: true, native_settled: true };
480
480
  }, [
481
481
  ["provider", () => this.bridge.close()],
482
482
  ["provider_resource", () => finishDaemonProcess(this.state.config, this.state.provider_process)],
@@ -10,8 +10,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
10
10
  import { appendFile, chmod, lstat, mkdir, open, readFile, realpath, unlink, writeFile } from "node:fs/promises";
11
11
  import { spawn } from "node:child_process";
12
12
  import net from "node:net";
13
- import { durableDaemonDispatch, completeDaemonShutdown, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon, assertDaemonDispatch, assertDaemonRestartable } from "./daemon-operations.mjs";
13
+ import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon, assertDaemonDispatch, assertDaemonRestartable } from "./daemon-operations.mjs";
14
14
  import { waitForSettlement } from "./session-settlement.mjs";
15
+ import { nativeExecutionReceipt } from "./native-children.mjs";
15
16
  import { errorRecord, isProvenShellCorrection, reportedError } from "./operation-errors.mjs";
16
17
  import { checkObservedProfile, observeModel } from "./model-observations.mjs";
17
18
  import os from "node:os";
@@ -201,15 +202,35 @@ export class Runtime {
201
202
  async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
202
203
  async childTree(id, seen = new Set()) {
203
204
  if (seen.has(id)) throw new DaemonError("session_identity_mismatch", "OpenCode native topology contains a cycle");
204
- seen.add(id); const direct = await this.client.children(id) ?? [], nested = [];
205
+ seen.add(id); const direct = await this.ownedChildren(id), nested = [];
205
206
  for (const child of direct) {
206
207
  if (!child.id || child.parentID !== id) throw new DaemonError("session_identity_mismatch", "OpenCode child has a foreign native parent");
207
208
  nested.push(child, ...await this.childTree(child.id, seen));
208
209
  }
209
210
  return nested;
210
211
  }
211
- async describe(id) {
212
- const [info, messages, children, statuses] = await Promise.all([this.client.inspect(id), this.client.messages(id), this.childTree(id), this.client.statuses()]);
212
+ async ownedChildren(id) {
213
+ const direct = await this.client.children(id), children = new Map();
214
+ if (!Array.isArray(direct)) throw new DaemonError("native_child_observation_unavailable", "OpenCode child inventory is unavailable");
215
+ for (const child of direct) children.set(child.id, child);
216
+ for (const record of this.sessions.values()) if (record.parent_provider_session_id === id && !children.has(record.provider_session_id)) {
217
+ let child;
218
+ try { child = await this.client.inspect(record.provider_session_id); }
219
+ catch (cause) { throw new DaemonError("native_child_observation_unavailable", "Retained OpenCode child disappeared without completion proof", false, { provider_session_id: record.provider_session_id, cause: errorPayload(cause) }); }
220
+ if (child?.id !== record.provider_session_id) throw new DaemonError("session_identity_mismatch", "OpenCode retained child read returned another Session");
221
+ children.set(child.id, child);
222
+ }
223
+ for (const child of children.values()) if (!child.id || child.parentID !== id || child.fork) throw new DaemonError("session_identity_mismatch", "OpenCode child has a foreign native parent");
224
+ return [...children.values()];
225
+ }
226
+ async revision(id) {
227
+ if (!this.client.logs) return null;
228
+ const { watermark } = await this.client.logs(id);
229
+ return JSON.stringify([id, watermark]);
230
+ }
231
+ async describe(id, refreshes = 1) {
232
+ const before = new Map([[id, await this.revision(id)]]);
233
+ const [info, messages, children] = await Promise.all([this.client.inspect(id), this.client.messages(id), this.childTree(id)]);
213
234
  const parent = info.fork ? null : info.parentID ?? null, existing = this.sessions.get(id);
214
235
  if (info.id !== id || !existing && (!parent || !this.sessions.has(parent) || info.fork)
215
236
  || existing && (existing.parent_provider_session_id ?? null) !== parent) throw new DaemonError("foreign_session", "OpenCode Session is not in the retained owned tree");
@@ -219,17 +240,31 @@ export class Runtime {
219
240
  if (child.fork || retained && retained.parent_provider_session_id !== child.parentID) throw new DaemonError("session_identity_mismatch", "OpenCode child ancestry changed");
220
241
  this.retainSession(child, record.root_provider_session_id);
221
242
  }
222
- const pending = new Map();
243
+ const pending = new Map(), histories = new Map([[id, messages]]);
244
+ for (const child of children) before.set(child.id, await this.revision(child.id));
245
+ const statuses = await this.client.statuses();
223
246
  for (const session of [id, ...children.map(child => child.id)]) {
247
+ if (session !== id) histories.set(session, await this.client.messages(session));
224
248
  const value = this.client.pending ? await this.client.pending(session) : { inbox: [], forms: [], permissions: [] };
225
249
  pending.set(session, { ...value, shells: this.client.shells ? (await this.client.shells(session)).filter(shell => shell.status === "running") : [] });
226
250
  }
227
- const busy = session => Boolean(statuses[session] && statuses[session].type !== "idle") || Object.values(pending.get(session)).some(items => items.length > 0);
251
+ const changed = new Set(), unavailable = new Set();
252
+ for (const [session, seq] of before) {
253
+ const after = await this.revision(session);
254
+ if (seq === null || after === null) { unavailable.add(session); changed.add(session); }
255
+ else if (after !== seq) changed.add(session);
256
+ }
257
+ // Re-read one raced snapshot inside this request, without a new clock or
258
+ // quiet sleep. An absent fence has no reconciliation authority.
259
+ if (changed.size && !unavailable.size && refreshes > 0) return this.describe(id, refreshes - 1);
260
+ const currentMessages = session => { const history = histories.get(session); return history.slice(history.findLastIndex(message => message.info.role === "user") + 1); };
261
+ const busy = session => changed.has(session) || Boolean(statuses[session] && statuses[session].type !== "idle") || Object.values(pending.get(session)).some(items => items.length > 0)
262
+ || currentMessages(session).some(message => (message.parts ?? []).some(part => part.type === "tool" && ["pending", "running", "streaming"].includes(part.state?.status)));
228
263
  for (const child of children) {
229
- const history = await this.client.messages(child.id);
264
+ const history = histories.get(child.id);
230
265
  const index = history.findLastIndex(message => message.info.role === "user" && message.info.sessionID === child.id);
231
266
  const terminal = index >= 0 && history.slice(index + 1).findLast(message => message.info.role === "idle" && message.info.sessionID === child.id);
232
- if (!terminal || busy(child.id)) continue;
267
+ if (!terminal || busy(child.id)) { Object.assign(child, { status: changed.has(child.id) ? "unknown" : busy(child.id) ? "running" : "idle", native_turn_items: { observed: Boolean(terminal) && !changed.has(child.id), possible_effects: true, pending: busy(child.id) } }); continue; }
233
268
  const outcome = terminal.info.outcome;
234
269
  if (!["succeeded", "failed", "interrupted"].includes(outcome)) continue;
235
270
  Object.assign(child, { status: outcome === "succeeded" ? "completed" : outcome === "failed" ? "failed" : "cancelled",
@@ -237,7 +272,7 @@ export class Runtime {
237
272
  native_turn_items: { observed: true, possible_effects: true, pending: false },
238
273
  ...(terminal.info.error ? { provider_error: safeProviderError(terminal.info.error) } : {}) });
239
274
  }
240
- const unsettledChildren = children.filter(child => busy(child.id)).map(child => child.id), status = busy(id) ? "busy" : "idle";
275
+ const unsettledChildren = children.filter(child => busy(child.id)).map(child => child.id), status = changed.has(id) ? "unknown" : busy(id) ? "busy" : "idle";
241
276
  const lastAssistant = messages.findLast(message => message.info?.role === "assistant");
242
277
  const inputIndex = messages.findLastIndex(message => message.info.role === "user");
243
278
  const idle = messages.slice(inputIndex + 1).findLast(message => message.info.role === "idle");
@@ -245,18 +280,20 @@ export class Runtime {
245
280
  await this.observeModels(id, messages, parent);
246
281
  this.journaledTools ??= new Map();
247
282
  if (this.state.config.journal) journalToolObservations(this.state.config.journal, messages.flatMap(message => (message.parts ?? []).map(part => openCodeToolObservation(part, id, message.info?.id))), this.journaledTools);
248
- return { ...record, observed_profile: { provider: lastAssistant?.info?.providerID ?? info.model?.providerID ?? null, model: lastAssistant?.info?.modelID ?? info.model?.id ?? null, reasoning: lastAssistant?.info?.variant ?? info.model?.variant ?? "default", mode: info.agent ?? null },
283
+ const receipt = { ...record, observed_profile: { provider: lastAssistant?.info?.providerID ?? info.model?.providerID ?? null, model: lastAssistant?.info?.modelID ?? info.model?.id ?? null, reasoning: lastAssistant?.info?.variant ?? info.model?.variant ?? "default", mode: info.agent ?? null },
249
284
  assistant_text: (lastAssistant?.parts ?? []).filter(part => part.type === "text").map(part => part.text ?? "").join(""), info, status,
250
285
  route_conformance: { matches: [info, ...children].every(session => session.directory && path.resolve(session.directory) === path.resolve(this.state.config.cwd)), expected: this.state.config.cwd, actual: info.directory ?? null, actual_provenance: "native_session_readback" },
251
286
  route_violations: [info, ...children].filter(session => !session.directory || path.resolve(session.directory) !== path.resolve(this.state.config.cwd)).map(session => ({ provider_session_id: session.id, expected: this.state.config.cwd, actual: session.directory ?? null, actual_provenance: "native_session_readback" })),
252
287
  settled: status === "idle" && !unsettledChildren.length, unsettled_children: unsettledChildren,
253
288
  tree_session_ids: treeIds, unsettled_sessions: treeIds.filter(busy),
254
- tree_observations: treeIds.map(session => ({ provider_session_id: session, status: busy(session) ? "running" : "idle", settled: !busy(session) })),
289
+ tree_observations: treeIds.map(session => ({ provider_session_id: session, status: changed.has(session) ? "unknown" : busy(session) ? "running" : "idle", settled: !busy(session) })),
290
+ ...(changed.size ? { native_settlement: { confirmed: false, state: "unproven", reason: "native_revision_fence_unavailable_or_changed" } } : {}),
255
291
  terminal_turn_id: idle && inputIndex >= 0 ? messages[inputIndex].info.id : null,
256
292
  terminal_status: idle?.info.outcome === "failed" ? "failed" : idle?.info.outcome === "succeeded" ? "completed" : idle?.info.outcome ?? null,
257
293
  native_turn_items: { observed: Boolean(idle), possible_effects: messages.slice(inputIndex + 1).some(message => message.parts.some(part => part.type === "tool")),
258
- pending: busy(id) || messages.slice(inputIndex + 1).some(message => message.parts.some(part => part.type === "tool" && ["running", "streaming"].includes(part.state?.status))) },
294
+ pending: busy(id) },
259
295
  children, pending: pending.get(id), usage: usageSnapshot(messages, id), history_sha256: historyDigest(messages), message_count: messages.length };
296
+ return nativeExecutionReceipt(receipt, { daemonId: this.state.daemon_id, revision: before.get(id) ?? 0 });
260
297
  }
261
298
  async observeModels(id, messages, parent = null) {
262
299
  this.modelMessages ??= new Set();
@@ -301,18 +338,24 @@ export class Runtime {
301
338
  async cancelTree(id, seen = new Set()) {
302
339
  if (!this.sessions.has(id)) throw new DaemonError("foreign_session", "cannot cancel a Session without daemon ownership");
303
340
  if (seen.has(id)) return [];
304
- const native = await this.client.inspect(id), retained = this.sessions.get(id);
305
- if (native.id !== id || (retained.parent_provider_session_id ?? null) !== (native.fork ? null : native.parentID ?? null)) throw new DaemonError("session_identity_mismatch", "OpenCode cancellation Session ancestry changed");
306
- this.retainSession(native, retained.root_provider_session_id ?? id);
341
+ const retained = this.sessions.get(id), errors = [];
342
+ let native;
343
+ try { native = await this.client.inspect(id); }
344
+ catch (error) { errors.push({ session_id: id, phase: "inspect", ...errorPayload(error) }); }
345
+ if (native) {
346
+ if (native.id !== id || (retained.parent_provider_session_id ?? null) !== (native.fork ? null : native.parentID ?? null)) throw new DaemonError("session_identity_mismatch", "OpenCode cancellation Session ancestry changed");
347
+ try { this.retainSession(native, retained.root_provider_session_id ?? id); }
348
+ catch (error) { errors.push({ session_id: id, phase: "retain", ...errorPayload(error) }); }
349
+ }
307
350
  seen.add(id);
308
- const errors = []; let children = [];
309
- try { children = await this.client.children(id); } catch (error) { errors.push({ session_id: id, phase: "children", ...errorPayload(error) }); }
351
+ let children = [];
352
+ try { children = await this.ownedChildren(id); } catch (error) { errors.push({ session_id: id, phase: "children", ...errorPayload(error) });
353
+ children = [...this.sessions.values()].filter(record => record.parent_provider_session_id === id).map(record => ({ id: record.provider_session_id, parentID: id, directory: record.cwd })); }
310
354
  for (const child of children) {
311
355
  if (child.parentID !== id || child.fork || this.sessions.has(child.id) && this.sessions.get(child.id).parent_provider_session_id !== id) {
312
356
  errors.push({ session_id: child.id, phase: "identity", code: "session_identity_mismatch" }); continue;
313
357
  }
314
- this.retainSession(child, this.sessions.get(id)?.root_provider_session_id ?? id);
315
- try { errors.push(...await this.cancelTree(child.id, seen)); }
358
+ try { this.retainSession(child, this.sessions.get(id)?.root_provider_session_id ?? id); errors.push(...await this.cancelTree(child.id, seen)); }
316
359
  catch (error) { errors.push({ session_id: child.id, phase: "cancel_child", ...errorPayload(error) }); }
317
360
  }
318
361
  try { await this.client.abort(id); } catch (error) { errors.push({ session_id: id, phase: "abort", ...errorPayload(error) }); }
@@ -467,7 +510,10 @@ export class Runtime {
467
510
  },
468
511
  ...(params.cancelTree ? { cancel: id => this.cancelTree(id) } : {})
469
512
  });
470
- return { clean: true };
513
+ // Fresh native observation, not a prior productive result, owns the idle proof.
514
+ commitDaemonShutdown(this.paths.dir);
515
+ await this.persist({ active_tree: false });
516
+ return { clean: true, settled: true, native_settled: true };
471
517
  }, [
472
518
  ["provider", () => stopProcessGroup(this.child)],
473
519
  ["provider_resource", () => finishDaemonProcess(this.state.config, this.providerProcess)],
@@ -14,6 +14,33 @@ const lost = (message, details) => new OpenCodeError("operation_observation_lost
14
14
  export const requestDigest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
15
  export const nativeModel = ({ provider, model, variant }) => ({ providerID: provider, id: model, ...(variant && variant !== "default" ? { variant } : {}) });
16
16
 
17
+ /** Native finite session.log includes a control watermark, not a domain event. */
18
+ export function decodeFiniteLog(items, sessionId, after = 0) {
19
+ const fail = (reason, field, unproven = false) => { throw new OpenCodeError(unproven ? "opencode_observation_unproven" : "opencode_observation_corrupt", "OpenCode finite log has no trustworthy revision", false,
20
+ { native_outcome: "unknown", session_id: sessionId, cursor: after, reason, field }); };
21
+ if (!Number.isSafeInteger(after) || after < 0) fail("invalid_cursor", "after");
22
+ if (!Array.isArray(items)) fail("invalid_batch", "items");
23
+ const events = []; let watermark, cursor = after;
24
+ for (const item of items) {
25
+ if (watermark !== undefined) fail("frame_after_sync", "type");
26
+ const sync = item?.type === "log.synced", metadata = sync ? item : item?.durable;
27
+ if (!metadata) fail("missing_metadata", "durable");
28
+ if (metadata.aggregateID !== sessionId) fail("foreign_aggregate", sync ? "aggregateID" : "durable.aggregateID");
29
+ if (!Number.isSafeInteger(metadata.seq) || metadata.seq < 0) fail("invalid_sequence", sync ? "seq" : "durable.seq", sync && metadata.seq === undefined);
30
+ if (sync) {
31
+ if (metadata.seq < cursor) fail("watermark_behind_cursor", "seq");
32
+ watermark = metadata.seq;
33
+ } else {
34
+ if (typeof item.type !== "string" || !item.type.startsWith("session.") || !item.data || typeof item.data !== "object") fail("invalid_domain_event", "type/data");
35
+ if (item.data.sessionID !== undefined && item.data.sessionID !== sessionId) fail("foreign_session", "data.sessionID");
36
+ if (metadata.seq <= cursor) fail("non_increasing_sequence", "durable.seq");
37
+ cursor = metadata.seq; events.push(item);
38
+ }
39
+ }
40
+ if (watermark === undefined) fail("missing_sync", "log.synced", true);
41
+ return { events, watermark };
42
+ }
43
+
17
44
  /** info/parts is our normalized projection, not a second v1 native protocol. */
18
45
  export function normalizeMessage(message, sessionID) {
19
46
  if (!message?.id || !message.type) throw lost("OpenCode message has no native identity");
@@ -124,7 +151,7 @@ export class OpenCodeClient {
124
151
  try { for await (const event of api.session.log({ sessionID: id, after, follow: false }, { signal: controller.signal })) events.push(event); }
125
152
  catch (cause) { throw Object.assign(lost("OpenCode durable log observation failed", { session_id: id }), { cause }); }
126
153
  finally { clearInterval(timer); }
127
- return events;
154
+ return decodeFiniteLog(events, id, after);
128
155
  }
129
156
  async abort(id) {
130
157
  // Queued input must not become fresh work during interruption cleanup.
@@ -221,15 +248,15 @@ export class OpenCodeClient {
221
248
  for (;;) {
222
249
  try {
223
250
  await checkHookFailures?.();
224
- const events = await this.logs(id, state.cursor);
251
+ const { events, watermark } = await this.logs(id, state.cursor);
225
252
  let delivered = state.delivered === true, terminal = state.terminal;
226
253
  for (const event of events) {
227
- if (!Number.isInteger(event.durable?.seq) || event.durable.seq <= state.cursor) continue;
228
254
  if (event.type === "session.inbox.delivered" && event.data.inboxID === messageId) { delivered = true; terminal = null; }
229
255
  if (delivered && event.type === "session.execution.started") terminal = null;
230
256
  if (delivered && ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"].includes(event.type)) terminal = { type: event.type, error: event.data.error };
231
257
  state = { ...state, cursor: event.durable.seq, delivered, terminal }; await save?.(state);
232
258
  }
259
+ if (state.cursor !== watermark) { state = { ...state, cursor: watermark }; await save?.(state); }
233
260
  let runningTools = false;
234
261
  const observe = async (session, visited = new Set()) => {
235
262
  if (visited.has(session)) throw lost("OpenCode topology contains a cycle"); visited.add(session); known.add(session);