@deksden-com/dd-flow-cli 0.9.0-beta.100 → 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/dist/build-info.json +3 -3
- package/dist/cli/hook-ingress.js +5 -10
- package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
- package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- 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 +13 -2
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
- package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
- package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
- package/dist/harness-runtime/lib/process-json.mjs +3 -2
- package/dist/services/code-checks.js +53 -16
- package/dist/services/controller-fanout.js +37 -6
- package/dist/services/eval-snapshots.js +148 -38
- package/dist/services/external-work-launch.js +60 -5
- package/dist/services/prompts.js +2 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-worker.js +16 -11
- package/dist/services/run-controller-adapter.js +9 -0
- package/dist/services/runs.js +3 -0
- package/dist/services/runtime-scope-stop.js +66 -6
- package/dist/services/vnext-code-review.js +6 -2
- package/dist/services/vnext-code.js +1 -0
- package/dist/services/vnext-fanout.js +9 -1
- package/dist/services/vnext-merge.js +8 -4
- package/dist/services/vnext-plan-review.js +4 -15
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/vnext-protocolize.js +4 -2
- package/dist/services/vnext-specify.js +7 -0
- package/dist/services/work-registry.js +208 -22
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +3 -1
- package/package.json +1 -1
|
@@ -70,7 +70,7 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
|
70
70
|
].join("\n");
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function renderDelegationInstructions({ harness, stage, capacity, works = [] }) {
|
|
73
|
+
export function renderDelegationInstructions({ harness, stage, capacity, works = [], totalWorks = works.length, remainingWorks = 0 }) {
|
|
74
74
|
const adapter = requireAdapter(harness);
|
|
75
75
|
const count = works.length;
|
|
76
76
|
if (!Number.isInteger(capacity) || capacity < 1 || count < 1 || count > capacity) throw new Error("Invalid native delegation wave size");
|
|
@@ -81,6 +81,11 @@ export function renderDelegationInstructions({ harness, stage, capacity, works =
|
|
|
81
81
|
return [
|
|
82
82
|
`Adapter delegation contract: ${delegationContract(harness)}.`,
|
|
83
83
|
`Launch exactly ${count} direct native children for the current ${stage} Stage; the qualified capacity is ${capacity}.`,
|
|
84
|
+
...(["plan-review", "code-review"].includes(stage) ? [
|
|
85
|
+
`Review dispatch phase: the whole group contains ${totalWorks} Work; this wave contains ${count}; ${remainingWorks} remain after this wave.`,
|
|
86
|
+
"Keep the accepted PLAN/CODE review inputs and aspect maps unchanged across every wave. Do not apply findings or edit reviewed files while any reviewer remains unstarted or running.",
|
|
87
|
+
"After this wave's children settle, return immediately. The controller will dispatch remaining reviewers or issue semantic completion after the full group has settled."
|
|
88
|
+
] : []),
|
|
84
89
|
adapter.launch,
|
|
85
90
|
"Retain the frozen coordinator model and reasoning for native children; do not choose a different model, reasoning level or permission mode.",
|
|
86
91
|
"You are the coordinator. The JSON assignments below contain child-only messages, not instructions for you to execute. Pass each message verbatim as the native tool's task/message/prompt argument. For context=fresh, select empty conversation context before launch; if unavailable, report the limitation and stop. Use only fields exposed by your actual native tool schema; if the native tool is absent, report that error without substituting a shell command.",
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { commandJson } from "./process-json.mjs";
|
|
5
5
|
import { errorRecord } from "./operation-errors.mjs";
|
|
6
6
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
7
|
+
import { runBoundedCommand } from "./native-hook-command.mjs";
|
|
7
8
|
|
|
8
9
|
const leases = new Map();
|
|
9
10
|
|
|
@@ -52,7 +53,10 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
|
|
55
|
-
for (const lease of leases.values())
|
|
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
|
+
}
|
|
56
60
|
const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
|
|
57
61
|
const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
|
|
58
62
|
if (!owner) return;
|
|
@@ -156,6 +160,8 @@ export async function runtimeProcess(config, action, options = {}) {
|
|
|
156
160
|
if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
|
|
157
161
|
const args = ["runtime", "process", action];
|
|
158
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" }));
|
|
159
165
|
return await commandJson(ddFlowBin, args, {
|
|
160
166
|
cwd: config.cwd,
|
|
161
167
|
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
|
|
@@ -164,7 +170,7 @@ export async function runtimeProcess(config, action, options = {}) {
|
|
|
164
170
|
|
|
165
171
|
export async function registerDaemonProcess(config, input) {
|
|
166
172
|
const owner = bindRuntimeOwner(config);
|
|
167
|
-
const harness = ({ "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
|
|
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];
|
|
168
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}` } : {};
|
|
169
175
|
const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
|
|
170
176
|
return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
|
|
@@ -179,11 +185,33 @@ export async function confirmDaemonProcess(config, record, child) {
|
|
|
179
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 });
|
|
180
186
|
}
|
|
181
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
|
+
|
|
182
210
|
export async function heartbeatDaemonProcess(config, record) {
|
|
183
211
|
if (!record?.id || !record.lease_token) return;
|
|
184
212
|
let lease = leases.get(record.id);
|
|
185
213
|
if (!lease) {
|
|
186
|
-
lease = { pending: null, error: null, timer: null, lost: false };
|
|
214
|
+
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000 };
|
|
187
215
|
leases.set(record.id, lease);
|
|
188
216
|
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
189
217
|
lease.timer.unref();
|
|
@@ -198,6 +226,7 @@ export async function heartbeatDaemonProcess(config, record) {
|
|
|
198
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 } });
|
|
199
227
|
}
|
|
200
228
|
lease.error = null;
|
|
229
|
+
lease.expiresAt = Date.now() + 300_000;
|
|
201
230
|
} catch (cause) {
|
|
202
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 } });
|
|
203
232
|
throw lease.error;
|
|
@@ -1,31 +1,105 @@
|
|
|
1
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
|
+
}
|
|
2
57
|
|
|
3
58
|
/** Preserve the native hook protocol across the CLI process boundary. */
|
|
4
|
-
export async function
|
|
59
|
+
export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
|
|
60
|
+
const started = performance.now();
|
|
5
61
|
const script = /\.[cm]?js$/.test(bin);
|
|
6
62
|
const output = await new Promise((resolve, reject) => {
|
|
7
63
|
const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
|
|
8
|
-
env: { ...process.env,
|
|
64
|
+
cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
|
|
9
65
|
});
|
|
10
|
-
let stdout = "", stderr = "", failure;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
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);
|
|
13
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")); });
|
|
14
86
|
child.stderr.setEncoding("utf8").on("data", chunk => { stderr = (stderr + chunk).slice(-65_536); });
|
|
15
87
|
child.stdin.on("error", error => { failure ??= error; });
|
|
16
|
-
child.on("error", error =>
|
|
88
|
+
child.on("error", error => finish(failure ?? error));
|
|
17
89
|
child.on("close", code => {
|
|
18
|
-
|
|
19
|
-
if (failure) return reject(failure);
|
|
90
|
+
if (failure) { void (termination ?? Promise.resolve()).then(() => finish(failure)); return; }
|
|
20
91
|
if (code !== 0) {
|
|
21
|
-
|
|
22
|
-
try { record = JSON.parse(stderr.trim()).error; } catch { /* Non-JSON stderr remains diagnostic evidence. */ }
|
|
23
|
-
return reject(Object.assign(new Error(record?.message ?? (stderr.trim() || "Native hook admission failed")), { code: record?.code ?? "native_hook_failed", details: record?.details }));
|
|
92
|
+
return finish(reportedFailure(stdout, stderr, "Native hook admission failed", "native_hook_failed"));
|
|
24
93
|
}
|
|
25
|
-
|
|
94
|
+
finish(null, stdout);
|
|
26
95
|
});
|
|
27
|
-
child.stdin.end(
|
|
96
|
+
child.stdin.end(input);
|
|
28
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 });
|
|
29
103
|
let result;
|
|
30
104
|
try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
|
|
31
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 });
|
|
@@ -5,17 +5,18 @@ function failure(message, code) {
|
|
|
5
5
|
return Object.assign(new Error(message), { code });
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
function reportedFailure(stdout, stderr, fallback) {
|
|
8
|
+
export function reportedFailure(stdout, stderr, fallback, fallbackCode = "flow_reconciliation_failed") {
|
|
9
9
|
for (const text of [stdout, stderr]) {
|
|
10
10
|
for (const line of [text.trim(), ...text.trim().split(/\n/).reverse()]) {
|
|
11
11
|
try {
|
|
12
12
|
const value = JSON.parse(line);
|
|
13
13
|
const error = value?.error;
|
|
14
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);
|
|
15
16
|
} catch { /* Not a structured CLI error line. */ }
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
|
-
return failure(stderr.trim() || stdout.trim() || fallback,
|
|
19
|
+
return failure(stderr.trim() || stdout.trim() || fallback, fallbackCode);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function processTarget(executable, args) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
|
|
2
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
@@ -43,6 +44,28 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
43
44
|
throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
|
|
44
45
|
return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Planned aliases are qualification-owned extensions: their provider Work is
|
|
49
|
+
* expected to materialize them after PLAN. Freeze the project policy and
|
|
50
|
+
* existing aliases, while still validating every planned alias definition at
|
|
51
|
+
* execution time. Unknown edits and changes to existing policy remain part
|
|
52
|
+
* of the hash and therefore fail closed.
|
|
53
|
+
*/
|
|
54
|
+
export function codeCheckProfileHash(workspaceRoot, checks = []) {
|
|
55
|
+
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
56
|
+
if (!profile)
|
|
57
|
+
return null;
|
|
58
|
+
const planned = new Set(checks.filter(check => check.availability === "planned" && check.command.startsWith("@check/")).map(check => check.command));
|
|
59
|
+
if (!planned.size)
|
|
60
|
+
return crypto.createHash("sha256").update(JSON.stringify(profile)).digest("hex");
|
|
61
|
+
const canonical = {
|
|
62
|
+
...profile,
|
|
63
|
+
aliases: Object.fromEntries(Object.entries(profile.aliases).filter(([alias]) => !planned.has(alias))),
|
|
64
|
+
ports_by_alias: Object.fromEntries(Object.entries(profile.ports_by_alias ?? {}).filter(([alias]) => !planned.has(alias))),
|
|
65
|
+
inputs_by_alias: Object.fromEntries(Object.entries(profile.inputs_by_alias ?? {}).filter(([alias]) => !planned.has(alias)))
|
|
66
|
+
};
|
|
67
|
+
return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
68
|
+
}
|
|
46
69
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
47
70
|
const { profile, file } = readCodeCheckProfile(workspaceRoot);
|
|
48
71
|
return commands.map((command) => {
|
|
@@ -139,7 +162,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
|
139
162
|
const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
|
|
140
163
|
for (const check of input.checks)
|
|
141
164
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
142
|
-
const {
|
|
165
|
+
const { profile } = readCodeCheckProfile(input.workspaceRoot);
|
|
166
|
+
const profileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
|
|
143
167
|
if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
|
|
144
168
|
throw new AppError("check_profile_drift", "The frozen check profile differs from the workspace profile", 1, { expected_profile_hash: input.expectedProfileHash, actual_profile_hash: profileHash });
|
|
145
169
|
const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; const inputs = profile?.inputs_by_alias?.[check.command]; return { ...check, ...(check.ports || !ports ? {} : { ports }), ...(check.inputs || !inputs ? {} : { inputs }) }; });
|
|
@@ -239,7 +263,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
|
239
263
|
input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
|
|
240
264
|
}
|
|
241
265
|
if (receipts.every(receipt => receipt.status === "passed")) {
|
|
242
|
-
const current = workspaceState(input.workspaceRoot, [input.runHome]),
|
|
266
|
+
const current = workspaceState(input.workspaceRoot, [input.runHome]), currentProfileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
|
|
243
267
|
for (const receipt of receipts) {
|
|
244
268
|
const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
|
|
245
269
|
const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
|
|
@@ -414,7 +438,7 @@ export function workspaceChangedPaths(root) {
|
|
|
414
438
|
// A workspace can be intentionally gitless. Keep the same conservative
|
|
415
439
|
// file inventory used for fingerprints instead of making result validation
|
|
416
440
|
// fail before the work contract can report its actual error.
|
|
417
|
-
if (
|
|
441
|
+
if (isGitlessWorkspace(root, status))
|
|
418
442
|
return listFiles(root);
|
|
419
443
|
if (status.status !== 0 || status.error)
|
|
420
444
|
throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
@@ -434,7 +458,8 @@ export function workspaceChangedPaths(root) {
|
|
|
434
458
|
paths.push(normalizeWorkspacePath(root, prior));
|
|
435
459
|
}
|
|
436
460
|
}
|
|
437
|
-
|
|
461
|
+
const ownedHook = paths.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root) : null;
|
|
462
|
+
return [...new Set(paths)].filter(file => file !== ownedHook).sort();
|
|
438
463
|
}
|
|
439
464
|
/** Ignored machine state cannot be reported as a product change. Tracked paths
|
|
440
465
|
* remain eligible regardless of their directory name. */
|
|
@@ -443,15 +468,15 @@ export function ignoredWorkspacePaths(root, paths) {
|
|
|
443
468
|
if (!normalized.length)
|
|
444
469
|
return [];
|
|
445
470
|
const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
|
|
451
|
-
return [];
|
|
452
|
-
if (![0, 1].includes(status.status ?? -1) || status.error)
|
|
471
|
+
// The canonical owned hook has one meaning across Git status, check cache
|
|
472
|
+
// and MERGE, without changing a sibling worktree's shared Git excludes.
|
|
473
|
+
const gitless = isGitlessWorkspace(root, status);
|
|
474
|
+
if (!gitless && (![0, 1].includes(status.status ?? -1) || status.error))
|
|
453
475
|
throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
454
|
-
|
|
476
|
+
const ownedHook = normalized.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, gitless ? [] : undefined) : null;
|
|
477
|
+
if (gitless)
|
|
478
|
+
return ownedHook ? [ownedHook] : [];
|
|
479
|
+
return [...new Set([...status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)), ...(ownedHook ? [ownedHook] : [])])].sort();
|
|
455
480
|
}
|
|
456
481
|
export function normalizeWorkspacePath(root, value) {
|
|
457
482
|
if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
|
|
@@ -480,11 +505,14 @@ function deduplicate(checks, commands) {
|
|
|
480
505
|
return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
|
|
481
506
|
}
|
|
482
507
|
function workspaceState(root, ignoredRoots = []) {
|
|
483
|
-
const git =
|
|
508
|
+
const git = workspaceGitFiles(root, ["-co", "--exclude-standard"]);
|
|
484
509
|
const ignored = ignoredRoots.map(entry => path.resolve(entry));
|
|
485
|
-
const listing = [...new Set(git
|
|
510
|
+
const listing = [...new Set(git ?? listFiles(root))].sort();
|
|
511
|
+
const ownedHook = listing.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, git ? undefined : []) : null;
|
|
486
512
|
const files = new Map(), hash = crypto.createHash("sha256");
|
|
487
513
|
for (const relative of listing) {
|
|
514
|
+
if (relative === ownedHook)
|
|
515
|
+
continue;
|
|
488
516
|
const file = path.resolve(root, relative);
|
|
489
517
|
if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
|
|
490
518
|
continue;
|
|
@@ -677,8 +705,17 @@ function collectCheckArtifacts(root, required) {
|
|
|
677
705
|
const missing = required.filter((relative) => !retained.has(path.normalize(relative)));
|
|
678
706
|
return { complete: missing.length === 0, missing, items };
|
|
679
707
|
}
|
|
680
|
-
function listFiles(root
|
|
681
|
-
|
|
708
|
+
function listFiles(root) {
|
|
709
|
+
const allowed = projectServicePathFilter(root, [], []);
|
|
710
|
+
const ownedHook = ownedWorkspaceHookPath(root, []);
|
|
711
|
+
const visit = (current) => fs.readdirSync(current, { withFileTypes: true }).flatMap(entry => {
|
|
712
|
+
const absolute = path.join(current, entry.name);
|
|
713
|
+
if ([".git", "node_modules"].includes(entry.name) || !allowed(absolute))
|
|
714
|
+
return [];
|
|
715
|
+
return entry.isDirectory() ? visit(absolute) : [path.relative(root, absolute)];
|
|
716
|
+
});
|
|
717
|
+
return visit(root).filter(file => file.split(path.sep).join("/") !== ownedHook).sort();
|
|
718
|
+
}
|
|
682
719
|
export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
683
720
|
return new Promise((resolve) => {
|
|
684
721
|
const started = Date.now();
|
|
@@ -4,6 +4,7 @@ import { getFlowRunVariables } from "./runs.js";
|
|
|
4
4
|
import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
|
|
5
5
|
import { resolveExecutionPolicy } from "./execution-policy.js";
|
|
6
6
|
import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
7
|
+
import { assertWorkLaunchReady } from "./work-registry.js";
|
|
7
8
|
function semanticCompletionPrompt(stage, completed) {
|
|
8
9
|
const task = stage === "plan-review"
|
|
9
10
|
? "Classify the reviewer findings, apply every accepted correction in the current PLAN-REVIEW Work, and write the authoritative review decision."
|
|
@@ -85,7 +86,11 @@ export async function nextControllerFanout(context, input) {
|
|
|
85
86
|
for (const work of wave)
|
|
86
87
|
if (!work.work_id || !work.start_command)
|
|
87
88
|
throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
|
|
88
|
-
|
|
89
|
+
if (input.stage === "plan-review" || input.stage === "code-review")
|
|
90
|
+
for (const work of wave)
|
|
91
|
+
assertWorkLaunchReady(context, work.work_id);
|
|
92
|
+
const totalWorks = fanout.works.created + fanout.works.running + fanout.works.completed + fanout.works.failed + fanout.works.cancelled;
|
|
93
|
+
return prompt("dispatch_work", renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave, totalWorks, remainingWorks: fanout.works.created - wave.length }));
|
|
89
94
|
}
|
|
90
95
|
if (fanout.works.running)
|
|
91
96
|
return { kind: "wait", reason: "work_receipts_pending" };
|
|
@@ -109,11 +114,21 @@ export function controllerNativeChildren(receipt, rootSessionId) {
|
|
|
109
114
|
const value = object(raw);
|
|
110
115
|
const id = text(value, ["provider_session_id", "session_id", "sessionId", "childSessionId", "conversation_id", "conversationId", "id", "subagentId"]);
|
|
111
116
|
const parent = text(value, ["parent_provider_session_id", "parent_session_id", "parentSessionId", "parentID", "parentId"]) ?? scopedParent;
|
|
112
|
-
if (!id
|
|
117
|
+
if (!id)
|
|
113
118
|
return;
|
|
119
|
+
const outcomeError = object(value.outcome_error);
|
|
120
|
+
if (typeof outcomeError.code === "string")
|
|
121
|
+
throw new AppError(outcomeError.code, String(outcomeError.message ?? "Native child outcome is unconfirmed"), 1, { ...object(outcomeError.details), session_id: id, source });
|
|
114
122
|
const state = String(value.status ?? value.state ?? fallback).toLowerCase();
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
if (state === "lost")
|
|
124
|
+
throw new AppError("native_child_outcome_lost", "Native child ended without a recoverable outcome", 1, { session_id: id, parent_session_id: parent, source, native_status: state });
|
|
125
|
+
// Provider idle means no current activity, not a Work result. Only an
|
|
126
|
+
// explicit terminal result or a separately proven root settlement may be
|
|
127
|
+
// terminal here; unknown states stay unknown for reconciliation.
|
|
128
|
+
const status = ["completed", "complete", "done", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled", "interrupted"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
|
|
129
|
+
const rootChild = parent === rootSessionId;
|
|
130
|
+
const settled = rootChild && status === "unknown" && settledTree.has(id) ? "settled_by_root" : status;
|
|
131
|
+
candidates.push({ session_id: id, parent_session_id: parent ?? "", status: settled, source, ...(rootChild ? {} : { provenance: "parent_mismatch" }) });
|
|
117
132
|
};
|
|
118
133
|
for (const value of list(receipt.descendants))
|
|
119
134
|
add(value, "adapter.descendants");
|
|
@@ -122,12 +137,28 @@ export function controllerNativeChildren(receipt, rootSessionId) {
|
|
|
122
137
|
const subagents = object(object(receipt.evidence).subagents ?? receipt.subagents);
|
|
123
138
|
for (const value of list(subagents.running))
|
|
124
139
|
add(value, "zcode/session/subagents", rootSessionId, "running");
|
|
125
|
-
for (const value of
|
|
140
|
+
for (const value of list(subagents.completed))
|
|
126
141
|
add(value, "zcode/session/subagents", rootSessionId, "completed");
|
|
142
|
+
for (const value of list(object(subagents.ended).items)) {
|
|
143
|
+
const status = String(object(value).status ?? "unknown");
|
|
144
|
+
if (!["success", "failed", "cancelled", "lost"].includes(status))
|
|
145
|
+
throw new AppError("native_child_outcome_unavailable", "ZCode ended child has no recognized native outcome", 1, { child: value });
|
|
146
|
+
add(value, "zcode/session/subagents", rootSessionId);
|
|
147
|
+
}
|
|
127
148
|
const unique = new Map();
|
|
149
|
+
const rank = { unknown: 0, running: 1, settled_by_root: 2, completed: 3, failed: 3, cancelled: 3 };
|
|
128
150
|
for (const child of candidates) {
|
|
129
151
|
const previous = unique.get(child.session_id);
|
|
130
|
-
if (
|
|
152
|
+
if (previous && previous.parent_session_id !== child.parent_session_id) {
|
|
153
|
+
// Keep the contradictory parent visible to the existing provenance gate,
|
|
154
|
+
// independently of input order or which source happened to say success.
|
|
155
|
+
const foreign = [previous, child].filter(item => item.parent_session_id !== rootSessionId).sort((a, b) => a.parent_session_id.localeCompare(b.parent_session_id))[0];
|
|
156
|
+
unique.set(child.session_id, { ...foreign, provenance: "parent_mismatch" });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (previous && rank[previous.status] === 3 && rank[child.status] === 3 && previous.status !== child.status)
|
|
160
|
+
throw new AppError("native_child_outcome_conflict", "Native child has contradictory terminal outcomes", 1, { session_id: child.session_id, observations: [previous, child] });
|
|
161
|
+
if (!previous || rank[child.status] > rank[previous.status])
|
|
131
162
|
unique.set(child.session_id, child);
|
|
132
163
|
}
|
|
133
164
|
return [...unique.values()].sort((left, right) => left.session_id.localeCompare(right.session_id));
|