@deksden-com/dd-flow-cli 0.9.0-beta.140 → 0.9.0-beta.142
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 +14 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/command-inputs.js +1 -1
- package/dist/cli/command-option-descriptions.js +1 -0
- package/dist/cli/help.js +1 -0
- package/dist/cli/run-cli.js +9 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +7 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +3 -3
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +13 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +64 -12
- package/dist/harness-runtime/lib/dd-opencode.mjs +12 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/managed-daemon.d.mts +3 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +36 -7
- package/dist/harness-runtime/lib/operation-errors.d.mts +2 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +11 -0
- package/dist/services/check-runner-evidence.js +102 -20
- package/dist/services/code-checks.js +69 -33
- package/dist/services/runtime-budget.js +16 -0
- package/dist/services/stage-lifecycle.js +57 -13
- package/dist/services/tooling.js +14 -12
- package/dist/services/verification-matrix.js +3 -2
- package/dist/services/vnext-plan.js +1 -2
- package/dist/services/vnext-protocolize.js +121 -10
- package/dist/services/worktrees.js +8 -5
- package/dist/shared/errors.js +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.142
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Wait for the owned OpenCode v2 listener through typed SDK transport startup failures and expose bounded read-only scope inventory batches for validated retirement-proof reuse.
|
|
8
|
+
- Retry no-effect physical confirmation on the same held check, bootstrap or service process before dispatch, without replaying commands or extending exhausted authority.
|
|
9
|
+
|
|
10
|
+
## 0.9.0-beta.141
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 49dbfa3: Retain precise check and startup failures, validate bound browser inactivity proof,
|
|
15
|
+
and observe test progress without losing diagnostics or rollback guarantees.
|
|
16
|
+
|
|
3
17
|
## 0.9.0-beta.140
|
|
4
18
|
|
|
5
19
|
- Compare observable birth after EPERM so a foreign reused PID is not mistaken for the retained owner; unknown identity remains conservative.
|
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-10-
|
|
3
|
+
"cli_version": "0.9.0-beta.142",
|
|
4
|
+
"cli_commit": "a5b068e5cc52809ac0ea0687e40a49f9379bb198",
|
|
5
|
+
"built_at": "2026-10-18T10:20:25.955Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
8
|
"commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
|
|
@@ -156,7 +156,7 @@ export const commandInputs = {
|
|
|
156
156
|
"run control pause": route(0, `${control} request-id`, "grace-ms wait-ms escalate", "force"),
|
|
157
157
|
"run control stop": route(0, `${control} request-id`, "grace-ms wait-ms escalate", "force"),
|
|
158
158
|
"runtime scope fence": route(0, "scope-id request-id"),
|
|
159
|
-
"runtime scope status": route(0, "scope-id"),
|
|
159
|
+
"runtime scope status": route(0, "", "scope-id scope-ids-json"),
|
|
160
160
|
"runtime scope stop": route(0, "scope-id request-id"),
|
|
161
161
|
"runtime scope control": route(0, "scope-id request-id mode", "manifest-json", "", "", { mode: ["pause", "stop"] }),
|
|
162
162
|
"runtime scope serve": route(0, "scope-id generation"),
|
|
@@ -143,6 +143,7 @@ export const runtimeOptionDescriptions = {
|
|
|
143
143
|
schema: "Registered JSON schema name against which the supplied file is validated.",
|
|
144
144
|
"schema-dir": "Explicit schema directory for validation; not a workflow handler registry or permission to execute code.",
|
|
145
145
|
"scope-id": "Exact retained runtime/EVAL scope identity; operations must not affect neighboring scopes.",
|
|
146
|
+
"scope-ids-json": "Read-only JSON array of 1..16 unique retained scope IDs; returns ordered fresh inventories without fencing or stopping them.",
|
|
146
147
|
"semantic-file": "Stage semantic result artifact; applicable schema and finish checks are selected independently.",
|
|
147
148
|
session: "Physical Session selector for source-backed usage aggregation.",
|
|
148
149
|
"session-id": "Exact retained physical Session identity for registration, status, stop or usage synchronization.",
|
package/dist/cli/help.js
CHANGED
|
@@ -169,6 +169,7 @@ never registers a project, selects a model automatically or creates a runtime DB
|
|
|
169
169
|
["runtime scope", `Usage:
|
|
170
170
|
dd-flow runtime scope fence --scope-id EVAL_ID --request-id ID --json
|
|
171
171
|
dd-flow runtime scope status --scope-id EVAL_ID --json
|
|
172
|
+
dd-flow runtime scope status --scope-ids-json '["EVAL_ID","QUAL_ID"]' --json
|
|
172
173
|
dd-flow runtime scope stop --scope-id EVAL_ID --request-id ID --json
|
|
173
174
|
dd-flow runtime scope control --scope-id EVAL_ID --request-id ID --mode pause|stop [--manifest-json JSON] --json
|
|
174
175
|
dd-flow runtime scope resume --scope-id EVAL_ID --request-id ID --generation N --capture-key SHA256 --json
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -92,7 +92,7 @@ import { prepareCommandRoute } from "./command-inputs.js";
|
|
|
92
92
|
import { parseLifecycleCommand } from "../services/lifecycle-command.js";
|
|
93
93
|
import { dispatchRuntimeProcess, prepareRuntimeProcess } from "./runtime-process.js";
|
|
94
94
|
import { reconcileFlowRunHealth } from "../services/run-health.js";
|
|
95
|
-
import { fenceRuntimeBudget, runtimeBudgetStatus, prepareRuntimeScopeControl, prepareRuntimeScopeFence } from "../services/runtime-budget.js";
|
|
95
|
+
import { fenceRuntimeBudget, runtimeBudgetStatus, prepareRuntimeScopeStatus, prepareRuntimeScopeControl, prepareRuntimeScopeFence } from "../services/runtime-budget.js";
|
|
96
96
|
import { stopRuntimeScope } from "../services/runtime-scope-stop.js";
|
|
97
97
|
import { launchRuntimeScopeControl, launchRuntimeScopeResume, prepareRuntimeScopeReconciliation, prepareRuntimeScopeServing, reconcileRuntimeScopeControl, serveRuntimeScopeControl } from "../services/runtime-scope-worker.js";
|
|
98
98
|
import { prepareRuntimeScopeResumeRequest } from "../services/runtime-scope-resume.js";
|
|
@@ -1017,6 +1017,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
|
|
|
1017
1017
|
return prepareError(error, { parameter: "from/output" });
|
|
1018
1018
|
}
|
|
1019
1019
|
}
|
|
1020
|
+
if (route.key === "runtime scope status")
|
|
1021
|
+
prepareRuntimeScopeStatus(optionalOption(parsed, "scope-id"), optionalOption(parsed, "scope-ids-json"));
|
|
1020
1022
|
if (["runtime scope fence", "runtime scope stop"].includes(route.key))
|
|
1021
1023
|
prepareRuntimeScopeFence(requiredOption(parsed, "scope-id"), requiredOption(parsed, "request-id"));
|
|
1022
1024
|
if (route.key === "runtime scope resume")
|
|
@@ -2778,8 +2780,12 @@ async function dispatchRuntime(context, command, parsed, progress, prepared = {}
|
|
|
2778
2780
|
return { ok: true, ...await stopRuntimeScope(context, requiredOption(parsed, "scope-id"), requiredOption(parsed, "request-id")) };
|
|
2779
2781
|
}
|
|
2780
2782
|
if (parsed.positional.length === 1 && parsed.positional[0] === "status") {
|
|
2781
|
-
if ([...parsed.options.keys()].some(key =>
|
|
2782
|
-
throw new AppError("usage", "
|
|
2783
|
+
if ([...parsed.options.keys()].some(key => !["scope-id", "scope-ids-json"].includes(key)) || parsed.options.size !== 1 || [...parsed.options.values()].some(values => values.length !== 1))
|
|
2784
|
+
throw new AppError("usage", "Use exactly one of --scope-id or --scope-ids-json", 2);
|
|
2785
|
+
const ids = prepareRuntimeScopeStatus(optionalOption(parsed, "scope-id"), optionalOption(parsed, "scope-ids-json"));
|
|
2786
|
+
if (parsed.options.has("scope-ids-json")) {
|
|
2787
|
+
return { ok: true, scopes: ids.map(id => runtimeBudgetStatus(context, id)) };
|
|
2788
|
+
}
|
|
2783
2789
|
return { ok: true, ...runtimeBudgetStatus(context, requiredOption(parsed, "scope-id")) };
|
|
2784
2790
|
}
|
|
2785
2791
|
if (parsed.positional.length !== 1 || parsed.positional[0] !== "fence" || [...parsed.options.keys()].some(key => !["scope-id", "request-id"].includes(key)) || [...parsed.options.values()].some(values => values.length !== 1))
|
|
@@ -21,7 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import { observeModel } from "./model-observations.mjs";
|
|
22
22
|
import { AGY_VERSION_TIMEOUT_MS, AgyError, agyTerminalFailure, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
|
|
23
23
|
import { settledNativeTree } from "./native-children.mjs";
|
|
24
|
-
import { prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
24
|
+
import { retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
25
25
|
import { readNativeLines, streamNativeDiagnostics, nativeObservationError, admitNativeRequestBuffer } from "./native-streams.mjs";
|
|
26
26
|
|
|
27
27
|
const REQUEST_SCHEMA = "dd-agy/daemon-request@1", RESPONSE_SCHEMA = "dd-agy/daemon-response@1", STATE_SCHEMA = "dd-agy/daemon-state@1";
|
|
@@ -677,8 +677,12 @@ export async function serveDaemon(stateDir) {
|
|
|
677
677
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
|
|
678
678
|
try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
|
|
679
679
|
catch (error) {
|
|
680
|
-
await runtime.abortProvider("daemon_start_failed");
|
|
681
|
-
|
|
680
|
+
try { await runtime.abortProvider("daemon_start_failed"); }
|
|
681
|
+
catch (cleanup) { error.details = { ...error.details, cleanup_failed: errorRecord(cleanup) }; }
|
|
682
|
+
try { if (!error.details?.cleanup_failed) await runtime.finishResource("failed", "daemon_start_failed"); }
|
|
683
|
+
catch (cleanup) { error.details = { ...error.details, resource_cleanup_failed: errorRecord(cleanup) }; }
|
|
684
|
+
Object.assign(state, { shutdown_state: error.details?.cleanup_failed || error.details?.resource_cleanup_failed ? "cleanup_failed" : "failed", active_tree: Boolean(error.details?.cleanup_failed) });
|
|
685
|
+
await retainStartupFailure({ state, publish: snapshot => runtime.persist(snapshot), log: paths.log, error });
|
|
682
686
|
await new Promise(resolve => server.close(resolve));
|
|
683
687
|
throw error;
|
|
684
688
|
}
|
|
@@ -15,7 +15,7 @@ import { spawn } from "node:child_process";
|
|
|
15
15
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
16
16
|
import { errorRecord } from "./operation-errors.mjs";
|
|
17
17
|
import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
18
|
-
import { assertDaemonOwnership, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
18
|
+
import { assertDaemonOwnership, assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
19
19
|
|
|
20
20
|
export function codexBridgeEnv(config, daemonId) { return { ...config.env, ...managedFlowEnv(config, daemonId) }; }
|
|
21
21
|
|
|
@@ -94,14 +94,14 @@ export async function startDaemon(options) {
|
|
|
94
94
|
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
95
95
|
await removeSocket(paths.socket); const resourceProcess = await registerDaemonProcess(config, { kind: "codex-daemon", owner: `codex:${path.basename(paths.dir)}`, operation: `codex-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log }); const state = { schema_id: stateSchema, daemon_id: randomUUID(), pid: null, cwd: config.cwd, journal: config.journal, config, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", sessions: previous?.sessions ?? [], resource_process: resourceProcess }; await writeState(paths.state, state);
|
|
96
96
|
const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [options.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
97
|
-
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed", details: observationDetails(startupClock, "daemon-start", child.pid) }); }
|
|
97
|
+
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) { assertStartupChild(child, state, await readState(paths.state), paths.log); await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed", details: observationDetails(startupClock, "daemon-start", child.pid) }); }
|
|
98
98
|
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.state), state.daemon_id); throw error; }
|
|
99
99
|
}
|
|
100
100
|
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(locations(options.stateDir).state) }); }
|
|
101
101
|
|
|
102
102
|
export async function serveDaemon(stateDir) {
|
|
103
103
|
const paths = locations(stateDir); const state = await readState(paths.state); if (!state || state.schema_id !== stateSchema) throw Object.assign(new Error("daemon state missing"), { code: "daemon_state_missing" });
|
|
104
|
-
const bridge = new CodexBridge({ ...state.config, env: { ...codexBridgeEnv(state.config, state.daemon_id), CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot) }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
104
|
+
const bridge = new CodexBridge({ ...state.config, env: { ...codexBridgeEnv(state.config, state.daemon_id), CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
105
105
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
106
106
|
const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.catch(() => {}).then(() => writeState(paths.state, snapshot)); return persist; };
|
|
107
107
|
const dispatch = async (operation, params, operationId) => {
|
|
@@ -12,7 +12,7 @@ import { durableDaemonDispatch, inspectDaemonOperation, settleDaemonOperation, c
|
|
|
12
12
|
import { ObservationClock, validateObservationDuration } from "./observation-clock.mjs";
|
|
13
13
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
14
14
|
import { errorRecord } from "./operation-errors.mjs";
|
|
15
|
-
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess } from "./managed-daemon.mjs";
|
|
15
|
+
import { retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess } from "./managed-daemon.mjs";
|
|
16
16
|
|
|
17
17
|
const REQUEST = "dd-droid/daemon-request@1", RESPONSE = "dd-droid/daemon-response@1";
|
|
18
18
|
async function removeSocket(file) { try { if (!(await lstat(file)).isSocket()) throw new DroidError("unsafe_socket", "Refusing to replace non-socket daemon path"); await unlink(file); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
@@ -123,7 +123,18 @@ export async function serveDaemon(stateDir) {
|
|
|
123
123
|
}); });
|
|
124
124
|
trackDaemonConnections(server);
|
|
125
125
|
try { await runtime.start(); scheduleDaemonProcessHeartbeat(state.config, state.resource_process); await save({ shutdown_state: "running" }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); }
|
|
126
|
-
catch (error) {
|
|
126
|
+
catch (error) {
|
|
127
|
+
let cleanup, cleanupError;
|
|
128
|
+
try { cleanup = await runtime.closeTree(true); }
|
|
129
|
+
catch (failure) { cleanupError = errorRecord(failure); error.details = { ...error.details, cleanup_failed: cleanupError }; }
|
|
130
|
+
try { await removeDroidAuth(state.config); }
|
|
131
|
+
catch (failure) { error.details = { ...error.details, auth_cleanup_failed: errorRecord(failure) }; }
|
|
132
|
+
if (!cleanupError) try { await finishDaemonProcess(state.config, state.resource_process, "failed", "startup_failed"); }
|
|
133
|
+
catch (failure) { error.details = { ...error.details, resource_cleanup_failed: errorRecord(failure) }; }
|
|
134
|
+
Object.assign(state, { shutdown_state: cleanupError || error.details?.resource_cleanup_failed || error.details?.auth_cleanup_failed ? "cleanup_failed" : "failed", ...(cleanupError ? { cleanup_error: cleanupError } : { cleanup }) });
|
|
135
|
+
await retainStartupFailure({ state, publish: save, log: paths.log, error });
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
127
138
|
await new Promise(resolve => server.once("close", resolve));
|
|
128
139
|
}
|
|
129
140
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -161,7 +161,7 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
|
|
|
161
161
|
|
|
162
162
|
function sameConfig(state, config) { return ["cwd", "journal", "bin", "authSource", "model", "reasoning", "mode", "ddFlowBin", "ddFlowHome", "projectRoot", "sessionArchive", "resourceHome", "noFlow"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null)); }
|
|
163
163
|
export function grokStartupFailure(state) {
|
|
164
|
-
if (state?.shutdown_state
|
|
164
|
+
if (!["failed", "cleanup_failed"].includes(state?.shutdown_state) || !state.startup_error) return null;
|
|
165
165
|
const failure = state.startup_error;
|
|
166
166
|
return new DaemonError(failure.code ?? "daemon_start_failed", failure.message ?? "Grok daemon failed before becoming ready", failure.retryable === true, failure.details);
|
|
167
167
|
}
|
|
@@ -462,7 +462,7 @@ export async function serveDaemon(stateDir) {
|
|
|
462
462
|
const session = runtime.sessions.get(sessionId);
|
|
463
463
|
const rootProviderSessionId = session?.root_provider_session_id ?? sessionId;
|
|
464
464
|
await observeAcpToolCall(state.config, { daemonId: state.daemon_id, rootProviderSessionId, parentProviderSessionId: session?.parent_provider_session_id ?? null, observedProfile: bridge.modelProfiles?.get(sessionId) ?? {} }, message);
|
|
465
|
-
} });
|
|
465
|
+
} }); const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "grok-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log }); runtime = new Runtime(paths, state, bridge, initialized);
|
|
466
466
|
for (const fact of await readModelObservations(state.config.journal)) {
|
|
467
467
|
if (fact.harness === "grok-acp" && fact.source?.channel === "x.ai/subagent_spawned" && runtime.sessions.has(fact.session_id)
|
|
468
468
|
&& (runtime.descendants.get(fact.session_id)?.attempt_id ?? null) === (fact.source.attempt_id ?? null) && fact.observed?.model) {
|
|
@@ -21,7 +21,7 @@ import { writeJsonAtomic } from "./runner-events.mjs";
|
|
|
21
21
|
import { OpenCodeClient, OpenCodeError, historyDigest, usageSnapshot, nativeModel } from "./dd-opencode.mjs";
|
|
22
22
|
import { safeProviderError } from "./provider-overload-policy.mjs";
|
|
23
23
|
import { acceptNativeHookRequest, hookBudget, readNativeHookFailures } from "./native-hook-command.mjs";
|
|
24
|
-
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
24
|
+
import { assertStartupChild, retainStartupFailure, prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
25
25
|
|
|
26
26
|
const REQUEST_SCHEMA = "dd-opencode/daemon-request@1", RESPONSE_SCHEMA = "dd-opencode/daemon-response@1", STATE_SCHEMA = "dd-opencode/daemon-state@2", ARCHIVE_SCHEMA = "dd-opencode/session-archive@2";
|
|
27
27
|
class DaemonError extends Error { constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; } }
|
|
@@ -114,7 +114,7 @@ async function bootstrapCredentials(config, client) {
|
|
|
114
114
|
if ((entry || legacy) && !selected) throw new DaemonError("opencode_credentials_unproven", "Selected portable credential is not active in the private server");
|
|
115
115
|
return selected ? "active_private" : "absent";
|
|
116
116
|
}
|
|
117
|
-
async function importArchive(config, client) {
|
|
117
|
+
async function importArchive(config, client, onProgress = async () => {}) {
|
|
118
118
|
if (!config.sessionArchive) return null;
|
|
119
119
|
const manifest = JSON.parse(await readFile(config.sessionArchive, "utf8"));
|
|
120
120
|
if (manifest.schema_id !== ARCHIVE_SCHEMA || !manifest.payload || !/^2\./.test(manifest.opencode_version)) throw new DaemonError("invalid_session_archive", "Use the archived v1 engine for legacy OpenCode archives");
|
|
@@ -133,6 +133,7 @@ async function importArchive(config, client) {
|
|
|
133
133
|
if (info?.id !== transfer.info.id || info.location?.directory !== config.cwd || (info.parentID ?? null) !== (transfer.info.parentID ?? null))
|
|
134
134
|
throw new DaemonError("session_import_unproven", "OpenCode import did not preserve native identity/location");
|
|
135
135
|
records.push({ provider_session_id: info.id, parent_provider_session_id: info.id === root ? null : info.parentID, root_provider_session_id: root, cwd: config.cwd });
|
|
136
|
+
await onProgress(info.id);
|
|
136
137
|
}
|
|
137
138
|
return records;
|
|
138
139
|
}
|
|
@@ -169,10 +170,10 @@ export async function startDaemon(options) {
|
|
|
169
170
|
const state = { schema_id: STATE_SCHEMA, native_protocol: 2, daemon_id: daemonId, shutdown_state: "starting", active_tree: false, auth_status: config.authStatus, versions: { opencode: config.version }, config, sessions: previous?.sessions ?? [], operations: previous?.operations ?? {}, resource_process: resourceProcess }; await writeJson(paths.state, state);
|
|
170
171
|
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();
|
|
171
172
|
try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id); while (!startupClock.sample(await startupProgress())) {
|
|
172
|
-
|
|
173
|
+
assertStartupChild(child, state, await readState(paths.dir), paths.log);
|
|
173
174
|
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; }
|
|
174
175
|
} throw Object.assign(new DaemonError("daemon_start_failed", "daemon did not become ready", true), { details: observationDetails(startupClock, 'daemon-start', child.pid) }); }
|
|
175
|
-
catch (error) { await
|
|
176
|
+
catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), daemonId); throw error; }
|
|
176
177
|
}
|
|
177
178
|
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) }); };
|
|
178
179
|
|
|
@@ -358,9 +359,9 @@ export class Runtime {
|
|
|
358
359
|
}
|
|
359
360
|
if (operation === "daemon.doctor") {
|
|
360
361
|
const info = await this.client.health();
|
|
362
|
+
if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
|
|
361
363
|
await this.client.call("model.list", { location: { directory: this.state.config.cwd } });
|
|
362
364
|
const plugins = await this.client.call("plugin.list", { location: { directory: this.state.config.cwd } });
|
|
363
|
-
if (info.pid !== this.child?.pid || info.version !== this.state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode endpoint is not the owned process");
|
|
364
365
|
const pluginID = this.state.config.noFlow ? "dd-flow-security" : "dd-flow";
|
|
365
366
|
if (!plugins.data.some(plugin => plugin.id === pluginID && plugin.state?.status === "active")) throw new DaemonError("opencode_plugin_missing", "Owned v2 plugin is not active");
|
|
366
367
|
return { compatible: true, native_protocol: 2, observed_runtime: this.state.versions, productive_hooks: !this.state.config.noFlow, versions: this.state.versions, plugin_sha256: this.state.config.pluginSha256, auth_status: this.state.auth_status };
|
|
@@ -459,6 +460,56 @@ export class Runtime {
|
|
|
459
460
|
throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
|
|
460
461
|
}
|
|
461
462
|
}
|
|
463
|
+
|
|
464
|
+
/** Read-only readiness probes share the startup quiet window. A repeated
|
|
465
|
+
* ServiceUnavailable response is not activation progress. */
|
|
466
|
+
export async function awaitOpenCodeStartup(client, state, child, { timeoutMs = SETUP_STARTUP_MS, publish = async () => {} } = {}) {
|
|
467
|
+
const clock = new ObservationClock({ timeoutMs });
|
|
468
|
+
let phase = "server", attempt = 0; const observedPhases = new Set();
|
|
469
|
+
const mark = async value => {
|
|
470
|
+
if (state.startup_phase === value) return;
|
|
471
|
+
state.startup_phase = value; await publish(state);
|
|
472
|
+
if (!observedPhases.has(value)) { observedPhases.add(value); clock.sample(value); }
|
|
473
|
+
};
|
|
474
|
+
const remaining = () => {
|
|
475
|
+
if (clock.sample()) throw new DaemonError("daemon_start_failed", "OpenCode startup made no further progress", false, observationDetails(clock, "native-readiness", child?.pid));
|
|
476
|
+
if (child && (child.exitCode !== null || child.signalCode !== null)) throw new DaemonError("opencode_unavailable", "Owned OpenCode process exited during startup", false, { exit_code: child.exitCode, signal: child.signalCode });
|
|
477
|
+
return Math.max(1, Math.ceil(clock.timeoutMs - clock.elapsed));
|
|
478
|
+
};
|
|
479
|
+
client.readObservation = () => ({ timeoutMs: remaining() });
|
|
480
|
+
try {
|
|
481
|
+
while (true) {
|
|
482
|
+
remaining();
|
|
483
|
+
try {
|
|
484
|
+
if (phase === "server") {
|
|
485
|
+
const identity = await client.health({ timeoutMs: remaining() });
|
|
486
|
+
if (identity.pid !== child?.pid || identity.version !== state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode startup returned another server");
|
|
487
|
+
await mark("owned_server_ready"); phase = "location";
|
|
488
|
+
}
|
|
489
|
+
if (phase === "location") {
|
|
490
|
+
await client.call("model.list", { location: { directory: state.config.cwd } });
|
|
491
|
+
await mark("location_models_ready"); phase = "plugin";
|
|
492
|
+
}
|
|
493
|
+
const plugins = await client.call("plugin.list", { location: { directory: state.config.cwd } });
|
|
494
|
+
if (!Array.isArray(plugins?.data)) throw new DaemonError("opencode_configuration_invalid", "OpenCode returned invalid plugin readiness");
|
|
495
|
+
const id = state.config.noFlow ? "dd-flow-security" : "dd-flow", plugin = plugins.data.find(value => value.id === id);
|
|
496
|
+
if (["failed", "error", "missing", "disabled"].includes(plugin?.state?.status)) throw new DaemonError("opencode_plugin_failed", "Required owned plugin failed activation", false, { plugin_id: id, status: plugin.state.status });
|
|
497
|
+
if (plugin?.state?.status === "active") { await mark("required_plugin_active"); return { remaining, clock }; }
|
|
498
|
+
// A status transition of this required plugin is real new evidence.
|
|
499
|
+
if (["loading", "activating", "registered"].includes(plugin?.state?.status)) await mark(`required_plugin_${plugin.state.status}`);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
const transient = error.code === "opencode_unavailable" && error.retryable === true
|
|
502
|
+
// Safe pre-identity reads use the normalized observation category,
|
|
503
|
+
// not a version-specific SDK error wrapper. No productive RPC here.
|
|
504
|
+
&& (phase === "server" || error.details?.native_type === "ServiceUnavailableError");
|
|
505
|
+
if (!transient) throw error;
|
|
506
|
+
}
|
|
507
|
+
const pause = Math.min([250, 500, 1000, 2000][Math.min(attempt++, 3)], remaining());
|
|
508
|
+
await new Promise(resolve => setTimeout(resolve, pause));
|
|
509
|
+
}
|
|
510
|
+
} catch (error) { delete client.readObservation; throw error; }
|
|
511
|
+
}
|
|
512
|
+
|
|
462
513
|
export async function serveDaemon(stateDir) {
|
|
463
514
|
const paths = locations(stateDir), state = await readState(paths.dir); if (state?.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing");
|
|
464
515
|
let child, providerProcess;
|
|
@@ -470,13 +521,14 @@ export async function serveDaemon(stateDir) {
|
|
|
470
521
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
471
522
|
scheduleDaemonProcessHeartbeat(state.config, providerProcess);
|
|
472
523
|
state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
|
|
473
|
-
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd });
|
|
474
|
-
const
|
|
475
|
-
if (identity.pid !== child.pid || identity.version !== state.versions.opencode) throw new DaemonError("foreign_server", "OpenCode startup returned another server");
|
|
524
|
+
const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd });
|
|
525
|
+
const startup = await awaitOpenCodeStartup(client, state, child, { publish: snapshot => writeJson(paths.state, snapshot) });
|
|
476
526
|
state.auth_status = await bootstrapCredentials(state.config, client);
|
|
477
|
-
|
|
527
|
+
state.startup_phase = "credentials_ready"; await writeJson(paths.state, state); startup.clock.sample(state.startup_phase);
|
|
528
|
+
const imported = await importArchive(state.config, client, async id => { state.startup_phase = `session_imported:${id}`; await writeJson(paths.state, state); startup.clock.sample(state.startup_phase); }); if (imported) state.sessions.push(...imported);
|
|
529
|
+
startup.remaining(); delete client.readObservation;
|
|
478
530
|
await chmod(state.config.database, 0o600);
|
|
479
|
-
const runtime = new Runtime(paths, state, client, child, providerProcess); await
|
|
531
|
+
const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
|
|
480
532
|
const server = net.createServer(connection => {
|
|
481
533
|
connection.setEncoding("utf8"); let buffer = "", consumed = false;
|
|
482
534
|
connection.on("data", chunk => {
|
|
@@ -499,8 +551,8 @@ export async function serveDaemon(stateDir) {
|
|
|
499
551
|
trackDaemonConnections(server); runtime.server = server;
|
|
500
552
|
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await runtime.persist({ shutdown_state: "running", active_tree: false, server_port: port }); return await new Promise(() => {});
|
|
501
553
|
} catch (error) {
|
|
502
|
-
await
|
|
503
|
-
await
|
|
554
|
+
await cleanupFailedStart(state.config, providerProcess, child, error);
|
|
555
|
+
await retainStartupFailure({ state, publish: snapshot => writeJson(paths.state, snapshot), log: paths.log, error }); throw error;
|
|
504
556
|
}
|
|
505
557
|
}
|
|
506
558
|
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -51,7 +51,7 @@ export class OpenCodeClient {
|
|
|
51
51
|
async call(method, input, { timeoutMs, progress, onDispatched, productive = false, signal } = {}) {
|
|
52
52
|
const observation = this.readObservation?.();
|
|
53
53
|
timeoutMs ??= observation?.timeoutMs ?? this.timeoutMs; progress ??= observation?.progress;
|
|
54
|
-
const
|
|
54
|
+
const controller = new AbortController();
|
|
55
55
|
let timer;
|
|
56
56
|
const abort = () => { controller.abort(); clearInterval(timer); };
|
|
57
57
|
signal?.addEventListener("abort", abort, { once: true });
|
|
@@ -59,6 +59,13 @@ export class OpenCodeClient {
|
|
|
59
59
|
timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
|
|
60
60
|
if (signal?.aborted) clearInterval(timer);
|
|
61
61
|
try {
|
|
62
|
+
// Cover initialization as well as RPC. Abort still reaches the owned
|
|
63
|
+
// request; an unresolved module import cannot start a late RPC.
|
|
64
|
+
let rejectAbort;
|
|
65
|
+
const aborted = new Promise((_, reject) => { rejectAbort = () => reject(Object.assign(new Error("OpenCode probe aborted"), { name: "AbortError" })); controller.signal.addEventListener("abort", rejectAbort, { once: true }); });
|
|
66
|
+
let api;
|
|
67
|
+
try { if (controller.signal.aborted) rejectAbort(); api = await Promise.race([this.ready, aborted]); }
|
|
68
|
+
finally { controller.signal.removeEventListener("abort", rejectAbort); }
|
|
62
69
|
const keys = method.split("."), name = keys.pop(), domain = keys.reduce((value, key) => value?.[key], api);
|
|
63
70
|
if (typeof domain?.[name] !== "function") throw new OpenCodeError("opencode_capability_missing", `OpenCode v2 does not expose ${method}`);
|
|
64
71
|
const options = { signal: controller.signal };
|
|
@@ -70,13 +77,15 @@ export class OpenCodeClient {
|
|
|
70
77
|
if (cause._tag === "SessionNotFoundError" || cause._tag === "NotFoundError") throw new OpenCodeError("session_not_found", "OpenCode native Session is unavailable");
|
|
71
78
|
if (productive && ["InvalidRequestError", "ConflictError", "UnauthorizedError", "ForbiddenError"].includes(cause._tag))
|
|
72
79
|
throw new OpenCodeError("opencode_admission_rejected", "OpenCode rejected native admission", false, { effect: "no_effect", native_type: cause._tag });
|
|
80
|
+
if (!productive && ["UnauthorizedError", "ForbiddenError"].includes(cause._tag)) throw new OpenCodeError("opencode_auth_rejected", "OpenCode rejected authentication", false, { method, native_type: cause._tag });
|
|
81
|
+
if (!productive && ["InvalidRequestError", "ConflictError"].includes(cause._tag)) throw new OpenCodeError("opencode_configuration_invalid", "OpenCode rejected the diagnostic request", false, { method, native_type: cause._tag });
|
|
73
82
|
if (productive || controller.signal.aborted) throw Object.assign(lost(`OpenCode observer lost during ${method}`, { method }), { cause });
|
|
74
83
|
throw Object.assign(new OpenCodeError("opencode_unavailable", `OpenCode ${method} observation failed`, true,
|
|
75
84
|
{ method, native_type: cause._tag ?? cause.name ?? null, native_reason: cause.reason ?? null }), { cause });
|
|
76
85
|
} finally { clearInterval(timer); signal?.removeEventListener("abort", abort); }
|
|
77
86
|
}
|
|
78
|
-
async health() {
|
|
79
|
-
const info = await this.call("server.info", undefined, { timeoutMs: 5_000 });
|
|
87
|
+
async health(options = {}) {
|
|
88
|
+
const info = await this.call("server.info", undefined, { timeoutMs: 5_000, ...options });
|
|
80
89
|
if (!/^2\./.test(info?.version) || !Number.isInteger(info.pid)) throw new OpenCodeError("unsupported_version", "OpenCode v2 server identity is unavailable");
|
|
81
90
|
return info;
|
|
82
91
|
}
|
|
@@ -10,7 +10,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
|
|
|
10
10
|
import net from "node:net";
|
|
11
11
|
import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
13
|
+
import { assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
14
14
|
import { acceptNativeHookRequest, hookBudget, readNativeHookFailures } from "./native-hook-command.mjs";
|
|
15
15
|
import { parseLifecycleCommand, continuationCommand } from "./lifecycle-command.mjs";
|
|
16
16
|
import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
|
|
@@ -186,6 +186,7 @@ export async function startDaemon(options) {
|
|
|
186
186
|
await confirmDaemonProcess(config, resourceProcess, child);
|
|
187
187
|
const startupClock = new ObservationClock({ timeoutMs: SETUP_STARTUP_MS }); const startupProgress = startupObserver(paths.dir, state.daemon_id);
|
|
188
188
|
while (!startupClock.sample(await startupProgress())) {
|
|
189
|
+
assertStartupChild(child, state, await readState(paths.dir), paths.log);
|
|
189
190
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
190
191
|
try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
|
|
191
192
|
catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
|
|
@@ -704,7 +705,7 @@ export async function serveDaemon(stateDir) {
|
|
|
704
705
|
const state = await readState(paths.dir);
|
|
705
706
|
if (!state || state.schema_id !== STATE_SCHEMA) throw new DaemonError("daemon_state_missing", "daemon state is missing or incompatible");
|
|
706
707
|
const bridge = new AcpBridge({ ...state.config, env: zcodeBridgeEnv(state.config, state.daemon_id) });
|
|
707
|
-
const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "zcode-provider", publish: snapshot => writeState(paths.state, snapshot) });
|
|
708
|
+
const initialized = await startManagedBridge({ config: state.config, state, bridge, kind: "zcode-provider", publish: snapshot => writeState(paths.state, snapshot), log: paths.log });
|
|
708
709
|
const runtime = new DaemonRuntime(paths, state, bridge, initialized);
|
|
709
710
|
scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
|
|
710
711
|
await removeSocket(paths.socket, paths.dir);
|
|
@@ -11,3 +11,6 @@ export function finishDaemonProcess(config: RuntimeConfig, record: ProcessBindin
|
|
|
11
11
|
export function cleanupFailedDaemonStart(config: RuntimeConfig, record: ProcessBinding | null, child: ChildProcess | null, original: Error & { details?: Record<string, unknown> }, readState: () => Promise<Record<string, unknown>>, daemonId: string): Promise<void>;
|
|
12
12
|
export function completeBudgetOperation(stateDir: string, request: { id: string; operation: string; params?: { sessionId?: string } }, result: unknown, observe?: () => Promise<Record<string, unknown>>): Promise<Record<string, unknown>>;
|
|
13
13
|
export function stopProcessGroup(child: ChildProcess | null | undefined, graceMs?: number, options?: { detached?: boolean; settlementMs?: number }): Promise<void>;
|
|
14
|
+
export function assertStartupChild(child: Pick<ChildProcess, "pid" | "exitCode" | "signalCode">, expected: Record<string, unknown>, observed: Record<string, unknown> | null, log: string): void;
|
|
15
|
+
export function retainStartupFailure(input: { state: Record<string, unknown>; publish: (state: Record<string, unknown>) => Promise<unknown>; log: string; error: Error & { code?: string; details?: Record<string, unknown> } }): Promise<void>;
|
|
16
|
+
export function startManagedBridge(input: { config: RuntimeConfig; state: Record<string, unknown>; bridge: { child: ChildProcess | null; options: { onSpawn?: (child: ChildProcess) => Promise<void> }; start: () => Promise<unknown> }; kind: string; publish: (state: Record<string, unknown>) => Promise<unknown>; log?: string }): Promise<unknown>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { lstat, readFile, readdir, mkdir } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, readdir, mkdir, appendFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { commandJson } from "./process-json.mjs";
|
|
5
|
-
import { errorRecord } from "./operation-errors.mjs";
|
|
5
|
+
import { errorRecord, reportedError, diagnosticJson } from "./operation-errors.mjs";
|
|
6
6
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
7
7
|
import { runBoundedCommand } from "./native-hook-command.mjs";
|
|
8
8
|
import { createRenewalState, maintenanceRetryable, renewalConfirmed, renewalDelay, renewalFailure, renewalRemaining, RENEWAL_POLICY } from "./lease-renewal.mjs";
|
|
@@ -352,11 +352,12 @@ export async function confirmDaemonProcess(config, record, child) {
|
|
|
352
352
|
|
|
353
353
|
/** Provider bridges use a different process group from their daemon. Publish
|
|
354
354
|
* the exact binding before initialize can start productive provider traffic. */
|
|
355
|
-
export async function startManagedBridge({ config, state, bridge, kind, publish }) {
|
|
356
|
-
|
|
357
|
-
await publish(state);
|
|
358
|
-
const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
355
|
+
export async function startManagedBridge({ config, state, bridge, kind, publish, log }) {
|
|
356
|
+
let provider;
|
|
359
357
|
try {
|
|
358
|
+
state.pid = process.pid;
|
|
359
|
+
await publish(state);
|
|
360
|
+
provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
360
361
|
state.provider_process = provider;
|
|
361
362
|
state.startup_phase = "owner_registered";
|
|
362
363
|
await publish(state);
|
|
@@ -372,7 +373,35 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
|
|
|
372
373
|
await publish(state);
|
|
373
374
|
return initialized;
|
|
374
375
|
}
|
|
375
|
-
catch (error) {
|
|
376
|
+
catch (error) {
|
|
377
|
+
await cleanupFailedStart(config, provider, bridge.child, error);
|
|
378
|
+
state.shutdown_state = error.details?.cleanup_failed ? "cleanup_failed" : "failed";
|
|
379
|
+
await retainStartupFailure({ state, publish, log, error });
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A retained cause belongs to this startup generation, never a previous PID. */
|
|
385
|
+
export function assertStartupChild(child, expected, observed, log) {
|
|
386
|
+
const sameGeneration = observed?.daemon_id === expected.daemon_id && (observed?.generation ?? null) === (expected.generation ?? null)
|
|
387
|
+
&& (observed?.config?.runtime_owner?.owner_id ?? null) === (expected.config?.runtime_owner?.owner_id ?? null)
|
|
388
|
+
&& (observed?.config?.runtime_owner?.operation_id ?? null) === (expected.config?.runtime_owner?.operation_id ?? null);
|
|
389
|
+
if (sameGeneration && observed?.startup_error) throw reportedError(observed.startup_error, "Native startup failed");
|
|
390
|
+
if (child.exitCode !== null || child.signalCode !== null) throw Object.assign(new Error("Native daemon exited before readiness"), {
|
|
391
|
+
code: "daemon_start_failed", retryable: false, details: { daemon_id: expected.daemon_id,
|
|
392
|
+
pid: child.pid ?? null, exit_code: child.exitCode, signal: child.signalCode, daemon_log: log }
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function retainStartupFailure({ state, publish, log, error }) {
|
|
397
|
+
state.startup_error = JSON.parse(diagnosticJson(errorRecord(error)));
|
|
398
|
+
try { await publish(state); }
|
|
399
|
+
catch (publication) {
|
|
400
|
+
error.details = { ...error.details, startup_publication_error: errorRecord(publication), daemon_log: log };
|
|
401
|
+
// No credentials/config/argv: the log is only a bounded error record fallback.
|
|
402
|
+
try { if (log) await appendFile(log, `${diagnosticJson({ daemon_id: state.daemon_id, startup_error: errorRecord(error) })}\n`, { mode: 0o600 }); }
|
|
403
|
+
catch (logging) { error.details.startup_log_error = errorRecord(logging); }
|
|
404
|
+
}
|
|
376
405
|
}
|
|
377
406
|
|
|
378
407
|
export async function closeManagedBridge(config, state, bridge) {
|
|
@@ -9,6 +9,8 @@ export interface ErrorRecord {
|
|
|
9
9
|
cleanup_error?: ErrorRecord;
|
|
10
10
|
}
|
|
11
11
|
export function errorRecord(error: unknown, depth?: number): ErrorRecord;
|
|
12
|
+
export function redactDiagnostic(value: string): string;
|
|
13
|
+
export function diagnosticJson(value: unknown): string;
|
|
12
14
|
export function isObservationLoss(error: unknown): boolean;
|
|
13
15
|
export function isProvenShellCorrection(error: unknown): boolean;
|
|
14
16
|
export function reportedError(value: Record<string, unknown>, fallback: string): Error & ErrorRecord;
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// An observer losing its response does not establish that a provider Turn failed.
|
|
2
2
|
export const OPERATION_ERROR_CONTRACT_VERSION = "operation-errors@2";
|
|
3
|
+
export function redactDiagnostic(value) {
|
|
4
|
+
return value.replace(/\b(Bearer|Basic)\s+[^\s"']+/gi, "$1 <redacted>")
|
|
5
|
+
.replace(/(^|[^\w-])([\w-]+)(["']?\s*[=:]\s*["']?)([^\s"',;&}]+)/g,
|
|
6
|
+
(whole, prefix, key, separator) => /token|secret|password|api[_-]?key|authorization/i.test(key)
|
|
7
|
+
? `${prefix}${key}${separator}<redacted>` : whole)
|
|
8
|
+
.replace(/(\b[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1<redacted>@");
|
|
9
|
+
}
|
|
10
|
+
export function diagnosticJson(value) {
|
|
11
|
+
return JSON.stringify(value, (key, item) => /token|secret|password|api[_-]?key|authorization/i.test(key)
|
|
12
|
+
? "<redacted>" : typeof item === "string" ? redactDiagnostic(item) : item, 2);
|
|
13
|
+
}
|
|
3
14
|
// Only use after the native delivery/operation owner has been verified.
|
|
4
15
|
export function isProvenShellCorrection(error) {
|
|
5
16
|
const details = error?.details;
|