@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.
- package/CHANGELOG.md +71 -0
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +6 -2
- package/dist/cli/command-metadata.js +18 -2
- package/dist/cli/diagnostic-invocation.js +46 -0
- package/dist/cli/diagnostic.js +120 -0
- package/dist/cli/help.js +35 -0
- package/dist/cli/memory-flow-admission.js +4 -2
- package/dist/cli/memory-init.js +5 -2
- package/dist/cli/run-cli.js +16 -15
- package/dist/cli.js +6 -0
- package/dist/domain/diagnostic-aspects.js +103 -0
- package/dist/domain/diagnostic-flows.js +8 -0
- package/dist/harness-runtime/lib/agy-native-inspection.mjs +216 -0
- package/dist/harness-runtime/lib/agy-native_pb.js +117 -0
- package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +84 -10
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +306 -34
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +27 -13
- package/dist/harness-runtime/lib/dd-codex.mjs +4 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/dd-droid.mjs +70 -28
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +66 -20
- package/dist/harness-runtime/lib/dd-opencode.mjs +30 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +7 -6
- package/dist/harness-runtime/lib/driver-recovery.mjs +9 -8
- package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +11 -3
- package/dist/harness-runtime/lib/native-children.d.mts +8 -1
- package/dist/harness-runtime/lib/native-children.mjs +114 -8
- package/dist/harness-runtime/lib/native-request-proof.mjs +1 -1
- package/dist/harness-runtime/lib/observation-clock.d.mts +2 -2
- package/dist/harness-runtime/lib/observation-clock.mjs +7 -4
- package/dist/harness-runtime/lib/provider-overload-policy.d.mts +1 -1
- package/dist/harness-runtime/lib/provider-overload-policy.mjs +1 -1
- package/dist/harness-runtime/lib/tool-observations.mjs +1 -1
- package/dist/schemas/diagnostic-stage-result.schema.json +354 -0
- package/dist/schemas/diagnostic-work-result.schema.json +296 -0
- package/dist/schemas/execution-bundle.schema.json +7 -1
- package/dist/schemas/project-flow-pack-manifest.schema.json +1 -1
- package/dist/services/check-environment.js +21 -0
- package/dist/services/check-runner-evidence.js +267 -15
- package/dist/services/code-checks.js +23 -15
- package/dist/services/controller-execution.js +10 -4
- package/dist/services/controller-fanout.js +13 -6
- package/dist/services/controller-memory-worker.js +3 -2
- package/dist/services/dashboard.js +2 -2
- package/dist/services/descriptor-run-settlement.js +5 -3
- package/dist/services/descriptor-run-start.js +22 -3
- package/dist/services/descriptor-stage-coordinator.js +37 -4
- package/dist/services/descriptor-stage-settlement.js +2 -1
- package/dist/services/diagnostic-evidence.js +73 -0
- package/dist/services/diagnostic-report-publication.js +72 -0
- package/dist/services/diagnostic-report.js +67 -0
- package/dist/services/diagnostic-run-bundle.js +74 -0
- package/dist/services/diagnostic-snapshot.js +159 -0
- package/dist/services/diagnostic-stage-handlers.js +108 -0
- package/dist/services/diagnostic-stage-results.js +132 -0
- package/dist/services/diagnostic-start.js +84 -0
- package/dist/services/diagnostic-work-batch.js +34 -0
- package/dist/services/diagnostic-work-collection.js +97 -0
- package/dist/services/diagnostic-work-registration.js +101 -0
- package/dist/services/diagnostic-work-results.js +93 -0
- package/dist/services/eval-snapshots.js +85 -29
- package/dist/services/execution-bundles.js +19 -7
- package/dist/services/flow-catalog.js +6 -1
- package/dist/services/frozen-engine.js +37 -2
- package/dist/services/hybrid-contracts.js +2 -0
- package/dist/services/installed-flow-source.js +25 -3
- package/dist/services/lifecycle-invocations.js +147 -34
- package/dist/services/memory-init-contexts.js +16 -0
- package/dist/services/memory-init-controller-start.js +12 -4
- package/dist/services/memory-init-lint-capability.js +5 -5
- package/dist/services/memory-init-native-capacity.js +4 -4
- package/dist/services/memory-init-preflight.js +10 -16
- package/dist/services/memory-init-worktrunk-capability.js +3 -7
- package/dist/services/memory-layer.js +4 -3
- package/dist/services/memory-tool-probe.js +25 -0
- package/dist/services/memory-verification-checks.js +15 -4
- package/dist/services/native-daemon-history.js +2 -1
- package/dist/services/provider-capacity.js +43 -38
- package/dist/services/release-cache.js +8 -0
- package/dist/services/run-control.js +2 -1
- package/dist/services/run-controller-adapter.js +2 -1
- package/dist/services/run-controller-process.js +2 -1
- package/dist/services/run-controller-recovery.js +2 -1
- package/dist/services/run-controller.js +85 -15
- package/dist/services/run-fork.js +1 -1
- package/dist/services/runs.js +6 -1
- package/dist/services/runtime-scope-resume.js +12 -6
- package/dist/services/runtime-scope-stop.js +3 -2
- package/dist/services/runtime-service.js +2 -1
- package/dist/services/stage-handlers.js +16 -3
- package/dist/services/stage-pause.js +4 -1
- package/dist/services/version-status.js +1 -1
- package/dist/services/vnext-code.js +2 -2
- package/dist/services/vnext-fanout.js +31 -1
- package/dist/services/work-registry.js +37 -6
- package/dist/services/workspace-bootstrap.js +2 -1
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
271
|
-
if (id !== this.rootId && !this.topology.has(
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
470
|
-
const
|
|
471
|
-
const
|
|
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
|
|
474
|
-
|
|
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
|
-
|
|
580
|
-
const
|
|
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.
|
|
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
|
|
212
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
try { children = await this.
|
|
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
|
-
|
|
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);
|