@deksden-com/dd-flow-cli 0.9.0-beta.21 → 0.9.0-beta.23
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 +17 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/run-cli.js +3 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +34 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +22 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +30 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +28 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +18 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +62 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +78 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +249 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +90 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +382 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +123 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +434 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +279 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +167 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +153 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +80 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +420 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +593 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +119 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +119 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +33 -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 +61 -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 +24 -0
- package/dist/services/execution-policy.js +61 -0
- package/dist/services/external-work-launch.js +76 -0
- package/dist/services/harness-adapter.js +31 -14
- package/dist/services/hooks.js +43 -20
- package/dist/services/runs.js +2 -1
- package/dist/services/stage-context.js +2 -2
- package/dist/services/vnext-execution-profile.js +38 -3
- package/dist/services/work-registry.js +8 -1
- package/dist/storage/database.js +21 -0
- package/package.json +19 -14
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { withRunnerLock as withLock } from "./runner-lock.mjs";
|
|
5
|
+
import { isObservationLoss, errorRecord } from "./operation-errors.mjs";
|
|
6
|
+
import { operationContext } from "./operation-context.mjs";
|
|
7
|
+
|
|
8
|
+
const appendQueues = new Map();
|
|
9
|
+
|
|
10
|
+
export function canonicalJson(value) {
|
|
11
|
+
const normalize = (item) => {
|
|
12
|
+
if (Array.isArray(item)) return item.map(normalize);
|
|
13
|
+
if (item && typeof item === "object") return Object.fromEntries(Object.keys(item).sort().map((key) => [key, normalize(item[key])]));
|
|
14
|
+
return item;
|
|
15
|
+
};
|
|
16
|
+
return `${JSON.stringify(normalize(value))}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sha256(value) {
|
|
20
|
+
return createHash("sha256").update(value).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hashJson(value) { return sha256(canonicalJson(value)); }
|
|
24
|
+
|
|
25
|
+
export async function writeJsonAtomic(file, value) {
|
|
26
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
27
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
28
|
+
try {
|
|
29
|
+
const payload = await open(temporary, "wx", 0o600);
|
|
30
|
+
try { await payload.writeFile(`${JSON.stringify(value, null, 2)}\n`); await payload.sync(); }
|
|
31
|
+
finally { await payload.close(); }
|
|
32
|
+
await rename(temporary, file);
|
|
33
|
+
const directory = await open(path.dirname(file), "r");
|
|
34
|
+
try { await directory.sync(); }
|
|
35
|
+
finally { await directory.close(); }
|
|
36
|
+
} finally { await unlink(temporary).catch(error => { if (error.code !== "ENOENT") throw error; }); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readJsonLines(file, label = "JSONL") {
|
|
40
|
+
try {
|
|
41
|
+
const source = await readFile(file, "utf8");
|
|
42
|
+
return source.split("\n").filter(Boolean).map((line, index) => {
|
|
43
|
+
try { return JSON.parse(line); }
|
|
44
|
+
catch { throw new Error(`invalid ${label} record at ${file}:${index + 1}`); }
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error?.code === "ENOENT") return [];
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function readEvents(file) {
|
|
53
|
+
const records = await readJsonLines(file, "runner event");
|
|
54
|
+
return records.map((record, index) => {
|
|
55
|
+
try { return validateRunnerEvent(record); }
|
|
56
|
+
catch { throw new Error(`invalid runner event at ${file}:${index + 1}`); }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateRunnerEvent(value) {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.specversion !== "1.0" || typeof value.id !== "string" || typeof value.source !== "string" || typeof value.type !== "string" || typeof value.time !== "string" || !value.data || typeof value.data !== "object" || !Number.isInteger(value.data.sequence) || value.data.sequence < 1) {
|
|
62
|
+
throw new Error("invalid normalized runner event");
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function appendEvent(file, input) {
|
|
68
|
+
const prior = appendQueues.get(file) ?? Promise.resolve();
|
|
69
|
+
const current = prior.then(() => withLock(file, async () => {
|
|
70
|
+
const events = await readEvents(file);
|
|
71
|
+
// Internal callers can validate a dispatch or bind its generation under
|
|
72
|
+
// the same cross-process lock that orders cancellation and completion.
|
|
73
|
+
if (input.deduplicate && input.id) { const existing = events.find(event => event.id === input.id); if (existing) return existing; }
|
|
74
|
+
if (input.beforeAppend?.(events) === false) return null;
|
|
75
|
+
const sequence = events.length + 1;
|
|
76
|
+
const event = {
|
|
77
|
+
specversion: "1.0",
|
|
78
|
+
id: input.id ?? `EVT-${randomUUID()}`,
|
|
79
|
+
source: input.source,
|
|
80
|
+
type: input.type,
|
|
81
|
+
time: input.time ?? new Date().toISOString(),
|
|
82
|
+
subject: input.subject ?? "runner",
|
|
83
|
+
datacontenttype: "application/json",
|
|
84
|
+
runid: input.runId,
|
|
85
|
+
executionid: input.executionId,
|
|
86
|
+
traceid: input.traceId,
|
|
87
|
+
spanid: input.spanId ?? randomUUID(),
|
|
88
|
+
...(input.parentSpanId ? { parentspanid: input.parentSpanId } : {}),
|
|
89
|
+
data: { sequence, ...input.data }
|
|
90
|
+
};
|
|
91
|
+
const context = operationContext.getStore();
|
|
92
|
+
if (context?.eventsFile === file && context.executionId === input.executionId && context.operationId && input.executionId) {
|
|
93
|
+
event.data.execution_operation_id ??= context.operationId;
|
|
94
|
+
}
|
|
95
|
+
validateRunnerEvent(event);
|
|
96
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
97
|
+
const handle = await open(file, "a");
|
|
98
|
+
try { await handle.write(`${JSON.stringify(event)}\n`); await handle.sync(); }
|
|
99
|
+
finally { await handle.close(); }
|
|
100
|
+
return event;
|
|
101
|
+
}));
|
|
102
|
+
const tail = current.catch(() => {}); appendQueues.set(file, tail);
|
|
103
|
+
try { return await current; }
|
|
104
|
+
finally { if (appendQueues.get(file) === tail) appendQueues.delete(file); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function reduceEvents(events) {
|
|
108
|
+
const operations = new Map();
|
|
109
|
+
let state = "planned";
|
|
110
|
+
for (const event of events) {
|
|
111
|
+
const data = event?.data ?? {};
|
|
112
|
+
if (["dev.dd.eval.planned", "dev.dd.eval.completed"].includes(event.type) && typeof data.state === "string") state = data.state;
|
|
113
|
+
if (typeof data.operation_id !== "string") continue;
|
|
114
|
+
const prior = operations.get(data.operation_id) ?? { id: data.operation_id, requested: false, started: false, terminal: null };
|
|
115
|
+
if (event.type.endsWith(".requested")) prior.requested = true;
|
|
116
|
+
if (event.type.endsWith(".started")) prior.started = true;
|
|
117
|
+
if (event.type.endsWith(".observation_lost")) prior.observation_lost = data.error ?? { code: "observation_lost" };
|
|
118
|
+
if (event.type.endsWith(".completed") || event.type.endsWith(".failed") || event.type.endsWith(".cancelled")) {
|
|
119
|
+
const terminal = event.type.split(".").at(-1);
|
|
120
|
+
const result = terminal === "completed" ? data.result : { error: data.error ?? null };
|
|
121
|
+
const resultHash = hashJson({ terminal, result });
|
|
122
|
+
if (prior.terminal && (prior.terminal !== terminal || prior.result_hash !== resultHash)) {
|
|
123
|
+
throw Object.assign(new Error(`runner operation ${data.operation_id} has conflicting terminal events`), { code: "journal_conflict", operation_id: data.operation_id });
|
|
124
|
+
}
|
|
125
|
+
prior.terminal = terminal;
|
|
126
|
+
prior.result = result;
|
|
127
|
+
prior.result_hash = resultHash;
|
|
128
|
+
}
|
|
129
|
+
operations.set(data.operation_id, prior);
|
|
130
|
+
}
|
|
131
|
+
return { state, operations: Object.fromEntries([...operations].map(([id, value]) => [id, value])) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function recordOperation({ eventsFile, source, runId, executionId, traceId, operationId, operation, subject = "runner", action }) {
|
|
135
|
+
const existing = await withLock(`${eventsFile}.${operationId.replace(/[^a-zA-Z0-9._-]/g, "_")}`, async () => {
|
|
136
|
+
const current = reduceEvents(await readEvents(eventsFile)).operations[operationId];
|
|
137
|
+
if (current?.terminal === "completed") return current;
|
|
138
|
+
if (current?.terminal) throw Object.assign(new Error(`operation ${operationId} is already ${current.terminal}; use explicit recovery instead of replaying it`), { code: "operation_terminal", operation_id: operationId, terminal: current.terminal });
|
|
139
|
+
if (current?.started && !current.terminal) {
|
|
140
|
+
const error = new Error(current.observation_lost
|
|
141
|
+
? `operation ${operationId} has no confirmed outcome; reconcile its provider state before retrying`
|
|
142
|
+
: `operation ${operationId} is already in progress; reconcile before retrying`);
|
|
143
|
+
error.code = current.observation_lost ? "operation_observation_lost" : "operation_in_progress";
|
|
144
|
+
error.operation_id = operationId;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
await appendEvent(eventsFile, { source, runId, executionId, traceId, subject, type: "dev.dd.eval.operation.requested", data: { operation_id: operationId, operation, status: "requested" } });
|
|
148
|
+
await appendEvent(eventsFile, { source, runId, executionId, traceId, subject, type: "dev.dd.eval.operation.started", data: { operation_id: operationId, operation, status: "started" } });
|
|
149
|
+
return null;
|
|
150
|
+
});
|
|
151
|
+
if (existing) return { reused: true, operation_id: operationId, result: existing.result };
|
|
152
|
+
try {
|
|
153
|
+
const result = await operationContext.run({ operationId, eventsFile, runId, executionId }, action);
|
|
154
|
+
await completeOperation({ eventsFile, source, runId, executionId, traceId, subject, operationId, operation, result });
|
|
155
|
+
return { reused: false, operation_id: operationId, result };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (isObservationLoss(error)) await observationLost({ eventsFile, source, runId, executionId, traceId, subject, operationId, operation, error });
|
|
158
|
+
else await failOperation({ eventsFile, source, runId, executionId, traceId, subject, operationId, operation, error });
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function completeOperation({ eventsFile, source, runId, executionId, traceId, subject = "runner", operationId, operation, result }) {
|
|
164
|
+
return await settleOperation({ eventsFile, source, runId, executionId, traceId, subject, operationId, operation, terminal: "completed", result });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function failOperation({ eventsFile, source, runId, executionId, traceId, subject = "runner", operationId, operation, error }) {
|
|
168
|
+
return await settleOperation({ eventsFile, source, runId, executionId, traceId, subject, operationId, operation, terminal: "failed", result: { error: errorRecord(error) } });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A lost observer response is deliberately non-terminal: replay could duplicate a provider Turn. */
|
|
172
|
+
export async function observationLost({ eventsFile, source, runId, executionId, traceId, subject = "runner", operationId, operation, error }) {
|
|
173
|
+
return await withLock(`${eventsFile}.${operationId.replace(/[^a-zA-Z0-9._-]/g, "_")}`, async () => {
|
|
174
|
+
const existing = reduceEvents(await readEvents(eventsFile)).operations[operationId];
|
|
175
|
+
if (existing?.terminal) return { reused: true, operation_id: operationId, result: existing.result };
|
|
176
|
+
if (!existing?.started) throw Object.assign(new Error(`operation ${operationId} has no started receipt`), { code: "operation_not_started" });
|
|
177
|
+
if (existing.observation_lost) return { reused: true, operation_id: operationId, observation_lost: existing.observation_lost };
|
|
178
|
+
const recorded = errorRecord(error);
|
|
179
|
+
await appendEvent(eventsFile, { source, runId, executionId, traceId, subject, type: "dev.dd.eval.operation.observation_lost", data: { operation_id: operationId, operation, status: "observation_lost", error: recorded } });
|
|
180
|
+
return { reused: false, operation_id: operationId, observation_lost: recorded };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function settleOperation({ eventsFile, source, runId, executionId, traceId, subject, operationId, operation, terminal, result }) {
|
|
185
|
+
return await withLock(`${eventsFile}.${operationId.replace(/[^a-zA-Z0-9._-]/g, "_")}`, async () => {
|
|
186
|
+
const existing = reduceEvents(await readEvents(eventsFile)).operations[operationId];
|
|
187
|
+
const expectedHash = hashJson({ terminal, result });
|
|
188
|
+
if (existing?.terminal) {
|
|
189
|
+
if (existing.terminal === terminal && existing.result_hash === expectedHash) return { reused: true, operation_id: operationId, result: existing.result };
|
|
190
|
+
throw Object.assign(new Error(`runner operation ${operationId} has conflicting terminal result`), { code: "journal_conflict", operation_id: operationId });
|
|
191
|
+
}
|
|
192
|
+
if (!existing?.started) throw Object.assign(new Error(`operation ${operationId} has no started receipt`), { code: "operation_not_started" });
|
|
193
|
+
const type = terminal === "completed" ? "dev.dd.eval.operation.completed" : "dev.dd.eval.operation.failed";
|
|
194
|
+
const data = terminal === "completed"
|
|
195
|
+
? { operation_id: operationId, operation, status: terminal, result }
|
|
196
|
+
: { operation_id: operationId, operation, status: terminal, error: result.error };
|
|
197
|
+
await appendEvent(eventsFile, { source, runId, executionId, traceId, subject, type, data });
|
|
198
|
+
return { reused: false, operation_id: operationId, result };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function release(lock, owner) {
|
|
6
|
+
try { unlinkSync(path.join(lock, owner)); } catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
7
|
+
// Never recursively remove a directory: another owner may already have acquired it.
|
|
8
|
+
try { rmdirSync(lock); } catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function reclaimDeadOwner(lock) {
|
|
12
|
+
let entries;
|
|
13
|
+
try { entries = readdirSync(lock); } catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
14
|
+
if (entries.length !== 1 || !/^owner-.*\.json$/.test(entries[0])) return;
|
|
15
|
+
let owner;
|
|
16
|
+
try { owner = JSON.parse(readFileSync(path.join(lock, entries[0]), "utf8")); } catch { return; }
|
|
17
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return;
|
|
18
|
+
try { process.kill(owner.pid, 0); }
|
|
19
|
+
catch (error) { if (error.code === "ESRCH") release(lock, entries[0]); }
|
|
20
|
+
// A live/reused PID or unknown owner is conservative: age never proves death.
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function withRunnerLock(file, action, { timeoutMs = 30_000 } = {}) {
|
|
24
|
+
const lock = `${file}.lock`;
|
|
25
|
+
const owner = `owner-${process.pid}-${randomUUID()}.json`;
|
|
26
|
+
const deadline = performance.now() + timeoutMs;
|
|
27
|
+
for (;;) {
|
|
28
|
+
let acquired = false;
|
|
29
|
+
try { mkdirSync(lock); acquired = true; }
|
|
30
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
31
|
+
if (acquired) {
|
|
32
|
+
// No await between acquisition and identifying the owner.
|
|
33
|
+
try { writeFileSync(path.join(lock, owner), JSON.stringify({ pid: process.pid }), { flag: "wx" }); }
|
|
34
|
+
catch (error) { release(lock, owner); throw error; }
|
|
35
|
+
try { return await action(); }
|
|
36
|
+
finally { release(lock, owner); }
|
|
37
|
+
}
|
|
38
|
+
reclaimDeadOwner(lock);
|
|
39
|
+
if (performance.now() >= deadline) throw Object.assign(new Error(`runner lock has a live or unconfirmed owner: ${lock}; inspect its owner before recovery`), { code: "runner_lock_timeout" });
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
2
|
+
|
|
3
|
+
/** Diagnostic failure never prevents cancellation of already owned Sessions.
|
|
4
|
+
* Only a fresh successful observation can establish settlement. */
|
|
5
|
+
export async function waitForSettlement({ observe, cancel, knownSessions = () => [], timeoutMs = 5_000 }) {
|
|
6
|
+
const deadline = performance.now() + timeoutMs;
|
|
7
|
+
const cancelled = new Set(), errors = [];
|
|
8
|
+
for (;;) {
|
|
9
|
+
let state;
|
|
10
|
+
try { state = await observe(); }
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (!cancel) throw error;
|
|
13
|
+
errors.push({ phase: "observe", ...errorRecord(error) });
|
|
14
|
+
state = { sessions: knownSessions(), active: true, observation_error: errorRecord(error) };
|
|
15
|
+
}
|
|
16
|
+
if (!state.active && state.sessions.length === 0) return { ...state, cleanup_errors: errors };
|
|
17
|
+
if (!cancel || performance.now() >= deadline) throw Object.assign(new Error("daemon still owns an unsettled Session tree"), { code: "tree_not_settled", details: { ...state, cleanup_errors: errors } });
|
|
18
|
+
for (const id of state.sessions) if (!cancelled.has(id)) {
|
|
19
|
+
cancelled.add(id);
|
|
20
|
+
try { await cancel(id); } catch (error) { errors.push({ phase: "cancel", session_id: id, ...errorRecord(error) }); }
|
|
21
|
+
}
|
|
22
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { harnessKey } from "./harness-config.js";
|
|
6
|
+
/** Reads one complete profile. A policy selects profiles; it never field-merges them. */
|
|
7
|
+
export function loadAgentProfile(ddFlowHome, id) {
|
|
8
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id))
|
|
9
|
+
throw new AppError("agent_profile_invalid", "Agent profile id is invalid", 2, { id });
|
|
10
|
+
const file = path.join(ddFlowHome, "agent-profiles", `${id}.json`);
|
|
11
|
+
let value;
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch (cause) {
|
|
16
|
+
throw new AppError("agent_profile_missing", "Agent profile is unavailable", 1, { id, file, cause: String(cause) });
|
|
17
|
+
}
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
throw new AppError("agent_profile_invalid", "Agent profile must be an object", 2, { id, file });
|
|
20
|
+
const profile = value;
|
|
21
|
+
if (profile.schema_id !== "dd-flow/agent-profile@1" || profile.id !== id
|
|
22
|
+
|| !["codex", "zcode", "agy", "opencode", "grok", "droid"].includes(String(profile.harness))
|
|
23
|
+
|| !nonempty(profile.provider) || !nonempty(profile.model) || !nonempty(profile.reasoning) || !nonempty(profile.mode)
|
|
24
|
+
|| (profile.permission !== "allow" && profile.permission !== "deny")) {
|
|
25
|
+
throw new AppError("agent_profile_invalid", "Agent profile does not match dd-flow/agent-profile@1", 2, { id, file });
|
|
26
|
+
}
|
|
27
|
+
return profile;
|
|
28
|
+
}
|
|
29
|
+
/** Resolves a frozen policy with explicit topology. Same profiles do not turn external work into native work. */
|
|
30
|
+
export function resolveExecutionPolicy(input) {
|
|
31
|
+
const stage = input.stage ? input.policy.stage_overrides?.[input.stage] : undefined;
|
|
32
|
+
const coordinatorId = input.work?.agent_profile_id ?? stage?.agent_profile_id ?? input.policy.agent_profile_id;
|
|
33
|
+
if (!coordinatorId)
|
|
34
|
+
throw new AppError("execution_profile_missing", "Execution policy needs agent_profile_id before external dispatch", 1, { stage: input.stage ?? null });
|
|
35
|
+
const coordinator = loadAgentProfile(input.ddFlowHome, coordinatorId);
|
|
36
|
+
const delegation = input.work?.delegation ?? stage?.delegation ?? input.policy.delegation ?? { mode: "native" };
|
|
37
|
+
validateDelegation(delegation);
|
|
38
|
+
const worker = loadAgentProfile(input.ddFlowHome, delegation.agent_profile_id ?? coordinator.id);
|
|
39
|
+
if (delegation.mode === "native" && harnessKey(worker.harness) !== harnessKey(coordinator.harness)) {
|
|
40
|
+
throw new AppError("native_harness_mismatch", "Native delegation cannot change harness", 2, { coordinator_harness: coordinator.harness, worker_harness: worker.harness });
|
|
41
|
+
}
|
|
42
|
+
const resolved = {
|
|
43
|
+
coordinator,
|
|
44
|
+
worker,
|
|
45
|
+
delegation: { mode: delegation.mode, max_parallel: delegation.mode === "external" ? delegation.max_parallel ?? 1 : null }
|
|
46
|
+
};
|
|
47
|
+
return { ...resolved, profile_hash: digest(resolved) };
|
|
48
|
+
}
|
|
49
|
+
export function runtimeHarness(profile) { return harnessKey(profile.harness); }
|
|
50
|
+
function validateDelegation(value) {
|
|
51
|
+
if (value.mode !== "native" && value.mode !== "external")
|
|
52
|
+
throw new AppError("execution_profile_invalid", "Delegation mode must be native or external", 2);
|
|
53
|
+
if (value.agent_profile_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value.agent_profile_id))
|
|
54
|
+
throw new AppError("execution_profile_invalid", "Delegation profile id is invalid", 2);
|
|
55
|
+
if (value.max_parallel !== undefined && (!Number.isInteger(value.max_parallel) || value.max_parallel < 1))
|
|
56
|
+
throw new AppError("execution_profile_invalid", "External max_parallel must be a positive integer", 2);
|
|
57
|
+
if (value.mode === "native" && value.max_parallel !== undefined)
|
|
58
|
+
throw new AppError("execution_profile_invalid", "Native delegation does not accept max_parallel", 2);
|
|
59
|
+
}
|
|
60
|
+
function nonempty(value) { return typeof value === "string" && Boolean(value.trim()); }
|
|
61
|
+
function digest(value) { return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ensureWorkLaunchStorage } from "../storage/database.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
7
|
+
import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
|
|
8
|
+
import { resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
|
|
9
|
+
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
10
|
+
import { workStartCommand } from "./work-registry.js";
|
|
11
|
+
/**
|
|
12
|
+
* Creates an external provider root for one already-created Work. The launch
|
|
13
|
+
* is not a Work claim: only the worker's trusted `work start` hook binds it.
|
|
14
|
+
*/
|
|
15
|
+
export async function launchExternalWork(context, input) {
|
|
16
|
+
ensureWorkLaunchStorage(context.db);
|
|
17
|
+
const work = context.db.get("SELECT work_id, project_id, run_id, status, payload_json FROM works WHERE work_id = ?", [input.workId]);
|
|
18
|
+
if (!work)
|
|
19
|
+
throw new AppError("not_found", "Work was not found", 1, { work_id: input.workId });
|
|
20
|
+
if (work.status !== "created")
|
|
21
|
+
throw new AppError("invalid_work_state", "Only created Work can be launched externally", 2, { work_id: work.work_id, status: work.status });
|
|
22
|
+
const run = context.db.get("SELECT run_root, workspace_root, index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]);
|
|
23
|
+
if (!run?.run_root)
|
|
24
|
+
throw new AppError("runtime_missing", "Work has no materialized RUN root", 1, { work_id: work.work_id });
|
|
25
|
+
const existing = context.db.get("SELECT operation_id, status, provider_session_id, profile_hash, journal_path FROM work_launches WHERE work_id = ?", [work.work_id]);
|
|
26
|
+
if (existing)
|
|
27
|
+
return { ok: true, work_id: work.work_id, operation_id: existing.operation_id, status: existing.status, provider_session_id: existing.provider_session_id, profile_hash: existing.profile_hash, journal_path: existing.journal_path, idempotent: true };
|
|
28
|
+
const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
29
|
+
const snapshot = JSON.parse(run.index_json);
|
|
30
|
+
const override = workOverride(work.payload_json);
|
|
31
|
+
const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: snapshot.execution_profile?.settings?.execution ?? {}, ...(input.stage ? { stage: input.stage } : {}), ...(override ? { work: override } : {}) });
|
|
32
|
+
if (resolved.delegation.mode !== "external")
|
|
33
|
+
throw new AppError("external_launch_not_selected", "This Work's frozen policy selects native delegation", 2, { work_id: work.work_id });
|
|
34
|
+
const operationId = `OPR-${crypto.randomUUID()}`;
|
|
35
|
+
const root = path.join(run.run_root, "external-launches", operationId);
|
|
36
|
+
const journal = path.join(root, "adapter.events.jsonl");
|
|
37
|
+
fs.mkdirSync(root, { recursive: true });
|
|
38
|
+
const now = context.now();
|
|
39
|
+
context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify(resolved), resolved.profile_hash, root, journal, now, now]);
|
|
40
|
+
const executable = resolveHarnessCommand(context.ddFlowHome, runtimeHarness(resolved.worker), "adapter");
|
|
41
|
+
const common = ["--state-dir", root, "--journal", journal, "--cwd", run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.workspace_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
|
|
42
|
+
const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
|
|
43
|
+
try {
|
|
44
|
+
const call = (args, timeoutMs) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
45
|
+
await call(["daemon", "start", ...common]);
|
|
46
|
+
const created = await call(["session", "create", ...common]);
|
|
47
|
+
const sessionId = adapterSessionId(created);
|
|
48
|
+
if (!sessionId)
|
|
49
|
+
throw new AppError("harness_adapter_invalid", "Harness adapter did not return a typed Session id", 1, { work_id: work.work_id, harness: runtimeHarness(resolved.worker) });
|
|
50
|
+
const promptFile = path.join(root, "launch.md");
|
|
51
|
+
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(context, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n`);
|
|
52
|
+
const prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
|
|
53
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
54
|
+
context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created, prompt: prompted }), context.now(), operationId]);
|
|
55
|
+
return { ok: true, work_id: work.work_id, operation_id: operationId, status: "start_requested", session: { harness_id: runtimeHarness(resolved.worker), session_id: sessionId }, profile_hash: resolved.profile_hash, journal_path: journal };
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
context.db.run("UPDATE work_launches SET status = 'failed', adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ error: String(error) }), context.now(), operationId]);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function workOverride(payload) {
|
|
63
|
+
if (!payload)
|
|
64
|
+
return undefined;
|
|
65
|
+
let value;
|
|
66
|
+
try {
|
|
67
|
+
value = JSON.parse(payload);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
73
|
+
return undefined;
|
|
74
|
+
const execution = value.execution_override;
|
|
75
|
+
return execution && typeof execution === "object" && !Array.isArray(execution) ? execution : undefined;
|
|
76
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { AppError } from "../shared/errors.js";
|
|
3
|
+
const maxAdapterOutputBytes = 1_000_000;
|
|
3
4
|
/** Runs one harness command with the lifecycle guarantees shared by agent services. */
|
|
4
5
|
export function runHarnessAdapter(input) {
|
|
5
6
|
const timeoutMs = input.timeoutMs ?? 120_000;
|
|
@@ -8,6 +9,7 @@ export function runHarnessAdapter(input) {
|
|
|
8
9
|
const child = spawn(input.executable, input.args, { env: input.env, detached, stdio: ["ignore", "pipe", "pipe"] });
|
|
9
10
|
let stdout = "";
|
|
10
11
|
let stderr = "";
|
|
12
|
+
let outputTooLarge = false;
|
|
11
13
|
let timedOut = false;
|
|
12
14
|
let killTimer;
|
|
13
15
|
const terminate = (signal) => { try {
|
|
@@ -20,11 +22,19 @@ export function runHarnessAdapter(input) {
|
|
|
20
22
|
const heartbeat = input.progress && input.progressMessage ? setInterval(() => input.progress?.(`${input.progressMessage}; waiting for adapter completion`), 15_000) : undefined;
|
|
21
23
|
const timer = setTimeout(() => { timedOut = true; terminate("SIGTERM"); killTimer = setTimeout(() => terminate("SIGKILL"), 5_000); }, timeoutMs);
|
|
22
24
|
const evidence = (value) => input.onEvidence?.({ executable: input.executable, args: input.args, ...value, at: new Date().toISOString() });
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const append = (current, chunk) => {
|
|
26
|
+
if (Buffer.byteLength(current) + Buffer.byteLength(chunk) > maxAdapterOutputBytes) {
|
|
27
|
+
outputTooLarge = true;
|
|
28
|
+
terminate("SIGTERM");
|
|
29
|
+
return current;
|
|
30
|
+
}
|
|
31
|
+
return current + chunk;
|
|
32
|
+
};
|
|
33
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout = append(stdout, chunk); });
|
|
34
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr = append(stderr, chunk); });
|
|
25
35
|
child.once("error", (error) => { clearTimeout(timer); if (heartbeat)
|
|
26
36
|
clearInterval(heartbeat); if (killTimer)
|
|
27
|
-
clearTimeout(killTimer); evidence({ status: null, error: error.message }); reject(new AppError("
|
|
37
|
+
clearTimeout(killTimer); evidence({ status: null, error: error.message }); reject(new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), error: error.message })); });
|
|
28
38
|
child.once("close", (status, signal) => {
|
|
29
39
|
clearTimeout(timer);
|
|
30
40
|
if (heartbeat)
|
|
@@ -32,15 +42,20 @@ export function runHarnessAdapter(input) {
|
|
|
32
42
|
if (killTimer)
|
|
33
43
|
clearTimeout(killTimer);
|
|
34
44
|
evidence({ status, signal, timed_out: timedOut, stdout, stderr });
|
|
45
|
+
if (outputTooLarge)
|
|
46
|
+
return reject(new AppError("harness_adapter_output_limit", "Harness adapter exceeded the bounded reply limit", 1, { executable: input.executable, args: input.args.slice(0, 3), max_output_bytes: maxAdapterOutputBytes }));
|
|
35
47
|
if (timedOut)
|
|
36
|
-
return reject(new AppError("
|
|
48
|
+
return reject(new AppError("harness_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }));
|
|
37
49
|
if (status !== 0)
|
|
38
|
-
return reject(new AppError("
|
|
50
|
+
return reject(new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr }));
|
|
39
51
|
try {
|
|
40
|
-
|
|
52
|
+
const reply = JSON.parse(stdout);
|
|
53
|
+
if (!reply || typeof reply !== "object" || Array.isArray(reply))
|
|
54
|
+
throw new Error("not object");
|
|
55
|
+
resolve(reply);
|
|
41
56
|
}
|
|
42
57
|
catch {
|
|
43
|
-
reject(new AppError("
|
|
58
|
+
reject(new AppError("harness_adapter_invalid", "Harness adapter returned one invalid JSON object", 1, { executable: input.executable }));
|
|
44
59
|
}
|
|
45
60
|
});
|
|
46
61
|
});
|
|
@@ -48,12 +63,14 @@ export function runHarnessAdapter(input) {
|
|
|
48
63
|
export function adapterSessionId(value) {
|
|
49
64
|
if (!value || typeof value !== "object")
|
|
50
65
|
return null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
const reply = value;
|
|
67
|
+
for (const key of ["provider_session_id", "adapter_session_id", "session_id"])
|
|
68
|
+
if (typeof reply[key] === "string" && reply[key].trim())
|
|
69
|
+
return reply[key];
|
|
70
|
+
const session = reply.session;
|
|
71
|
+
if (session && typeof session === "object" && !Array.isArray(session))
|
|
72
|
+
for (const key of ["provider_session_id", "session_id"])
|
|
73
|
+
if (typeof session[key] === "string" && String(session[key]).trim())
|
|
74
|
+
return session[key];
|
|
58
75
|
return null;
|
|
59
76
|
}
|
package/dist/services/hooks.js
CHANGED
|
@@ -324,10 +324,12 @@ export function handleCodexHook(context, input) {
|
|
|
324
324
|
// Codex retains the root session_id for a thread-spawned child, while the
|
|
325
325
|
// payload transcript belongs to the current agent_id. Keep lineage separate
|
|
326
326
|
// from ownership: otherwise a child overwrites the root transcript binding.
|
|
327
|
-
const
|
|
328
|
-
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
|
|
327
|
+
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
329
328
|
const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
|
|
330
329
|
const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
|
|
330
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: stringValue(payload.cwd) ?? null });
|
|
331
|
+
const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
|
|
332
|
+
const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
|
|
331
333
|
const observedSession = flowPayload
|
|
332
334
|
? bindObservedFlowSession(context, project, {
|
|
333
335
|
...flowPayload,
|
|
@@ -338,7 +340,6 @@ export function handleCodexHook(context, input) {
|
|
|
338
340
|
: undefined;
|
|
339
341
|
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
340
342
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
341
|
-
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
342
343
|
const inserted = recordHookEvent(context, {
|
|
343
344
|
projectId: project.id,
|
|
344
345
|
protocolId,
|
|
@@ -458,6 +459,10 @@ export function handleZcodeEvent(context, input) {
|
|
|
458
459
|
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
459
460
|
const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
|
|
460
461
|
const agentId = stringValue(runtime.agentId);
|
|
462
|
+
const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
463
|
+
harness: "zcode-acp", providerSessionId, command
|
|
464
|
+
})).digest("hex");
|
|
465
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
461
466
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
462
467
|
...flowPayload,
|
|
463
468
|
harness: "zcode-acp",
|
|
@@ -465,9 +470,6 @@ export function handleZcodeEvent(context, input) {
|
|
|
465
470
|
agent_id: agentId ?? null,
|
|
466
471
|
parent_session_id: parentSessionId
|
|
467
472
|
}, sessionId) : undefined;
|
|
468
|
-
const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
469
|
-
harness: "zcode-acp", providerSessionId, command
|
|
470
|
-
})).digest("hex");
|
|
471
473
|
const payload = {
|
|
472
474
|
session_id: providerSessionId,
|
|
473
475
|
agent_id: agentId,
|
|
@@ -564,12 +566,13 @@ export function handleGrokEvent(context, input) {
|
|
|
564
566
|
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
565
567
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
566
568
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
567
|
-
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
568
|
-
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
569
|
-
}, sessionId) : undefined;
|
|
570
569
|
const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
|
|
571
570
|
harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
|
|
572
571
|
})).digest("hex");
|
|
572
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
|
|
573
|
+
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
574
|
+
...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
|
|
575
|
+
}, sessionId) : undefined;
|
|
573
576
|
const payload = {
|
|
574
577
|
...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
|
|
575
578
|
command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
|
|
@@ -668,6 +671,7 @@ function handleControlledToolEvent(context, input, event, harness) {
|
|
|
668
671
|
}
|
|
669
672
|
}
|
|
670
673
|
const agentId = stringValue(session.agent_id);
|
|
674
|
+
assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
|
|
671
675
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
672
676
|
...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
|
|
673
677
|
parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
|
|
@@ -914,21 +918,24 @@ export function claimRecentMatchingHookEvent(context, input) {
|
|
|
914
918
|
/** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
|
|
915
919
|
export function findRecentMatchingHookEvent(context, input) {
|
|
916
920
|
const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
|
|
917
|
-
const find = () => context.db.
|
|
921
|
+
const find = () => context.db.all(`SELECT id, event_key FROM hook_events
|
|
918
922
|
WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
|
|
919
|
-
ORDER BY id DESC LIMIT
|
|
920
|
-
let
|
|
923
|
+
ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter]);
|
|
924
|
+
let events = find();
|
|
921
925
|
// Some harnesses publish nested-agent tool events concurrently with command
|
|
922
926
|
// execution. Give the trusted event a short bounded window to reach SQLite.
|
|
923
927
|
const deadline = Date.now() + 250;
|
|
924
|
-
while (
|
|
928
|
+
while (events.length === 0 && Date.now() < deadline) {
|
|
925
929
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
926
|
-
|
|
930
|
+
events = find();
|
|
927
931
|
}
|
|
928
|
-
if (!
|
|
932
|
+
if (events.length === 0 || !events[0]?.event_key) {
|
|
929
933
|
throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
|
|
930
934
|
}
|
|
931
|
-
|
|
935
|
+
if (events.length > 1) {
|
|
936
|
+
throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
|
|
937
|
+
}
|
|
938
|
+
return { id: events[0].id, eventKey: events[0].event_key };
|
|
932
939
|
}
|
|
933
940
|
/** Fallback for Desktop paths that execute the original command after a hook rewrite. */
|
|
934
941
|
export function claimBootstrapHookEvent(context, input) {
|
|
@@ -1405,12 +1412,11 @@ function sessionBinding(context, projectId, sessionId) {
|
|
|
1405
1412
|
return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
|
|
1406
1413
|
}
|
|
1407
1414
|
function recordHookEvent(context, input) {
|
|
1408
|
-
|
|
1409
|
-
if (existing)
|
|
1415
|
+
if (assertHookEventReplay(context, input))
|
|
1410
1416
|
return false;
|
|
1411
1417
|
context.db.run(`INSERT INTO hook_events
|
|
1412
|
-
|
|
1413
|
-
|
|
1418
|
+
(project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
|
|
1419
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1414
1420
|
input.projectId,
|
|
1415
1421
|
input.protocolId,
|
|
1416
1422
|
input.harness,
|
|
@@ -1437,6 +1443,23 @@ function recordHookEvent(context, input) {
|
|
|
1437
1443
|
]);
|
|
1438
1444
|
return true;
|
|
1439
1445
|
}
|
|
1446
|
+
/** A duplicate delivery is harmless only when all durable identity facts match. */
|
|
1447
|
+
function assertHookEventReplay(context, input) {
|
|
1448
|
+
const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
|
|
1449
|
+
FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
|
|
1450
|
+
if (existing) {
|
|
1451
|
+
const observed = {
|
|
1452
|
+
harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
|
|
1453
|
+
daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
|
|
1454
|
+
event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
|
|
1455
|
+
};
|
|
1456
|
+
if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
|
|
1457
|
+
throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
|
|
1458
|
+
}
|
|
1459
|
+
return true;
|
|
1460
|
+
}
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1440
1463
|
function projectForHook(context, explicitRoot, cwd) {
|
|
1441
1464
|
if (explicitRoot)
|
|
1442
1465
|
return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
|