@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +33 -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/domain/stage-catalog.js +1 -1
- 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/codex-capacity-policy.d.mts +10 -0
- package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -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 +159 -15
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
- package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
- package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
- package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
- 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 +11 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
- 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/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +3 -3
- package/dist/schemas/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +93 -27
- package/dist/services/codex-capacity.js +251 -74
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -75
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +21 -9
- package/dist/services/harness-adapter.js +195 -25
- 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 +117 -53
- package/dist/services/merge-server.js +66 -23
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- package/dist/services/review-deferral.js +23 -0
- 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 +177 -51
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-recovery.js +17 -0
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +207 -107
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +21 -30
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +40 -7
- 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/verification-matrix.js +30 -1
- package/dist/services/vnext-code-review.js +26 -13
- 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 +30 -11
- package/dist/services/vnext-plan-review.js +102 -35
- 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 +43 -6
- 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,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,11 +1,16 @@
|
|
|
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";
|
|
5
7
|
import net from "node:net";
|
|
6
|
-
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
8
|
+
import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
7
9
|
import path from "node:path";
|
|
8
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart,
|
|
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"];
|
|
@@ -163,13 +131,14 @@ export async function startDaemon(options) {
|
|
|
163
131
|
await prepareRuntimeOwner(config, paths.dir);
|
|
164
132
|
await mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
165
133
|
await chmod(paths.dir, 0o700);
|
|
134
|
+
let previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
166
135
|
try {
|
|
167
136
|
const status = await callDaemon(paths.dir, "daemon.status", {}, 1000);
|
|
168
137
|
if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration");
|
|
169
138
|
return { ...status, already_running: true };
|
|
170
139
|
} catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
171
140
|
|
|
172
|
-
|
|
141
|
+
previous = await readState(paths.dir); assertDaemonRestartable(previous);
|
|
173
142
|
if (previous?.shutdown_state === "clean") {
|
|
174
143
|
if (!sameNativeRouting(previous.config, config)) throw new DaemonError("daemon_config_mismatch", "retained ZCode native storage routing changed");
|
|
175
144
|
await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
@@ -211,8 +180,8 @@ export async function startDaemon(options) {
|
|
|
211
180
|
await log.close();
|
|
212
181
|
try {
|
|
213
182
|
await confirmDaemonProcess(config, resourceProcess, child);
|
|
214
|
-
const
|
|
215
|
-
while (
|
|
183
|
+
const startupClock = new ObservationClock({ timeoutMs: 30_000 });
|
|
184
|
+
while (!startupClock.sample()) {
|
|
216
185
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
217
186
|
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
218
187
|
catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
@@ -227,7 +196,7 @@ export async function startDaemon(options) {
|
|
|
227
196
|
export async function stopDaemon(options) {
|
|
228
197
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
229
198
|
const { socket } = locations(options.stateDir);
|
|
230
|
-
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
|
|
199
|
+
return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs, { readState: () => readState(options.stateDir) });
|
|
231
200
|
}
|
|
232
201
|
|
|
233
202
|
export class DaemonRuntime {
|
|
@@ -241,7 +210,7 @@ export class DaemonRuntime {
|
|
|
241
210
|
persist(patch = {}) {
|
|
242
211
|
Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] });
|
|
243
212
|
const snapshot = structuredClone(this.state);
|
|
244
|
-
this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot));
|
|
213
|
+
this.persisting = this.persisting.catch(() => {}).then(() => writeState(this.paths.state, snapshot));
|
|
245
214
|
return this.persisting;
|
|
246
215
|
}
|
|
247
216
|
|
|
@@ -250,13 +219,18 @@ export class DaemonRuntime {
|
|
|
250
219
|
const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
|
|
251
220
|
const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
|
|
252
221
|
return {
|
|
253
|
-
...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,
|
|
254
223
|
neverPromptedRootProof: this.neverPromptedRootProof(params.sessionId),
|
|
255
224
|
daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
|
|
256
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
|
+
},
|
|
257
231
|
// The bridge owns the quiet timer; the daemon owns the durable status.
|
|
258
232
|
// Do not infer provider state or cancel here.
|
|
259
|
-
onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs }) => {
|
|
233
|
+
onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs, cursor }) => {
|
|
260
234
|
const request = this.state.active_request;
|
|
261
235
|
// `sessionId` is ACP identity; the durable request's `session_id` is
|
|
262
236
|
// native/provider identity. These strings can coincide but are not
|
|
@@ -285,7 +259,8 @@ export class DaemonRuntime {
|
|
|
285
259
|
// The probe is asynchronous. Do not recreate a completed or
|
|
286
260
|
// reconnected request with a stale callback snapshot.
|
|
287
261
|
const current = this.state.active_request;
|
|
288
|
-
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;
|
|
289
264
|
await this.persist({ active_request: {
|
|
290
265
|
...current,
|
|
291
266
|
observation: { state: "quiet", request_id: requestId, detected_at: new Date().toISOString(), last_activity_at: new Date(lastActivityAt).toISOString(), quiet_threshold_ms: idleTimeoutMs, diagnostics },
|
|
@@ -388,9 +363,15 @@ export class DaemonRuntime {
|
|
|
388
363
|
if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
|
|
389
364
|
this.activeProductive = name;
|
|
390
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);
|
|
391
372
|
try {
|
|
392
373
|
await this.persist({ active_tree: true, active_operation: name, ...(request ? { active_request: request } : {}) });
|
|
393
|
-
const result = await task();
|
|
374
|
+
const result = await Promise.race([task(), hookFailed]);
|
|
394
375
|
this.track(result);
|
|
395
376
|
const running = this.treeRunning(result);
|
|
396
377
|
await this.persist({ active_tree: running, active_operation: null, active_request: null });
|
|
@@ -402,12 +383,12 @@ export class DaemonRuntime {
|
|
|
402
383
|
// daemon state no longer carried the only correlation to it.
|
|
403
384
|
const active = this.state?.active_request;
|
|
404
385
|
try {
|
|
405
|
-
await this.persist({
|
|
386
|
+
await hookBudget(1_000, null).run(() => this.persist({
|
|
406
387
|
active_operation: null,
|
|
407
388
|
active_request: active?.allocation
|
|
408
389
|
? { ...active, outcome: "unknown", error: errorPayload(error) }
|
|
409
390
|
: null,
|
|
410
|
-
});
|
|
391
|
+
}), "failure_persistence");
|
|
411
392
|
} catch (cleanupError) {
|
|
412
393
|
// Preserve the operation's primary failure if durable cleanup itself
|
|
413
394
|
// is unavailable. The caller will retain the outcome as unknown and
|
|
@@ -415,7 +396,15 @@ export class DaemonRuntime {
|
|
|
415
396
|
if (error && typeof error === "object") error.cleanup_error = errorPayload(cleanupError);
|
|
416
397
|
}
|
|
417
398
|
throw error;
|
|
418
|
-
} 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; }
|
|
419
408
|
}
|
|
420
409
|
|
|
421
410
|
async requireSettled(params) {
|
|
@@ -447,7 +436,8 @@ export class DaemonRuntime {
|
|
|
447
436
|
await this.persist();
|
|
448
437
|
}
|
|
449
438
|
|
|
450
|
-
async dispatch(operation, params) {
|
|
439
|
+
async dispatch(operation, params, operationId = null) {
|
|
440
|
+
if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
|
|
451
441
|
if (operation === "daemon.status") return {
|
|
452
442
|
daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
|
|
453
443
|
cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
|
|
@@ -460,10 +450,24 @@ export class DaemonRuntime {
|
|
|
460
450
|
const sessionId = String(params.sessionId ?? "");
|
|
461
451
|
const session = this.sessions.get(sessionId);
|
|
462
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 });
|
|
463
|
-
|
|
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;
|
|
464
466
|
}
|
|
467
|
+
if (operation === "hook.failed") { await this.observeHookFailures(); return { observed: true }; }
|
|
465
468
|
if (operation === "session.create") {
|
|
466
469
|
const request = {
|
|
470
|
+
operation_id: operationId,
|
|
467
471
|
request_id: randomUUID(),
|
|
468
472
|
session_id: params.sessionId ?? null,
|
|
469
473
|
adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
|
|
@@ -477,6 +481,7 @@ export class DaemonRuntime {
|
|
|
477
481
|
if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
|
|
478
482
|
const known = this.sessions.get(params.sessionId);
|
|
479
483
|
const request = {
|
|
484
|
+
operation_id: operationId,
|
|
480
485
|
request_id: randomUUID(),
|
|
481
486
|
session_id: params.sessionId,
|
|
482
487
|
adapter_session_id: params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId,
|
|
@@ -548,12 +553,17 @@ export class DaemonRuntime {
|
|
|
548
553
|
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
549
554
|
return result;
|
|
550
555
|
}
|
|
551
|
-
if (operation === "daemon.stop") return await this.prepareStop(params.cancelTree === true)
|
|
556
|
+
if (operation === "daemon.stop") return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), () => this.prepareStop(params.cancelTree === true), [
|
|
557
|
+
["provider_resource", () => finishDaemonProcess(this.state.config, this.state.provider_process)],
|
|
558
|
+
["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
|
|
559
|
+
]);
|
|
552
560
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
553
561
|
}
|
|
554
562
|
|
|
555
563
|
async prepareStop(cancel) {
|
|
564
|
+
if (this.activeProductive && !cancel) throw new DaemonError("tree_not_settled", "productive operation is running");
|
|
556
565
|
if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
566
|
+
if (!this.state.native_stop_proof || this.state.native_stop_proof.daemon_id !== this.state.daemon_id) {
|
|
557
567
|
const unsettled = [];
|
|
558
568
|
for (const session of this.sessions.values()) {
|
|
559
569
|
if (session.close_receipt) {
|
|
@@ -603,6 +613,10 @@ export class DaemonRuntime {
|
|
|
603
613
|
}
|
|
604
614
|
}
|
|
605
615
|
if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
|
|
616
|
+
commitDaemonShutdown(this.paths.dir);
|
|
617
|
+
await this.persist({ native_stop_proof: { daemon_id: this.state.daemon_id, sessions: [...this.sessions.keys()], observed_at: new Date().toISOString() } });
|
|
618
|
+
}
|
|
619
|
+
commitDaemonShutdown(this.paths.dir);
|
|
606
620
|
// A cooperative close can make the native Session non-resident while its
|
|
607
621
|
// original ACP prompt is still pending. For an explicitly whole-RUN
|
|
608
622
|
// cancellation this daemon owns the bridge process group, so retire that
|
|
@@ -614,7 +628,7 @@ export class DaemonRuntime {
|
|
|
614
628
|
if (typeof this.bridge.close === "function") await this.bridge.close();
|
|
615
629
|
bridgeClosed = true;
|
|
616
630
|
} catch (error) {
|
|
617
|
-
throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false);
|
|
631
|
+
throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details);
|
|
618
632
|
}
|
|
619
633
|
}
|
|
620
634
|
const deadline = Date.now() + 5000;
|
|
@@ -625,24 +639,11 @@ export class DaemonRuntime {
|
|
|
625
639
|
// A bridge without a close method occurs only in in-memory diagnostics: it
|
|
626
640
|
// owns no OS process boundary. Production AcpBridge always has one.
|
|
627
641
|
try { if (!bridgeClosed && typeof this.bridge.close === "function") await this.bridge.close(); }
|
|
628
|
-
catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false); }
|
|
642
|
+
catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details); }
|
|
629
643
|
await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null, active_request: null, physical_settlement: { confirmed_at: new Date().toISOString(), bridge_pid: this.bridge.child?.pid ?? null } });
|
|
630
644
|
return { daemon_id: this.state.daemon_id, stopped: true, clean: true, physical_settlement: this.state.physical_settlement, _shutdown: true };
|
|
631
645
|
}
|
|
632
646
|
|
|
633
|
-
async shutdown() {
|
|
634
|
-
// prepareStop has already closed the owned bridge. Signal shutdown may get
|
|
635
|
-
// here directly, so close remains idempotent.
|
|
636
|
-
await closeManagedBridge(this.state.config, this.state, this.bridge);
|
|
637
|
-
await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
|
|
638
|
-
const resourceProcess = this.state.resource_process;
|
|
639
|
-
if (resourceProcess?.id && resourceProcess.lease_token) {
|
|
640
|
-
try { await finishDaemonProcess(this.state.config, resourceProcess, "stopped", "daemon_stopped"); }
|
|
641
|
-
catch { /* Keep the state; the next reconciler owns stale processes. */ }
|
|
642
|
-
}
|
|
643
|
-
await new Promise((resolve) => this.server.close(resolve));
|
|
644
|
-
await removeSocket(this.paths.socket, this.paths.dir);
|
|
645
|
-
}
|
|
646
647
|
}
|
|
647
648
|
|
|
648
649
|
export function zcodeBridgeEnv(config, daemonId) {
|
|
@@ -660,27 +661,31 @@ export async function serveDaemon(stateDir) {
|
|
|
660
661
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
661
662
|
await removeSocket(paths.socket, paths.dir);
|
|
662
663
|
const server = net.createServer((connection) => {
|
|
663
|
-
connection.setEncoding("utf8"); let buffer = "";
|
|
664
|
+
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
664
665
|
connection.on("data", (chunk) => {
|
|
665
|
-
|
|
666
|
+
if (consumed) return;
|
|
667
|
+
buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; }
|
|
666
668
|
const newline = buffer.indexOf("\n");
|
|
667
669
|
if (newline < 0) return;
|
|
670
|
+
consumed = true;
|
|
668
671
|
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
669
672
|
void (async () => {
|
|
670
673
|
let request;
|
|
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 ?? {}));
|
|
678
|
+
if (result?._shutdown) retireDaemonEndpoint(server);
|
|
675
679
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
|
|
676
|
-
if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
|
|
677
680
|
} catch (error) {
|
|
681
|
+
await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
|
|
678
682
|
connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
|
|
679
683
|
}
|
|
680
684
|
})();
|
|
681
685
|
});
|
|
682
686
|
});
|
|
683
687
|
runtime.server = server;
|
|
688
|
+
trackDaemonConnections(server);
|
|
684
689
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); });
|
|
685
690
|
await chmod(paths.socket, 0o600);
|
|
686
691
|
await runtime.persist({ shutdown_state: "running", active_tree: false });
|