@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90
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 +460 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +304 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1409 -382
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/command-context.js +80 -0
- package/dist/services/config.js +11 -8
- package/dist/services/continuation-outcome.js +20 -0
- package/dist/services/controller-fanout.js +132 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -73
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +587 -299
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +118 -7
- package/dist/services/lifecycle-invocations.js +1055 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/repair-intents.js +40 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +789 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +343 -0
- package/dist/services/runs.js +368 -74
- package/dist/services/runtime-budget.js +434 -0
- package/dist/services/runtime-command.js +43 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +84 -55
- package/dist/services/stage-work-graph.js +35 -0
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +195 -88
- package/dist/services/vnext-code.js +285 -252
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +160 -17
- package/dist/services/vnext-merge.js +291 -104
- package/dist/services/vnext-plan-review.js +139 -81
- package/dist/services/vnext-plan.js +138 -69
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +88 -65
- package/dist/services/work-registry.js +716 -168
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/entity-references.js +8 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +459 -32
- package/dist/storage/paths.js +17 -4
- package/dist/storage/work-references.js +23 -0
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +24 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
|
|
2
|
+
import { ToolObservations, agyToolObservation } from "./tool-observations.mjs";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { appendFile, chmod, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { ObservationClock } from "./observation-clock.mjs";
|
|
8
|
+
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { observeModel } from "./model-observations.mjs";
|
|
11
|
+
import { AgyError, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
|
|
12
|
+
import { prepareRuntimeOwner, cleanupFailedStart, runtimeProcess, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
13
|
+
|
|
14
|
+
const REQUEST_SCHEMA = "dd-agy/daemon-request@1", RESPONSE_SCHEMA = "dd-agy/daemon-response@1", STATE_SCHEMA = "dd-agy/daemon-state@1";
|
|
15
|
+
class DaemonError extends AgyError {}
|
|
16
|
+
const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw new DaemonError("invalid_path", `${label} must be an absolute path`); return path.resolve(value); };
|
|
17
|
+
function locations(stateDir) {
|
|
18
|
+
const dir = absolute(stateDir, "--state-dir"), digest = createHash("sha256").update(dir).digest("hex").slice(0, 22), local = path.join(dir, "daemon.sock");
|
|
19
|
+
// `tsx` creates Unix-domain sockets below TMPDIR. An evaluation path is
|
|
20
|
+
// intentionally descriptive and can exceed the platform's socket-path cap,
|
|
21
|
+
// so provider temp files must not inherit that path.
|
|
22
|
+
return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-agy-${digest}.sock`, state: path.join(dir, "daemon.json"), journal: path.join(dir, "events.jsonl"), log: path.join(dir, "daemon.log"), gemini: path.join(dir, "gemini"), runtime: path.join(dir, "gemini", "runtime"), config: path.join(dir, "gemini", "config"), temporary: `/tmp/dd-agy-tmp-${digest}` };
|
|
23
|
+
}
|
|
24
|
+
async function writeJson(file, value) { const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, file); }
|
|
25
|
+
async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
26
|
+
async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace a non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
27
|
+
const shell = value => `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
28
|
+
function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) delete env[key]; return env; }
|
|
29
|
+
function errorPayload(error) { return { code: error.code ?? "operation_failed", message: error.message ?? String(error), retryable: error.retryable === true, ...(error.details === undefined ? {} : { details: error.details }) }; }
|
|
30
|
+
async function forwardUsage(config, sessionId, result, tools, descendants) {
|
|
31
|
+
if (!config.ddFlowBin || !config.projectRoot) return;
|
|
32
|
+
const snapshot = usageSnapshot(result, tools), hasChildren = descendants.length > 0;
|
|
33
|
+
if (snapshot.total_tokens === null) return { status: "unavailable", reason: "provider_did_not_report_total_tokens" };
|
|
34
|
+
const payload = { provider_session_id: sessionId, daemon_id: config.daemonId, observed_at: new Date().toISOString(), usage_scope: hasChildren ? "unknown" : "physical_session", completeness: hasChildren ? "partial" : "complete", usage: { inputTokens: snapshot.input_tokens, outputTokens: snapshot.output_tokens, reasoningTokens: snapshot.reasoning_tokens, cacheReadInputTokens: snapshot.cache_read_tokens, totalTokens: snapshot.total_tokens }, tool_calls: snapshot.tool_calls };
|
|
35
|
+
const target = executable(config.ddFlowBin, ["agy", "usage", "ingest", "--project-root", config.projectRoot, "--json"]);
|
|
36
|
+
await new Promise((resolve, reject) => { const child = spawn(target.command, target.args, { env: { ...process.env, DD_FLOW_HOME: config.ddFlowHome }, stdio: ["pipe", "ignore", "pipe"] }); let stderr = ""; child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk); child.on("error", reject); child.on("close", code => code === 0 ? resolve() : reject(new DaemonError("agy_usage_ingest_failed", stderr.trim() || "dd-flow rejected Antigravity usage"))); child.stdin.end(`${JSON.stringify(payload)}\n`); });
|
|
37
|
+
return { status: "ingested" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function prepare(paths, config) {
|
|
41
|
+
for (const dir of [paths.dir, paths.gemini, paths.runtime, paths.config, paths.temporary]) { await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); }
|
|
42
|
+
if (config.noFlow) {
|
|
43
|
+
// Technical probes still need native settlement, but must not install
|
|
44
|
+
// workspace hooks or forward any lifecycle mutation to dd-flow.
|
|
45
|
+
const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --event Stop --state-dir ${shell(paths.dir)} --json`;
|
|
46
|
+
await writeJson(path.join(paths.config, "hooks.json"), { "dd-flow-observer": { Stop: [{ type: "command", command, timeout: 30 }] } });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --event __EVENT__ --state-dir ${shell(paths.dir)} --project-root ${shell(config.projectRoot)} --dd-flow-bin ${shell(config.ddFlowBin)} --dd-flow-home ${shell(config.ddFlowHome)} --json`;
|
|
50
|
+
const hooks = { "dd-flow": {
|
|
51
|
+
// Only shell lifecycle commands require trusted pre-execution identity.
|
|
52
|
+
// PostToolUse only recorded observations; native updates and Stop settle work.
|
|
53
|
+
PreToolUse: [{ matcher: "^run_command$", hooks: [{ type: "command", command: command.replace("__EVENT__", "PreToolUse"), timeout: 30 }] }],
|
|
54
|
+
Stop: [{ type: "command", command: command.replace("__EVENT__", "Stop"), timeout: 30 }]
|
|
55
|
+
} };
|
|
56
|
+
// Register in workspace discovery before creating any Session: native root
|
|
57
|
+
// and children load these named hook definitions without a global duplicate.
|
|
58
|
+
// Replace our prior handlers, including the obsolete wildcard post hook,
|
|
59
|
+
// without dropping unrelated commands in a shared matcher group.
|
|
60
|
+
const workspaceConfig = path.join(config.projectRoot, ".agents");
|
|
61
|
+
await mkdir(workspaceConfig, { recursive: true, mode: 0o700 });
|
|
62
|
+
const workspaceHooks = path.join(workspaceConfig, "hooks.json");
|
|
63
|
+
let existing = {};
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(await readFile(workspaceHooks, "utf8"));
|
|
66
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("not an object");
|
|
67
|
+
existing = parsed;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code !== "ENOENT") throw new DaemonError("workspace_hooks_invalid", `cannot install Antigravity lifecycle hooks: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
const isManagedHandler = handler => typeof handler?.command === "string"
|
|
72
|
+
&& handler.command.includes(config.entryPath) && handler.command.includes("hook handle");
|
|
73
|
+
const events = ["PreToolUse", "PostToolUse", "PreInvocation", "PostInvocation", "Stop"];
|
|
74
|
+
const retain = handlers => handlers.flatMap(item => {
|
|
75
|
+
if (!Array.isArray(item?.hooks)) return isManagedHandler(item) ? [] : [item];
|
|
76
|
+
const remaining = item.hooks.filter(handler => !isManagedHandler(handler));
|
|
77
|
+
return remaining.length ? [{ ...item, hooks: remaining }] : [];
|
|
78
|
+
});
|
|
79
|
+
const workspace = Object.create(null), legacy = Object.create(null);
|
|
80
|
+
for (const [name, definition] of Object.entries(existing)) {
|
|
81
|
+
// Migrate the old adapter's event-keyed workspace format.
|
|
82
|
+
if (events.includes(name) && Array.isArray(definition)) {
|
|
83
|
+
const remaining = retain(definition);
|
|
84
|
+
if (remaining.length) legacy[name] = remaining;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!definition || Array.isArray(definition) || typeof definition !== "object") {
|
|
88
|
+
throw new DaemonError("workspace_hooks_invalid", `invalid named hook definition: ${name}`);
|
|
89
|
+
}
|
|
90
|
+
const cleaned = { ...definition };
|
|
91
|
+
for (const event of events) if (Array.isArray(cleaned[event])) {
|
|
92
|
+
const remaining = retain(cleaned[event]);
|
|
93
|
+
if (remaining.length || cleaned[event].length === 0) cleaned[event] = remaining;
|
|
94
|
+
else delete cleaned[event];
|
|
95
|
+
}
|
|
96
|
+
workspace[name] = cleaned;
|
|
97
|
+
}
|
|
98
|
+
if (Object.keys(legacy).length) {
|
|
99
|
+
let name = "dd-flow-legacy";
|
|
100
|
+
while (Object.hasOwn(workspace, name)) name += "-retained";
|
|
101
|
+
workspace[name] = legacy;
|
|
102
|
+
}
|
|
103
|
+
const managed = workspace["dd-flow"] ?? {};
|
|
104
|
+
if (managed.enabled === false) throw new DaemonError("workspace_hooks_invalid", "dd-flow lifecycle hooks are disabled");
|
|
105
|
+
for (const [event, handlers] of Object.entries(hooks["dd-flow"])) {
|
|
106
|
+
managed[event] = [...(managed[event] ?? []), ...handlers];
|
|
107
|
+
}
|
|
108
|
+
workspace["dd-flow"] = managed;
|
|
109
|
+
await writeJson(workspaceHooks, workspace);
|
|
110
|
+
// Workspace discovery covers root and children. Installing the same handler
|
|
111
|
+
// globally as well invokes it twice, even under the same hook name.
|
|
112
|
+
await writeJson(path.join(paths.config, "hooks.json"), {});
|
|
113
|
+
config.hooksSha256 = createHash("sha256").update(JSON.stringify(hooks)).digest("hex");
|
|
114
|
+
config.workspaceHooksPath = workspaceHooks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
118
|
+
const paths = locations(stateDir), request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
119
|
+
return await new Promise((resolve, reject) => {
|
|
120
|
+
const client = net.createConnection(paths.socket); let buffer = "", settled = false, timer, checking = false, lastActivity = Date.now();
|
|
121
|
+
const clock = new ObservationClock({ timeoutMs, onGap: gap => { void appendFile(paths.journal, `${JSON.stringify({ kind: "observation_gap", payload: { ...gap, operation_id: request.id } })}\n`, { mode: 0o600 }).catch(() => {}); } });
|
|
122
|
+
const finish = (error, result) => { if (settled) return; settled = true; if (timer) clearInterval(timer); client.destroy(); error ? reject(error) : resolve(result); };
|
|
123
|
+
const poll = Math.min(5_000, Math.max(25, Math.floor(timeoutMs / 20)));
|
|
124
|
+
timer = setInterval(async () => {
|
|
125
|
+
if (settled || checking) return;
|
|
126
|
+
checking = true;
|
|
127
|
+
try {
|
|
128
|
+
if (operation !== "session.prompt") {
|
|
129
|
+
if (clock.sample()) finish(new DaemonError("daemon_timeout", `${operation} timed out`, true));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let status = null;
|
|
133
|
+
try { status = await callDaemon(stateDir, "daemon.status", {}, Math.min(timeoutMs, 5_000)); }
|
|
134
|
+
catch { /* The original RPC retains the authoritative failure. */ }
|
|
135
|
+
const observed = Date.parse(status?.last_activity_at ?? "");
|
|
136
|
+
const progressed = status?.active_tree === true && Number.isFinite(observed) && observed > lastActivity;
|
|
137
|
+
if (progressed) lastActivity = observed;
|
|
138
|
+
if (!clock.sample(progressed ? lastActivity : undefined, progressed ? lastActivity : undefined)) return;
|
|
139
|
+
finish(new DaemonError(status?.active_tree ? "subject_liveness_timeout" : "operation_observation_lost", `${operation} has no recent productive provider activity`, true, {
|
|
140
|
+
operation_id: request.id,
|
|
141
|
+
last_activity_at: status?.last_activity_at ?? null,
|
|
142
|
+
active_tree: status?.active_tree === true
|
|
143
|
+
}));
|
|
144
|
+
} finally { checking = false; }
|
|
145
|
+
}, poll);
|
|
146
|
+
client.setEncoding("utf8"); client.on("connect", () => client.write(`${JSON.stringify(request)}\n`)); client.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; try { const response = JSON.parse(buffer.slice(0, index)); response.ok ? finish(null, response.result) : finish(new DaemonError(response.error.code, response.error.message, response.error.retryable, response.error.details)); } catch (error) { finish(new DaemonError("daemon_protocol_mismatch", error.message)); } }); client.on("error", error => finish(new DaemonError("daemon_not_running", error.message, true))); client.on("end", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true))); client.on("close", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sameConfig(state, config) { return ["cwd","journal","bin","provider","model","reasoning","mode","projectRoot","ddFlowBin","ddFlowHome","resourceHome","noFlow"].every(key => (state.config?.[key] ?? null) === (config[key] ?? null)); }
|
|
151
|
+
// Called only after authorizeRetainedDaemonResume verifies the old daemon,
|
|
152
|
+
// provider processes and operation ledger. Native outcomes remain historical;
|
|
153
|
+
// their old liveness flags cannot describe the replacement process tree.
|
|
154
|
+
export function retainedAgyState(previous, sessionId) {
|
|
155
|
+
if (!sessionId || !previous?.sessions?.some(session => session.provider_session_id === sessionId)) return {};
|
|
156
|
+
if (previous.shutdown_state !== "clean" || previous.active_tree !== false) return previous;
|
|
157
|
+
return {
|
|
158
|
+
...previous,
|
|
159
|
+
unclaimed_activity: false,
|
|
160
|
+
retained_tree_settlement: { daemon_id: previous.daemon_id, session_id: sessionId, turn_generation: previous.turn_generation ?? 0, evidence: "verified_clean_daemon_shutdown" },
|
|
161
|
+
session_observations: (previous.session_observations ?? []).map(([id, observation]) => [id, id === sessionId ? { ...observation, retained_stop: observation.stop ?? null, stop: null } : observation]),
|
|
162
|
+
descendants: (previous.descendants ?? []).map(child => child.tree_settled === true ? child : { ...child, tree_settled: true, settlement_evidence: "prior_clean_daemon_shutdown", settlement_daemon_id: previous.daemon_id })
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export async function startDaemon(options) {
|
|
166
|
+
const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
167
|
+
const previous = await readState(paths.dir), noFlow = options.noFlow === true, cwd = await realpath(absolute(options.cwd, "--cwd")), config = { cwd, journal: options.journal ? absolute(options.journal, "--journal") : paths.journal, bin: options.bin ?? process.env.DD_AGY_BIN ?? "agy", provider: String(options.provider ?? "google"), model: String(options.model ?? "gemini-3.1-pro-high"), reasoning: String(options.reasoning ?? "high"), mode: String(options.mode ?? "accept-edits"), projectRoot: noFlow ? cwd : await realpath(absolute(options.projectRoot, "--project-root")), ddFlowBin: noFlow ? null : absolute(options.ddFlowBin, "--dd-flow-bin"), ddFlowHome: noFlow ? null : absolute(options.ddFlowHome, "--dd-flow-home"), resourceHome: noFlow ? null : (process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null), env: noFlow ? {} : { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") }, entryPath: absolute(options.entryPath, "dd-agy entry"), daemonId: randomUUID(), resumeSessionId: options.sessionId ?? null, geminiDir: paths.gemini, appDataDir: "runtime", temporary: paths.temporary, noFlow };
|
|
168
|
+
await prepareRuntimeOwner(config, paths.dir);
|
|
169
|
+
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1000); if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration"); return { ...status, already_running: true }; } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; }
|
|
170
|
+
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
171
|
+
if (previous?.shutdown_state === "running" && previous.active_tree) throw new DaemonError("invalid_harness_crash", "previous Antigravity daemon died with an active or unproven tree");
|
|
172
|
+
await removeSocket(paths); await prepare(paths, config);
|
|
173
|
+
const version = (await runAgy(config.bin, ["--version"], { cwd: config.cwd, env: sanitizedEnv(config), timeoutMs: 10_000 })).stdout.trim();
|
|
174
|
+
const resourceProcess = await registerDaemonProcess(config, { kind: "agy-daemon", owner: `agy:${path.basename(paths.dir)}`, operation: `agy-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
|
|
175
|
+
const retained = retainedAgyState(previous, config.resumeSessionId);
|
|
176
|
+
await writeJson(paths.state, { schema_id: STATE_SCHEMA, daemon_id: config.daemonId, shutdown_state: "starting", active_tree: false, unclaimed_activity: retained.unclaimed_activity ?? false, retained_tree_settlement: retained.retained_tree_settlement ?? null, versions: { agy: version }, config, sessions: [], session_observations: retained.session_observations ?? [], descendants: retained.descendants ?? [], tool_calls: retained.tool_calls ?? [], completed_tool_steps: retained.completed_tool_steps ?? [], tool_identity_complete: retained.tool_identity_complete !== false, turn_generation: retained.turn_generation ?? 0, last_result: retained.last_result ?? null, resource_process: resourceProcess });
|
|
177
|
+
const log = await open(paths.log, "a", 0o600), child = spawn(process.execPath, [config.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: sanitizedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
178
|
+
try {
|
|
179
|
+
await confirmDaemonProcess(config, resourceProcess, child);
|
|
180
|
+
const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 100));
|
|
181
|
+
const persisted = await readState(paths.dir);
|
|
182
|
+
if (persisted?.shutdown_state === "failed") {
|
|
183
|
+
const failure = persisted.startup_error ?? {};
|
|
184
|
+
throw new DaemonError(failure.code ?? "daemon_start_failed", failure.message ?? "Antigravity daemon failed before becoming ready", failure.retryable === true, failure.details);
|
|
185
|
+
}
|
|
186
|
+
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1000); if (status.provider_ready) return status; } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; }
|
|
187
|
+
}
|
|
188
|
+
throw new DaemonError("daemon_start_failed", "Antigravity daemon did not become ready", true);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await cleanupFailedStart(config, resourceProcess, child, error);
|
|
191
|
+
const persisted = await readState(paths.dir).catch(() => null);
|
|
192
|
+
if (persisted?.provider_process) {
|
|
193
|
+
try { await runtimeProcess(config, "stop", { id: persisted.provider_process.id, "lease-token": persisted.provider_process.lease_token }); }
|
|
194
|
+
catch (cleanup) { error.details = { ...error.details, provider_cleanup_failed: errorPayload(cleanup), provider_process_id: persisted.provider_process.id }; }
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
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); };
|
|
200
|
+
|
|
201
|
+
export class Runtime {
|
|
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; }
|
|
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 }); } }
|
|
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"])]; }
|
|
206
|
+
requireSessionIdentity(requestedId) {
|
|
207
|
+
const observedId = this.init?.conversation_id ?? null;
|
|
208
|
+
if (!requestedId || requestedId === observedId) return observedId;
|
|
209
|
+
throw new DaemonError("session_identity_mismatch", "requested Antigravity Session is not owned by this daemon", false, {
|
|
210
|
+
harness: "antigravity-cli", expected_session_id: requestedId, observed_session_id: observedId
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
markActivity() { this.lastActivityAt = new Date().toISOString(); }
|
|
214
|
+
observeProviderActivity(event) {
|
|
215
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(event)).digest("hex");
|
|
216
|
+
if (this.lastProviderEvent === fingerprint) return;
|
|
217
|
+
this.lastProviderEvent = fingerprint;
|
|
218
|
+
if (["init", "step_update", "result"].includes(event.event)) this.markActivity();
|
|
219
|
+
}
|
|
220
|
+
async start(sessionId = null) {
|
|
221
|
+
if (this.child && !this.exited) {
|
|
222
|
+
await this.readyPromise;
|
|
223
|
+
this.requireSessionIdentity(sessionId);
|
|
224
|
+
return this.init;
|
|
225
|
+
}
|
|
226
|
+
await this.providerFinalization;
|
|
227
|
+
const target = executable(this.config.bin, this.args(sessionId)); this.exited = false; this.buffer = ""; this.init = null;
|
|
228
|
+
let readyResolve, readyReject; this.readyPromise = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
229
|
+
// The provider can reject before durable registration finishes. Keep a
|
|
230
|
+
// handler attached during that small window; `start()` still returns the
|
|
231
|
+
// original rejection below.
|
|
232
|
+
void this.readyPromise.catch(() => {});
|
|
233
|
+
this.providerProcess = await registerDaemonProcess(this.config, { kind: "agy-provider", owner: `agy:${path.basename(this.paths.dir)}`, operation: `agy-provider:${path.basename(this.paths.dir)}`, stdout: this.paths.log, stderr: this.paths.log });
|
|
234
|
+
const providerProcess = this.providerProcess;
|
|
235
|
+
this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
236
|
+
const providerChild = this.child;
|
|
237
|
+
let allowProviderFinalization;
|
|
238
|
+
const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
|
|
239
|
+
this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; this.draining = this.draining.then(() => this.drain(readyResolve)).catch(error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } }); });
|
|
240
|
+
this.child.stderr.setEncoding("utf8").on("data", chunk => void this.journal("stderr", { text: String(chunk).slice(0, 4000) }));
|
|
241
|
+
this.child.on("error", error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } });
|
|
242
|
+
// A dead leader is not yet a finalized tree: stop must also join the
|
|
243
|
+
// pending drain, process-group check and final persistence/lease writes.
|
|
244
|
+
this.providerFinalization = new Promise((resolve, reject) => {
|
|
245
|
+
this.child.on("close", (code, signal) => {
|
|
246
|
+
void this.draining.then(async () => {
|
|
247
|
+
const interrupted = Boolean(this.active); this.exited = true; const beforeInit = !this.init;
|
|
248
|
+
const error = this.startupError ?? new DaemonError(beforeInit ? "agy_init_missing" : "agy_terminal_result_missing", `Antigravity exited ${beforeInit ? "before init" : "before result"} (${code ?? signal})`);
|
|
249
|
+
if (beforeInit) readyReject(error);
|
|
250
|
+
if (this.active) { this.active.reject(error); this.active = null; }
|
|
251
|
+
const patch = beforeInit ? { shutdown_state: "failed", startup_error: errorPayload(error), provider_exit: { code, signal }, active_tree: false } : { provider_exit: { code, signal }, active_tree: interrupted };
|
|
252
|
+
await providerRegistered;
|
|
253
|
+
this.retryProviderFinalization = async (graceMs = 1_000) => {
|
|
254
|
+
try {
|
|
255
|
+
await stopProcessGroup(providerChild, graceMs);
|
|
256
|
+
await this.persist(patch);
|
|
257
|
+
await finishDaemonProcess(this.config, providerProcess, beforeInit || interrupted ? "failed" : "stopped", beforeInit || interrupted ? "provider_interrupted" : "provider_stopped");
|
|
258
|
+
this.retryProviderFinalization = null;
|
|
259
|
+
} catch (cleanup) { await this.persist({ shutdown_state: "cleanup_failed", active_tree: true, cleanup_error: errorPayload(cleanup) }); throw cleanup; }
|
|
260
|
+
finally { if (beforeInit) this.server?.close(); }
|
|
261
|
+
};
|
|
262
|
+
await this.retryProviderFinalization();
|
|
263
|
+
}).then(resolve, reject);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
void this.providerFinalization.catch(() => {});
|
|
267
|
+
try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_process: providerProcess, provider_pid: this.child.pid }); }
|
|
268
|
+
catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
|
|
269
|
+
finally { allowProviderFinalization(); }
|
|
270
|
+
let readyTimer;
|
|
271
|
+
try { return await Promise.race([this.readyPromise, new Promise((_, reject) => { readyTimer = setTimeout(() => reject(new DaemonError("agy_init_timeout", "Antigravity init timed out", true)), 30_000); })]); }
|
|
272
|
+
finally { clearTimeout(readyTimer); }
|
|
273
|
+
}
|
|
274
|
+
async drain(readyResolve) { let index; while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).trim(); this.buffer = this.buffer.slice(index + 1); if (!line) continue; let event; try { event = JSON.parse(line); } catch { await this.journal("invalid_json", { line: line.slice(0, 1000) }); continue; }
|
|
275
|
+
// Record an early provider rejection before journalling: a short-lived
|
|
276
|
+
// child can close while the asynchronous write is still pending.
|
|
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 });
|
|
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 ?? {}); } } }
|
|
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 }; }
|
|
281
|
+
async finishTurn(result) {
|
|
282
|
+
if (result.status === "RUNNING") { await this.persist({ active_tree: true }); return; }
|
|
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 });
|
|
284
|
+
if (result.conversation_id && result.conversation_id !== this.init?.conversation_id) {
|
|
285
|
+
await this.journal("foreign_terminal_observed", { conversation_id: result.conversation_id, result });
|
|
286
|
+
const child = this.descendants.get(result.conversation_id);
|
|
287
|
+
if (child) this.descendants.set(result.conversation_id, { ...child, status: result.status === "SUCCESS" ? "completed" : result.status === "ERROR" ? "failed" : "cancelled" });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const current = this.active;
|
|
291
|
+
// The ordered stream returns one result per input. Model usage counters
|
|
292
|
+
// are not request IDs: native command rejections can report zero turns.
|
|
293
|
+
// prompt() drains earlier events before accepting another input.
|
|
294
|
+
if (!current && this.lastResult) { await this.journal("stale_terminal_observed", { result }); return; }
|
|
295
|
+
this.lastResult = result; this.markActivity();
|
|
296
|
+
const rootStop = this.sessionObservations.get(this.init?.conversation_id)?.stop;
|
|
297
|
+
const pendingTerminal = current && result.status === "SUCCESS";
|
|
298
|
+
if (pendingTerminal) current.terminalResult = result;
|
|
299
|
+
if (pendingTerminal && rootStop?.fullyIdle === true) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" });
|
|
300
|
+
const terminalReceipt = pendingTerminal ? this.receipt(result) : null;
|
|
301
|
+
await this.persist({ active_tree: pendingTerminal ? !terminalReceipt.settled : result.status === "RUNNING" || rootStop?.fullyIdle === false, last_result: result });
|
|
302
|
+
try { this.usageIngest = await forwardUsage(this.config, result.conversation_id ?? this.init.conversation_id, result, this.toolSnapshot(), [...this.descendants.values()]); }
|
|
303
|
+
catch (error) { this.usageIngest = { status: "failed", error: error.message ?? String(error) }; await this.journal("usage_ingest_failed", this.usageIngest); }
|
|
304
|
+
if (!current) return;
|
|
305
|
+
if (this.active !== current) return;
|
|
306
|
+
if (result.status === "ERROR" || result.status === "CANCELLED") {
|
|
307
|
+
this.active = null;
|
|
308
|
+
const message = String(result.error ?? "Antigravity returned ERROR"); const normalized = message.toLowerCase();
|
|
309
|
+
const code = result.status === "CANCELLED" ? "agy_turn_cancelled" : /quota|insufficient.?credit|billing|usage limit/.test(normalized) ? "agy_provider_quota_exhausted"
|
|
310
|
+
: /rate.?limit|too many requests|\b429\b/.test(normalized) ? "agy_provider_rate_limited"
|
|
311
|
+
: /auth|unauthori[sz]ed|forbidden|account/.test(normalized) ? "agy_provider_account"
|
|
312
|
+
: "agy_provider_failed";
|
|
313
|
+
return current.reject(new DaemonError(code, message, ["agy_provider_quota_exhausted", "agy_provider_rate_limited"].includes(code), { provider_result: result, provider_error_scope: current.previousError && current.previousError === result.error ? "repeated_conversation_error" : "terminal_result", usage_ingest: this.usageIngest }));
|
|
314
|
+
}
|
|
315
|
+
if (terminalReceipt.settled) { this.active = null; current.resolve(terminalReceipt); }
|
|
316
|
+
}
|
|
317
|
+
receipt(result = this.lastResult) {
|
|
318
|
+
const assistantText = typeof result?.response === "string" ? result.response : typeof result?.text === "string" ? result.text : null;
|
|
319
|
+
const root = this.init?.conversation_id; const observation = this.sessionObservations.get(root);
|
|
320
|
+
const treeSettled = [...this.descendants.values()].every(child => child.tree_settled === true || ["completed", "failed", "cancelled", "settled_by_root"].includes(child.status));
|
|
321
|
+
// A terminal root result is the provider's authoritative end-of-turn
|
|
322
|
+
// observation when AGY does not emit a root Stop hook. A received
|
|
323
|
+
// non-idle Stop still wins, and every discovered child must be terminal.
|
|
324
|
+
const rootSettled = observation?.stop?.fullyIdle !== false;
|
|
325
|
+
const terminalPending = this.active?.terminalResult === result;
|
|
326
|
+
return { harness: "antigravity-cli", runtime_family: "antigravity", provider_session_id: root ?? null, adapter_session_id: root ?? null, cwd: this.config.cwd, profile: this.profile, result, ...(assistantText === null ? {} : { assistant_text: assistantText }), usage: usageSnapshot(result ?? {}, this.toolSnapshot()), usage_ingest: this.usageIngest, descendants: [...this.descendants.values()], transcript_path: observation?.transcript_path ?? null, settled: Boolean(!this.state.unclaimed_activity && (!this.active || terminalPending) && result && ["SUCCESS", "ERROR", "CANCELLED"].includes(result.status) && treeSettled && rootSettled) };
|
|
327
|
+
}
|
|
328
|
+
async zeroUsage() { await forwardUsage(this.config, this.init.conversation_id, { usage: {} }, this.toolSnapshot(), []); }
|
|
329
|
+
async prompt(text, assertDispatch = captureDispatchGuard(this)) {
|
|
330
|
+
if (!text) throw new DaemonError("prompt_required", "prompt is required");
|
|
331
|
+
await this.start(); await this.draining;
|
|
332
|
+
if (this.active) throw new DaemonError("operation_in_progress", "an Antigravity turn is already running");
|
|
333
|
+
if (this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled)) throw new DaemonError("tree_not_settled", "the prior Antigravity agent tree is not fully settled");
|
|
334
|
+
let resolveTurn, rejectTurn;
|
|
335
|
+
const reply = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject; });
|
|
336
|
+
// Cancellation can reject while admission persistence is still pending.
|
|
337
|
+
void reply.catch(() => {});
|
|
338
|
+
const current = { resolve: resolveTurn, reject: rejectTurn, generation: ++this.turnGeneration, previousError: this.lastResult?.error ?? null };
|
|
339
|
+
// Reserve before persistence so different operation IDs cannot overlap.
|
|
340
|
+
this.active = current;
|
|
341
|
+
const root = this.init?.conversation_id, prior = this.sessionObservations.get(root) ?? {};
|
|
342
|
+
this.sessionObservations.set(root, { ...prior, step_floor: prior.last_step_index ?? null, stop: null, last_hook: null });
|
|
343
|
+
this.lastProviderEvent = null;
|
|
344
|
+
try {
|
|
345
|
+
await this.persist({ active_tree: true });
|
|
346
|
+
assertDispatch();
|
|
347
|
+
if (this.active !== current) throw new DaemonError("operation_cancelled", "prompt reservation was superseded");
|
|
348
|
+
this.child.stdin.write(`${JSON.stringify({ event: "user", message: { content: text } })}\n`, error => {
|
|
349
|
+
if (error) { if (this.active === current) this.active = null; current.reject(error); }
|
|
350
|
+
});
|
|
351
|
+
} catch (error) { if (this.active === current) this.active = null; current.reject(error); }
|
|
352
|
+
return await reply;
|
|
353
|
+
}
|
|
354
|
+
childActivity(sessionId) {
|
|
355
|
+
if (sessionId === this.init?.conversation_id && !this.active) this.state.unclaimed_activity = true;
|
|
356
|
+
const child = this.descendants.get(sessionId);
|
|
357
|
+
if (!child) return;
|
|
358
|
+
this.descendants.set(sessionId, { ...child, previous_terminal_status: child.status === "unknown" ? child.previous_terminal_status : child.status, status: "unknown", tree_settled: false, settlement_evidence: null, settlement_daemon_id: null, activity_generation: this.turnGeneration });
|
|
359
|
+
}
|
|
360
|
+
async childForHook(conversationId) {
|
|
361
|
+
if (conversationId === this.init?.conversation_id) return null;
|
|
362
|
+
const deadline = Date.now() + 1_000;
|
|
363
|
+
while (!this.descendants.has(conversationId) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
|
|
364
|
+
const child = this.descendants.get(conversationId);
|
|
365
|
+
if (!child) throw new DaemonError("agy_child_identity_unconfirmed", "Antigravity hook has no confirmed native parent", true, { conversation_id: conversationId });
|
|
366
|
+
return child;
|
|
367
|
+
}
|
|
368
|
+
async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const settled = terminalPending ? this.receipt(this.lastResult) : null; const completed = settled?.settled ? this.active : null; if (completed) this.active = null; const parent = child?.parent_provider_session_id ?? null;
|
|
369
|
+
const observed = payload.model ? observedProfile(payload) : {};
|
|
370
|
+
await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: conversationId, parentSessionId: parent, requested: this.config, observed, source: "native.hook", evidence: payload.model ? "configured" : "unavailable", reason: payload.model ? null : "hook_omits_current_model" });
|
|
371
|
+
const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, turn_generation: this.turnGeneration, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(completed ? { active_tree: false } : {}); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null }); completed?.resolve(settled); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
|
|
372
|
+
}
|
|
373
|
+
async finalizeProviderForControl() {
|
|
374
|
+
const pending = this.providerFinalization;
|
|
375
|
+
try { await pending; }
|
|
376
|
+
catch (error) {
|
|
377
|
+
// A leaderless group may have exited naturally since the last check.
|
|
378
|
+
// Only control may recheck it; startup never retries failed cleanup.
|
|
379
|
+
if (this.providerFinalization !== pending) return await this.providerFinalization;
|
|
380
|
+
if (error.code !== "process_group_ownership_unknown" || !this.retryProviderFinalization) throw error;
|
|
381
|
+
this.providerFinalization = this.retryProviderFinalization(5_000);
|
|
382
|
+
await this.providerFinalization;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async cancel() { cancelPendingDispatch(this); if (!this.child) return { ...this.receipt(), settled: Boolean(this.exited) }; if (!this.exited) { try { process.kill(-this.child.pid, "SIGINT"); } catch (error) { if (error.code !== "ESRCH") throw error; } const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await stopProcessGroup(this.child, 5_000); } await this.finalizeProviderForControl(); await this.persist({ active_tree: false }); return { ...this.receipt(), cancelled: true, settled: Boolean(this.exited) };
|
|
386
|
+
}
|
|
387
|
+
async close(cancelTree) { if (cancelTree) cancelPendingDispatch(this); if (!cancelTree && (this.active || this.state.active_tree || this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled))) throw new DaemonError("tree_not_settled", "Antigravity root or child tree is still running"); if (cancelTree) await this.cancel(); else if (this.child && !this.exited) this.child.stdin.end(); const deadline = Date.now() + 5000; while (!this.exited && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); if (!this.exited) await this.cancel(); await this.providerFinalization; }
|
|
388
|
+
async abortProvider(reason) {
|
|
389
|
+
const child = this.child, providerProcess = this.providerProcess;
|
|
390
|
+
await stopProcessGroup(child);
|
|
391
|
+
await this.finalizeProviderForControl();
|
|
392
|
+
await finishDaemonProcess(this.config, providerProcess, "failed", reason);
|
|
393
|
+
}
|
|
394
|
+
async finishResource(state = "stopped", reason = "daemon_stopped") { await finishDaemonProcess(this.config, this.state.resource_process, state, reason); }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function serveDaemon(stateDir) {
|
|
398
|
+
const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
|
|
399
|
+
const runtime = new Runtime(paths, state); await removeSocket(paths); const server = net.createServer(socket => { let buffer = ""; socket.setEncoding("utf8"); socket.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; let request; try { request = JSON.parse(buffer.slice(0, index)); } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, ok: false, error: errorPayload(error) })}\n`); return; } void durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} })).then(result => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result })}\n`), error => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: false, error: errorPayload(error) })}\n`)); }); }); runtime.server = server;
|
|
400
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
|
|
401
|
+
try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
|
|
402
|
+
catch (error) {
|
|
403
|
+
await runtime.abortProvider("daemon_start_failed");
|
|
404
|
+
await runtime.persist({ shutdown_state: "failed", startup_error: errorPayload(error), active_tree: false }); await runtime.finishResource("failed", "daemon_start_failed");
|
|
405
|
+
await new Promise(resolve => server.close(resolve));
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
await new Promise(resolve => server.once("close", resolve));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function dispatch(runtime, request) {
|
|
412
|
+
if (request.schema_id !== REQUEST_SCHEMA) throw new DaemonError("daemon_protocol_mismatch", "unsupported dd-agy request schema"); const params = request.params ?? {};
|
|
413
|
+
if (request.operation === "daemon.status") return { daemon_id: runtime.config.daemonId, shutdown_state: runtime.state.shutdown_state, active_tree: runtime.state.active_tree, last_activity_at: runtime.lastActivityAt, provider_ready: Boolean(runtime.init && !runtime.exited), pid: process.pid, provider_pid: runtime.child?.pid ?? null, versions: runtime.state.versions, config: runtime.config, sessions: runtime.state.sessions ?? [], receipt: runtime.receipt() };
|
|
414
|
+
if (request.operation === "session.create") { if (params.sessionId) throw new DaemonError("invalid_create", "create does not accept a Session ID"); if (params.prompt) throw new DaemonError("invalid_create", "create does not execute a prompt; use session.prompt"); await runtime.start(); await runtime.zeroUsage(); return runtime.receipt(); }
|
|
415
|
+
if (request.operation === "session.prompt") { const assertDispatch = captureDispatchGuard(runtime); await runtime.start(params.sessionId ?? null); runtime.requireSessionIdentity(params.sessionId); return await runtime.prompt(params.prompt, assertDispatch); }
|
|
416
|
+
if (request.operation === "session.inspect") { runtime.requireSessionIdentity(params.sessionId); return runtime.receipt(); }
|
|
417
|
+
if (request.operation === "session.resume") { await runtime.start(params.sessionId ?? runtime.init?.conversation_id ?? null); runtime.requireSessionIdentity(params.sessionId); return runtime.receipt(); }
|
|
418
|
+
if (request.operation === "session.cancel") { runtime.requireSessionIdentity(params.sessionId); return await runtime.cancel(); }
|
|
419
|
+
if (request.operation === "hook.observe") return await runtime.observeHook(params.event, params.payload ?? {});
|
|
420
|
+
if (request.operation === "daemon.stop") { await runtime.close(params.cancelTree === true); await runtime.persist({ shutdown_state: "clean", active_tree: false }); await runtime.finishResource(); setImmediate(() => runtime.server?.close()); return { stopped: true, clean: true, settled: true, daemon_id: runtime.config.daemonId }; }
|
|
421
|
+
throw new DaemonError("unknown_operation", `unknown dd-agy daemon operation: ${request.operation}`);
|
|
422
|
+
}
|
|
423
|
+
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { checkObservedProfile } from "./model-observations.mjs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MODEL = "gemini-3.1-pro-high";
|
|
9
|
+
|
|
10
|
+
export class AgyError extends Error {
|
|
11
|
+
constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function executable(bin, args = []) {
|
|
15
|
+
return /\.[cm]?js$/.test(bin) ? { command: process.execPath, args: [bin, ...args] } : { command: bin, args };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runAgy(bin, args, options = {}) {
|
|
19
|
+
const target = executable(bin, args);
|
|
20
|
+
return await new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn(target.command, target.args, { cwd: options.cwd, env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
22
|
+
let stdout = "", stderr = "", settled = false;
|
|
23
|
+
const timer = setTimeout(() => { child.kill("SIGTERM"); finish(new AgyError("agy_timeout", `${args[0] ?? "agy"} timed out`, true)); }, options.timeoutMs ?? 30_000);
|
|
24
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(value); };
|
|
25
|
+
child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk);
|
|
26
|
+
child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
|
|
27
|
+
child.on("error", error => finish(error));
|
|
28
|
+
child.on("close", code => code === 0 ? finish(null, { stdout, stderr, code }) : finish(new AgyError("agy_cli_failed", stderr.trim() || stdout.trim() || `agy exited ${code}`, false, { exit_code: code })));
|
|
29
|
+
child.stdin.end(options.stdin ?? "");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function usageSnapshot(result = {}, toolCalls = {}) {
|
|
34
|
+
const usage = result.usage && typeof result.usage === "object" ? result.usage : {};
|
|
35
|
+
const numberOrNull = value => value == null || !Number.isFinite(Number(value)) ? null : Number(value);
|
|
36
|
+
return {
|
|
37
|
+
// A missing counter is unknown, not a measured zero.
|
|
38
|
+
input_tokens: numberOrNull(usage.input_tokens), output_tokens: numberOrNull(usage.output_tokens),
|
|
39
|
+
reasoning_tokens: numberOrNull(usage.thinking_tokens), cache_read_tokens: numberOrNull(usage.cache_read_tokens),
|
|
40
|
+
total_tokens: numberOrNull(usage.total_tokens), tool_calls: toolCalls
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function observedProfile(init = {}, requested = {}) {
|
|
45
|
+
return { provider: init.provider ?? null, model: init.model ?? null, reasoning: init.reasoning ?? null, mode: init.mode ?? null, permission_mode: init.permission_mode ?? null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertProfile(requested, observed) { return checkObservedProfile(requested, observed, { required: requested.permission_mode ? ["permission_mode"] : [] }); }
|
|
49
|
+
|
|
50
|
+
export async function doctor(options = {}) {
|
|
51
|
+
const bin = options.bin ?? process.env.DD_AGY_BIN ?? "agy", temporary = await mkdtemp(path.join(os.tmpdir(), "dd-agy-doctor-"));
|
|
52
|
+
try {
|
|
53
|
+
const version = (await runAgy(bin, ["--version"], { timeoutMs: 10_000 })).stdout.trim();
|
|
54
|
+
const models = await runAgy(bin, [`--gemini_dir=${temporary}`, "--app_data_dir=runtime", "models"], { timeoutMs: options.timeoutMs ?? 30_000 });
|
|
55
|
+
const modelIds = models.stdout.split(/\r?\n/).map(line => line.split(/\s+/)[0]).filter(Boolean);
|
|
56
|
+
if (!modelIds.includes(options.model ?? DEFAULT_MODEL)) throw new AgyError("agy_model_unavailable", `required Antigravity model is unavailable: ${options.model ?? DEFAULT_MODEL}`);
|
|
57
|
+
return { compatible: true, observed_runtime: { agy: version, dd_harness_contract: "dd-agy-harness@1" }, isolation: { gemini_dir: "verified", app_data_dir: "verified" }, auth: "available", models: modelIds };
|
|
58
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function callDaemon(stateDir: string, operation: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, unknown>>;
|