@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75
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 +404 -0
- package/README.md +65 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- 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.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -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 +121 -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.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/runtime/context.js +3 -1
- 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/harness-config.schema.json +23 -0
- 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/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -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 +211 -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 +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -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 +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +224 -216
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +605 -157
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { processSnapshot } from "./process-snapshot.mjs";
|
|
2
|
+
import { ToolObservations, droidToolObservations, journalToolObservations } from "./tool-observations.mjs";
|
|
3
|
+
export { processSnapshot } from "./process-snapshot.mjs";
|
|
4
|
+
import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { appendFile, chmod, copyFile, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { spawn, execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
|
|
12
|
+
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
13
|
+
import { errorRecord, reportedError } from "./operation-errors.mjs";
|
|
14
|
+
import { checkObservedProfile, observeModel } from "./model-observations.mjs";
|
|
15
|
+
import { DroidMetadataReader, readDroidRoutingLog } from "./droid-observation.mjs";
|
|
16
|
+
const metadataReaders = new Map();
|
|
17
|
+
function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataReaders.set(factory, new DroidMetadataReader(factory)); return metadataReaders.get(factory); }
|
|
18
|
+
import { confirmDaemonProcess, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
19
|
+
import { renderAdapterPolicy } from "./delegation-instructions.mjs";
|
|
20
|
+
|
|
21
|
+
const run = promisify(execFile);
|
|
22
|
+
export const DROID_CONTRACT = "dd-droid-harness@1";
|
|
23
|
+
export const DROID_PROTOCOL = "1.201.0";
|
|
24
|
+
const LATE_RESPONSE_GRACE_MS = 5_000;
|
|
25
|
+
export class DroidError extends Error {
|
|
26
|
+
constructor(code, message, details) { super(message); this.code = code; if (details) this.details = details; }
|
|
27
|
+
}
|
|
28
|
+
export const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw new DroidError("invalid_path", `${label} must be absolute`); return path.resolve(value); };
|
|
29
|
+
export const executable = (bin, args = []) => /\.[cm]?js$/.test(bin) ? { command: process.execPath, args: [bin, ...args] } : { command: bin, args };
|
|
30
|
+
const shell = value => `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
31
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
32
|
+
const responseSummary = event => ({ request_id: event?.id ?? null, has_result: event?.result !== undefined,
|
|
33
|
+
result_keys: event?.result && typeof event.result === "object" && !Array.isArray(event.result) ? Object.keys(event.result).slice(0, 32) : [], error: event?.error ?? null });
|
|
34
|
+
export async function readJson(file) { try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
35
|
+
export function droidPaths(stateDir) {
|
|
36
|
+
const dir = absolute(stateDir, "state directory"), digest = createHash("sha256").update(dir).digest("hex").slice(0, 24);
|
|
37
|
+
const local = path.join(dir, "daemon.sock");
|
|
38
|
+
return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-droid-${digest}.sock`, state: path.join(dir, "daemon.json"), log: path.join(dir, "daemon.log"), home: path.join(dir, "home"), factory: path.join(dir, "home", ".factory"), bindings: path.join(dir, "native-operations") };
|
|
39
|
+
}
|
|
40
|
+
export function droidEnvironment(config) {
|
|
41
|
+
const env = { ...process.env, FACTORY_HOME_OVERRIDE: config.home, FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome, DD_FLOW_BIN: config.ddFlowBin } : {}) };
|
|
42
|
+
// This adapter deliberately uses the operator's existing Factory login.
|
|
43
|
+
for (const key of Object.keys(env)) if (key.endsWith("API_KEY")) delete env[key];
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
46
|
+
export async function prepareDroidHome(config) {
|
|
47
|
+
const paths = droidPaths(config.stateDir);
|
|
48
|
+
for (const dir of [paths.dir, paths.home, paths.factory, paths.bindings, path.join(paths.factory, "droids")]) { await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); }
|
|
49
|
+
const auth = path.join(config.authHome ?? os.homedir(), ".factory");
|
|
50
|
+
for (const name of ["auth.encrypted", "auth.v2.key", "auth.v2.file", "auth.json"]) {
|
|
51
|
+
try { await copyFile(path.join(auth, name), path.join(paths.factory, name)); await chmod(path.join(paths.factory, name), 0o600); }
|
|
52
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
53
|
+
}
|
|
54
|
+
await writeJsonAtomic(path.join(paths.factory, "settings.json"), { cloudSessionSync: false, enabledPlugins: {}, enableHooks: true, hooksDisabled: false, enableCustomDroids: true, ideAutoConnect: false, subagentAutonomyLevel: "inherit" });
|
|
55
|
+
const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --state-dir ${shell(paths.dir)}`;
|
|
56
|
+
const hooks = Object.fromEntries(["SessionStart", "PreToolUse", "PostToolUse", "Stop", "SessionEnd", "SubagentStop"].map(event => [event, [{ matcher: "*", hooks: [{ type: "command", command, timeout: 30 }] }]]));
|
|
57
|
+
await writeJsonAtomic(path.join(paths.factory, "hooks.json"), hooks);
|
|
58
|
+
const worker = `---\nname: dd-flow-worker\ndescription: Execute one explicit dd-flow Work packet or a technical capacity marker\nmodel: ${config.model}\nreasoningEffort: ${config.reasoning}\nmcpServers: []\n---\nFor a Work assignment, your first technical action is the exact standalone work start command supplied by the coordinator. If that command is absent, report an incomplete packet and stop: do not run stage pause, stage finish, or another lifecycle command. Follow only the returned Work packet, invoke its exact work finish or fail command, then stop. A Task child never executes or finishes its coordinator's Stage Work. Do not create children or take another Work. For a technical capacity marker, only return the requested marker; do not read the project or call dd-flow.\n`;
|
|
59
|
+
await writeFile(path.join(paths.factory, "droids", "dd-flow-worker.md"), worker, { mode: 0o600 });
|
|
60
|
+
return { hooks_sha256: createHash("sha256").update(JSON.stringify(hooks)).digest("hex"), worker_sha256: createHash("sha256").update(worker).digest("hex") };
|
|
61
|
+
}
|
|
62
|
+
export async function removeDroidAuth(config) {
|
|
63
|
+
for (const name of ["auth.encrypted", "auth.v2.key", "auth.v2.file", "auth.json"]) await unlink(path.join(config.home, ".factory", name)).catch(error => { if (error.code !== "ENOENT") throw error; });
|
|
64
|
+
}
|
|
65
|
+
export async function droidVersion(bin) { const target = executable(bin, ["--version"]); return (await run(target.command, target.args, { timeout: 10_000 })).stdout.trim(); }
|
|
66
|
+
|
|
67
|
+
/** The qualified OpenAI profile reports uncached input separately from caches;
|
|
68
|
+
* output already includes thinking. Keep native counters alongside normalization. */
|
|
69
|
+
export function physicalUsage(value) {
|
|
70
|
+
if (!value || !["inputTokens", "outputTokens", "cacheReadTokens", "cacheCreationTokens", "thinkingTokens"].every(key => Number.isFinite(value[key]) && value[key] >= 0)) return null;
|
|
71
|
+
const input = value.inputTokens + value.cacheReadTokens + value.cacheCreationTokens;
|
|
72
|
+
return { totalTokens: input + value.outputTokens, inputTokens: input, uncachedInputTokens: value.inputTokens, cacheReadTokens: value.cacheReadTokens, cacheCreationTokens: value.cacheCreationTokens, outputTokens: value.outputTokens, reasoningTokens: value.thinkingTokens, native: value };
|
|
73
|
+
}
|
|
74
|
+
export function parseDroidTranscript(text, settings, expectedId) {
|
|
75
|
+
const records = []; let complete = true;
|
|
76
|
+
for (const line of text.split("\n")) { if (!line.trim()) continue; try { records.push(JSON.parse(line)); } catch { complete = false; } }
|
|
77
|
+
const header = records.find(record => record.type === "session_start");
|
|
78
|
+
if (!header || header.id !== expectedId) throw new DroidError("session_identity_mismatch", "Droid transcript has a different or missing native Session ID", { expected_session_id: expectedId, observed_session_id: header?.id ?? null });
|
|
79
|
+
const tools = new ToolObservations();
|
|
80
|
+
for (const record of records) for (const event of droidToolObservations(record, expectedId)) tools.add(event);
|
|
81
|
+
if (!complete) tools.gaps.add("transcript_incomplete");
|
|
82
|
+
return { header, records, outcomes: records.filter(record => record.type === "agent_turn_outcome"), usage: physicalUsage(settings?.tokenUsage), settings, complete, tool_calls: tools.summary() };
|
|
83
|
+
}
|
|
84
|
+
export async function readDroidSession(factory, id) {
|
|
85
|
+
const file = await metadataReader(factory).locate(id);
|
|
86
|
+
if (!file) return null;
|
|
87
|
+
const text = await readFile(file, "utf8"); let settings = null, complete = true;
|
|
88
|
+
try { settings = await readJson(file.replace(/\.jsonl$/, ".settings.json")); } catch (error) { if (!(error instanceof SyntaxError)) throw error; complete = false; }
|
|
89
|
+
const parsed = parseDroidTranscript(text, settings, id);
|
|
90
|
+
return { ...parsed, complete: parsed.complete && complete, transcript_path: file, source_sha256: createHash("sha256").update(text).digest("hex") };
|
|
91
|
+
}
|
|
92
|
+
const terminal = value => ["completed", "cancelled", "failed", "process_exit", "error"].includes(value);
|
|
93
|
+
const status = value => value === "completed" ? "completed" : ["cancelled", "process_exit"].includes(value) ? "cancelled" : ["failed", "error"].includes(value) ? "failed" : "unknown";
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
function descendants(rows, rootPid) { const ids = new Set([rootPid]); for (;;) { const size = ids.size; for (const row of rows) if (ids.has(row.ppid)) ids.add(row.pid); if (size === ids.size) return rows.filter(row => ids.has(row.pid)); } }
|
|
97
|
+
|
|
98
|
+
export class DroidRuntime {
|
|
99
|
+
constructor(config, state, persist) {
|
|
100
|
+
this.config = config; this.state = state; this.paths = droidPaths(config.stateDir); this.persist = persist; this.pending = new Map(); this.lateResponses = new Map(); this.active = null; this.child = null; this.closed = true; this.loaded = false; this.ownedProcesses = state.owned_processes ?? []; this.closing = false; this.workingState = "idle"; this.protocol = null; this.rootId = state.root_session_id ?? null; this.topology = new Map(state.descendants?.map(child => [child.provider_session_id, child]) ?? []); this.draining = Promise.resolve(); this.journalling = Promise.resolve(); this.activity = 0; this.providerProcess = null; this.cancelled = false; this.lastResult = state.last_result ?? null; this.metadata = metadataReader(this.paths.factory); this.fatalError = state.fatal_error ? reportedError(state.fatal_error) : null;
|
|
101
|
+
}
|
|
102
|
+
async journal(kind, payload) { const line = JSON.stringify({ schema_id: "dd-droid/event@1", observed_at: new Date().toISOString(), kind, ...payload }); this.journalling = this.journalling.then(() => appendFile(this.config.journal, `${line}\n`, { mode: 0o600 })); return await this.journalling; }
|
|
103
|
+
async save(patch = {}) { return await this.persist({ root_session_id: this.rootId, descendants: [...this.topology.values()], working_state: this.workingState, last_activity: this.activity, ...patch }); }
|
|
104
|
+
async start() {
|
|
105
|
+
if (this.child && !this.closed) return;
|
|
106
|
+
this.closed = false; this.loaded = false; this.closing = false; this.protocol = null; this.buffer = "";
|
|
107
|
+
const args = ["exec", "--input-format", "stream-jsonrpc", "--output-format", "stream-jsonrpc"];
|
|
108
|
+
const target = executable(this.config.bin, args);
|
|
109
|
+
this.providerProcess = await registerDaemonProcess(this.config, { kind: "droid-provider", owner: `droid:${this.config.daemonId}`, operation: `droid-provider:${randomUUID()}`, stdout: this.paths.log, stderr: this.paths.log });
|
|
110
|
+
this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: droidEnvironment(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
111
|
+
this.child.stdout.setEncoding("utf8").on("data", chunk => { this.buffer += chunk; let at; while ((at = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, at); this.buffer = this.buffer.slice(at + 1); this.draining = this.draining.then(async () => { if (!line.trim()) return; await this.event(JSON.parse(line)); }).catch(async error => { await this.handleObservationError(error); if (!this.fatalError) this.rejectPending(error); }); } });
|
|
112
|
+
this.child.stderr.setEncoding("utf8").on("data", chunk => { void this.journal("stderr", { text: chunk.slice(0, 4000) }).catch(() => {}); });
|
|
113
|
+
this.child.on("error", error => this.rejectPending(error));
|
|
114
|
+
this.child.on("close", (code, signal) => { this.closed = true; void this.draining.finally(() => { const error = new DroidError("operation_observation_lost", "Droid process exited before a confirmed reply", { code, signal }); this.rejectPending(error); }); });
|
|
115
|
+
await this.captureProcesses(); await confirmDaemonProcess(this.config, this.providerProcess, this.child); await heartbeatDaemonProcess(this.config, this.providerProcess); await this.save({ provider_pid: this.child.pid, provider_process: this.providerProcess });
|
|
116
|
+
clearInterval(this.processObserver);
|
|
117
|
+
this.processObserver = setInterval(() => { if (!this.processObservation) this.processObservation = this.captureProcesses().catch(error => { this.ownershipError = error; }).finally(() => { this.processObservation = null; }); }, 1_000);
|
|
118
|
+
this.processObserver.unref();
|
|
119
|
+
const models = await this.request("droid.list_models", {});
|
|
120
|
+
if (this.protocol !== DROID_PROTOCOL) throw new DroidError("droid_protocol_mismatch", "Unqualified Factory protocol", { expected: DROID_PROTOCOL, observed: this.protocol });
|
|
121
|
+
this.models = models.models ?? [];
|
|
122
|
+
clearInterval(this.modelObserver);
|
|
123
|
+
this.modelObserver = setInterval(() => {
|
|
124
|
+
if (!this.loaded || this.closing || this.modelObservation) return;
|
|
125
|
+
this.modelObservation = this.observeModels().catch(error => this.handleObservationError(error)).finally(() => { this.modelObservation = null; });
|
|
126
|
+
}, 2_000);
|
|
127
|
+
this.modelObserver.unref();
|
|
128
|
+
}
|
|
129
|
+
rejectPending(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); if (this.active && !this.closing) this.active.reject(error); }
|
|
130
|
+
async event(event) {
|
|
131
|
+
await this.journal("native", { event });
|
|
132
|
+
if (event.type === "response") {
|
|
133
|
+
this.protocol ??= event.factoryProtocolVersion;
|
|
134
|
+
const pending = this.pending.get(event.id); if (!pending) { this.lateResponses.set(event.id, event); while (this.lateResponses.size > 128) this.lateResponses.delete(this.lateResponses.keys().next().value); await this.journal("late_response", responseSummary(event)); return; }
|
|
135
|
+
this.pending.delete(event.id); clearTimeout(pending.timer);
|
|
136
|
+
if (pending.timedOut) await this.journal("late_response", { ...responseSummary(event), method: pending.method, grace_ms: LATE_RESPONSE_GRACE_MS });
|
|
137
|
+
event.error ? pending.reject(new DroidError("droid_rpc_error", event.error.message ?? "Droid rejected request", event.error)) : pending.resolve(event.result ?? {}); return;
|
|
138
|
+
}
|
|
139
|
+
if (event.method === "droid.request_permission" || event.method === "droid.ask_user") {
|
|
140
|
+
const result = event.method === "droid.request_permission" ? { selectedOption: "cancel" } : { cancelled: true, answers: [] };
|
|
141
|
+
this.send({ jsonrpc: "2.0", type: "response", factoryApiVersion: "1.0.0", factoryProtocolVersion: DROID_PROTOCOL, id: event.id, result });
|
|
142
|
+
await this.journal("unexpected_input_rejected", { method: event.method }); return;
|
|
143
|
+
}
|
|
144
|
+
if (event.method !== "droid.session_notification") return;
|
|
145
|
+
const { sessionId, notification: n } = event.params ?? {}; if (!n) return;
|
|
146
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(event.params)).digest("hex");
|
|
147
|
+
if (fingerprint !== this.lastEvent) { this.activity += 1; this.lastEvent = fingerprint; }
|
|
148
|
+
if (sessionId && this.rootId && sessionId !== this.rootId && !this.topology.has(sessionId)) throw new DroidError("session_identity_mismatch", "Unrelated native Session notification");
|
|
149
|
+
if (n.type === "droid_working_state_changed" && (!sessionId || sessionId === this.rootId)) this.workingState = n.newState;
|
|
150
|
+
if (n.type === "child_session_available") {
|
|
151
|
+
if (!this.rootId || sessionId !== this.rootId || !n.childSessionId || !n.toolUseId) throw new DroidError("droid_child_identity_invalid", "Native child event lacks physical parent or tool identity");
|
|
152
|
+
this.topology.set(n.childSessionId, { provider_session_id: n.childSessionId, parent_provider_session_id: sessionId, tool_use_id: n.toolUseId, status: "running", source: "droid.child_session_available" }); await this.save();
|
|
153
|
+
}
|
|
154
|
+
if (n.type === "settings_updated" && this.loaded) await this.observeSettings(sessionId ?? this.rootId, n.settings, { kind: "native.settings_updated", event_sha256: fingerprint });
|
|
155
|
+
const current = this.active;
|
|
156
|
+
if (n.type === "create_message" && n.requestId === current?.requestId) {
|
|
157
|
+
if (n.message?.id !== current.turnId) throw new DroidError("droid_turn_identity_mismatch", "Droid did not preserve the requested message ID");
|
|
158
|
+
current.accepted = true; await this.saveBinding(current, { accepted: true });
|
|
159
|
+
}
|
|
160
|
+
if (n.type === "assistant_text_delta" && current) current.text += n.textDelta ?? "";
|
|
161
|
+
if (n.type === "agent_turn_completed" && current && n.turnId === current.turnId && (!sessionId || sessionId === this.rootId)) {
|
|
162
|
+
current.outcome = n; this.lastResult = n; await this.saveBinding(current, { native_outcome: n, assistant_text: current.text }); current.resolve(n);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
request(method, params, id = randomUUID(), timeoutMs = 30_000) {
|
|
166
|
+
if (!this.child || this.closed) return Promise.reject(new DroidError("operation_observation_lost", "Droid runtime is not connected"));
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const timer = observedTimeout(() => {
|
|
169
|
+
const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
|
|
170
|
+
pending.timedOut = true;
|
|
171
|
+
const timeoutError = new DroidError("rpc_timeout", `${method} has no observed reply`, { request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS });
|
|
172
|
+
void this.journal("request_deadline", { method, request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS });
|
|
173
|
+
pending.timer = observedTimeout(() => { if (this.pending.get(id) !== pending) return; this.pending.delete(id); reject(timeoutError); }, LATE_RESPONSE_GRACE_MS);
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
this.pending.set(id, { resolve, reject, timer, method });
|
|
176
|
+
try { this.send({ jsonrpc: "2.0", type: "request", factoryApiVersion: "1.0.0", factoryProtocolVersion: DROID_PROTOCOL, id, method, params }, error => { if (error) { clearTimeout(timer); this.pending.delete(id); reject(error); } }); }
|
|
177
|
+
catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
send(value, callback) {
|
|
181
|
+
const stdin = this.child?.stdin;
|
|
182
|
+
if (!stdin || stdin.destroyed) {
|
|
183
|
+
const error = new DroidError("bridge_pipe_broken", "Droid runtime stdin is closed");
|
|
184
|
+
this.rejectPending(error);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
try { stdin.write(`${JSON.stringify(value)}\n`, callback); }
|
|
188
|
+
catch (error) { this.rejectPending(error); throw error; }
|
|
189
|
+
}
|
|
190
|
+
profile(settings) {
|
|
191
|
+
const model = settings?.modelId ?? settings?.model ?? null;
|
|
192
|
+
const nativeModel = this.models?.find(item => (item.id ?? item.modelId) === model);
|
|
193
|
+
const result = { provider: settings?.provider ?? nativeModel?.provider ?? null, model, reasoning: settings?.reasoningEffort ?? null, mode: settings?.autonomyMode ?? (settings?.autonomyLevel ? `auto-${settings.autonomyLevel}` : null) };
|
|
194
|
+
checkObservedProfile(this.config, result, { required: this.config.mode ? ["mode"] : [] });
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
async observeSettings(id, settings, source, extra = {}) {
|
|
198
|
+
const observed = this.profile(settings);
|
|
199
|
+
const event = await observeModel({ journal: this.config.journal, harness: "droid-cli", sessionId: id, parentSessionId: id === this.rootId ? null : this.rootId,
|
|
200
|
+
requested: this.config, observed, source, ...extra });
|
|
201
|
+
if (id === this.rootId) this.observedProfile = observed;
|
|
202
|
+
this.modelEventIds ??= new Map();
|
|
203
|
+
if (event && this.modelEventIds.get(id) !== event.id) {
|
|
204
|
+
this.modelEventIds.set(id, event.id);
|
|
205
|
+
await this.save({ model_observation: { last_event_id: event.id, observed_at: event.observed_at } });
|
|
206
|
+
}
|
|
207
|
+
return observed;
|
|
208
|
+
}
|
|
209
|
+
validateMetadata(id, native) {
|
|
210
|
+
if (!native || path.resolve(native.header.cwd) !== this.config.cwd || native.header.id !== id || (id !== this.rootId && native.header.callingSessionId !== this.rootId)) throw new DroidError("session_identity_mismatch", "Native model observation is outside the owned Session tree");
|
|
211
|
+
if (id !== this.rootId && !this.topology.has(id)) this.topology.set(id, { provider_session_id: id, parent_provider_session_id: this.rootId, tool_use_id: native.header.callingToolUseId, status: "unknown", source: "native.metadata" });
|
|
212
|
+
}
|
|
213
|
+
async observeModels() {
|
|
214
|
+
if (!this.rootId || !this.loaded) return;
|
|
215
|
+
const catalog = path.join(this.paths.factory, "task-invocations.json");
|
|
216
|
+
const info = await stat(catalog).catch(error => { if (error.code === "ENOENT") return null; throw error; });
|
|
217
|
+
if (info && info.mtimeMs !== this.modelCatalogMtime) {
|
|
218
|
+
const value = await readJson(catalog);
|
|
219
|
+
for (const task of value?.invocations ?? []) if (task.parentSessionId === this.rootId && task.childSessionId) {
|
|
220
|
+
const native = await this.metadata.read(task.childSessionId);
|
|
221
|
+
if (native) this.validateMetadata(task.childSessionId, native);
|
|
222
|
+
}
|
|
223
|
+
this.modelCatalogMtime = info.mtimeMs;
|
|
224
|
+
}
|
|
225
|
+
const log = path.join(this.paths.factory, "logs", "droid-log-single.log");
|
|
226
|
+
const routing = await readDroidRoutingLog(log, this.state.model_log_cursor);
|
|
227
|
+
for (const transition of routing.events) {
|
|
228
|
+
const native = await this.metadata.read(transition.session_id);
|
|
229
|
+
if (!native || (transition.session_id !== this.rootId && native.header.callingSessionId !== this.rootId)) continue;
|
|
230
|
+
this.validateMetadata(transition.session_id, native);
|
|
231
|
+
await this.observeSettings(transition.session_id, { ...native.settings, modelId: transition.model, model: transition.model },
|
|
232
|
+
{ path: log, channel: "native_router", event_sha256: transition.event_sha256 }, { reason: transition.reason, nativeTimestamp: transition.native_timestamp, previousModel: transition.previous_model });
|
|
233
|
+
}
|
|
234
|
+
await this.save({ model_log_cursor: routing.cursor });
|
|
235
|
+
for (const id of [this.rootId, ...this.topology.keys()]) {
|
|
236
|
+
const native = await this.metadata.read(id);
|
|
237
|
+
if (!native) {
|
|
238
|
+
await observeModel({ journal: this.config.journal, harness: "droid-cli", sessionId: id, requested: this.config, source: "native.settings", evidence: "unavailable", reason: "settings_missing_or_partial" });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
this.validateMetadata(id, native);
|
|
242
|
+
await this.observeSettings(id, native.settings, native.source);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async handleObservationError(error) {
|
|
246
|
+
if (["profile_integrity_violation", "session_identity_mismatch", "droid_child_identity_invalid", "droid_turn_identity_mismatch", "droid_hook_identity_invalid", "model_observation_storage_failed"].includes(error.code)) {
|
|
247
|
+
if (!this.fatalError) {
|
|
248
|
+
this.fatalError = error;
|
|
249
|
+
try { await this.save({ fatal_error: errorRecord(error) }); await this.journal("integrity_failure", { error: errorRecord(error) }); }
|
|
250
|
+
finally {
|
|
251
|
+
this.rejectPending(error);
|
|
252
|
+
// Never await cleanup from the native event drain: closeTree waits for it.
|
|
253
|
+
setTimeout(() => { void this.closeTree(true).catch(cleanup => this.save({ cleanup_error: errorRecord(cleanup) }).catch(() => {})); }, 0);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
try { await observeModel({ journal: this.config.journal, harness: "droid-cli", sessionId: this.rootId, requested: this.config, source: "native.observer", evidence: "unavailable", reason: error.code ?? "observation_unavailable" }); }
|
|
259
|
+
catch (failure) { await this.handleObservationError(Object.assign(new Error(`Cannot persist model observation: ${failure.message}`), { code: "model_observation_storage_failed" })); }
|
|
260
|
+
}
|
|
261
|
+
async saveBinding(binding, patch = {}) { Object.assign(binding.saved, patch); await writeJsonAtomic(path.join(this.paths.bindings, `${createHash("sha256").update(binding.operationId).digest("hex")}.json`), binding.saved); }
|
|
262
|
+
async create(operationId) {
|
|
263
|
+
if (this.rootId) throw new DroidError("session_already_created", "This execution daemon already owns a root Session");
|
|
264
|
+
await this.start(); const sessionId = randomUUID(); this.rootId = sessionId; const requestId = randomUUID();
|
|
265
|
+
const binding = { operationId, saved: { operation: "session.create", provider_session_id: sessionId, request_id: requestId } }; await this.saveBinding(binding);
|
|
266
|
+
const result = await this.request("droid.initialize_session", { machineId: "dd-eval", cwd: this.config.cwd, sessionId, modelId: this.config.model, reasoningEffort: this.config.reasoning, interactionMode: "auto", autonomyLevel: "high", autoRejectPermissionRequests: true, disableBuiltinSkills: true, disabledToolIds: ["AskUser"], mcpServers: [], systemPrompt: { type: "preset", preset: "droid", append: `${renderAdapterPolicy({ harness: "droid-cli" })} Use the same worker for technical capacity markers and separately declared productive Work. Never delegate an already-bound coordinator or Stage Work through Task: execute it in this root Session. A productive Task must receive the runner's exact work start command and may execute only that child Work; otherwise it must stop. User clarification belongs to the root coordinator through dd-flow stage pause, not native AskUser. At a work_fanout boundary, stop when the launcher says runner dispatches and wait for the runner's next instruction. Never use Missions.` } }, requestId);
|
|
267
|
+
if (result.sessionId !== sessionId) throw new DroidError("session_identity_mismatch", "Droid initialized a different Session");
|
|
268
|
+
this.rootId = result.sessionId; this.loaded = true; this.observedProfile = await this.observeSettings(this.rootId, result.settings, "native.initialize_session"); this.workingState = "idle"; await this.save();
|
|
269
|
+
return await this.inspect(sessionId);
|
|
270
|
+
}
|
|
271
|
+
requireIdentity(id) { if (!id || id !== this.rootId) throw new DroidError("session_identity_mismatch", "Requested root Session is not owned by this daemon", { requested: id ?? null, observed: this.rootId }); }
|
|
272
|
+
async resume(id) {
|
|
273
|
+
this.requireIdentity(id); if (this.state.cleanup?.forced) throw new DroidError("invalid_harness_crash", "Forced teardown is unclean; same-ID resume is not qualified for this execution"); if (this.closed && (await this.captureProcesses()).length) throw new DroidError("tree_not_settled", "Previous provider still owns processes; cancel the tree before resume"); if (!this.closed && this.loaded) return await this.inspect(id);
|
|
274
|
+
await this.start(); const result = await this.request("droid.load_session", { sessionId: id, loadAllMessages: false, autoRejectPermissionRequests: true, disableBuiltinSkills: true, disabledToolIds: ["AskUser"], mcpServers: [] });
|
|
275
|
+
const source = await readDroidSession(this.paths.factory, id); if (!source || path.resolve(source.header.cwd) !== this.config.cwd) throw new DroidError("session_identity_mismatch", "Resumed Session does not match its controlled workspace");
|
|
276
|
+
this.loaded = true; this.observedProfile = await this.observeSettings(id, result.settings, "native.load_session"); this.workingState = result.workingState ?? (result.isAgentLoopInProgress ? "unknown" : "idle"); this.cancelled = false; await this.save(); return await this.inspect(id);
|
|
277
|
+
}
|
|
278
|
+
async prompt(id, prompt, operationId) {
|
|
279
|
+
const assertDispatch = captureDispatchGuard(this);
|
|
280
|
+
if (this.fatalError) throw this.fatalError;
|
|
281
|
+
this.requireIdentity(id); if (!prompt) throw new DroidError("prompt_required", "prompt is required"); if (this.active) throw new DroidError("operation_busy", "Another Droid operation is active");
|
|
282
|
+
if (this.closed || !this.loaded) await this.resume(id);
|
|
283
|
+
if (this.ownershipError) throw new DroidError("process_ownership_unknown", "Owned process observation failed", { error: errorRecord(this.ownershipError) });
|
|
284
|
+
const before = await this.inspect(id); if (!before.settled) throw new DroidError("tree_not_settled", "Prior Droid tree has not settled");
|
|
285
|
+
let resolve, reject; const completed = new Promise((a, b) => { resolve = a; reject = b; }); void completed.catch(() => {});
|
|
286
|
+
const requestId = randomUUID(), turnId = randomUUID();
|
|
287
|
+
const current = { operationId, requestId, turnId, resolve, reject, text: "", saved: { operation: "session.prompt", provider_session_id: id, request_id: requestId, turn_id: turnId, accepted: false } };
|
|
288
|
+
this.active = current; this.cancelled = false; await this.saveBinding(current); await this.save({ active_tree: true });
|
|
289
|
+
const clock = new ObservationClock({ timeoutMs: this.config.inactivityMs ?? 600_000, onGap: gap => { void this.journal("observation_gap", { gap }).catch(() => {}); } });
|
|
290
|
+
let checkingTerminal = false;
|
|
291
|
+
const timer = setInterval(() => {
|
|
292
|
+
if (!clock.sample(this.activity) || checkingTerminal) return;
|
|
293
|
+
checkingTerminal = true;
|
|
294
|
+
void this.recoverOperation(operationId).then(() => {
|
|
295
|
+
if (!current.outcome) reject(new DroidError("subject_liveness_timeout", "Droid emitted no native progress within its inactivity window"));
|
|
296
|
+
}).catch(error => reject(new DroidError("operation_observation_lost", "Cannot reconcile native outcome before declaring inactivity", { error: errorRecord(error) })));
|
|
297
|
+
}, 1_000);
|
|
298
|
+
try {
|
|
299
|
+
assertDispatch();
|
|
300
|
+
await this.request("droid.add_user_message", { text: prompt, messageId: turnId }, requestId);
|
|
301
|
+
const outcome = await completed; await this.draining;
|
|
302
|
+
try {
|
|
303
|
+
const receipt = await this.inspect(id, { ignoreActive: true });
|
|
304
|
+
return { ...receipt, result: { ...outcome, status: status(outcome.reason) }, assistant_text: current.text, turn_id: turnId };
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// A terminal native outcome is durable independently of a later
|
|
307
|
+
// filesystem/topology observation. Preserve it and let settlement
|
|
308
|
+
// reconcile; do not convert it into a failed prompt.
|
|
309
|
+
return { provider_session_id: id, adapter_session_id: id, result: { ...outcome, status: status(outcome.reason) }, assistant_text: current.text, turn_id: turnId, settled: false, settlement_observation_error: errorRecord(error) };
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
await this.journal("prompt_error", { operation_id: operationId, error: errorRecord(error) }); throw error;
|
|
313
|
+
} finally { clearInterval(timer); if (this.active === current) this.active = null; await this.save({ active_tree: this.workingState !== "idle" }); }
|
|
314
|
+
}
|
|
315
|
+
async recoverOperation(operationId) {
|
|
316
|
+
const binding = await readJson(path.join(this.paths.bindings, `${createHash("sha256").update(operationId).digest("hex")}.json`));
|
|
317
|
+
if (!binding || binding.operation !== "session.prompt") return null;
|
|
318
|
+
this.requireIdentity(binding.provider_session_id);
|
|
319
|
+
const native = await readDroidSession(this.paths.factory, binding.provider_session_id);
|
|
320
|
+
const outcome = native?.outcomes.find(item => item.turnId === binding.turn_id);
|
|
321
|
+
if (!outcome || !terminal(outcome.reason)) return null;
|
|
322
|
+
let receipt;
|
|
323
|
+
try { receipt = await this.inspect(binding.provider_session_id, { ignoreActive: true }); }
|
|
324
|
+
catch (error) {
|
|
325
|
+
receipt = { provider_session_id: binding.provider_session_id, adapter_session_id: binding.provider_session_id, settled: false, settlement_observation_error: errorRecord(error) };
|
|
326
|
+
}
|
|
327
|
+
// A terminal root turn is not enough to recover the operation while a
|
|
328
|
+
// child Task or owned provider process is still active. Leave the
|
|
329
|
+
// durable binding for the normal settlement observer instead of exposing
|
|
330
|
+
// a misleading successful receipt.
|
|
331
|
+
if (receipt.settled !== true) return null;
|
|
332
|
+
const current = this.active;
|
|
333
|
+
if (current?.operationId === operationId && current.turnId === binding.turn_id) {
|
|
334
|
+
current.outcome = outcome; this.lastResult = outcome;
|
|
335
|
+
await this.saveBinding(current, { native_outcome: outcome });
|
|
336
|
+
current.resolve(outcome);
|
|
337
|
+
// Let the original dispatch persist its one terminal reply. Settlement
|
|
338
|
+
// is reconciled separately; do not race a second writer or replay it.
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
return { ...receipt, result: { ...outcome, status: status(outcome.reason) }, assistant_text: binding.assistant_text ?? "", turn_id: binding.turn_id, recovered_from: "native.agent_turn_outcome" };
|
|
342
|
+
}
|
|
343
|
+
async refreshTopology() {
|
|
344
|
+
let catalog;
|
|
345
|
+
try { catalog = await readJson(path.join(this.paths.factory, "task-invocations.json")); }
|
|
346
|
+
catch (error) { if (!(error instanceof SyntaxError)) throw error; this.topologyIncomplete = true; return [...this.topology.values()]; }
|
|
347
|
+
this.topologyIncomplete = false;
|
|
348
|
+
const invocations = catalog?.invocations ?? [];
|
|
349
|
+
const root = this.rootId ? await readDroidSession(this.paths.factory, this.rootId) : null;
|
|
350
|
+
const rootTools = new Map((root?.records ?? []).flatMap(record => record.message?.content ?? []).filter(block => block.type === "tool_use").map(block => [block.id, block]));
|
|
351
|
+
const groups = new Map();
|
|
352
|
+
for (const task of invocations) {
|
|
353
|
+
if (task.parentSessionId !== this.rootId || !task.childSessionId || !task.parentToolUseId) continue;
|
|
354
|
+
const tasks = groups.get(task.childSessionId) ?? []; tasks.push(task); groups.set(task.childSessionId, tasks);
|
|
355
|
+
}
|
|
356
|
+
for (const [id, tasks] of groups) {
|
|
357
|
+
const native = await readDroidSession(this.paths.factory, id);
|
|
358
|
+
if (!native || !native.settings) { this.topologyIncomplete = true; continue; }
|
|
359
|
+
if (native.header.callingSessionId !== this.rootId) throw new DroidError("droid_child_identity_invalid", "Task invocation disagrees with physical child parent");
|
|
360
|
+
const creationTool = native.header.callingToolUseId;
|
|
361
|
+
for (const task of tasks) {
|
|
362
|
+
if (task.parentToolUseId === creationTool) continue;
|
|
363
|
+
const tool = rootTools.get(task.parentToolUseId);
|
|
364
|
+
if (tool?.name !== "Task" || tool.input?.resume !== id) throw new DroidError("droid_child_identity_invalid", "Repeated child invocation has no matching native Task resume evidence");
|
|
365
|
+
}
|
|
366
|
+
await this.observeSettings(id, native.settings, { path: native.transcript_path, sha256: native.source_sha256 });
|
|
367
|
+
const prior = this.topology.get(id);
|
|
368
|
+
// Task resume adds a Turn to the same physical child, not another
|
|
369
|
+
// Session. Its transcript retains the original creation edge. Work
|
|
370
|
+
// reassignment is checked by dd-flow's physical Session binding.
|
|
371
|
+
const ordered = tasks.toSorted((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
|
372
|
+
const latest = ordered.at(-1); const running = tasks.some(task => !terminal(task.status));
|
|
373
|
+
const closed = running && prior?.source === "adapter.tree_closed";
|
|
374
|
+
this.topology.set(id, { provider_session_id: id, parent_provider_session_id: this.rootId, tool_use_id: creationTool, latest_tool_use_id: latest.parentToolUseId, task_id: latest.taskInvocationId, task_invocation_ids: ordered.map(task => task.taskInvocationId), status: closed ? prior.status : running ? "running" : status(latest.status), source: closed ? prior.source : "droid.task_invocations", transcript_path: native.transcript_path });
|
|
375
|
+
}
|
|
376
|
+
return [...this.topology.values()];
|
|
377
|
+
}
|
|
378
|
+
async snapshotUsage(id) {
|
|
379
|
+
const native = await readDroidSession(this.paths.factory, id); if (!native) return { status: "unavailable", provider_session_id: id };
|
|
380
|
+
this.journaledTools ??= new Map();
|
|
381
|
+
journalToolObservations(this.config.journal, [...native.records.flatMap(record => droidToolObservations(record, id)), ...native.tool_calls.reasons.map(reason => ({ reason }))], this.journaledTools);
|
|
382
|
+
const measured = native.usage;
|
|
383
|
+
const payload = { provider_session_id: id, daemon_id: this.config.daemonId, observed_at: new Date().toISOString(), usage_scope: "physical_session", completeness: native.complete && measured ? "complete" : "partial", usage: measured, tool_calls: native.tool_calls, source: { path: native.transcript_path, sha256: native.source_sha256 } };
|
|
384
|
+
if (measured && this.config.ddFlowBin) await this.flow("usage", "ingest", payload);
|
|
385
|
+
return { status: measured ? "measured" : "unavailable", ...payload };
|
|
386
|
+
}
|
|
387
|
+
async flow(family, command, payload) {
|
|
388
|
+
const target = executable(this.config.ddFlowBin, ["droid", family, command, "--project-root", this.config.projectRoot, "--json"]);
|
|
389
|
+
return await new Promise((resolve, reject) => {
|
|
390
|
+
const child = spawn(target.command, target.args, { cwd: this.config.cwd, env: droidEnvironment(this.config), stdio: ["pipe", "pipe", "pipe"] }); let stdout = "", stderr = "";
|
|
391
|
+
const timer = observedTimeout(() => { child.kill("SIGTERM"); reject(new DroidError("droid_flow_timeout", "dd-flow did not acknowledge Droid evidence")); }, 25_000);
|
|
392
|
+
child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk); child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
|
|
393
|
+
child.on("error", error => { clearTimeout(timer); reject(error); }); child.on("close", code => { clearTimeout(timer); if (code !== 0) return reject(new DroidError("droid_flow_rejected", stderr || "dd-flow rejected Droid evidence")); try { resolve(JSON.parse(stdout)); } catch (error) { reject(error); } }); child.stdin.end(`${JSON.stringify(payload)}\n`);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async inspect(id, { ignoreActive = false } = {}) {
|
|
397
|
+
this.requireIdentity(id); await this.captureProcesses(); const children = await this.refreshTopology(); const source = await readDroidSession(this.paths.factory, id);
|
|
398
|
+
const usage = []; for (const sessionId of [id, ...children.map(child => child.provider_session_id)]) usage.push(await this.snapshotUsage(sessionId));
|
|
399
|
+
const settled = !this.topologyIncomplete && (!this.active || ignoreActive) && (this.closed || this.workingState === "idle") && children.every(child => terminal(child.status));
|
|
400
|
+
return { harness: "droid-cli", runtime_family: "droid", session: { harness_id: "droid-cli", session_id: id }, provider_session_id: id, adapter_session_id: id, cwd: this.config.cwd, observed_profile: source?.settings ? await this.observeSettings(id, source.settings, { path: source.transcript_path, sha256: source.source_sha256 }) : this.observedProfile, observed_runtime: { droid: this.config.version, factory_protocol: this.protocol ?? DROID_PROTOCOL, dd_harness_contract: DROID_CONTRACT }, status: settled ? "idle" : "running", settled, descendants: children, transcript_path: source?.transcript_path ?? null, usage, result: this.lastResult, provider_pid: this.closed ? null : this.child?.pid ?? null };
|
|
401
|
+
}
|
|
402
|
+
async observeHook(payload, eventId) {
|
|
403
|
+
try { return await this.observeHookChecked(payload, eventId); }
|
|
404
|
+
catch (error) { await this.handleObservationError(error); throw error; }
|
|
405
|
+
}
|
|
406
|
+
async observeHookChecked(payload, eventId) {
|
|
407
|
+
const id = payload.session_id, event = payload.hook_event_name;
|
|
408
|
+
if (!id || !event || !eventId) throw new DroidError("droid_hook_invalid", "Hook lacks native identity or event ID");
|
|
409
|
+
// Hooks can precede the root initialize response or the child notification.
|
|
410
|
+
const deadline = Date.now() + 3_000; let native;
|
|
411
|
+
do { native = ["PreToolUse", "PostToolUse"].includes(event) ? await this.metadata.read(id) : await readDroidSession(this.paths.factory, id); if (native) break; await sleep(25); } while (Date.now() < deadline);
|
|
412
|
+
if (!native || path.resolve(native.header.cwd) !== this.config.cwd || path.resolve(payload.cwd) !== this.config.cwd || await realpath(payload.transcript_path) !== await realpath(native.transcript_path)) throw new DroidError("droid_hook_identity_invalid", "Hook is outside its controlled native Session/workspace");
|
|
413
|
+
const parent = native.header.callingSessionId ?? null;
|
|
414
|
+
if (parent && parent !== this.rootId) throw new DroidError("droid_hook_identity_invalid", "Hook child belongs to another root");
|
|
415
|
+
if (!parent && this.rootId && id !== this.rootId) throw new DroidError("droid_hook_identity_invalid", "Hook came from an unrelated root");
|
|
416
|
+
if (parent && !terminal(this.topology.get(id)?.status)) this.topology.set(id, { ...(this.topology.get(id) ?? {}), provider_session_id: id, parent_provider_session_id: parent, tool_use_id: native.header.callingToolUseId, status: "running", source: "droid.session_start" });
|
|
417
|
+
this.activity += 1; await this.journal("hook", { event_id: eventId, event, session_id: id, parent_session_id: parent }); await this.save();
|
|
418
|
+
if (!["PreToolUse", "PostToolUse"].includes(event)) return {};
|
|
419
|
+
if (this.fatalError) return { continue: false, stopReason: this.fatalError.message };
|
|
420
|
+
let profile;
|
|
421
|
+
try { profile = await this.observeSettings(id, native.settings, { path: native.transcript_path, sha256: native.source_sha256 }); }
|
|
422
|
+
catch (error) { await this.handleObservationError(error); return { continue: false, stopReason: error.message }; }
|
|
423
|
+
if (!this.config.ddFlowBin || payload.tool_name !== "Execute") return {};
|
|
424
|
+
await this.snapshotUsage(id);
|
|
425
|
+
return await this.flow("event", "handle", { schema_id: "dd-flow/droid-tool-event@1", phase: event === "PreToolUse" ? "before" : "after", event_id: eventId, daemon_id: this.config.daemonId, session: { provider_session_id: id, parent_provider_session_id: parent, directory: this.config.cwd }, tool: payload.tool_name, input: payload.tool_input, profile, transcript_path: native.transcript_path, outcome: { status: payload.tool_response?.is_error ? "error" : "completed" } });
|
|
426
|
+
}
|
|
427
|
+
async captureProcesses() {
|
|
428
|
+
const rows = await processSnapshot();
|
|
429
|
+
const roots = this.ownedProcesses.filter(old => rows.some(row => row.pid === old.pid && row.started === old.started));
|
|
430
|
+
if (this.child && !this.closed) roots.push({ pid: this.child.pid });
|
|
431
|
+
for (const root of roots) for (const row of descendants(rows, root.pid)) if (!this.ownedProcesses.some(old => old.pid === row.pid && old.started === row.started)) this.ownedProcesses.push(row);
|
|
432
|
+
await this.save({ owned_processes: this.ownedProcesses });
|
|
433
|
+
return rows.filter(row => !row.zombie && this.ownedProcesses.some(old => old.pid === row.pid && old.started === row.started));
|
|
434
|
+
}
|
|
435
|
+
async closeTree(cancel = false) {
|
|
436
|
+
if (this.cleanupPromise) return await this.cleanupPromise;
|
|
437
|
+
this.cleanupPromise = this.closeOwnedTree(cancel);
|
|
438
|
+
try { return await this.cleanupPromise; } finally { this.cleanupPromise = null; }
|
|
439
|
+
}
|
|
440
|
+
async closeOwnedTree(cancel) {
|
|
441
|
+
await this.captureProcesses();
|
|
442
|
+
if (cancel) cancelPendingDispatch(this);
|
|
443
|
+
this.closing = true; this.cancelled = cancel;
|
|
444
|
+
if (cancel && !this.closed) { try { await this.request("droid.interrupt_session", {}, randomUUID(), 5_000); } catch (error) { await this.journal("interrupt_unobserved", { error: errorRecord(error) }); } }
|
|
445
|
+
let forced = this.state.cleanup?.forced === true;
|
|
446
|
+
try { if (!this.closed && this.rootId) await this.request("droid.close_session", { reason: "other" }, randomUUID(), 10_000); else if (!this.closed) this.child.stdin.end(); } catch (error) { await this.journal("close_unobserved", { error: errorRecord(error) }); }
|
|
447
|
+
const alive = () => this.captureProcesses();
|
|
448
|
+
const deadline = Date.now() + 8_000; while ((!this.closed || (await alive()).length) && Date.now() < deadline) await sleep(100);
|
|
449
|
+
if (!this.closed || (await alive()).length) {
|
|
450
|
+
forced = true; if (!this.closed) await stopProcessGroup(this.child, 2_000).catch(error => this.journal("group_stop_unconfirmed", { error: errorRecord(error) }));
|
|
451
|
+
// Execute can own a separate process group. Kill only still-matching
|
|
452
|
+
// observed descendants; never use a process-name search or broad pkill.
|
|
453
|
+
for (const row of await alive()) { try { process.kill(row.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; } }
|
|
454
|
+
await sleep(1_000);
|
|
455
|
+
for (const row of await alive()) { try { process.kill(row.pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } }
|
|
456
|
+
await sleep(200);
|
|
457
|
+
}
|
|
458
|
+
const survivors = await alive(); if (!this.closed || survivors.length) throw new DroidError("tree_not_settled", "Droid cleanup did not settle its owned process tree", { survivors });
|
|
459
|
+
clearInterval(this.processObserver); clearInterval(this.modelObserver); await this.processObservation;
|
|
460
|
+
await this.draining; this.workingState = "idle";
|
|
461
|
+
if (this.active && !this.active.outcome) this.active.reject(new DroidError(cancel ? "operation_cancelled" : "operation_observation_lost", "Droid runtime closed before a native terminal reply"));
|
|
462
|
+
await finishDaemonProcess(this.config, this.providerProcess, forced ? "failed" : "stopped", forced ? "forced_tree_shutdown" : "native_tree_closed");
|
|
463
|
+
let topologyError = null;
|
|
464
|
+
try { await this.refreshTopology(); }
|
|
465
|
+
catch (error) { topologyError = errorRecord(error); await this.journal("closed_tree_topology_error", { error: topologyError }); }
|
|
466
|
+
for (const [id, child] of this.topology) if (!terminal(child.status)) this.topology.set(id, { ...child, status: "cancelled", source: "adapter.tree_closed", native_status: child.status });
|
|
467
|
+
const cleanup = { settled: true, forced, observed_processes: this.ownedProcesses.length, ...(topologyError ? { topology_error: topologyError } : {}) };
|
|
468
|
+
await this.save({ active_tree: false, cleanup });
|
|
469
|
+
return cleanup;
|
|
470
|
+
}
|
|
471
|
+
}
|