@deksden-com/dd-flow-cli 0.9.0-beta.124 → 0.9.0-beta.127
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +10 -1
- package/dist/build-info.json +7 -7
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +4 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/hook-ingress.js +1 -1
- package/dist/cli/run-cli.js +126 -193
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +51 -0
- package/dist/cli/runtime-process.js +128 -0
- package/dist/cli.js +8 -5
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
- package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
- package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
- package/dist/harness-runtime/lib/managed-daemon.d.mts +10 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
- package/dist/harness-runtime/lib/native-children.d.mts +13 -0
- package/dist/harness-runtime/lib/native-children.mjs +78 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.mjs +136 -38
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
- package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +79 -9
- package/dist/services/codex-capacity.js +117 -41
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -76
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +13 -8
- package/dist/services/harness-adapter.js +175 -11
- package/dist/services/hooks.js +65 -21
- package/dist/services/lifecycle-invocations.js +404 -32
- package/dist/services/managed-lease-monitor.js +128 -0
- package/dist/services/managed-processes.js +103 -47
- package/dist/services/merge-server.js +58 -21
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- package/dist/services/run-control-receipt.js +1 -1
- package/dist/services/run-control-worker.js +177 -118
- package/dist/services/run-control.js +99 -8
- package/dist/services/run-controller-adapter.js +169 -46
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +166 -94
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +1 -56
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +39 -6
- package/dist/services/runtime-budget.js +32 -15
- package/dist/services/runtime-scope-control.js +8 -0
- package/dist/services/runtime-scope-resume.js +30 -8
- package/dist/services/runtime-scope-worker.js +118 -73
- package/dist/services/runtime-service.js +6 -7
- package/dist/services/scope-writer-cleanup.js +3 -2
- package/dist/services/stage-context.js +135 -0
- package/dist/services/stage-lifecycle.js +3 -15
- package/dist/services/stage-pause.js +3 -2
- package/dist/services/usage.js +14 -3
- package/dist/services/vnext-code-review.js +10 -9
- package/dist/services/vnext-code.js +10 -8
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +75 -2
- package/dist/services/vnext-fanout.js +5 -0
- package/dist/services/vnext-merge.js +10 -9
- package/dist/services/vnext-plan-review.js +21 -17
- package/dist/services/vnext-plan.js +9 -7
- package/dist/services/vnext-protocolize.js +15 -9
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +34 -4
- package/dist/services/workspace-bootstrap.js +14 -3
- package/dist/shared/errors.js +11 -6
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +80 -21
- package/package.json +7 -4
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { readFile } from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
|
|
5
4
|
import { AcpBridge } from "./dd-zcode.mjs";
|
|
6
5
|
import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
6
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
7
|
+
import { nativeObservationError } from "./native-streams.mjs";
|
|
7
8
|
|
|
8
9
|
const GROK_HARNESS_CONTRACT = "dd-grok-harness@1";
|
|
9
10
|
|
|
@@ -14,11 +15,7 @@ function executable(command, args = []) { const resolved = command ?? process.en
|
|
|
14
15
|
|
|
15
16
|
async function commandOutput(command, args) {
|
|
16
17
|
const target = executable(command, args);
|
|
17
|
-
return await
|
|
18
|
-
const child = spawn(target.command, target.args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = "";
|
|
19
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk)); child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
|
|
20
|
-
child.on("error", reject); child.on("close", (code) => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${target.command} exited ${code}`)));
|
|
21
|
-
});
|
|
18
|
+
return await commandText(target.command, target.args, { phase: "native-version" });
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
function profile(options) { return { provider: "xai", model: options.model ?? null, reasoning: options.reasoning ?? null, mode: options.mode ?? "bypassPermissions" }; }
|
|
@@ -56,8 +53,10 @@ function bridgeOptions(options) {
|
|
|
56
53
|
|
|
57
54
|
async function withBridge(options, operation) {
|
|
58
55
|
const bridge = new AcpBridge(bridgeOptions(options));
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
let primaryError, result;
|
|
57
|
+
try { const initialized = await bridge.start(); result = await operation(bridge, initialized); return result; }
|
|
58
|
+
catch (error) { primaryError = error; throw error; }
|
|
59
|
+
finally { try { await bridge.close(); } catch (error) { if (!primaryError) throw nativeObservationError(error, result === undefined ? {} : { result }); primaryError.cleanup_error = errorRecord(error); primaryError.details = { ...primaryError.details, cleanup_error: primaryError.cleanup_error }; } }
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
export function observedProfile(initialized, requested) {
|
|
@@ -88,10 +87,7 @@ async function forwardUsage(options, providerSessionId, usage, toolCalls) {
|
|
|
88
87
|
const measured = usage.usage && typeof usage.usage === "object" ? usage.usage : usage;
|
|
89
88
|
const input = JSON.stringify({ provider_session_id: providerSessionId, daemon_id: options.daemonId ?? null, observed_at: new Date().toISOString(), usage: measured, tool_calls: toolCalls, usage_scope: usageScope(options, providerSessionId), completeness: measured.usageIsIncomplete ? "partial" : "complete" });
|
|
90
89
|
const target = executable(options.ddFlowBin, ["grok", "usage", "ingest", "--project-root", absolute(options.projectRoot, "--project-root"), "--json"]);
|
|
91
|
-
await
|
|
92
|
-
const child = spawn(target.command, target.args, { env: { ...process.env, ...(options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {}) }, stdio: ["pipe", "ignore", "pipe"] }); let stderr = "";
|
|
93
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); child.on("error", reject); child.on("close", (code) => code === 0 ? resolve() : reject(new Error(stderr || "dd-flow grok usage failed"))); child.stdin.end(`${input}\n`);
|
|
94
|
-
});
|
|
90
|
+
await commandText(target.command, target.args, { input: `${input}\n`, env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {}, phase: "usage-ingest" });
|
|
95
91
|
}
|
|
96
92
|
|
|
97
93
|
async function forwardUsageBestEffort(options, providerSessionId, usage, toolCalls) {
|
|
@@ -153,10 +149,15 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
153
149
|
requireJournal(options); const sessionId = await identity(bridge, options); const prompt = text(options.prompt, "prompt"); const requested = requiredProfile(options); const profileReceipt = assertProfile(requested, observedProfile(options.initialized, requested));
|
|
154
150
|
const local = { ...options, rootProviderSessionId: options.rootProviderSessionId ?? sessionId }; const initial_usage_error = await forwardUsageBestEffort(local, sessionId, await sessionUsage(bridge, sessionId), sessionToolCalls(local, bridge, sessionId));
|
|
155
151
|
options.assertDispatch?.();
|
|
156
|
-
|
|
152
|
+
bridge.dispatchOperationId = options.operationId;
|
|
153
|
+
const cursor = bridge.toolCursor(); const textCursor = bridge.assistantTextCursor(sessionId); const turn = await bridge.request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, null, { idleSessionId: sessionId, idleTimeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 600_000 });
|
|
154
|
+
try {
|
|
155
|
+
await bridge.flush();
|
|
157
156
|
const evidence = await inspect(bridge, sessionId, local); evidence.tool_calls = recordToolCalls(local, bridge, sessionId, cursor); const usage_ingest_error = await forwardUsageBestEffort(local, sessionId, evidence.usage, evidence.tool_calls); if (initial_usage_error) evidence.initial_usage_error = initial_usage_error; if (usage_ingest_error) evidence.usage_ingest_error = usage_ingest_error;
|
|
158
157
|
if (!options.allowBackground && (evidence.subagents?.subagents ?? []).length) { await cancelTree(bridge, sessionId); fail("background Grok Build subagents cannot outlive the one-shot dd-grok connection"); }
|
|
159
158
|
return receipt(options, { harness: "grok-acp", provider_session_id: sessionId, adapter_session_id: sessionId, assistant_text: bridge.assistantTextSince(sessionId, textCursor), turn, profile: profileReceipt, evidence });
|
|
159
|
+
} catch (error) { throw Object.assign(new Error("Grok native Turn completed but mandatory observation failed", { cause: error }), { code: "native_outcome_observation_failed", retryable: false,
|
|
160
|
+
details: { provider_session_id: sessionId, native_outcome: { status: "completed", turn }, observation_error: errorRecord(error), settled: false, journal_locator: options.journal } }); }
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
export async function inspectSession(options) { requireJournal(options); return await withBridge(options, async (bridge, initialized) => await inspectSessionWithBridge(bridge, { ...options, initialized })); }
|
|
@@ -173,3 +174,4 @@ export async function forkSessionWithBridge(bridge, options) {
|
|
|
173
174
|
const child = text(result.newSessionId, "Grok Build fork newSessionId"); return receipt(options, { harness: "grok-acp", parent_provider_session_id: sessionId, provider_session_id: child, adapter_session_id: child, target, fork: result });
|
|
174
175
|
}
|
|
175
176
|
export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
|
|
177
|
+
import { commandText } from "./process-json.mjs";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { callDaemonObserved } from './daemon-observation.mjs';
|
|
2
|
+
import { admitNativeRequestBuffer } from "./native-streams.mjs";
|
|
1
3
|
import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
|
|
2
4
|
import { openCodeToolObservation, journalToolObservations } from "./tool-observations.mjs";
|
|
3
|
-
import {
|
|
5
|
+
import { ObservationClock } from "./observation-clock.mjs";
|
|
4
6
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
7
|
import { appendFile, chmod, copyFile, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
6
8
|
import { spawn } from "node:child_process";
|
|
@@ -24,24 +26,8 @@ async function readState(dir) { try { return JSON.parse(await readFile(locations
|
|
|
24
26
|
async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
25
27
|
function executable(bin, args = []) { return /\.[cm]?js$/.test(bin) ? { command: process.execPath, args: [bin, ...args] } : { command: bin, args }; }
|
|
26
28
|
function isolatedEnv(config) { return { ...process.env, XDG_DATA_HOME: config.dataHome, XDG_CONFIG_HOME: config.configHome, XDG_CACHE_HOME: config.cacheHome, OPENCODE_CONFIG_DIR: config.adapterDir, OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: config.password, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.ddFlowBin ? { DD_OPENCODE_DD_FLOW_BIN: config.ddFlowBin, DD_OPENCODE_NODE_BIN: process.execPath, DD_OPENCODE_PROJECT_ROOT: config.projectRoot } : {}), DD_OPENCODE_DAEMON_ID: config.daemonId, DD_OPENCODE_PROVIDER: config.provider, DD_OPENCODE_MODEL: config.model, DD_OPENCODE_VARIANT: config.variant, DD_OPENCODE_AGENT: config.agent, DD_OPENCODE_SERVER_VERSION: config.version ?? "unknown", DD_OPENCODE_PLUGIN_SHA256: config.pluginSha256 ?? "unknown" }; }
|
|
27
|
-
async function run(bin, args, config, { stdin, timeoutMs = 30_000 } = {}) {
|
|
28
|
-
|
|
29
|
-
return await new Promise((resolve, reject) => {
|
|
30
|
-
const child = spawn(target.command, target.args, { cwd: config.cwd, env: isolatedEnv(config), detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
|
|
31
|
-
let stdout = "", stderr = "", timeoutError, inputError, cleanup;
|
|
32
|
-
const timer = setTimeout(() => {
|
|
33
|
-
timeoutError = new DaemonError("opencode_timeout", `${args[0]} timed out`, true);
|
|
34
|
-
cleanup = stopProcessGroup(child, 1_000).catch(error => { timeoutError.details = { cleanup_error: errorRecord(error) }; reject(timeoutError); });
|
|
35
|
-
}, timeoutMs);
|
|
36
|
-
child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk); child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
|
|
37
|
-
child.on("error", error => { clearTimeout(timer); reject(error); });
|
|
38
|
-
child.on("close", async code => { clearTimeout(timer); await cleanup; if (timeoutError) reject(timeoutError); else if (inputError) reject(inputError); else if (code === 0) resolve(stdout); else reject(new DaemonError("opencode_cli_failed", stderr.trim() || `${target.command} exited ${code}`)); });
|
|
39
|
-
child.stdin.on("error", error => {
|
|
40
|
-
inputError ??= error;
|
|
41
|
-
cleanup ??= stopProcessGroup(child, 1_000).catch(cause => { clearTimeout(timer); inputError.details = { ...inputError.details, cleanup_error: errorRecord(cause) }; reject(timeoutError ?? inputError); });
|
|
42
|
-
});
|
|
43
|
-
child.stdin.end(stdin ?? "");
|
|
44
|
-
});
|
|
29
|
+
export async function run(bin, args, config, { stdin, timeoutMs = 30_000 } = {}) {
|
|
30
|
+
return await commandText(bin, args, { cwd: config.cwd, env: isolatedEnv(config), input: stdin ?? null, timeoutMs, phase: "native-utility" });
|
|
45
31
|
}
|
|
46
32
|
async function forwardUsage(config, sessionId, snapshot) {
|
|
47
33
|
if (!config.ddFlowBin) return;
|
|
@@ -49,7 +35,7 @@ async function forwardUsage(config, sessionId, snapshot) {
|
|
|
49
35
|
await run(config.ddFlowBin, ["opencode", "usage", "ingest", "--project-root", config.projectRoot, "--json"], config, { stdin: `${JSON.stringify(payload)}\n` });
|
|
50
36
|
}
|
|
51
37
|
async function freePort() { return await new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); }); }
|
|
52
|
-
|
|
38
|
+
const errorPayload = errorRecord;
|
|
53
39
|
|
|
54
40
|
async function writePlugin(paths, config) {
|
|
55
41
|
const directory = path.join(paths.config, "opencode", "plugins"); await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -66,7 +52,10 @@ async function prepare(paths, config, authSource) {
|
|
|
66
52
|
async function importArchive(paths, config, archiveFile) { if (!archiveFile) return null; const manifest = JSON.parse(await readFile(absolute(archiveFile, "--session-archive"), "utf8")); if (manifest.schema_id !== ARCHIVE_SCHEMA || !manifest.payload) throw new DaemonError("invalid_session_archive", "OpenCode archive is incompatible"); if (manifest.opencode_version !== config.version) throw new DaemonError("session_archive_version_mismatch", "OpenCode archive version differs from the daemon version", false, { archive_version: manifest.opencode_version, current_version: config.version }); const payload = Buffer.from(manifest.payload, "base64").toString("utf8"); await writeFile(paths.archive, payload, { mode: 0o600 }); const stdout = await run(config.bin, ["import", paths.archive], config); const match = stdout.match(/ses_[A-Za-z0-9]+/); return { provider_session_id: match?.[0] ?? manifest.provider_session_id, manifest };
|
|
67
53
|
}
|
|
68
54
|
|
|
69
|
-
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
55
|
+
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
56
|
+
const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
57
|
+
return await callDaemonObserved({ stateDir, socket: locations(stateDir).socket, request, responseSchema: RESPONSE_SCHEMA, harness: 'opencode', timeoutMs });
|
|
58
|
+
}
|
|
70
59
|
function sameConfig(state, config) { return ["cwd","journal","bin","provider","model","variant","agent","projectRoot","ddFlowBin","ddFlowHome","sessionArchive","resourceHome","noFlow"].every(key => (state.config?.[key] ?? null) === (config[key] ?? null)); }
|
|
71
60
|
export async function startDaemon(options) {
|
|
72
61
|
const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
@@ -81,7 +70,7 @@ export async function startDaemon(options) {
|
|
|
81
70
|
const resourceProcess = await registerDaemonProcess(config, { kind: "opencode-daemon", owner: `opencode:${path.basename(paths.dir)}`, operation: `opencode-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
82
71
|
const state = { schema_id: STATE_SCHEMA, daemon_id: daemonId, shutdown_state: "starting", active_tree: false, auth_status: config.authStatus, versions: { opencode: config.version }, config, sessions: imported ? [{ provider_session_id: imported.provider_session_id, parent_provider_session_id: null, root_provider_session_id: imported.provider_session_id, cwd: config.cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess }; await writeJson(paths.state, state);
|
|
83
72
|
const log = await open(paths.log, "a", 0o600), entry = absolute(options.entryPath, "dd-opencode entry"); const child = spawn(process.execPath, [entry, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
84
|
-
try { await confirmDaemonProcess(config, resourceProcess, child); const
|
|
73
|
+
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: 20_000 }); while (!startupClock.sample()) { await new Promise(resolve => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
|
|
85
74
|
catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
|
|
86
75
|
}
|
|
87
76
|
export const stopDaemon = async 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(options.stateDir) }); };
|
|
@@ -139,7 +128,7 @@ export class Runtime {
|
|
|
139
128
|
try { await this.client.abort(id); } catch (error) { errors.push({ session_id: id, phase: "abort", ...errorPayload(error) }); }
|
|
140
129
|
return errors;
|
|
141
130
|
}
|
|
142
|
-
async dispatch(operation, params) {
|
|
131
|
+
async dispatch(operation, params, operationId = null) {
|
|
143
132
|
const assertDispatch = captureDispatchGuard(this);
|
|
144
133
|
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume", "session.archive"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
145
134
|
if (operation === "daemon.status") { const { password, ...config } = this.state.config; return { daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, auth_status: this.state.auth_status, shutdown_state: this.state.shutdown_state, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config }; }
|
|
@@ -161,7 +150,8 @@ export class Runtime {
|
|
|
161
150
|
});
|
|
162
151
|
if (operation === "session.inspect" || operation === "session.resume") { const result = await this.describe(params.sessionId); await this.persist(); return result; }
|
|
163
152
|
if (operation === "session.prompt") { await this.requireSettled(params.sessionId); return this.productive(operation, async () => { assertDispatch(); const reply = await this.client.prompt(params.sessionId, { prompt: params.prompt, provider: this.state.config.provider, model: this.state.config.model, variant: this.state.config.variant, agent: this.state.config.agent,
|
|
164
|
-
assertDispatch,
|
|
153
|
+
assertDispatch, livenessTimeoutMs: params.livenessTimeoutMs ?? this.client.timeoutMs,
|
|
154
|
+
onDispatched: () => this.journal("native_dispatch", { operation_id: operationId, provider_session_id: params.sessionId }),
|
|
165
155
|
onModelMessages: (id, messages, parent) => this.observeModels(id, messages, parent),
|
|
166
156
|
onObservationError: error => observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: params.sessionId, requested: this.state.config, source: "native.message_observer", evidence: "unavailable", reason: error.code ?? "observation_unavailable" })
|
|
167
157
|
}); const assistant_text = reply.parts.filter(part => part.type === "text").map(part => part.text ?? "").join("");
|
|
@@ -233,17 +223,17 @@ export async function serveDaemon(stateDir) {
|
|
|
233
223
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
234
224
|
scheduleDaemonProcessHeartbeat(state.config, providerProcess);
|
|
235
225
|
state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
|
|
236
|
-
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const
|
|
226
|
+
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const startupClock = new ObservationClock({ timeoutMs: 15_000 }); while (!startupClock.sample()) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
|
|
237
227
|
const server = net.createServer(connection => {
|
|
238
228
|
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
239
229
|
connection.on("data", chunk => {
|
|
240
230
|
if (consumed) return;
|
|
241
|
-
buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
|
|
231
|
+
buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; } const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
|
|
242
232
|
void (async () => {
|
|
243
233
|
let request;
|
|
244
234
|
try {
|
|
245
235
|
request = JSON.parse(buffer.slice(0, at));
|
|
246
|
-
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
236
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}, request.id), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
247
237
|
if (result._shutdown) retireDaemonEndpoint(server);
|
|
248
238
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
249
239
|
} catch (error) {
|
|
@@ -260,3 +250,4 @@ export async function serveDaemon(stateDir) {
|
|
|
260
250
|
}
|
|
261
251
|
}
|
|
262
252
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
253
|
+
import { commandText } from "./process-json.mjs";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { ToolObservations, openCodeToolObservation } from "./tool-observations.mjs";
|
|
3
|
-
import { observedTimeout } from "./observation-clock.mjs";
|
|
3
|
+
import { observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
|
|
4
|
+
import { assertNativeLine } from "./native-streams.mjs";
|
|
5
|
+
import { operationProgressReporter } from "./operation-progress.mjs";
|
|
4
6
|
|
|
5
7
|
export class OpenCodeError extends Error {
|
|
6
8
|
constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; }
|
|
@@ -10,18 +12,24 @@ export class OpenCodeClient {
|
|
|
10
12
|
constructor({ baseUrl, password, directory, timeoutMs = 1_800_000 }) {
|
|
11
13
|
this.baseUrl = new URL(baseUrl); this.password = password; this.directory = directory; this.timeoutMs = timeoutMs;
|
|
12
14
|
}
|
|
13
|
-
async request(method, pathname, body, { directory = this.directory, timeoutMs = this.timeoutMs, progress } = {}) {
|
|
15
|
+
async request(method, pathname, body, { directory = this.directory, timeoutMs = this.timeoutMs, progress, onDispatched } = {}) {
|
|
14
16
|
const url = new URL(pathname, this.baseUrl); if (directory) url.searchParams.set("directory", directory);
|
|
15
17
|
const controller = new AbortController(); const timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
|
|
16
18
|
try {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
+
const pending = fetch(url, { method, headers: { authorization: `Basic ${Buffer.from(`opencode:${this.password}`).toString("base64")}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal });
|
|
20
|
+
void pending.catch(() => {}); await onDispatched?.(); const response = await pending;
|
|
21
|
+
let text = "";
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
if (response.body) for await (const chunk of response.body) { text += decoder.decode(chunk, { stream: true }); assertNativeLine(text); }
|
|
24
|
+
text += decoder.decode(); assertNativeLine(text);
|
|
25
|
+
let value = null; try { value = text ? JSON.parse(text) : null; } catch { value = text; }
|
|
19
26
|
if (!response.ok) throw new OpenCodeError(response.status === 404 ? "session_not_found" : "opencode_http_error", `OpenCode ${method} ${pathname} returned ${response.status}`, response.status >= 500, value);
|
|
20
27
|
return value;
|
|
21
28
|
} catch (error) {
|
|
22
|
-
|
|
29
|
+
controller.abort();
|
|
30
|
+
if (error instanceof OpenCodeError || error.code === "operation_observation_lost") throw error;
|
|
23
31
|
// Aborting HTTP only loses the observer; it does not cancel the provider.
|
|
24
|
-
throw new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname });
|
|
32
|
+
throw Object.assign(new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname }), { cause: error });
|
|
25
33
|
} finally { clearTimeout(timer); }
|
|
26
34
|
}
|
|
27
35
|
health() { return this.request("GET", "/global/health", undefined, { directory: null, timeoutMs: 5_000 }); }
|
|
@@ -31,11 +39,15 @@ export class OpenCodeClient {
|
|
|
31
39
|
statuses() { return this.request("GET", "/session/status"); }
|
|
32
40
|
children(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/children`); }
|
|
33
41
|
messages(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/message`); }
|
|
34
|
-
async prompt(id, { prompt, provider, model, variant, agent, onModelMessages, onObservationError, assertDispatch }) {
|
|
42
|
+
async prompt(id, { prompt, provider, model, variant, agent, onModelMessages, onObservationError, assertDispatch, onDispatched, livenessTimeoutMs = this.timeoutMs }) {
|
|
43
|
+
validateObservationDuration(livenessTimeoutMs);
|
|
35
44
|
const previous = await this.messages(id);
|
|
36
45
|
if (!Array.isArray(previous)) throw new OpenCodeError("operation_observation_lost", "OpenCode history cannot establish the prompt boundary", true);
|
|
37
46
|
const previousIds = new Set(previous.map(message => message.info?.id).filter(Boolean));
|
|
38
|
-
|
|
47
|
+
const publish = operationProgressReporter();
|
|
48
|
+
let marker, observation = null, cursor = 0, signature = null, highWater = 0;
|
|
49
|
+
const seenSignatures = new Set();
|
|
50
|
+
const floors = new Map([[id, previousIds]]);
|
|
39
51
|
// Native message content is progress; an HTTP poll or busy status is not.
|
|
40
52
|
// Include descendants so a coordinating parent may legitimately be silent.
|
|
41
53
|
const observe = async (session, seen = new Set(), parent = null) => {
|
|
@@ -46,21 +58,38 @@ export class OpenCodeClient {
|
|
|
46
58
|
this.request("GET", `/session/${encodeURIComponent(session)}/message`, undefined, options),
|
|
47
59
|
this.request("GET", `/session/${encodeURIComponent(session)}/children`, undefined, options)
|
|
48
60
|
]);
|
|
61
|
+
if (!Array.isArray(messages) || !Array.isArray(children)) throw new OpenCodeError("operation_observation_lost", "OpenCode observation shape is unproven", true);
|
|
49
62
|
await onModelMessages?.(session, messages, parent);
|
|
50
|
-
|
|
63
|
+
// A newly discovered child does not turn its old transcript into work.
|
|
64
|
+
if (!floors.has(session)) floors.set(session, new Set(messages.map(message => message.info?.id).filter(Boolean)));
|
|
65
|
+
const current = messages.filter(message => message.info?.role === "assistant" && message.info?.sessionID === session && !floors.get(session).has(message.info?.id));
|
|
66
|
+
const content = current.map(message => ({ id: message.info?.id, parts: (message.parts ?? []).filter(part => ["text", "reasoning", "tool", "subtask"].includes(part.type)).map(stablePart) })).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
67
|
+
const checked = children.filter(child => child.parentID === session && typeof child.id === "string").sort((a, b) => a.id.localeCompare(b.id));
|
|
68
|
+
const descendants = (await Promise.all(checked.map(child => observe(child.id, seen, session)))).filter(value => value.length > 0);
|
|
69
|
+
return content.some(message => message.parts.length) || descendants.length ? [session, content, ...descendants] : [];
|
|
51
70
|
};
|
|
52
71
|
const refreshObservation = () => observation ??= (async () => {
|
|
53
|
-
try {
|
|
72
|
+
try { const projection = JSON.stringify(await observe(id)), next = createHash("sha256").update(projection).digest("hex");
|
|
73
|
+
// Cumulative text can grow indefinitely without retaining every hash;
|
|
74
|
+
// non-growing snapshot states have bounded current-operation replay proof.
|
|
75
|
+
if (signature !== null && !seenSignatures.has(next) && (projection.length > highWater || seenSignatures.size < 1_024)) {
|
|
76
|
+
marker = { cursor: ++cursor, observed_at: Date.now() };
|
|
77
|
+
await publish("native_progress", { session_id: id, observed_at: new Date(marker.observed_at).toISOString() });
|
|
78
|
+
}
|
|
79
|
+
if (seenSignatures.size < 1_024) seenSignatures.add(next);
|
|
80
|
+
highWater = Math.max(highWater, projection.length);
|
|
81
|
+
signature = next;
|
|
82
|
+
}
|
|
54
83
|
catch (error) { await onObservationError?.(error); /* Missing data is not progress. */ }
|
|
55
84
|
})().finally(() => { observation = null; });
|
|
56
85
|
// Begin observing before the long-running request. Waiting for the first
|
|
57
86
|
// interval can let a short liveness window expire before any native
|
|
58
87
|
// content has had a chance to renew it.
|
|
59
88
|
void refreshObservation();
|
|
60
|
-
const timer = setInterval(() => { void refreshObservation(); }, Math.max(10, Math.min(5_000,
|
|
89
|
+
const timer = setInterval(() => { void refreshObservation(); }, Math.max(10, Math.min(5_000, livenessTimeoutMs / 4)));
|
|
61
90
|
try {
|
|
62
91
|
assertDispatch?.();
|
|
63
|
-
const reply = await this.request("POST", `/session/${encodeURIComponent(id)}/message`, { model: { providerID: provider, modelID: model }, variant, agent, parts: [{ type: "text", text: prompt }] }, { progress: () => marker });
|
|
92
|
+
const reply = await this.request("POST", `/session/${encodeURIComponent(id)}/message`, { model: { providerID: provider, modelID: model }, variant, agent, parts: [{ type: "text", text: prompt }] }, { timeoutMs: livenessTimeoutMs, progress: () => marker, onDispatched });
|
|
64
93
|
if (!reply?.info?.id || reply.info.sessionID !== id || reply.info.role !== "assistant" || previousIds.has(reply.info.id) || !Array.isArray(reply.parts)) {
|
|
65
94
|
throw new OpenCodeError("operation_observation_lost", "OpenCode did not return an identifiable current assistant response", true, { session_id: id, message_id: reply?.info?.id ?? null });
|
|
66
95
|
}
|
|
@@ -69,7 +98,8 @@ export class OpenCodeClient {
|
|
|
69
98
|
const message = native.data?.message ?? native.message ?? native.name ?? "OpenCode provider failed";
|
|
70
99
|
const status = native.data?.statusCode;
|
|
71
100
|
const code = status === 402 || /payment required|quota|usage balance exhausted|insufficient.?credit|billing/i.test(message) ? "provider_quota_exhausted" : /rate.?limit|too many requests/i.test(message) ? "provider_rate_limited" : status === 429 ? "provider_limit_unknown" : "opencode_provider_failed";
|
|
72
|
-
throw new OpenCodeError(code, message, code === "provider_rate_limited", { session_id: id, message_id: reply.info.id, provider_error: native
|
|
101
|
+
throw new OpenCodeError(code, message, code === "provider_rate_limited", { session_id: id, provider_session_id: id, message_id: reply.info.id, provider_error: native,
|
|
102
|
+
observed_at: new Date().toISOString(), native_source: "opencode.reply.info.error", turn_id: reply.info.id });
|
|
73
103
|
}
|
|
74
104
|
return reply;
|
|
75
105
|
}
|
|
@@ -81,7 +111,7 @@ export class OpenCodeClient {
|
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
function stablePart(part) {
|
|
84
|
-
if (part.type === "text") return { type:
|
|
114
|
+
if (part.type === "text" || part.type === "reasoning") return { type: part.type, text: part.text, synthetic: part.synthetic === true };
|
|
85
115
|
if (part.type === "tool") return { type: "tool", tool: part.tool, state: { status: part.state?.status, input: part.state?.input, output: part.state?.output, error: part.state?.error } };
|
|
86
116
|
if (part.type === "subtask") return { type: "subtask", prompt: part.prompt, description: part.description, agent: part.agent };
|
|
87
117
|
return { type: part.type };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { callDaemonObserved } from './daemon-observation.mjs';
|
|
2
|
+
import { admitNativeRequestBuffer } from "./native-streams.mjs";
|
|
3
|
+
import { ObservationClock } from "./observation-clock.mjs";
|
|
2
4
|
import { spawn } from "node:child_process";
|
|
3
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
6
|
import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
@@ -6,6 +8,9 @@ import net from "node:net";
|
|
|
6
8
|
import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
7
9
|
import path from "node:path";
|
|
8
10
|
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
11
|
+
import { acceptNativeHookRequest, hookBudget, readNativeHookFailures } from "./native-hook-command.mjs";
|
|
12
|
+
import { parseLifecycleCommand, continuationCommand } from "./lifecycle-command.mjs";
|
|
13
|
+
import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
|
|
9
14
|
|
|
10
15
|
import {
|
|
11
16
|
AcpBridge,
|
|
@@ -80,48 +85,11 @@ async function removeSocket(socket, dir) {
|
|
|
80
85
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
code: error.code ?? "operation_failed",
|
|
86
|
-
message: error.message ?? String(error),
|
|
87
|
-
retryable: error.retryable === true,
|
|
88
|
-
...(error.details === undefined ? {} : { details: error.details }),
|
|
89
|
-
};
|
|
90
|
-
}
|
|
88
|
+
const errorPayload = errorRecord;
|
|
91
89
|
|
|
92
90
|
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
93
|
-
const { socket } = locations(stateDir);
|
|
94
91
|
const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
95
|
-
return await
|
|
96
|
-
const client = net.createConnection(socket);
|
|
97
|
-
let buffer = ""; let settled = false;
|
|
98
|
-
// A prompt has its own sliding ACP liveness deadline. Do not add an
|
|
99
|
-
// independent wall-clock deadline here: it can cut off an actively
|
|
100
|
-
// producing Turn merely because it is long.
|
|
101
|
-
const timer = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
102
|
-
? observedTimeout(() => { client.destroy(); reject(new DaemonError("daemon_timeout", `${operation} timed out`, true)); }, timeoutMs)
|
|
103
|
-
: null;
|
|
104
|
-
const finish = (error, value) => {
|
|
105
|
-
if (settled) return;
|
|
106
|
-
settled = true; if (timer) clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value);
|
|
107
|
-
};
|
|
108
|
-
client.setEncoding("utf8");
|
|
109
|
-
client.on("connect", () => client.write(`${JSON.stringify(request)}\n`));
|
|
110
|
-
client.on("data", (chunk) => {
|
|
111
|
-
buffer += chunk;
|
|
112
|
-
const newline = buffer.indexOf("\n");
|
|
113
|
-
if (newline < 0) return;
|
|
114
|
-
try {
|
|
115
|
-
const response = JSON.parse(buffer.slice(0, newline));
|
|
116
|
-
if (response.schema_id !== RESPONSE_SCHEMA || response.id !== request.id) return finish(new DaemonError("daemon_protocol_mismatch", "invalid daemon response"));
|
|
117
|
-
if (!response.ok) return finish(new DaemonError(response.error?.code ?? "operation_failed", response.error?.message ?? "daemon operation failed", response.error?.retryable, response.error?.details));
|
|
118
|
-
finish(null, response.result);
|
|
119
|
-
} catch (error) { finish(new DaemonError("daemon_protocol_mismatch", error.message)); }
|
|
120
|
-
});
|
|
121
|
-
client.on("error", (error) => finish(new DaemonError("daemon_not_running", error.message, true)));
|
|
122
|
-
client.on("end", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
|
|
123
|
-
client.on("close", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
|
|
124
|
-
});
|
|
92
|
+
return await callDaemonObserved({ stateDir, socket: locations(stateDir).socket, request, responseSchema: RESPONSE_SCHEMA, harness: 'zcode', timeoutMs });
|
|
125
93
|
}
|
|
126
94
|
|
|
127
95
|
const ZCODE_ROUTING_ENV = ["ZCODE_HOME", "ZCODE_STORAGE_DIR", "ZCODE_SESSION_DB_PATH"];
|
|
@@ -212,8 +180,8 @@ export async function startDaemon(options) {
|
|
|
212
180
|
await log.close();
|
|
213
181
|
try {
|
|
214
182
|
await confirmDaemonProcess(config, resourceProcess, child);
|
|
215
|
-
const
|
|
216
|
-
while (
|
|
183
|
+
const startupClock = new ObservationClock({ timeoutMs: 30_000 });
|
|
184
|
+
while (!startupClock.sample()) {
|
|
217
185
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
218
186
|
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
219
187
|
catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
@@ -251,13 +219,18 @@ export class DaemonRuntime {
|
|
|
251
219
|
const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
|
|
252
220
|
const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
|
|
253
221
|
return {
|
|
254
|
-
...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
|
|
222
|
+
...this.state.config, ...params, operationId: this.state.active_request?.request_id, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
|
|
255
223
|
neverPromptedRootProof: this.neverPromptedRootProof(params.sessionId),
|
|
256
224
|
daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
|
|
257
225
|
assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); },
|
|
226
|
+
onActivity: async ({ sessionId, requestId, progress }) => {
|
|
227
|
+
const request = this.state.active_request;
|
|
228
|
+
if (!request || request.adapter_session_id !== sessionId || this.activeGeneration !== request.generation) return;
|
|
229
|
+
await this.persist({ active_request: { ...request, observation: { state: "productive", request_id: requestId, progress } } });
|
|
230
|
+
},
|
|
258
231
|
// The bridge owns the quiet timer; the daemon owns the durable status.
|
|
259
232
|
// Do not infer provider state or cancel here.
|
|
260
|
-
onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs }) => {
|
|
233
|
+
onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs, cursor }) => {
|
|
261
234
|
const request = this.state.active_request;
|
|
262
235
|
// `sessionId` is ACP identity; the durable request's `session_id` is
|
|
263
236
|
// native/provider identity. These strings can coincide but are not
|
|
@@ -286,7 +259,8 @@ export class DaemonRuntime {
|
|
|
286
259
|
// The probe is asynchronous. Do not recreate a completed or
|
|
287
260
|
// reconnected request with a stale callback snapshot.
|
|
288
261
|
const current = this.state.active_request;
|
|
289
|
-
if (!current || current.request_id !== request.request_id || current.adapter_session_id !== adapterSessionId || this.activeGeneration !== request.generation
|
|
262
|
+
if (!current || current.request_id !== request.request_id || current.adapter_session_id !== adapterSessionId || this.activeGeneration !== request.generation
|
|
263
|
+
|| cursor !== undefined && this.bridge.pending?.get(requestId)?.progress?.cursor !== cursor && cursor !== 0) return;
|
|
290
264
|
await this.persist({ active_request: {
|
|
291
265
|
...current,
|
|
292
266
|
observation: { state: "quiet", request_id: requestId, detected_at: new Date().toISOString(), last_activity_at: new Date(lastActivityAt).toISOString(), quiet_threshold_ms: idleTimeoutMs, diagnostics },
|
|
@@ -389,9 +363,15 @@ export class DaemonRuntime {
|
|
|
389
363
|
if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
|
|
390
364
|
this.activeProductive = name;
|
|
391
365
|
this.activeGeneration = this.controlGeneration ?? 0;
|
|
366
|
+
const current = this.nativeHookOperation = { operationId: request?.operation_id, generation: this.activeGeneration, requests: new Map() };
|
|
367
|
+
let rejectHook;
|
|
368
|
+
const hookFailed = new Promise((_, reject) => { rejectHook = reject; });
|
|
369
|
+
void hookFailed.catch(() => {}); // A hook can fail while the dispatch receipt is still persisting.
|
|
370
|
+
current.reject = error => { if (this.nativeHookOperation !== current || current.error) return; current.error = error; this.bridge.lifecycleFailure ??= error; this.bridge.notificationError = this.bridge.lifecycleFailure; this.bridge.rejectAll(this.bridge.lifecycleFailure); rejectHook(this.bridge.lifecycleFailure); };
|
|
371
|
+
const timer = setInterval(() => { void this.observeHookFailures(current).catch(error => current.reject(error)); }, 100);
|
|
392
372
|
try {
|
|
393
373
|
await this.persist({ active_tree: true, active_operation: name, ...(request ? { active_request: request } : {}) });
|
|
394
|
-
const result = await task();
|
|
374
|
+
const result = await Promise.race([task(), hookFailed]);
|
|
395
375
|
this.track(result);
|
|
396
376
|
const running = this.treeRunning(result);
|
|
397
377
|
await this.persist({ active_tree: running, active_operation: null, active_request: null });
|
|
@@ -403,12 +383,12 @@ export class DaemonRuntime {
|
|
|
403
383
|
// daemon state no longer carried the only correlation to it.
|
|
404
384
|
const active = this.state?.active_request;
|
|
405
385
|
try {
|
|
406
|
-
await this.persist({
|
|
386
|
+
await hookBudget(1_000, null).run(() => this.persist({
|
|
407
387
|
active_operation: null,
|
|
408
388
|
active_request: active?.allocation
|
|
409
389
|
? { ...active, outcome: "unknown", error: errorPayload(error) }
|
|
410
390
|
: null,
|
|
411
|
-
});
|
|
391
|
+
}), "failure_persistence");
|
|
412
392
|
} catch (cleanupError) {
|
|
413
393
|
// Preserve the operation's primary failure if durable cleanup itself
|
|
414
394
|
// is unavailable. The caller will retain the outcome as unknown and
|
|
@@ -416,7 +396,15 @@ export class DaemonRuntime {
|
|
|
416
396
|
if (error && typeof error === "object") error.cleanup_error = errorPayload(cleanupError);
|
|
417
397
|
}
|
|
418
398
|
throw error;
|
|
419
|
-
} finally { this.activeProductive = null; this.activeGeneration = null; }
|
|
399
|
+
} finally { clearInterval(timer); current.requests.clear(); if (this.nativeHookOperation === current) this.nativeHookOperation = null; this.activeProductive = null; this.activeGeneration = null; }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async observeHookFailures(current = this.nativeHookOperation) {
|
|
403
|
+
if (!current || this.nativeHookOperation !== current || current.reading) return;
|
|
404
|
+
current.reading = true;
|
|
405
|
+
try { await readNativeHookFailures(this.paths.dir, current.requests, current, error => {
|
|
406
|
+
if (!isProvenShellCorrection(error)) current.reject(error);
|
|
407
|
+
}); } finally { current.reading = false; }
|
|
420
408
|
}
|
|
421
409
|
|
|
422
410
|
async requireSettled(params) {
|
|
@@ -448,7 +436,7 @@ export class DaemonRuntime {
|
|
|
448
436
|
await this.persist();
|
|
449
437
|
}
|
|
450
438
|
|
|
451
|
-
async dispatch(operation, params) {
|
|
439
|
+
async dispatch(operation, params, operationId = null) {
|
|
452
440
|
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
453
441
|
if (operation === "daemon.status") return {
|
|
454
442
|
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
@@ -462,10 +450,24 @@ export class DaemonRuntime {
|
|
|
462
450
|
const sessionId = String(params.sessionId ?? "");
|
|
463
451
|
const session = this.sessions.get(sessionId);
|
|
464
452
|
if (!session?.root_provider_session_id) throw new DaemonError("hook_identity_unknown", "ZCode hook Session is not a tracked controlled Session", false, { session_id: sessionId });
|
|
465
|
-
|
|
453
|
+
const identity = { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} };
|
|
454
|
+
const payload = params.payload, command = payload?.tool_input?.command ?? payload?.tool_input?.cmd;
|
|
455
|
+
if (payload?.hook_event_name === "PreToolUse" && typeof command === "string" && (["lifecycle", "auxiliary"].includes(parseLifecycleCommand(command).participation) || continuationCommand(command))) {
|
|
456
|
+
const current = this.nativeHookOperation, active = this.state.active_request;
|
|
457
|
+
const tool = payload.tool_use_id ?? payload.tool_call_id;
|
|
458
|
+
const pending = [...(this.bridge.pending?.values() ?? [])].some(request => request.method === "session/prompt" && request.sessionId === active?.adapter_session_id);
|
|
459
|
+
const root = active?.session_id ?? active?.allocation?.provider_session_id;
|
|
460
|
+
if (!current || !active || !pending || current.generation !== (this.controlGeneration ?? 0) || session.root_provider_session_id !== root || !tool) throw new DaemonError("native_hook_unproven", "Native hook has no exact active Session operation");
|
|
461
|
+
const binding = await hookBudget(1_000, null).run(() => acceptNativeHookRequest(this.paths.dir, { daemon_id: this.state.daemon_id, operation_id: current.operationId, turn_generation: current.generation, provider_session_id: sessionId, root_provider_session_id: session.root_provider_session_id, tool_call_id: tool, event: "PreToolUse", command_sha256: createHash("sha256").update(command).digest("hex"), native_cwd: payload.cwd ?? null }, current), "hook_binding");
|
|
462
|
+
if (this.nativeHookOperation !== current) throw new DaemonError("native_hook_unproven", "Native hook operation changed during acceptance");
|
|
463
|
+
current.requests.set(binding.request_id, { current, binding }); identity.nativeHookBinding = binding;
|
|
464
|
+
}
|
|
465
|
+
return identity;
|
|
466
466
|
}
|
|
467
|
+
if (operation === "hook.failed") { await this.observeHookFailures(); return { observed: true }; }
|
|
467
468
|
if (operation === "session.create") {
|
|
468
469
|
const request = {
|
|
470
|
+
operation_id: operationId,
|
|
469
471
|
request_id: randomUUID(),
|
|
470
472
|
session_id: params.sessionId ?? null,
|
|
471
473
|
adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
|
|
@@ -479,6 +481,7 @@ export class DaemonRuntime {
|
|
|
479
481
|
if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
|
|
480
482
|
const known = this.sessions.get(params.sessionId);
|
|
481
483
|
const request = {
|
|
484
|
+
operation_id: operationId,
|
|
482
485
|
request_id: randomUUID(),
|
|
483
486
|
session_id: params.sessionId,
|
|
484
487
|
adapter_session_id: params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId,
|
|
@@ -661,7 +664,7 @@ export async function serveDaemon(stateDir) {
|
|
|
661
664
|
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
662
665
|
connection.on("data", (chunk) => {
|
|
663
666
|
if (consumed) return;
|
|
664
|
-
buffer += chunk;
|
|
667
|
+
buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; }
|
|
665
668
|
const newline = buffer.indexOf("\n");
|
|
666
669
|
if (newline < 0) return;
|
|
667
670
|
consumed = true;
|
|
@@ -671,7 +674,7 @@ export async function serveDaemon(stateDir) {
|
|
|
671
674
|
try {
|
|
672
675
|
request = JSON.parse(line);
|
|
673
676
|
if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
|
|
674
|
-
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
677
|
+
const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}, request.id), () => runtime.dispatch("session.inspect", request.params ?? {}));
|
|
675
678
|
if (result?._shutdown) retireDaemonEndpoint(server);
|
|
676
679
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
677
680
|
} catch (error) {
|