@deksden-com/dd-flow-cli 0.9.0-beta.10 → 0.9.0-beta.103
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 +491 -0
- package/README.md +68 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +305 -0
- package/dist/cli/help.js +89 -13
- package/dist/cli/hook-ingress.js +77 -0
- package/dist/cli/input-preparation.js +116 -0
- package/dist/cli/run-cli.js +1437 -384
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/DD-ZCODE.md +99 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +52 -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/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +502 -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 +149 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +387 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +640 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +978 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +135 -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 +296 -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 +108 -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 +71 -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/harness-runtime/lib/zcode-usage.mjs +54 -0
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/code-work-result.schema.json +3 -3
- 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 +583 -99
- 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 +165 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +952 -83
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +165 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +596 -300
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +103 -17
- package/dist/services/lifecycle-contract.js +30 -0
- package/dist/services/lifecycle-invocations.js +1128 -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 +25 -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/review-copy.js +136 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +368 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +231 -0
- package/dist/services/run-controller-capture.js +136 -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 +803 -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 +371 -74
- package/dist/services/runtime-budget.js +439 -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 +159 -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 +190 -80
- package/dist/services/vnext-code.js +283 -248
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +172 -17
- package/dist/services/vnext-merge.js +296 -105
- package/dist/services/vnext-plan-review.js +141 -94
- package/dist/services/vnext-plan.js +142 -69
- package/dist/services/vnext-protocolize.js +132 -57
- package/dist/services/vnext-specify.js +95 -65
- package/dist/services/work-registry.js +891 -162
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +88 -36
- 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,296 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, mkdir } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { commandJson } from "./process-json.mjs";
|
|
5
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
6
|
+
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
7
|
+
import { runBoundedCommand } from "./native-hook-command.mjs";
|
|
8
|
+
|
|
9
|
+
const leases = new Map();
|
|
10
|
+
|
|
11
|
+
/** Freeze resource ownership independently of whether Subject hooks are enabled. */
|
|
12
|
+
export function bindRuntimeOwner(config, env = process.env) {
|
|
13
|
+
const raw = Object.hasOwn(config, "runtime_owner") ? config.runtime_owner === null ? undefined : JSON.stringify(config.runtime_owner) : env.DD_FLOW_RUNTIME_OWNER;
|
|
14
|
+
if (raw === undefined) return config.runtime_owner = null;
|
|
15
|
+
let owner;
|
|
16
|
+
try { if (raw.length > 16_384) throw new Error(); owner = JSON.parse(raw); }
|
|
17
|
+
catch { throw Object.assign(new Error("runtime ownership descriptor is invalid"), { code: "runtime_owner_invalid" }); }
|
|
18
|
+
const fields = ["schema_id", "owner_id", "role", "project_id", "run_id", "work_id", "operation_id", "state_dir", "dd_flow_home", "dd_flow_bin", "resource_home", "adapter_executable", "budget"];
|
|
19
|
+
const text = value => typeof value === "string" && value.trim().length > 0;
|
|
20
|
+
if (!owner || typeof owner !== "object" || Array.isArray(owner) || Object.keys(owner).some(key => !fields.includes(key))
|
|
21
|
+
|| owner.schema_id !== "dd-flow/runtime-owner@1" || !text(owner.owner_id) || !text(owner.operation_id)
|
|
22
|
+
|| !["subject", "worker", "merge", "judge", "probe"].includes(owner.role)
|
|
23
|
+
|| ["state_dir", "dd_flow_home", "dd_flow_bin", "resource_home"].some(key => !text(owner[key]) || !path.isAbsolute(owner[key]))
|
|
24
|
+
|| (owner.adapter_executable !== undefined && (!text(owner.adapter_executable) || !path.isAbsolute(owner.adapter_executable)))
|
|
25
|
+
|| ["project_id", "run_id", "work_id"].some(key => owner[key] !== undefined && !text(owner[key]))
|
|
26
|
+
|| (["subject", "worker", "merge"].includes(owner.role) && (!text(owner.project_id) || !text(owner.run_id)))
|
|
27
|
+
|| (["worker", "merge"].includes(owner.role) && !text(owner.work_id))
|
|
28
|
+
|| (["judge", "probe"].includes(owner.role) && owner.work_id !== undefined)) {
|
|
29
|
+
throw Object.assign(new Error("runtime ownership descriptor does not match its role and scope"), { code: "runtime_owner_invalid" });
|
|
30
|
+
}
|
|
31
|
+
if (owner.budget !== undefined) {
|
|
32
|
+
const budget = owner.budget, names = ["codex-desktop", "zcode-acp", "grok-acp", "antigravity-cli", "opencode-server", "droid-cli"];
|
|
33
|
+
if (!budget || typeof budget !== "object" || Array.isArray(budget) || Object.keys(budget).some(key => !["schema_id", "scope_id", "per_harness"].includes(key)) || budget.schema_id !== "dd-flow/runtime-budget@1" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(budget.scope_id ?? "") || !budget.per_harness || typeof budget.per_harness !== "object" || Array.isArray(budget.per_harness) || Object.entries(budget.per_harness).some(([name, limit]) => !names.includes(name) || !Number.isSafeInteger(limit) || limit < 1 || limit > 1024)) throw Object.assign(new Error("runtime owner budget is invalid"), { code: "runtime_budget_invalid" });
|
|
34
|
+
}
|
|
35
|
+
config.runtime_owner = Object.fromEntries(fields.filter(key => owner[key] !== undefined).map(key => [key, owner[key]]));
|
|
36
|
+
return config.runtime_owner;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Reject rebinding before provider setup, including the already-running path. */
|
|
40
|
+
export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
|
|
41
|
+
const owner = bindRuntimeOwner(config, env);
|
|
42
|
+
if (owner && path.resolve(owner.state_dir) !== path.resolve(stateDir)) {
|
|
43
|
+
throw Object.assign(new Error("runtime owner belongs to another state directory"), { code: "runtime_owner_mismatch" });
|
|
44
|
+
}
|
|
45
|
+
let previous;
|
|
46
|
+
try { previous = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8")); }
|
|
47
|
+
catch (error) { if (error.code === "ENOENT") return owner; throw error; }
|
|
48
|
+
const retained = bindRuntimeOwner({ runtime_owner: previous?.config?.runtime_owner ?? null }, {});
|
|
49
|
+
if (JSON.stringify(retained) !== JSON.stringify(owner)) {
|
|
50
|
+
throw Object.assign(new Error("retained daemon ownership differs; use explicit recovery instead of rebinding its sessions"), { code: "runtime_owner_mismatch" });
|
|
51
|
+
}
|
|
52
|
+
return owner;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
|
|
56
|
+
for (const lease of leases.values()) {
|
|
57
|
+
if (lease.error) throw lease.error;
|
|
58
|
+
if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown" });
|
|
59
|
+
}
|
|
60
|
+
const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
|
|
61
|
+
const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
|
|
62
|
+
if (!owner) return;
|
|
63
|
+
const record = state.resource_process;
|
|
64
|
+
if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
|
|
65
|
+
for (;;) {
|
|
66
|
+
assertCurrent();
|
|
67
|
+
const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
|
|
68
|
+
assertCurrent();
|
|
69
|
+
if (result?.ok === true && result.admitted === true && result.process_id === record.id) return;
|
|
70
|
+
if (result?.ok !== true || result.reason !== "provider_capacity" || result.process_id !== record.id) throw Object.assign(new Error("productive runtime admission was not confirmed"), { code: "process_ownership_unknown" });
|
|
71
|
+
const predecessor = result.blocking_turns?.find(turn => turn?.process_id === record.id
|
|
72
|
+
&& turn?.session_id === dispatch["native-session-id"]
|
|
73
|
+
&& turn?.operation_id !== dispatch["operation-id"]);
|
|
74
|
+
if (predecessor) throw Object.assign(new Error("the same managed Session has an unsettled productive predecessor; reconcile it before dispatching another Turn"), {
|
|
75
|
+
code: "settlement_reconciliation_required",
|
|
76
|
+
details: { session_id: dispatch["native-session-id"], predecessor_operation_id: predecessor.operation_id, predecessor_operation: predecessor.operation }
|
|
77
|
+
});
|
|
78
|
+
// The daemon, not its disposable socket client, owns this queue. Recheck
|
|
79
|
+
// the RUN fence and process lease on every admission attempt.
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Snapshot terminal operations BEFORE observing native idle. A later Turn
|
|
85
|
+
* cannot be released by an older observation of the same Session. */
|
|
86
|
+
export async function budgetObservationScope(stateDir, sessionId) {
|
|
87
|
+
const state = await budgetState(stateDir);
|
|
88
|
+
if (!state?.config?.runtime_owner?.budget || !sessionId) return null;
|
|
89
|
+
const daemonId = state.daemon_id ?? state.config?.daemonId, operationIds = [];
|
|
90
|
+
for (const name of await readdir(path.join(stateDir, "operations")).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
|
|
91
|
+
const directory = path.join(stateDir, "operations", name);
|
|
92
|
+
try {
|
|
93
|
+
const requested = JSON.parse(await readFile(path.join(directory, "requested.json"), "utf8"));
|
|
94
|
+
const result = JSON.parse(await readFile(path.join(directory, "result.json"), "utf8"));
|
|
95
|
+
if (requested.daemon_id === daemonId && requested.session_id === sessionId && ["completed", "failed", "interrupted"].includes(result.state) && ["session.prompt", "session.start", "session.resume"].includes(requested.operation)) operationIds.push(requested.operation_id);
|
|
96
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
97
|
+
}
|
|
98
|
+
return { stateDir, state, daemonId, sessionId, operationIds };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function releaseBudgetObservation(scope, observation) {
|
|
102
|
+
if (!scope || !scope.operationIds.length || observation?.settled !== true) return;
|
|
103
|
+
const id = observation.provider_session_id ?? observation.session_id ?? observation.adapter_session_id;
|
|
104
|
+
if (id !== scope.sessionId) throw Object.assign(new Error("budget observation belongs to another Session"), { code: "session_identity_mismatch" });
|
|
105
|
+
const directory = path.join(scope.stateDir, "budget-observations");
|
|
106
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
107
|
+
const file = path.join(directory, `${randomUUID()}.json`);
|
|
108
|
+
await writeJsonAtomic(file, { daemon_id: scope.daemonId, session_id: scope.sessionId, settled: true, operation_ids: scope.operationIds, observed_at: new Date().toISOString() });
|
|
109
|
+
await releaseBudget(scope.state, { "observation-file": file });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function completeBudgetOperation(stateDir, request, result, observe) {
|
|
113
|
+
const state = await budgetState(stateDir);
|
|
114
|
+
if (!state?.config?.runtime_owner?.budget) return { state: "not_required" };
|
|
115
|
+
if (["session.create", "session.fork"].includes(request.operation)) {
|
|
116
|
+
await releaseBudget(state, { "operation-id": request.id });
|
|
117
|
+
return { state: "settled", operation_ids: [request.id], source: "creation_receipt" };
|
|
118
|
+
}
|
|
119
|
+
const sessionId = request.params?.sessionId;
|
|
120
|
+
// A productive daemon receipt is bound to this exact dispatch. Reuse its
|
|
121
|
+
// complete tree proof instead of immediately asking the provider again.
|
|
122
|
+
if (["session.prompt", "session.start", "session.resume"].includes(request.operation)
|
|
123
|
+
&& result?.settled === true
|
|
124
|
+
&& typeof sessionId === "string"
|
|
125
|
+
&& (result.provider_session_id ?? result.session_id ?? result.adapter_session_id) === sessionId) {
|
|
126
|
+
const scope = { stateDir, state, daemonId: state.daemon_id ?? state.config?.daemonId, sessionId, operationIds: [request.id] };
|
|
127
|
+
await releaseBudgetObservation(scope, result);
|
|
128
|
+
return { state: "settled", operation_ids: [request.id], source: "operation_receipt" };
|
|
129
|
+
}
|
|
130
|
+
// A known live tree is not a reason to poll it again. Preserve capacity and
|
|
131
|
+
// let the next explicit reconciliation obtain fresh proof.
|
|
132
|
+
if (result?.settled === false) return { state: "pending", reason: "tree_unsettled" };
|
|
133
|
+
const scope = await budgetObservationScope(stateDir, request.params?.sessionId);
|
|
134
|
+
if (!scope?.operationIds.length) return { state: "not_required" };
|
|
135
|
+
// Reconciliation requires a fresh, explicit proof. Treating an arbitrary
|
|
136
|
+
// productive reply as proof would silently release a live turn.
|
|
137
|
+
if (!observe) return { state: "pending", reason: "settlement_evidence_required" };
|
|
138
|
+
const observation = await observe();
|
|
139
|
+
if (observation?.settled !== true) return { state: "pending", reason: "tree_unsettled" };
|
|
140
|
+
await releaseBudgetObservation(scope, observation);
|
|
141
|
+
return { state: "settled", operation_ids: scope.operationIds, source: "reconciliation_observation" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function budgetState(stateDir) {
|
|
145
|
+
try { return JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8")); }
|
|
146
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function releaseBudget(state, options) {
|
|
150
|
+
const record = state.resource_process, owner = state.config.runtime_owner;
|
|
151
|
+
const receipt = await runtimeProcess(state.config, "release-turn", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...options });
|
|
152
|
+
if (receipt?.ok !== true) throw Object.assign(new Error("runtime budget release was not confirmed"), { code: "runtime_budget_release_unconfirmed" });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runtimeProcess(config, action, options = {}) {
|
|
156
|
+
const owner = config.runtime_owner;
|
|
157
|
+
const resourceHome = owner?.resource_home ?? config.resourceHome;
|
|
158
|
+
const ddFlowHome = owner?.dd_flow_home ?? config.ddFlowHome;
|
|
159
|
+
const ddFlowBin = owner?.dd_flow_bin ?? config.ddFlowBin;
|
|
160
|
+
if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
|
|
161
|
+
const args = ["runtime", "process", action];
|
|
162
|
+
for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
|
|
163
|
+
if (action === "heartbeat") return JSON.parse(await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
|
|
164
|
+
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs: 15_000, timeoutCode: "process_heartbeat_timeout" }));
|
|
165
|
+
return await commandJson(ddFlowBin, args, {
|
|
166
|
+
cwd: config.cwd,
|
|
167
|
+
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function registerDaemonProcess(config, input) {
|
|
172
|
+
const owner = bindRuntimeOwner(config);
|
|
173
|
+
const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
|
|
174
|
+
const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
|
|
175
|
+
const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
|
|
176
|
+
return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function confirmDaemonProcess(config, record, child) {
|
|
180
|
+
if (!record) return;
|
|
181
|
+
if (!child?.pid) {
|
|
182
|
+
await finishDaemonProcess(config, record, "failed", "daemon_spawn_missing_pid");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Provider bridges use a different process group from their daemon. Publish
|
|
189
|
+
* the exact binding before initialize can start productive provider traffic. */
|
|
190
|
+
export async function startManagedBridge({ config, state, bridge, kind, publish }) {
|
|
191
|
+
state.pid = process.pid;
|
|
192
|
+
await publish(state);
|
|
193
|
+
const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
194
|
+
bridge.options.onSpawn = async child => {
|
|
195
|
+
await confirmDaemonProcess(config, provider, child);
|
|
196
|
+
await heartbeatDaemonProcess(config, provider);
|
|
197
|
+
state.provider_pid = child.pid;
|
|
198
|
+
state.provider_process = provider;
|
|
199
|
+
await publish(state);
|
|
200
|
+
};
|
|
201
|
+
try { return await bridge.start(); }
|
|
202
|
+
catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function closeManagedBridge(config, state, bridge) {
|
|
206
|
+
await bridge.close();
|
|
207
|
+
await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function heartbeatDaemonProcess(config, record) {
|
|
211
|
+
if (!record?.id || !record.lease_token) return;
|
|
212
|
+
let lease = leases.get(record.id);
|
|
213
|
+
if (!lease) {
|
|
214
|
+
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000 };
|
|
215
|
+
leases.set(record.id, lease);
|
|
216
|
+
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
217
|
+
lease.timer.unref();
|
|
218
|
+
}
|
|
219
|
+
if (lease.lost) throw lease.error;
|
|
220
|
+
if (lease.pending) return await lease.pending;
|
|
221
|
+
lease.pending = (async () => {
|
|
222
|
+
try {
|
|
223
|
+
const result = await runtimeProcess(config, "heartbeat", { id: record.id, "lease-token": record.lease_token, "lease-ms": 300_000 });
|
|
224
|
+
if (result?.ok !== true) {
|
|
225
|
+
lease.lost = true; clearInterval(lease.timer);
|
|
226
|
+
throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
|
|
227
|
+
}
|
|
228
|
+
lease.error = null;
|
|
229
|
+
lease.expiresAt = Date.now() + 300_000;
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
|
|
232
|
+
throw lease.error;
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
try { await lease.pending; } finally { lease.pending = null; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function finishDaemonProcess(config, process, state = "stopped", reason = "daemon_stopped") {
|
|
239
|
+
if (process?.id && process.lease_token) {
|
|
240
|
+
const lease = leases.get(process.id); if (lease) clearInterval(lease.timer);
|
|
241
|
+
const result = await runtimeProcess(config, "finish", { id: process.id, "lease-token": process.lease_token, state, reason });
|
|
242
|
+
if (result?.ok !== true) throw Object.assign(new Error("managed process finish was not accepted"), { code: "process_lease_lost", details: { process_id: process.id } });
|
|
243
|
+
leases.delete(process.id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Preserve the startup error and retain the lease/resources if cleanup cannot
|
|
248
|
+
* prove termination. A failed cleanup is not a terminal managed process. */
|
|
249
|
+
export async function cleanupFailedStart(config, record, child, original) {
|
|
250
|
+
try {
|
|
251
|
+
await stopProcessGroup(child);
|
|
252
|
+
await finishDaemonProcess(config, record, "failed", original.code ?? "startup_failed");
|
|
253
|
+
} catch (cleanup) {
|
|
254
|
+
original.details = { ...original.details, cleanup_failed: errorRecord(cleanup), process_id: record?.id ?? null, pid: child?.pid ?? null };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function stopProcessGroup(child, graceMs = 1_000) {
|
|
259
|
+
if (!child?.pid) return;
|
|
260
|
+
// Harness bridges are detached on Unix, so their own process group is the
|
|
261
|
+
// smallest reliable unit of cleanup. Windows has no equivalent negative-PID
|
|
262
|
+
// signal operation; there the direct child is the safe best effort.
|
|
263
|
+
const target = process.platform === "win32" ? child.pid : -child.pid;
|
|
264
|
+
const alive = () => { try { process.kill(target, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; if (error.code === "EPERM") return true; throw error; } };
|
|
265
|
+
if (!alive()) return;
|
|
266
|
+
if ((child.exitCode !== undefined && child.exitCode !== null) || (child.signalCode !== undefined && child.signalCode !== null)) {
|
|
267
|
+
// The leader can exit a few ticks before its helpers. Observe their normal
|
|
268
|
+
// shutdown before failing ownership; never signal a leaderless group.
|
|
269
|
+
const deadline = Date.now() + Math.max(1_000, graceMs);
|
|
270
|
+
while (alive() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
|
|
271
|
+
if (!alive()) return;
|
|
272
|
+
throw Object.assign(new Error("process group remains after its leader exited; ownership cannot be proven from this child handle"), { code: "process_group_ownership_unknown", details: { pid: child.pid } });
|
|
273
|
+
}
|
|
274
|
+
process.kill(target, "SIGTERM");
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
276
|
+
if (!alive()) return;
|
|
277
|
+
if (child.exitCode != null || child.signalCode != null) return stopProcessGroup(child, graceMs);
|
|
278
|
+
process.kill(target, "SIGKILL");
|
|
279
|
+
// Grace controls escalation, not the OS acknowledgement budget. A caller
|
|
280
|
+
// testing immediate escalation must still allow the kernel to reap children.
|
|
281
|
+
const deadline = Date.now() + Math.max(1_000, graceMs);
|
|
282
|
+
while (alive() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
283
|
+
if (alive()) throw Object.assign(new Error(`process group ${child.pid} did not stop`), { code: "process_group_stop_incomplete" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A daemon acknowledgement is not a completed stop until its endpoint closes. */
|
|
287
|
+
export async function confirmDaemonStopped(request, socket, timeoutMs = 30_000) {
|
|
288
|
+
const result = await request();
|
|
289
|
+
const deadline = Date.now() + timeoutMs;
|
|
290
|
+
while (Date.now() < deadline) {
|
|
291
|
+
try { await lstat(socket); }
|
|
292
|
+
catch (error) { if (error.code === "ENOENT") return result; throw error; }
|
|
293
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
294
|
+
}
|
|
295
|
+
throw Object.assign(new Error("daemon acknowledged stop but its socket remained live"), { code: "daemon_stop_incomplete", details: { socket } });
|
|
296
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { withRunnerLock } from "./runner-lock.mjs";
|
|
5
|
+
|
|
6
|
+
const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
7
|
+
const fields = ["provider", "model", "reasoning", "mode", "permission_mode"];
|
|
8
|
+
export const observedFields = value => Object.fromEntries(fields.map(key => [key, text(value?.[key])]));
|
|
9
|
+
export const modelObservationFile = journal => path.join(path.dirname(journal), "model-observations.jsonl");
|
|
10
|
+
|
|
11
|
+
/** Routing is allowed. Permission/autonomy changes remain integrity failures.
|
|
12
|
+
* Missing native fields are unknown, never proof that the request was applied. */
|
|
13
|
+
export function checkObservedProfile(requested, observed, { strict = false, required = [] } = {}) {
|
|
14
|
+
const unknown = fields.filter(key => requested?.[key] && !observed?.[key]);
|
|
15
|
+
const changes = fields.filter(key => requested?.[key] && observed?.[key] && requested[key] !== observed[key]);
|
|
16
|
+
const violations = changes.filter(key => strict || ["mode", "permission_mode"].includes(key));
|
|
17
|
+
const missing = required.filter(key => !observed?.[key]);
|
|
18
|
+
if (violations.length || missing.length) throw Object.assign(new Error("native execution profile integrity is not confirmed"), {
|
|
19
|
+
code: "profile_integrity_violation", details: { requested: observedFields(requested), observed: observedFields(observed), violations, missing }
|
|
20
|
+
});
|
|
21
|
+
return { requested: observedFields(requested), observed: observedFields(observed), status: unknown.length ? "incomplete" : changes.length ? "mixed" : "matched", matched: !unknown.length && !changes.length, unknown, changes };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readModelObservations(journal) {
|
|
25
|
+
try { const text = await readFile(modelObservationFile(journal), "utf8"); return text.slice(0, text.lastIndexOf("\n") + 1).split("\n").filter(Boolean).map(JSON.parse); }
|
|
26
|
+
catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Only adapter-verified native facts enter this file. It records changes,
|
|
30
|
+
* not every tool or token; reading it never requires replaying native history. */
|
|
31
|
+
export async function observeModel({ journal, harness, sessionId, parentSessionId = null, requested = {}, observed = {}, source, evidence = "configured", reason = null, nativeTimestamp = null, previousModel = null }) {
|
|
32
|
+
if (!journal || !sessionId) return null;
|
|
33
|
+
if (!source || !["configured", "request", "response", "unavailable"].includes(evidence)) throw new Error("model observation requires a native source and evidence level");
|
|
34
|
+
const file = modelObservationFile(journal), profile = observedFields(observed);
|
|
35
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
|
+
return withRunnerLock(file, async () => {
|
|
37
|
+
const events = await readModelObservations(journal);
|
|
38
|
+
const replay = source?.event_sha256 ? events.find(item => item.session_id === sessionId && item.source?.event_sha256 === source.event_sha256) : null;
|
|
39
|
+
if (replay) return replay;
|
|
40
|
+
const channel = source?.channel ?? evidence;
|
|
41
|
+
const previous = events.findLast(item => item.harness === harness && item.session_id === sessionId && (item.source?.channel ?? item.evidence) === channel);
|
|
42
|
+
const normalizedReason = text(reason)?.slice(0, 200) ?? null;
|
|
43
|
+
if (previous && JSON.stringify(previous.observed) === JSON.stringify(profile) && previous.reason === normalizedReason && previous.evidence === evidence) return previous;
|
|
44
|
+
const previousKnown = events.findLast(item => item.harness === harness && item.session_id === sessionId && item.evidence === evidence && item.observed?.model);
|
|
45
|
+
const fromModel = text(previousModel) ?? previousKnown?.observed.model ?? null;
|
|
46
|
+
const changed = Boolean(profile.model && fromModel && profile.model !== fromModel);
|
|
47
|
+
const value = { schema_id: "dd-flow/model-observation@1", sequence: events.length + 1,
|
|
48
|
+
harness, session_id: sessionId, parent_session_id: parentSessionId,
|
|
49
|
+
kind: changed ? "model_changed" : !profile.model ? "model_observation_unavailable" : "model_observed",
|
|
50
|
+
previous_model: fromModel, requested: observedFields(requested), observed: profile,
|
|
51
|
+
source, evidence, reason: normalizedReason, native_timestamp: text(nativeTimestamp), observed_at: new Date().toISOString() };
|
|
52
|
+
value.id = createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
53
|
+
const handle = await open(file, "a", 0o600);
|
|
54
|
+
try {
|
|
55
|
+
const bytes = await readFile(file); const committed = bytes.lastIndexOf(10) + 1;
|
|
56
|
+
if (committed < bytes.length) await handle.truncate(committed);
|
|
57
|
+
await handle.write(`${JSON.stringify(value)}\n`); await handle.sync();
|
|
58
|
+
} finally { await handle.close(); }
|
|
59
|
+
const directory = await open(path.dirname(file), "r");
|
|
60
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
61
|
+
return value;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function modelAttribution(events) {
|
|
66
|
+
const sessions = new Map();
|
|
67
|
+
for (const event of events) {
|
|
68
|
+
const key = `${event.harness}:${event.session_id}`;
|
|
69
|
+
const session = sessions.get(key) ?? { harness: event.harness, session_id: event.session_id, parent_session_id: event.parent_session_id, requested: event.requested, observations: [] };
|
|
70
|
+
session.observations.push(event); sessions.set(key, session);
|
|
71
|
+
}
|
|
72
|
+
const models = [...new Set(events.map(event => event.observed?.model).filter(Boolean))];
|
|
73
|
+
const incomplete = !events.length || [...sessions.values()].some(session => session.observations.some(event => event.evidence === "unavailable" || !event.observed?.model));
|
|
74
|
+
return { models, mixed: models.length > 1 || events.some(event => event.observed?.model && event.requested?.model && event.observed.model !== event.requested.model),
|
|
75
|
+
observation_completeness: incomplete ? "incomplete" : "available_native_sources", transitions: events.filter(event => event.kind === "model_changed"),
|
|
76
|
+
sessions: [...sessions.values()], usage_attribution: "session_totals_unattributed_to_model", note: "Native settings/events do not prove undisclosed server routing or per-model token costs." };
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { reportedFailure } from "./process-json.mjs";
|
|
3
|
+
|
|
4
|
+
/** Native command hooks have a 30s outer limit; leave room for transport and cleanup. */
|
|
5
|
+
export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
|
|
6
|
+
const started = performance.now();
|
|
7
|
+
const duration = Math.max(0, Math.min(timeoutMs, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeoutMs));
|
|
8
|
+
const remaining = (phase, maximum = duration) => {
|
|
9
|
+
const value = duration - (performance.now() - started);
|
|
10
|
+
if (value <= 0) throw Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" } });
|
|
11
|
+
return Math.max(1, Math.min(maximum, value));
|
|
12
|
+
};
|
|
13
|
+
return { deadlineAt: Date.now() + duration, remaining, async run(action, phase) {
|
|
14
|
+
const timeoutMs = remaining(phase);
|
|
15
|
+
let timer;
|
|
16
|
+
try { return await Promise.race([Promise.resolve().then(action), new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, effect: "unknown" } })), timeoutMs); })]); }
|
|
17
|
+
finally { clearTimeout(timer); }
|
|
18
|
+
} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Bound native hook ingress before parsing or opening adapter state. */
|
|
22
|
+
export function readNativeHookInput(stream, budget, maxBytes = 1_048_576) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const chunks = []; let bytes = 0, done = false;
|
|
25
|
+
const cleanup = () => { clearTimeout(timer); stream.off("data", onData); stream.off("end", onEnd); stream.off("error", onError); };
|
|
26
|
+
const finish = (error, value) => { if (done) return; done = true; cleanup(); if (error) { stream.destroy(); reject(error); } else resolve(value); };
|
|
27
|
+
const onData = chunk => {
|
|
28
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
29
|
+
bytes += buffer.length;
|
|
30
|
+
if (bytes > maxBytes) finish(Object.assign(new Error("Native hook input exceeds 1 MiB"), { code: "native_hook_input_too_large" }));
|
|
31
|
+
else chunks.push(buffer);
|
|
32
|
+
};
|
|
33
|
+
const onEnd = () => finish(null, Buffer.concat(chunks).toString("utf8"));
|
|
34
|
+
const onError = error => finish(error);
|
|
35
|
+
let timeoutMs;
|
|
36
|
+
try { timeoutMs = budget.remaining("input"); } catch (error) { reject(error); return; }
|
|
37
|
+
const timer = setTimeout(() => finish(Object.assign(new Error("Native hook deadline exceeded during input"), { code: "native_hook_timeout", details: { phase: "input", effect: "unknown" } })), timeoutMs);
|
|
38
|
+
stream.on("data", onData).once("end", onEnd).once("error", onError);
|
|
39
|
+
stream.resume();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function terminateCommandTree(child, { platform = process.platform, launch = spawn, timeoutMs = 400 } = {}) {
|
|
44
|
+
if (!child.pid) return;
|
|
45
|
+
if (platform !== "win32") { process.kill(-child.pid, "SIGKILL"); return; }
|
|
46
|
+
// Node's child.kill on Windows only terminates the leader. taskkill is the
|
|
47
|
+
// native subtree primitive; bound its own lifetime as well as the request.
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
const killer = launch("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
50
|
+
let settled = false, timer;
|
|
51
|
+
const finish = error => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(); };
|
|
52
|
+
timer = setTimeout(() => { killer.kill("SIGKILL"); killer.unref(); finish(Object.assign(new Error("Windows subtree cleanup timed out"), { code: "process_cleanup_timeout" })); }, timeoutMs);
|
|
53
|
+
killer.once("error", finish);
|
|
54
|
+
killer.once("close", code => finish(code === 0 ? null : Object.assign(new Error(`taskkill exited ${code}`), { code: "process_cleanup_failed" })));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Preserve the native hook protocol across the CLI process boundary. */
|
|
59
|
+
export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
|
|
60
|
+
const started = performance.now();
|
|
61
|
+
const script = /\.[cm]?js$/.test(bin);
|
|
62
|
+
const output = await new Promise((resolve, reject) => {
|
|
63
|
+
const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
|
|
64
|
+
cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
|
|
65
|
+
});
|
|
66
|
+
let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
|
|
67
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(cleanupTimer); error ? reject(error) : resolve(value); };
|
|
68
|
+
const stop = error => {
|
|
69
|
+
if (termination || settled) return;
|
|
70
|
+
failure ??= error;
|
|
71
|
+
failure.details = { ...failure.details, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" };
|
|
72
|
+
termination = terminateCommandTree(child).catch(cleanup => {
|
|
73
|
+
if (cleanup.code === "ESRCH") return;
|
|
74
|
+
failure.details.cleanup_error = { code: cleanup.code, message: cleanup.message };
|
|
75
|
+
failure.details.cleanup_unconfirmed = true;
|
|
76
|
+
child.kill("SIGKILL");
|
|
77
|
+
});
|
|
78
|
+
cleanupTimer ??= setTimeout(() => {
|
|
79
|
+
failure.details.cleanup_unconfirmed = true;
|
|
80
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
|
|
81
|
+
finish(failure);
|
|
82
|
+
}, 500);
|
|
83
|
+
};
|
|
84
|
+
const timer = setTimeout(() => stop(Object.assign(new Error("Bounded CLI request timed out"), { code: timeoutCode })), timeoutMs);
|
|
85
|
+
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; if (stdout.length > 1_048_576) stop(new Error("Native hook response exceeds protocol limit")); });
|
|
86
|
+
child.stderr.setEncoding("utf8").on("data", chunk => { stderr = (stderr + chunk).slice(-65_536); });
|
|
87
|
+
child.stdin.on("error", error => { failure ??= error; });
|
|
88
|
+
child.on("error", error => finish(failure ?? error));
|
|
89
|
+
child.on("close", code => {
|
|
90
|
+
if (failure) { void (termination ?? Promise.resolve()).then(() => finish(failure)); return; }
|
|
91
|
+
if (code !== 0) {
|
|
92
|
+
return finish(reportedFailure(stdout, stderr, "Native hook admission failed", "native_hook_failed"));
|
|
93
|
+
}
|
|
94
|
+
finish(null, stdout);
|
|
95
|
+
});
|
|
96
|
+
child.stdin.end(input);
|
|
97
|
+
});
|
|
98
|
+
return output;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
|
|
102
|
+
const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
|
|
103
|
+
let result;
|
|
104
|
+
try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
|
|
105
|
+
if (result?.ok === false && typeof result.error?.code === "string") throw Object.assign(new Error(result.error.message ?? "Native hook failed"), { code: result.error.code, details: result.error.details });
|
|
106
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || result.ok === false) throw Object.assign(new Error("Native hook did not return a successful protocol object"), { code: "native_hook_response_invalid" });
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Only observed time counts toward inactivity. A scheduling/clock gap is not
|
|
2
|
+
* evidence of OS sleep, nor of provider progress. Call sample regularly. */
|
|
3
|
+
export class ObservationClock {
|
|
4
|
+
constructor({ timeoutMs, gapMs = 60_000, wall = Date.now, monotonic = () => performance.now(), onGap = () => {} }) {
|
|
5
|
+
if (!(timeoutMs > 0) || !(gapMs > 0)) throw new RangeError("positive observation timeout and gap required");
|
|
6
|
+
Object.assign(this, { timeoutMs, gapMs, wall, monotonic, onGap });
|
|
7
|
+
this.lastWall = wall(); this.lastMono = monotonic(); this.elapsed = 0;
|
|
8
|
+
this.progress = undefined;
|
|
9
|
+
}
|
|
10
|
+
sample(progress, progressAt) {
|
|
11
|
+
const wall = this.wall(), mono = this.monotonic();
|
|
12
|
+
const delta = mono - this.lastMono, wallDelta = wall - this.lastWall;
|
|
13
|
+
const gap = delta > this.gapMs || wallDelta > this.gapMs || delta < 0 || wallDelta < 0;
|
|
14
|
+
if (gap) {
|
|
15
|
+
this.onGap({ source: "observer_clock", classification: "observation_gap", confirmed_sleep: false,
|
|
16
|
+
started_at: new Date(this.lastWall).toISOString(), observed_at: new Date(wall).toISOString(),
|
|
17
|
+
wall_delta_ms: wallDelta, monotonic_delta_ms: delta });
|
|
18
|
+
this.elapsed = 0;
|
|
19
|
+
} else this.elapsed += delta;
|
|
20
|
+
this.lastWall = wall; this.lastMono = mono;
|
|
21
|
+
if (progress !== undefined && progress !== this.progress) {
|
|
22
|
+
this.progress = progress;
|
|
23
|
+
this.elapsed = !gap && Number.isFinite(progressAt) ? Math.max(0, Math.min(this.timeoutMs, wall - progressAt)) : 0;
|
|
24
|
+
}
|
|
25
|
+
return this.elapsed >= this.timeoutMs;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Node timeout handle: callers can keep their existing clearTimeout cleanup. */
|
|
30
|
+
export function observedTimeout(callback, timeoutMs, options = {}) {
|
|
31
|
+
const clock = new ObservationClock({ timeoutMs, ...options });
|
|
32
|
+
const timer = setInterval(() => {
|
|
33
|
+
if (clock.sample(options.progress?.())) { clearInterval(timer); callback(); }
|
|
34
|
+
}, Math.max(1, Math.min(1_000, timeoutMs / 4)));
|
|
35
|
+
return timer;
|
|
36
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
// The existing runner operation owns its child driver requests, including calls
|
|
4
|
+
// nested in Judge/HITL/fanout. Async context keeps parallel executions separate.
|
|
5
|
+
export const operationContext = new AsyncLocalStorage();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// An observer losing its response does not establish that a provider Turn failed.
|
|
2
|
+
export function isObservationLoss(error) {
|
|
3
|
+
return ["rpc_timeout", "native_timeout", "native_outcome_unknown", "native_backend_dead", "native_backend_pipe_broken", "bridge_exited", "bridge_pipe_broken", "daemon_timeout", "turn_timeout", "operation_observation_lost", "daemon_connection_closed"].includes(error?.code);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function errorRecord(error, depth = 0) {
|
|
7
|
+
return {
|
|
8
|
+
code: typeof error?.code === "string" ? error.code : "operation_failed",
|
|
9
|
+
message: typeof error?.message === "string" ? error.message : String(error),
|
|
10
|
+
...(typeof error?.retryable === "boolean" ? { retryable: error.retryable } : {}),
|
|
11
|
+
...(error?.details !== undefined ? { details: error.details } : {}),
|
|
12
|
+
...(error?.cause && depth < 3 ? { cause: errorRecord(error.cause, depth + 1) } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function reportedError(value, fallback) {
|
|
17
|
+
const record = errorRecord({ ...value, message: value.message ?? fallback });
|
|
18
|
+
return Object.assign(new Error(record.message), record);
|
|
19
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { reportedError } from "./operation-errors.mjs";
|
|
3
|
+
|
|
4
|
+
function failure(message, code) {
|
|
5
|
+
return Object.assign(new Error(message), { code });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function reportedFailure(stdout, stderr, fallback, fallbackCode = "flow_reconciliation_failed") {
|
|
9
|
+
for (const text of [stdout, stderr]) {
|
|
10
|
+
for (const line of [text.trim(), ...text.trim().split(/\n/).reverse()]) {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(line);
|
|
13
|
+
const error = value?.error;
|
|
14
|
+
if (typeof error?.code === "string" && error.code) return reportedError(error, fallback);
|
|
15
|
+
if (typeof value?.code === "string" && value.code) return reportedError({ ...value, message: typeof error === "string" ? error : value.message }, fallback);
|
|
16
|
+
} catch { /* Not a structured CLI error line. */ }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return failure(stderr.trim() || stdout.trim() || fallback, fallbackCode);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function processTarget(executable, args) {
|
|
23
|
+
return /\.[cm]?js$/.test(executable) ? { command: process.execPath, args: [executable, ...args] } : { command: executable, args };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function commandJson(bin, args, { cwd, env = {}, input = null, onProgress = null } = {}) {
|
|
27
|
+
return await new Promise((resolve, reject) => {
|
|
28
|
+
// A canonical runtime may intentionally shadow the ambient `dd-flow`
|
|
29
|
+
// executable with its captured engine. Keep that choice in the supplied
|
|
30
|
+
// environment rather than letting a host-global shim leak into a run.
|
|
31
|
+
const executable = bin === "dd-flow" && typeof env.DD_FLOW_BIN === "string" ? env.DD_FLOW_BIN : bin;
|
|
32
|
+
const target = processTarget(executable, [...args, "--json"]);
|
|
33
|
+
const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env }, stdio: [input === null ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
34
|
+
let stdout = ""; let stderr = ""; let pendingStderr = ""; let settled = false; let progressError = null;
|
|
35
|
+
const finish = (error, value) => { if (settled) return; settled = true; error ? reject(error) : resolve(value); };
|
|
36
|
+
const deliverProgress = (line) => {
|
|
37
|
+
let value; try { value = JSON.parse(line); } catch { return; }
|
|
38
|
+
if (typeof onProgress !== "function" || progressError) return;
|
|
39
|
+
try { const returned = onProgress(value); if (returned && typeof returned.then === "function") { Promise.resolve(returned).catch(() => {}); throw failure("commandJson onProgress must be synchronous", "progress_callback_async"); } }
|
|
40
|
+
catch (error) { progressError = error; child.kill("SIGKILL"); }
|
|
41
|
+
};
|
|
42
|
+
child.stdout.setEncoding("utf8").on("data", (data) => { stdout += data; });
|
|
43
|
+
child.stderr.setEncoding("utf8").on("data", (data) => {
|
|
44
|
+
stderr += data; pendingStderr += data;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const end = pendingStderr.indexOf("\n"); if (end < 0) break;
|
|
47
|
+
const line = pendingStderr.slice(0, end); pendingStderr = pendingStderr.slice(end + 1);
|
|
48
|
+
deliverProgress(line);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
child.once("error", (error) => finish(progressError ?? error));
|
|
52
|
+
if (input !== null) child.stdin.end(input);
|
|
53
|
+
child.on("close", (status) => {
|
|
54
|
+
if (pendingStderr) deliverProgress(pendingStderr);
|
|
55
|
+
if (progressError) return finish(progressError);
|
|
56
|
+
if (status !== 0) return finish(reportedFailure(stdout, stderr, `${executable} exited ${status}`));
|
|
57
|
+
try { const value = JSON.parse(stdout.trim()); if (value?.ok === false) { const error = value.error && typeof value.error === "object" ? value.error : value; if (typeof error.code === "string") return finish(reportedError(error, `${bin} returned an error envelope`)); } finish(null, value); }
|
|
58
|
+
catch (error) { finish(failure(`${bin} returned invalid JSON: ${error.message}`, "flow_reconciliation_failed")); }
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function commandText(bin, args, { cwd, env = {} } = {}) {
|
|
64
|
+
return await new Promise((resolve, reject) => {
|
|
65
|
+
const executable = bin === "dd-flow" && typeof env.DD_FLOW_BIN === "string" ? env.DD_FLOW_BIN : bin;
|
|
66
|
+
const target = processTarget(executable, args);
|
|
67
|
+
const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = "";
|
|
68
|
+
child.stdout.setEncoding("utf8").on("data", (data) => { stdout += data; }); child.stderr.setEncoding("utf8").on("data", (data) => { stderr += data; }); child.on("error", reject);
|
|
69
|
+
child.on("close", (status) => status === 0 ? resolve(stdout.trim()) : reject(failure(stderr.trim() || stdout.trim() || `${executable} exited ${status}`, "command_failed")));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
|
|
5
|
+
/** Inspect OS descendants without using process names as ownership. A start-time
|
|
6
|
+
* identity prevents a later unrelated process with a recycled PID being killed. */
|
|
7
|
+
export async function processSnapshot() {
|
|
8
|
+
const { stdout } = await run("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,stat="], { timeout: 5_000 });
|
|
9
|
+
return stdout.trim().split("\n").flatMap(line => { const parts = line.trim().split(/\s+/); return parts.length >= 9 ? [{ pid: Number(parts[0]), ppid: Number(parts[1]), pgid: Number(parts[2]), started: parts.slice(3, 8).join(" "), zombie: parts[8].startsWith("Z") }] : []; });
|
|
10
|
+
}
|