@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74
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/dist/build-info.json +3 -3
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +15 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
- package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
- package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
- package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
- package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
- package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
- package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
- package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/services/code-checks.js +120 -19
- package/dist/services/controller-fanout.js +11 -0
- package/dist/services/engines.js +40 -9
- package/dist/services/eval-snapshots.js +30 -47
- package/dist/services/harness-adapter.js +12 -6
- package/dist/services/lifecycle-invocations.js +12 -8
- package/dist/services/native-session-control.js +6 -0
- package/dist/services/projects.js +6 -2
- package/dist/services/recovery-observation-budget.js +9 -1
- package/dist/services/run-control-worker.js +19 -0
- package/dist/services/run-control.js +87 -13
- package/dist/services/run-controller-adapter.js +134 -7
- package/dist/services/run-controller.js +11 -3
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/runs.js +1 -11
- package/dist/services/schema-validation.js +1 -1
- package/dist/services/stage-lifecycle.js +14 -2
- package/dist/services/usage.js +20 -87
- package/dist/services/vnext-code-review.js +35 -27
- package/dist/services/vnext-code.js +62 -26
- package/dist/services/vnext-merge.js +8 -8
- package/dist/services/vnext-plan-review.js +16 -16
- package/dist/services/vnext-plan.js +51 -11
- package/dist/services/work-registry.js +48 -27
- package/dist/shared/errors.js +12 -0
- package/package.json +1 -1
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-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.74",
|
|
4
|
+
"cli_commit": "b9495b1c198fded3294040d1dfdc34d36e6aa809",
|
|
5
|
+
"built_at": "2026-09-17T00:37:10.744Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
|
package/dist/cli/help.js
CHANGED
|
@@ -199,6 +199,7 @@ Usage:
|
|
|
199
199
|
dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> [--recovery-id <RCV-ID>] --json
|
|
200
200
|
dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
|
|
201
201
|
dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
|
|
202
|
+
dd-flow run fork --from <sealed-stage-entry-snapshot> --output <empty-directory> --engine-version <exact-version> --request-id <id> [--integrity-checksum <sha256>] --json
|
|
202
203
|
dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
203
204
|
dd-flow stat usage --run <RUN-ID|RUN-short-id> --project-root <root> [--stage <stage[,stage...]>] [--session <session-id>] --json
|
|
204
205
|
dd-flow stat run sessions ls --run <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
@@ -212,6 +213,7 @@ RUN-* is the execution envelope for one concrete flow launch. Semantic truth rem
|
|
|
212
213
|
|
|
213
214
|
run start and prepare-vnext-specify accept --execution-routing-file for vNext RUNs: dd-flow/execution-routing@1 with an execution object selects the complete routing policy from existing agent-profiles. RUN creation freezes the input and selected profiles without changing project settings.
|
|
214
215
|
run snapshot restore also accepts --execution-routing-file for a new stage-entry experiment. Its fresh DD_FLOW_HOME may contain only agent-profiles and the empty database; selected profiles are read before import, previous RUN policy is archived, and existing Work overrides must resolve against the selected frozen profiles. Recovery imports reject this option.
|
|
216
|
+
run fork restores one sealed stage-entry snapshot to an isolated project and DD_FLOW_HOME, archives source authority, then binds an exact engine only in that copy. It is idempotent by request ID and output receipt; it never resumes a provider session or changes its source RUN.
|
|
215
217
|
run prepare-vnext-specify allocates an otherwise unstarted vNext RUN for the canonical SPECIFY entry checkpoint. run recovery makes an interrupted RUN read-only, requires an explicit settled-tree receipt, captures immutable recovery evidence, then explicitly returns delegated Work to the ready graph. run snapshot is the eval-only checkpoint boundary: it snapshots one quiescent RUN, its dedicated DD_FLOW_HOME, and the exact project tree including its Git repository, then restores them into a fresh dedicated runtime. It is not a general runtime backup command and fails closed for a shared runtime.
|
|
216
218
|
|
|
217
219
|
Managed drive is not yet native-provider qualified. With --capture-root, its detached controller fences RUN writers and captures each completed Stage before dispatching its successor. The capture root must be outside the runtime, project and workspace. Status events expose boundary_captured receipts; capturing means writers are still fenced, and capture_failed retains the fence for operator reconciliation. External snapshot creation is rejected while a managed controller owns a live RUN.
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -55,6 +55,7 @@ import { finishStage, startStage } from "../services/stage-lifecycle.js";
|
|
|
55
55
|
import { addWorkBatch, assertStageLifecycleOwner, assertWorkLifecycleOwner, cancelWork, deleteWork, failWork, finishWork, listWorks, mutateWorkDeps, retryWork, showWork, startWork } from "../services/work-registry.js";
|
|
56
56
|
import { launchExternalWork } from "../services/external-work-launch.js";
|
|
57
57
|
import { createEvalBootstrapSnapshot, createEvalRunSnapshot, prepareVnextSpecifyRun, restoreEvalBootstrapSnapshot, restoreEvalRunSnapshot } from "../services/eval-snapshots.js";
|
|
58
|
+
import { forkRunFromSnapshot } from "../services/run-fork.js";
|
|
58
59
|
import { acceptRunRecovery, assertRunMutationAllowed, beginRunRecovery, inspectRunRecovery, recordRecoveryCapture, recoveryGuard, resumeRunRecovery, sealRunRecovery } from "../services/run-recovery.js";
|
|
59
60
|
import { recoveryRuntimeAdmission } from "../services/run-recovery-runtime.js";
|
|
60
61
|
import { loadExternalStageContext } from "../services/stage-context.js";
|
|
@@ -103,6 +104,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
103
104
|
return 0;
|
|
104
105
|
}
|
|
105
106
|
assertSingletonOptions(output.args);
|
|
107
|
+
if (output.args[0] === "run" && ["drive", "control"].includes(output.args[1] ?? "")) {
|
|
108
|
+
const controlArgs = parseArgs(output.args.slice(2));
|
|
109
|
+
requiredOption(controlArgs, "run");
|
|
110
|
+
requiredOption(controlArgs, "project-root");
|
|
111
|
+
}
|
|
106
112
|
if (io.stdin?.isTTY && ["work", "stage"].includes(output.args[0] ?? "") && output.args[1] === "finish"
|
|
107
113
|
&& hasOption(parseArgs(output.args.slice(2)), "result-stdin")) {
|
|
108
114
|
throw new AppError("usage", "--result-stdin requires non-interactive input. Pipe or redirect the complete JSON into this command, or use --result-file; interactive terminals can truncate long JSON and wait indefinitely for EOF.", 2);
|
|
@@ -1701,6 +1707,15 @@ function dispatchRun(context, command, parsed) {
|
|
|
1701
1707
|
});
|
|
1702
1708
|
throw new AppError("usage", "Use: dd-flow run snapshot bootstrap create|restore | dd-flow run snapshot create <RUN> (--stage-entry <stage>|--candidate) --project-root <path> --output <directory> | dd-flow run snapshot restore --snapshot <directory> --project-root <path>", 2);
|
|
1703
1709
|
}
|
|
1710
|
+
if (command === "fork") {
|
|
1711
|
+
return forkRunFromSnapshot(context, {
|
|
1712
|
+
snapshot: requiredOption(parsed, "from"),
|
|
1713
|
+
output: requiredOption(parsed, "output"),
|
|
1714
|
+
engineVersion: requiredOption(parsed, "engine-version"),
|
|
1715
|
+
requestId: requiredOption(parsed, "request-id"),
|
|
1716
|
+
...(optionalOption(parsed, "integrity-checksum") ? { integrityChecksum: optionalOption(parsed, "integrity-checksum") } : {})
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1704
1719
|
if (command === "prepare-vnext-specify") {
|
|
1705
1720
|
return prepareVnextSpecifyRun(context, { projectRoot: requiredOption(parsed, "project-root"), slug: requiredOption(parsed, "slug"), executionRoutingFile: optionalOption(parsed, "execution-routing-file") });
|
|
1706
1721
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
2
|
+
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { doctor } from "../lib/dd-agy.mjs";
|
|
@@ -21,11 +21,13 @@ async function hook(options) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
try {
|
|
24
|
-
const { positional, options } = parse(process.argv.slice(2)), [family, command] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, bin: options["agy-bin"], provider: options.provider, model: options.model, reasoning: options.reasoning, mode: options.mode, projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; let result;
|
|
24
|
+
const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, bin: options["agy-bin"], provider: options.provider, model: options.model, reasoning: options.reasoning, mode: options.mode, projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; let result;
|
|
25
25
|
if (family === "doctor") result = await doctor(common);
|
|
26
26
|
else if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...common, sessionId: options["session-id"], entryPath: process.argv[1] }, startDaemon);
|
|
27
27
|
else if (family === "daemon" && command === "serve") { await serveDaemon(common.stateDir); process.exit(0); }
|
|
28
28
|
else if (family === "daemon" && command === "status") result = await callDaemon(common.stateDir, "daemon.status");
|
|
29
|
+
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(common.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
30
|
+
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(common.stateDir, options["operation-id"]);
|
|
29
31
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(common.stateDir, options["operation-id"]);
|
|
30
32
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: common.stateDir, cancelTree: options["cancel-tree"], timeoutMs: common.timeoutMs });
|
|
31
33
|
else if (family === "hook" && command === "handle") { process.stdout.write(`${JSON.stringify(await hook(options))}\n`); process.exit(0); }
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
2
|
+
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { cancelSession, createSession, doctor, inspectSession, promptFromFile, promptSession, startSession } from "../lib/dd-codex.mjs";
|
|
4
4
|
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-codex-daemon.mjs";
|
|
5
5
|
|
|
6
6
|
function parse(argv) { const positional = []; const options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json", "cancel-tree"].includes(key)) { options[key] = true; continue; } const value = argv[++i]; if (value === undefined) throw new Error(`--${key} requires a value`); options[key] = value; } return { positional, options }; }
|
|
7
7
|
function common(options) { return { bin: options["codex-bin"], journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, sessionId: options["session-id"], turnId: options["turn-id"], model: options.model, reasoning: options.reasoning, ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], projectRoot: options["project-root"], timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; }
|
|
8
8
|
try {
|
|
9
|
-
const { positional, options } = parse(process.argv.slice(2)); const [family, command] = positional; const shared = common(options); let result;
|
|
9
|
+
const { positional, options } = parse(process.argv.slice(2)); const [family, command, modifier] = positional; const shared = common(options); let result;
|
|
10
10
|
if (family === "doctor") result = await doctor(shared);
|
|
11
11
|
else if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...shared, entryPath: process.argv[1] }, startDaemon);
|
|
12
12
|
else if (family === "daemon" && command === "serve") { await serveDaemon(shared.stateDir); process.exit(0); }
|
|
13
13
|
else if (family === "daemon" && command === "status") result = await callDaemon(shared.stateDir, "daemon.status");
|
|
14
|
+
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(shared.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
15
|
+
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(shared.stateDir, options["operation-id"]);
|
|
14
16
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(shared.stateDir, options["operation-id"]);
|
|
15
17
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ ...shared, cancelTree: options["cancel-tree"] });
|
|
16
18
|
else if (family === "session" && command === "create") result = shared.stateDir ? await callDaemon(shared.stateDir, "session.create", shared) : await createSession(shared);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
2
|
+
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import os from "node:os";
|
|
@@ -11,7 +11,7 @@ import { errorRecord } from "../lib/operation-errors.mjs";
|
|
|
11
11
|
function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i++) { const word = argv[i]; if (!word.startsWith("--")) { positional.push(word); continue; } const key = word.slice(2); if (["json", "cancel-tree", "tree", "no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`${word} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
|
|
12
12
|
async function input() { let text = ""; for await (const chunk of process.stdin) text += chunk; return text; }
|
|
13
13
|
try {
|
|
14
|
-
const { positional: [family, command], options } = parse(process.argv.slice(2));
|
|
14
|
+
const { positional: [family, command, modifier], options } = parse(process.argv.slice(2));
|
|
15
15
|
const config = { stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, projectRoot: options["project-root"], bin: options["droid-bin"] ?? "droid", entryPath: process.argv[1], journal: options.journal, authHome: options["auth-home"], model: options.model, provider: options.provider, reasoning: options.reasoning, mode: options.mode, ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1_000 : undefined };
|
|
16
16
|
let result;
|
|
17
17
|
if (family === "doctor") {
|
|
@@ -22,6 +22,8 @@ try {
|
|
|
22
22
|
} else if (family === "daemon" && command === "start") result = await durableDaemonStart(config, startDaemon);
|
|
23
23
|
else if (family === "daemon" && command === "serve") { await serveDaemon(config.stateDir); process.exit(0); }
|
|
24
24
|
else if (family === "daemon" && command === "status") result = await callDaemon(config.stateDir, "daemon.status");
|
|
25
|
+
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(config.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
26
|
+
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(config.stateDir, options["operation-id"]);
|
|
25
27
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(config.stateDir, options["operation-id"]);
|
|
26
28
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: config.stateDir, cancelTree: options["cancel-tree"] });
|
|
27
29
|
else if (family === "hook" && command === "handle") { const payload = JSON.parse(await input()); const eventId = randomUUID(); result = await callDaemon(config.stateDir, "hook.observe", { payload, eventId }, 25_000, eventId); }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
2
|
+
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { invokeNativeHook } from "../lib/native-hook-command.mjs";
|
|
4
4
|
import { cancelSession, createSession, doctor, forkSession, inspectSession, promptFromFile, promptSession } from "../lib/dd-grok.mjs";
|
|
5
5
|
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-grok-daemon.mjs";
|
|
@@ -14,11 +14,13 @@ async function hook(options) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
try {
|
|
17
|
-
const { positional, options } = parse(process.argv.slice(2)); const [family, command] = positional; const shared = common(options); let result;
|
|
17
|
+
const { positional, options } = parse(process.argv.slice(2)); const [family, command, modifier] = positional; const shared = common(options); let result;
|
|
18
18
|
if (family === "doctor") result = await doctor(shared);
|
|
19
19
|
else if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...shared, entryPath: process.argv[1] }, startDaemon);
|
|
20
20
|
else if (family === "daemon" && command === "serve") { await serveDaemon(shared.stateDir); process.exit(0); }
|
|
21
21
|
else if (family === "daemon" && command === "status") result = await callDaemon(shared.stateDir, "daemon.status");
|
|
22
|
+
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(shared.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
23
|
+
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(shared.stateDir, options["operation-id"]);
|
|
22
24
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(shared.stateDir, options["operation-id"]);
|
|
23
25
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: shared.stateDir, cancelTree: options["cancel-tree"], timeoutMs: shared.timeoutMs });
|
|
24
26
|
else if (family === "hook" && command === "handle") { process.stdout.write(`${JSON.stringify(await hook(options))}\n`); process.exit(0); }
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
2
|
+
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-opencode-daemon.mjs";
|
|
5
5
|
|
|
6
6
|
function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json","cancel-tree","no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`--${key} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
|
|
7
7
|
const prompt = async options => options["prompt-file"] ? await readFile(options["prompt-file"], "utf8") : options.prompt;
|
|
8
8
|
try {
|
|
9
|
-
const { positional, options } = parse(process.argv.slice(2)), [family, command] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model, variant: options.variant, agent: options.agent, authPath: options["auth-path"], projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, sessionArchive: options["session-archive"], timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; let result;
|
|
9
|
+
const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, bin: options["opencode-bin"], provider: options.provider, model: options.model, variant: options.variant, agent: options.agent, authPath: options["auth-path"], projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, sessionArchive: options["session-archive"], timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; let result;
|
|
10
10
|
if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...common, entryPath: process.argv[1] }, startDaemon);
|
|
11
11
|
else if (family === "daemon" && command === "serve") { await serveDaemon(common.stateDir); process.exit(0); }
|
|
12
12
|
else if (family === "daemon" && command === "status") result = await callDaemon(common.stateDir, "daemon.status");
|
|
13
|
+
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(common.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
14
|
+
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(common.stateDir, options["operation-id"]);
|
|
13
15
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(common.stateDir, options["operation-id"]);
|
|
14
16
|
else if (family === "doctor") result = await callDaemon(common.stateDir, "daemon.doctor");
|
|
15
17
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: common.stateDir, cancelTree: options["cancel-tree"], timeoutMs: common.timeoutMs });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { durableDaemonStart, inspectDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
2
|
+
import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
|
|
3
3
|
import { invokeNativeHook } from "../lib/native-hook-command.mjs";
|
|
4
4
|
import { cancelChild, cancelSession, createSession, doctor, forkSession, inspectSession, promptFromFile, promptSession } from "../lib/dd-zcode.mjs";
|
|
5
5
|
import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-zcode-daemon.mjs";
|
|
@@ -16,6 +16,12 @@ function parse(argv) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function common(options) {
|
|
19
|
+
const seconds = (name) => {
|
|
20
|
+
if (options[name] === undefined) return undefined;
|
|
21
|
+
const value = Number(options[name]);
|
|
22
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive finite number of seconds`);
|
|
23
|
+
return value * 1000;
|
|
24
|
+
};
|
|
19
25
|
return {
|
|
20
26
|
bin: options["zcode-acp-bin"], zcodePath: options["zcode-path"], journal: options.journal,
|
|
21
27
|
stateDir: options["state-dir"],
|
|
@@ -25,8 +31,9 @@ function common(options) {
|
|
|
25
31
|
permission: options.permission ?? "deny", ddFlowBin: options["dd-flow-bin"],
|
|
26
32
|
ddFlowHome: options["dd-flow-home"], projectRoot: options["project-root"],
|
|
27
33
|
env: options["dd-flow-home"] ? { DD_FLOW_HOME: options["dd-flow-home"] } : undefined,
|
|
28
|
-
timeoutMs:
|
|
29
|
-
livenessTimeoutMs:
|
|
34
|
+
timeoutMs: seconds("timeout"),
|
|
35
|
+
livenessTimeoutMs: seconds("liveness-timeout"),
|
|
36
|
+
requestTimeoutMs: seconds("request-timeout"),
|
|
30
37
|
};
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -40,21 +47,22 @@ async function hook(options) {
|
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
try {
|
|
43
|
-
const { positional, options } = parse(process.argv.slice(2)); const [family, command] = positional; let result;
|
|
50
|
+
const { positional, options } = parse(process.argv.slice(2)); const [family, command, modifier] = positional; let result;
|
|
44
51
|
const answers = options["answers-file"] ? JSON.parse(await promptFromFile(options["answers-file"])) : undefined;
|
|
45
52
|
const shared = common(options);
|
|
46
53
|
if (family === "doctor") result = await doctor(shared);
|
|
47
54
|
else if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...shared, entryPath: process.argv[1] }, startDaemon);
|
|
48
55
|
else if (family === "daemon" && command === "serve") { await serveDaemon(shared.stateDir); process.exit(0); }
|
|
49
56
|
else if (family === "daemon" && command === "status") result = await callDaemon(shared.stateDir, "daemon.status");
|
|
57
|
+
else if (family === "daemon" && command === "operation" && modifier === "interrupt") result = await interruptDaemonOperation(shared.stateDir, options["operation-id"], { control_id: options["control-id"], stop_operation_id: options["stop-operation-id"], daemon_id: options["daemon-id"], session_id: options["session-id"], evidence: options.evidence ? JSON.parse(options.evidence) : null });
|
|
58
|
+
else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(shared.stateDir, options["operation-id"]);
|
|
50
59
|
else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(shared.stateDir, options["operation-id"]);
|
|
51
60
|
else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: shared.stateDir, cancelTree: options["cancel-tree"], timeoutMs: shared.timeoutMs });
|
|
52
61
|
else if (family === "hook" && command === "handle") { process.stdout.write(`${JSON.stringify(await hook(options))}\n`); process.exit(0); }
|
|
53
62
|
else if (family === "session") {
|
|
54
63
|
const params = { ...shared, answers, ...(command === "create" || command === "prompt" ? { prompt: options["prompt-file"] ? await promptFromFile(options["prompt-file"]) : options.prompt } : {}), ...(command === "fork" ? { target: JSON.parse(options["target-json"] ?? "null") } : {}) };
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// RPC deadline would wrongly terminate a long but active Turn.
|
|
64
|
+
// The daemon owns the durable native-request deadline. A disposable RPC
|
|
65
|
+
// wait must not manufacture a second provider timeout.
|
|
58
66
|
const operation = `session.${command === "status" ? "inspect" : command}`;
|
|
59
67
|
const rpcTimeout = command === "prompt" && shared.timeoutMs === undefined
|
|
60
68
|
? null
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, link, unlink } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, link, unlink, open } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { hashJson, writeJsonAtomic } from "./runner-events.mjs";
|
|
@@ -35,7 +35,52 @@ export async function inspectDaemonOperation(root, id) {
|
|
|
35
35
|
const directory = location(root, id);
|
|
36
36
|
const requested = await read(path.join(directory, "requested.json"));
|
|
37
37
|
if (!requested) throw Object.assign(new Error("daemon operation is not recorded"), { code: "operation_not_found", details: { operation_id: id } });
|
|
38
|
-
|
|
38
|
+
const settlement = await read(path.join(directory, "settlement.json"));
|
|
39
|
+
return { ...requested, ...(await read(path.join(directory, "result.json")) ?? await read(path.join(directory, "interrupted.json")) ?? await read(path.join(directory, "observation-lost.json")) ?? { state: "running" }), ...(settlement ? { settlement } : {}) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Retry only the bookkeeping that follows a durable productive receipt.
|
|
43
|
+
* This is intentionally separate from dispatch: it never invokes a native
|
|
44
|
+
* prompt/create/fork operation, so recovery cannot duplicate agent work. */
|
|
45
|
+
export async function settleDaemonOperation(root, id) {
|
|
46
|
+
const current = await inspectDaemonOperation(root, id);
|
|
47
|
+
if (current.state !== "completed") return current;
|
|
48
|
+
if (current.settlement?.state === "settled") return current;
|
|
49
|
+
let settlement;
|
|
50
|
+
try {
|
|
51
|
+
settlement = await completeBudgetOperation(root, {
|
|
52
|
+
id: current.operation_id,
|
|
53
|
+
operation: current.operation,
|
|
54
|
+
params: { sessionId: current.session_id ?? undefined }
|
|
55
|
+
}, current.result);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
settlement = { state: "pending", reason: "settlement_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
58
|
+
}
|
|
59
|
+
try { await writeSettlement(root, id, settlement); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
settlement = { state: "blocked", reason: "settlement_storage_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
62
|
+
}
|
|
63
|
+
return { ...current, settlement };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A sealed whole-tree stop is the only authority that may turn an unknown
|
|
67
|
+
* productive request into a terminal interruption. This is deliberately a
|
|
68
|
+
* journal operation: a closed daemon cannot be asked to manufacture a reply. */
|
|
69
|
+
export async function interruptDaemonOperation(root, id, barrier) {
|
|
70
|
+
if (!barrier || typeof barrier !== "object" || typeof barrier.control_id !== "string" || !barrier.control_id) throw Object.assign(new Error("interruption requires a sealed control identity"), { code: "stop_barrier_invalid" });
|
|
71
|
+
const current = await inspectDaemonOperation(root, id);
|
|
72
|
+
if (["completed", "failed", "interrupted"].includes(current.state)) return current;
|
|
73
|
+
if (!(["running", "awaiting_provider"].includes(current.state)) || !productive.has(current.operation)) throw Object.assign(new Error("only an observed productive operation may be interrupted"), { code: "operation_not_interruptible" });
|
|
74
|
+
// A lazy session/create is allowed to have no ACP/native session id yet.
|
|
75
|
+
// The owned daemon identity plus the sealed stop operation is the authority
|
|
76
|
+
// in that case; never manufacture a session id merely to satisfy the check.
|
|
77
|
+
const barrierSession = barrier.session_id ?? null;
|
|
78
|
+
const currentSession = current.session_id ?? null;
|
|
79
|
+
if (barrier.daemon_id !== current.daemon_id || barrierSession !== currentSession) throw Object.assign(new Error("interruption barrier belongs to another daemon operation"), { code: "stop_barrier_mismatch" });
|
|
80
|
+
const value = { state: "interrupted", outcome: "interrupted", observed_at: new Date().toISOString(), interrupted_by: { control_id: barrier.control_id, stop_operation_id: barrier.stop_operation_id, evidence: barrier.evidence ?? null }, error: { code: "operation_interrupted", message: "Operation was interrupted after the owned native tree was stopped", cause: current.error ?? null } };
|
|
81
|
+
const saved = await writeTerminal(root, id, value);
|
|
82
|
+
if (saved.state !== "interrupted" || saved.interrupted_by?.control_id !== barrier.control_id) throw Object.assign(new Error("operation already has a conflicting terminal outcome"), { code: "operation_terminal_conflict" });
|
|
83
|
+
return await inspectDaemonOperation(root, id);
|
|
39
84
|
}
|
|
40
85
|
|
|
41
86
|
/** At-most-once dispatch and a durable terminal reply, including when the socket
|
|
@@ -51,8 +96,16 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
51
96
|
if (request.operation === "session.inspect") {
|
|
52
97
|
const scope = await budgetObservationScope(root, request.params?.sessionId);
|
|
53
98
|
const result = await action();
|
|
54
|
-
|
|
55
|
-
|
|
99
|
+
let settlement;
|
|
100
|
+
if (!scope?.operationIds.length) settlement = { state: "not_required" };
|
|
101
|
+
else if (result?.settled !== true) settlement = { state: "pending", reason: "tree_unsettled" };
|
|
102
|
+
else try {
|
|
103
|
+
await releaseBudgetObservation(scope, result);
|
|
104
|
+
settlement = { state: "settled", operation_ids: scope.operationIds, source: "inspection" };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
settlement = { state: "pending", reason: "settlement_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
107
|
+
}
|
|
108
|
+
return settlement.state === "not_required" || !result || typeof result !== "object" || Array.isArray(result) ? result : { ...result, settlement };
|
|
56
109
|
}
|
|
57
110
|
const control = ["daemon.stop", "session.cancel"].includes(request.operation);
|
|
58
111
|
if (!productive.has(request.operation) && !control) return await action();
|
|
@@ -65,8 +118,25 @@ export async function durableDaemonDispatch(root, request, action, observe) {
|
|
|
65
118
|
if (!control) assertCurrent();
|
|
66
119
|
return control ? await controlDispatch(root, request, action) : await action();
|
|
67
120
|
});
|
|
68
|
-
if (
|
|
69
|
-
|
|
121
|
+
if (control) return result;
|
|
122
|
+
let settlement;
|
|
123
|
+
try {
|
|
124
|
+
settlement = await completeBudgetOperation(root, request, result, observe);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
settlement = { state: "pending", reason: "settlement_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
127
|
+
}
|
|
128
|
+
try { await writeSettlement(root, request.id, settlement); }
|
|
129
|
+
catch (error) {
|
|
130
|
+
settlement = { state: "blocked", reason: "settlement_storage_error", observed_at: new Date().toISOString(), error: errorRecord(error) };
|
|
131
|
+
}
|
|
132
|
+
return settlement?.state === "not_required" || !result || typeof result !== "object" || Array.isArray(result)
|
|
133
|
+
? result
|
|
134
|
+
: { ...result, settlement };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function writeSettlement(root, id, value) {
|
|
138
|
+
const directory = location(root, id);
|
|
139
|
+
await writeJsonAtomic(path.join(directory, "settlement.json"), value);
|
|
70
140
|
}
|
|
71
141
|
|
|
72
142
|
/** Startup has no registered daemon yet. Its existing native implementation
|
|
@@ -99,7 +169,7 @@ async function journalDispatch(root, request, action) {
|
|
|
99
169
|
const saved = await inspectDaemonOperation(root, request.id);
|
|
100
170
|
if (saved.params_sha256 !== fingerprint) throw Object.assign(new Error("operation id was reused with different parameters"), { code: "operation_conflict", details: { operation_id: request.id } });
|
|
101
171
|
if (saved.state === "completed") return saved.result;
|
|
102
|
-
if (saved.state
|
|
172
|
+
if (["failed", "interrupted"].includes(saved.state)) throw reportedError(saved.error, "provider operation failed");
|
|
103
173
|
throw Object.assign(new Error("provider operation has no confirmed terminal result; inspect the existing operation instead of resending it"), { code: "operation_observation_lost", details: { operation_id: request.id, receipt_directory: directory } });
|
|
104
174
|
} finally { await unlink(temporary); }
|
|
105
175
|
let result;
|
|
@@ -108,10 +178,38 @@ async function journalDispatch(root, request, action) {
|
|
|
108
178
|
}
|
|
109
179
|
catch (error) {
|
|
110
180
|
const unknown = isObservationLoss(error);
|
|
111
|
-
await writeJsonAtomic(path.join(directory,
|
|
181
|
+
if (unknown) await writeJsonAtomic(path.join(directory, "observation-lost.json"), { state: "awaiting_provider", observed_at: new Date().toISOString(), error: errorRecord(error) });
|
|
182
|
+
else {
|
|
183
|
+
const terminal = await writeTerminal(root, request.id, { state: "failed", observed_at: new Date().toISOString(), error: errorRecord(error) });
|
|
184
|
+
if (terminal.state !== "failed") throw reportedError(terminal.error, "provider operation did not retain this failure");
|
|
185
|
+
}
|
|
112
186
|
throw error;
|
|
113
187
|
}
|
|
114
|
-
try {
|
|
115
|
-
|
|
188
|
+
try {
|
|
189
|
+
const terminal = await writeTerminal(root, request.id, { state: "completed", ...(startup ? { started_daemon_id: result?.daemon_id ?? null } : {}), finished_at: new Date().toISOString(), result });
|
|
190
|
+
if (terminal.state !== "completed") throw reportedError(terminal.error, "provider operation was terminalized before its late reply arrived");
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
if (cause?.code === "operation_interrupted" || cause?.code === "operation_terminal_conflict") throw cause;
|
|
193
|
+
throw Object.assign(new Error("provider returned but the durable reply could not be saved; do not resend the operation"), { code: "operation_observation_lost", cause, details: { operation_id: request.id, receipt_directory: directory } });
|
|
194
|
+
}
|
|
116
195
|
return result;
|
|
117
196
|
}
|
|
197
|
+
|
|
198
|
+
/** `result.json` is the one exclusive terminal slot. Old interrupted.json
|
|
199
|
+
* files remain readable for compatibility, but new writes never race them. */
|
|
200
|
+
async function writeTerminal(root, id, value) {
|
|
201
|
+
const file = path.join(location(root, id), "result.json");
|
|
202
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
203
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
204
|
+
try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); }
|
|
205
|
+
finally { await handle.close(); }
|
|
206
|
+
try {
|
|
207
|
+
await link(temporary, file);
|
|
208
|
+
return value;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error.code !== "EEXIST") throw error;
|
|
211
|
+
const saved = await read(file);
|
|
212
|
+
if (!saved || !["completed", "failed", "interrupted"].includes(saved.state)) throw Object.assign(new Error("operation terminal record is invalid"), { code: "operation_terminal_conflict" });
|
|
213
|
+
return saved;
|
|
214
|
+
} finally { await unlink(temporary); }
|
|
215
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
|
|
2
|
+
import { ToolObservations, agyToolObservation } from "./tool-observations.mjs";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { appendFile, chmod, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
@@ -198,8 +199,8 @@ export async function startDaemon(options) {
|
|
|
198
199
|
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); };
|
|
199
200
|
|
|
200
201
|
export class Runtime {
|
|
201
|
-
constructor(paths, state) { this.paths = paths; this.state = state; this.config = state.config; this.child = null; this.providerProcess = state.provider_process ?? null; this.providerFinalization = Promise.resolve(); this.buffer = ""; this.init = null; this.readyPromise = null; this.active = null; this.exited = false; this.
|
|
202
|
-
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations],
|
|
202
|
+
constructor(paths, state) { this.paths = paths; this.state = state; this.config = state.config; this.child = null; this.providerProcess = state.provider_process ?? null; this.providerFinalization = Promise.resolve(); this.buffer = ""; this.init = null; this.readyPromise = null; this.active = null; this.exited = false; this.toolObservations = new ToolObservations(); for (const reason of state.tool_observation_gaps ?? []) this.toolObservations.gaps.add(reason); for (const event of state.tool_observations ?? []) this.toolObservations.add(event); if (state.tool_calls?.length && !state.tool_observations) this.toolObservations.gaps.add("legacy_tools_not_attributed"); this.turnGeneration = state.turn_generation ?? 0; this.descendants = new Map((state.descendants ?? []).map(child => [child.provider_session_id, child])); this.lastResult = state.last_result ?? null; this.sessionObservations = new Map(state.session_observations ?? []); this.startupError = null; this.persisting = Promise.resolve(); this.draining = Promise.resolve(); this.lastActivityAt = state.last_activity_at ?? null; this.usageIngest = null; this.lastProviderEvent = null; }
|
|
203
|
+
persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.then(() => writeJson(this.paths.state, snapshot)); for (const processRecord of [this.state.resource_process, this.providerProcess]) if (processRecord?.id && processRecord.lease_token) this.persisting = this.persisting.then(async () => { try { await heartbeatDaemonProcess(this.config, processRecord); } catch {} }); return this.persisting; }
|
|
203
204
|
async journal(kind, payload = {}) { const line = `${JSON.stringify({ schema_id: "dd-agy/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), kind, ...payload })}\n`; for (const journal of new Set([this.paths.journal, this.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
|
|
204
205
|
args(sessionId) { return ["--input-format", "stream-json", "--output-format", "stream-json", "--model", this.config.model, "--effort", this.config.reasoning, "--mode", this.config.mode, "--dangerously-skip-permissions", "--print-timeout", "6h", `--gemini_dir=${this.paths.gemini}`, "--app_data_dir=runtime", ...(sessionId ? ["--conversation", sessionId] : ["--new-project"])]; }
|
|
205
206
|
requireSessionIdentity(requestedId) {
|
|
@@ -275,8 +276,8 @@ export class Runtime {
|
|
|
275
276
|
// child can close while the asynchronous write is still pending.
|
|
276
277
|
if (!this.init && event.event === "result" && event.result?.status === "ERROR") this.startupError = new DaemonError("agy_provider_rejected", String(event.result.error ?? "Antigravity rejected the Session before initialization"), false, { provider_result: event.result });
|
|
277
278
|
this.observeProviderActivity(event); await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") { if (this.startupError) throw this.startupError; await this.finishTurn(event.result ?? {}); } } }
|
|
278
|
-
observeStep(step) { const sessionId = step.conversation_id ?? this.init?.conversation_id; const observation = this.sessionObservations.get(sessionId) ?? {}; if (Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1)) this.childActivity(sessionId); if (Number.isInteger(step.step_index)) this.sessionObservations.set(sessionId, { ...observation, last_step_index: Math.max(observation.last_step_index ?? -1, step.step_index) });
|
|
279
|
-
toolSnapshot() { const
|
|
279
|
+
observeStep(step) { const sessionId = step.conversation_id ?? this.init?.conversation_id; const observation = this.sessionObservations.get(sessionId) ?? {}; if (Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1)) this.childActivity(sessionId); if (Number.isInteger(step.step_index)) this.sessionObservations.set(sessionId, { ...observation, last_step_index: Math.max(observation.last_step_index ?? -1, step.step_index) }); this.toolObservations.add(agyToolObservation(step, this.init?.conversation_id)); for (const child of step.subagent_info?.subagents ?? []) if (child.conversation_id) { const prior = this.descendants.get(child.conversation_id); this.descendants.set(child.conversation_id, { ...prior, provider_session_id: child.conversation_id, parent_provider_session_id: this.init?.conversation_id ?? null, role: child.role ?? null, subagent_type: child.type_name ?? null, log_uri: child.log_uri ?? null, workspace_uris: child.workspace_uris ?? [], status: prior?.status ?? "unknown" }); } }
|
|
280
|
+
toolSnapshot() { const summary = this.toolObservations.summary(); const failures_by_name = {}; for (const event of this.toolObservations.calls.values()) if (event.status === "failed") failures_by_name[event.name] = (failures_by_name[event.name] ?? 0) + 1; return { ...summary, by_name: summary.by_tool, failures_by_name }; }
|
|
280
281
|
async finishTurn(result) {
|
|
281
282
|
if (result.status === "RUNNING") { await this.persist({ active_tree: true }); return; }
|
|
282
283
|
if (!["SUCCESS", "ERROR", "CANCELLED"].includes(result.status)) throw new DaemonError("agy_terminal_result_invalid", "Antigravity result has no recognized terminal status", false, { provider_result: result });
|
|
@@ -6,6 +6,8 @@ import { stopProcessGroup } from "./managed-daemon.mjs";
|
|
|
6
6
|
import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
|
|
7
7
|
import { observeModel } from "./model-observations.mjs";
|
|
8
8
|
|
|
9
|
+
const LATE_RESPONSE_GRACE_MS = 5_000;
|
|
10
|
+
|
|
9
11
|
// Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
|
|
10
12
|
// intentionally one-shot: the provider owns durable Threads, while the caller
|
|
11
13
|
// owns the runner journal and can safely resume after a process restart.
|
|
@@ -13,6 +15,11 @@ function fail(message, code, details) { const error = new Error(message); if (co
|
|
|
13
15
|
function required(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
14
16
|
function absolute(value, label) { const resolved = path.resolve(required(value, label)); if (!path.isAbsolute(resolved)) fail(`${label} must be absolute`); return resolved; }
|
|
15
17
|
function now() { return new Date().toISOString(); }
|
|
18
|
+
function responseSummary(message) {
|
|
19
|
+
const result = message?.result;
|
|
20
|
+
return { request_id: message?.id ?? null, has_result: result !== undefined,
|
|
21
|
+
result_keys: result && typeof result === "object" && !Array.isArray(result) ? Object.keys(result).slice(0, 32) : [], error: message?.error ?? null };
|
|
22
|
+
}
|
|
16
23
|
|
|
17
24
|
export function parseCodexCliVersion(value) {
|
|
18
25
|
const match = String(value).trim().match(/^codex-cli\s+(\S+)$/m);
|
|
@@ -38,12 +45,13 @@ class Journal {
|
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export class CodexBridge {
|
|
41
|
-
constructor(options = {}) { this.options = options; this.nextId = 1; this.pending = new Map(); this.turns = new Map(); this.finalMessages = new Map(); this.descendants = new Map(); this.journal = new Journal(options.journal); this.stderr = ""; this.requestedProfiles = new Map(); this.modelObservations = Promise.resolve(); }
|
|
48
|
+
constructor(options = {}) { this.options = options; this.nextId = 1; this.pending = new Map(); this.lateResponses = new Map(); this.turns = new Map(); this.finalMessages = new Map(); this.descendants = new Map(); this.journal = new Journal(options.journal); this.stderr = ""; this.requestedProfiles = new Map(); this.modelObservations = Promise.resolve(); }
|
|
42
49
|
|
|
43
50
|
async start() {
|
|
44
51
|
const bin = this.options.bin ?? process.env.DD_CODEX_BIN ?? "codex";
|
|
45
52
|
this.child = spawn(bin, ["app-server"], { cwd: this.options.cwd ? absolute(this.options.cwd, "--cwd") : process.cwd(), env: { ...process.env, ...(this.options.env ?? {}) }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
|
|
46
53
|
this.child.stderr.setEncoding("utf8").on("data", (chunk) => { this.stderr += String(chunk); this.journal.write("stderr", { text: String(chunk) }); });
|
|
54
|
+
this.child.stdin?.on("error", (error) => this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })));
|
|
47
55
|
this.child.on("error", (error) => this.rejectAll(error));
|
|
48
56
|
this.child.on("exit", (code) => this.rejectAll(Object.assign(new Error(`Codex app-server exited ${code}${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`), { code: "bridge_exited" })));
|
|
49
57
|
readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
|
|
@@ -90,8 +98,15 @@ export class CodexBridge {
|
|
|
90
98
|
} else this.journal.write("inbound", message);
|
|
91
99
|
if (message.id !== undefined && message.method) { void this.answerRequest(message); return; }
|
|
92
100
|
if (message.id !== undefined) {
|
|
93
|
-
const pending = this.pending.get(message.id);
|
|
101
|
+
const pending = this.pending.get(message.id);
|
|
102
|
+
if (!pending) {
|
|
103
|
+
this.lateResponses.set(message.id, message);
|
|
104
|
+
while (this.lateResponses.size > 128) this.lateResponses.delete(this.lateResponses.keys().next().value);
|
|
105
|
+
this.journal.write("late_response", responseSummary(message));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
94
108
|
clearTimeout(pending.timer); this.pending.delete(message.id);
|
|
109
|
+
if (pending.timedOut) this.journal.write("late_response", { ...responseSummary(message), method: pending.method, grace_ms: LATE_RESPONSE_GRACE_MS });
|
|
95
110
|
message.error ? pending.reject(Object.assign(new Error(message.error.message ?? JSON.stringify(message.error)), { code: "rpc_error", details: message.error })) : pending.resolve(message.result ?? {});
|
|
96
111
|
return;
|
|
97
112
|
}
|
|
@@ -144,11 +159,30 @@ export class CodexBridge {
|
|
|
144
159
|
request(method, params = {}, timeoutMs = this.options.timeoutMs ?? 120_000) {
|
|
145
160
|
const id = this.nextId++;
|
|
146
161
|
return new Promise((resolve, reject) => {
|
|
147
|
-
const timer = observedTimeout(() => {
|
|
162
|
+
const timer = observedTimeout(() => {
|
|
163
|
+
const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
|
|
164
|
+
pending.timedOut = true;
|
|
165
|
+
const timeoutError = Object.assign(new Error(`${method} timed out`), { code: "rpc_timeout", details: { method, request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS } });
|
|
166
|
+
this.journal.write("request_deadline", timeoutError.details);
|
|
167
|
+
pending.timer = observedTimeout(() => {
|
|
168
|
+
if (this.pending.get(id) !== pending) return;
|
|
169
|
+
this.pending.delete(id); reject(timeoutError);
|
|
170
|
+
}, LATE_RESPONSE_GRACE_MS);
|
|
171
|
+
}, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
|
|
148
172
|
this.pending.set(id, { resolve, reject, timer, method }); this.send({ jsonrpc: "2.0", id, method, params });
|
|
149
173
|
});
|
|
150
174
|
}
|
|
151
|
-
send(value) {
|
|
175
|
+
send(value) {
|
|
176
|
+
this.journal.write("outbound", value);
|
|
177
|
+
const stdin = this.child?.stdin;
|
|
178
|
+
if (!stdin || stdin.destroyed) {
|
|
179
|
+
const error = Object.assign(new Error("Codex app-server stdin is closed"), { code: "bridge_pipe_broken" });
|
|
180
|
+
this.rejectAll(error);
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
try { stdin.write(`${JSON.stringify(value)}\n`); }
|
|
184
|
+
catch (error) { this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })); throw error; }
|
|
185
|
+
}
|
|
152
186
|
rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); }
|
|
153
187
|
async close() {
|
|
154
188
|
if (!this.child) return;
|