@deksden-com/dd-flow-cli 0.9.0-beta.121 → 0.9.0-beta.123
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 +5 -5
- package/dist/cli/hook-ingress.js +4 -3
- package/dist/cli/native-hook-ingress.js +34 -2
- package/dist/cli/run-cli.js +3 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +68 -6
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +21 -5
- package/dist/harness-runtime/lib/dd-codex.mjs +5 -1
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
- package/dist/harness-runtime/lib/dd-zcode.mjs +40 -16
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/model-observations.mjs +16 -9
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
- package/dist/runtime/context.js +1 -1
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/services/codex-capacity.js +131 -12
- package/dist/services/codex-hook-delivery.js +6 -2
- package/dist/services/eval-snapshots.js +13 -8
- package/dist/services/external-work-launch.js +27 -9
- package/dist/services/hooks.js +51 -59
- package/dist/services/lifecycle-command.js +1 -383
- package/dist/services/lifecycle-contract.js +1 -30
- package/dist/services/lifecycle-invocations.js +34 -13
- package/dist/services/merge-server.js +26 -9
- package/dist/services/review-copy.js +21 -0
- package/dist/services/run-controller-adapter.js +25 -3
- package/dist/services/run-controller-recovery.js +9 -2
- package/dist/services/run-controller.js +59 -20
- package/dist/services/run-observations.js +7 -2
- package/dist/services/runs.js +60 -47
- package/dist/services/stage-report-renderer.js +44 -2
- package/dist/services/verification-matrix.js +345 -0
- package/dist/services/vnext-code-review.js +30 -11
- package/dist/services/vnext-code.js +31 -10
- package/dist/services/vnext-merge.js +30 -7
- package/dist/services/vnext-plan-review.js +28 -7
- package/dist/services/vnext-plan.js +16 -3
- package/dist/services/work-registry.js +15 -1
- package/dist/storage/database.js +43 -9
- package/package.json +4 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { reportedFailure } from "./process-json.mjs";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
|
|
4
|
+
/** The caller supplies its qualified native limit; phases share that deadline. */
|
|
5
|
+
export function hookBudget(timeoutMs = 25_000, deadlineAt = process.env.DD_FLOW_HOOK_DEADLINE_AT === undefined ? null : Number(process.env.DD_FLOW_HOOK_DEADLINE_AT)) {
|
|
6
|
+
if (deadlineAt !== null && (!Number.isSafeInteger(deadlineAt) || deadlineAt < 0)) throw Object.assign(new Error("Invalid native hook deadline"), { code: "hook_deadline_invalid", details: { effect: "unknown" } });
|
|
6
7
|
const started = performance.now();
|
|
7
8
|
const duration = Math.max(0, Math.min(timeoutMs, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeoutMs));
|
|
8
9
|
const remaining = (phase, maximum = duration) => {
|
|
@@ -10,7 +11,7 @@ export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
|
|
|
10
11
|
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
12
|
return Math.max(1, Math.min(maximum, value));
|
|
12
13
|
};
|
|
13
|
-
return { deadlineAt: Date.now() + duration, remaining, async run(action, phase) {
|
|
14
|
+
return { deadlineAt: Math.floor(Date.now() + duration), remaining, async run(action, phase) {
|
|
14
15
|
const timeoutMs = remaining(phase);
|
|
15
16
|
let timer;
|
|
16
17
|
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); })]); }
|
|
@@ -57,14 +58,31 @@ export async function terminateCommandTree(child, { platform = process.platform,
|
|
|
57
58
|
|
|
58
59
|
/** Preserve the native hook protocol across the CLI process boundary. */
|
|
59
60
|
export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
|
|
61
|
+
const inherited = env.DD_FLOW_HOOK_DEADLINE_AT ?? process.env.DD_FLOW_HOOK_DEADLINE_AT;
|
|
62
|
+
if (inherited !== undefined) {
|
|
63
|
+
const deadline = Number(inherited);
|
|
64
|
+
if (!Number.isSafeInteger(deadline) || deadline < 0) throw Object.assign(new Error("Invalid native hook deadline"), { code: "hook_deadline_invalid", details: { effect: "no_effect" } });
|
|
65
|
+
timeoutMs = Math.min(timeoutMs, deadline - Date.now());
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw Object.assign(new Error("Native hook budget exhausted before subprocess launch"), { code: timeoutCode, details: { phase: "subprocess", effect: "no_effect" } });
|
|
60
68
|
const started = performance.now();
|
|
69
|
+
// Transport/physical cleanup has its own small tail inside the outer limit.
|
|
70
|
+
const deadlineAt = Math.floor(Date.now() + Math.max(0, timeoutMs - Math.min(250, timeoutMs / 10)));
|
|
61
71
|
const script = /\.[cm]?js$/.test(bin);
|
|
62
72
|
const output = await new Promise((resolve, reject) => {
|
|
63
73
|
const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
|
|
64
|
-
cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
|
|
74
|
+
cwd, env: { ...process.env, ...env, DD_FLOW_HOOK_DEADLINE_AT: String(deadlineAt) }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
|
|
65
75
|
});
|
|
66
76
|
let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
|
|
67
|
-
const finish = (error, value) => {
|
|
77
|
+
const finish = (error, value) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true; clearTimeout(timer); clearTimeout(cleanupTimer);
|
|
80
|
+
if (error) {
|
|
81
|
+
const diagnostics = hookDiagnostics(stderr);
|
|
82
|
+
if (diagnostics.length) error.details = { ...error.details, hook_diagnostics: diagnostics };
|
|
83
|
+
reject(error);
|
|
84
|
+
} else resolve(value);
|
|
85
|
+
};
|
|
68
86
|
const stop = error => {
|
|
69
87
|
if (termination || settled) return;
|
|
70
88
|
failure ??= error;
|
|
@@ -98,6 +116,19 @@ export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}
|
|
|
98
116
|
return output;
|
|
99
117
|
}
|
|
100
118
|
|
|
119
|
+
/** Only our structured phase allowlist crosses a timeout, never raw stderr/env. */
|
|
120
|
+
function hookDiagnostics(stderr) {
|
|
121
|
+
return stderr.split("\n").flatMap(line => {
|
|
122
|
+
try {
|
|
123
|
+
const value = JSON.parse(line);
|
|
124
|
+
if (value?.kind !== "native_hook_phase") return [];
|
|
125
|
+
const phase = typeof value.phase === "string" && /^[a-z_]{1,48}$/.test(value.phase) ? value.phase : null;
|
|
126
|
+
if (!phase) return [];
|
|
127
|
+
return [{ phase, ...Object.fromEntries(["elapsed_ms", "remaining_ms", "sqlite_extended_code"].filter(key => Number.isFinite(value[key])).map(key => [key, value[key]])) }];
|
|
128
|
+
} catch { return []; }
|
|
129
|
+
}).slice(-16);
|
|
130
|
+
}
|
|
131
|
+
|
|
101
132
|
export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
|
|
102
133
|
const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
|
|
103
134
|
let result;
|
|
@@ -10,6 +10,7 @@ export class ToolObservations {
|
|
|
10
10
|
calls: Map<string, ToolEvent>;
|
|
11
11
|
turns: Map<string, { session: { harness_id: string; session_id: string }; turn_id: string; started: boolean; completed: boolean }>;
|
|
12
12
|
gaps: Set<string>;
|
|
13
|
+
sources: Map<string, ToolEvent>;
|
|
13
14
|
add(event: ToolEvent | null): void;
|
|
14
15
|
observeTurn(event: { session: { harness_id: string; session_id: string }; turn_id: string; started?: boolean; completed?: boolean }): void;
|
|
15
16
|
summary(sessionId?: string, cursor?: Set<string>): { total: number; failures: number; pending: number; by_tool: Record<string, number>; completeness: string; outcome_completeness: string; sessions: Array<{ session: { harness_id: string; session_id: string }; total: number; failures: number; pending: number; by_tool: Record<string, number> }>; reasons: string[] };
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
/** Canonical physical-session tool accounting. Native decoding lives below;
|
|
4
4
|
* consumers never have to inspect provider envelopes or aggregate tokens. */
|
|
5
5
|
export class ToolObservations {
|
|
6
|
-
constructor() { this.calls = new Map(); this.turns = new Map(); this.gaps = new Set(); }
|
|
6
|
+
constructor() { this.calls = new Map(); this.turns = new Map(); this.gaps = new Set(); this.transcriptSessions = new Set(); this.sources = new Map(); }
|
|
7
7
|
observeTurn(event) {
|
|
8
8
|
if (!event?.session?.harness_id || !event.session.session_id || !event.turn_id) { this.gaps.add("turn_identity_missing"); return; }
|
|
9
9
|
const key = JSON.stringify([event.session, event.turn_id]);
|
|
@@ -13,10 +13,19 @@ export class ToolObservations {
|
|
|
13
13
|
}
|
|
14
14
|
add(event) {
|
|
15
15
|
if (!event) return;
|
|
16
|
-
if (event.reason)
|
|
16
|
+
if (event.reason) this.gaps.add(event.reason);
|
|
17
|
+
if (event.source_observed && event.session?.harness_id && event.session?.session_id) { this.sources.set(JSON.stringify(event.session), event); return; }
|
|
18
|
+
if (event.reason && !event.call_id) return;
|
|
17
19
|
const { harness_id, session_id } = event.session ?? {};
|
|
18
20
|
if (!harness_id || !session_id || !event.call_id) { this.gaps.add("tool_identity_missing"); return; }
|
|
19
21
|
const key = JSON.stringify([harness_id, session_id, event.epoch ?? "", event.call_id]);
|
|
22
|
+
const physical = JSON.stringify([harness_id, session_id]);
|
|
23
|
+
if (event.source?.channel === "agy.native_transcript") {
|
|
24
|
+
this.transcriptSessions.add(physical);
|
|
25
|
+
for (const [oldKey, call] of this.calls) if (JSON.stringify([call.session.harness_id, call.session.session_id]) === physical && call.source?.channel !== "agy.native_transcript") {
|
|
26
|
+
this.calls.delete(oldKey); this.gaps.add("agy_live_transcript_correlation_unproven");
|
|
27
|
+
}
|
|
28
|
+
} else if (this.transcriptSessions.has(physical)) { this.gaps.add("agy_live_transcript_correlation_unproven"); return; }
|
|
20
29
|
const old = this.calls.get(key);
|
|
21
30
|
// Replay and late starts cannot undo a terminal outcome. Conflicting
|
|
22
31
|
// terminal facts remain visible rather than silently choosing success.
|
|
@@ -28,12 +37,12 @@ export class ToolObservations {
|
|
|
28
37
|
: old?.status === "unknown" || event.status === "unknown" ? "unknown" : "running" });
|
|
29
38
|
}
|
|
30
39
|
summary(sessionId, cursor) {
|
|
31
|
-
const by_tool =
|
|
40
|
+
const by_tool = Object.create(null), sessions = new Map(), reasons = new Set(this.gaps); let total = 0, failures = 0, pending = 0;
|
|
32
41
|
for (const [key, value] of this.calls) {
|
|
33
42
|
if (sessionId !== undefined && value.session.session_id !== sessionId || cursor?.has(key)) continue;
|
|
34
43
|
const id = JSON.stringify(value.session);
|
|
35
44
|
if (value.name === "unknown") reasons.add("tool_start_missing");
|
|
36
|
-
const session = sessions.get(id) ?? { session: value.session, parent_session: value.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool:
|
|
45
|
+
const session = sessions.get(id) ?? { session: value.session, parent_session: value.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool: Object.create(null) };
|
|
37
46
|
total++; session.total++; by_tool[value.name] = (by_tool[value.name] ?? 0) + 1; session.by_tool[value.name] = (session.by_tool[value.name] ?? 0) + 1;
|
|
38
47
|
if (value.status === "failed") { failures++; session.failures++; }
|
|
39
48
|
if (!["completed", "failed"].includes(value.status)) { pending++; session.pending++; }
|
|
@@ -45,6 +54,10 @@ export class ToolObservations {
|
|
|
45
54
|
sessions.set(id, sessions.get(id) ?? { session: turn.session, parent_session: null, total: 0, failures: 0, pending: 0, by_tool: {} });
|
|
46
55
|
if (!turn.started || !turn.completed) reasons.add("turn_lifecycle_incomplete");
|
|
47
56
|
}
|
|
57
|
+
if (!cursor) for (const [id, event] of this.sources) {
|
|
58
|
+
if (sessionId !== undefined && event.session.session_id !== sessionId) continue;
|
|
59
|
+
sessions.set(id, sessions.get(id) ?? { session: event.session, parent_session: event.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool: {} });
|
|
60
|
+
}
|
|
48
61
|
return { schema_id: "dd-flow/tool-summary@1", scope: "physical_sessions", measurement: "observed_invocations", origin: "native_tool_events", aggregation: cursor ? "delta" : "cumulative", total, failures, pending, by_tool,
|
|
49
62
|
sessions: [...sessions.values()], completeness: reasons.size ? "partial" : "complete", outcome_completeness: pending ? "partial" : "complete", reasons: [...reasons] };
|
|
50
63
|
}
|
|
@@ -56,7 +69,7 @@ export class ToolObservations {
|
|
|
56
69
|
export function journalToolObservations(file, events, previous) {
|
|
57
70
|
const batch = new ToolObservations();
|
|
58
71
|
for (const event of events) batch.add(event);
|
|
59
|
-
const records = [...batch.calls.values(), ...[...batch.gaps].map(reason => ({ reason }))];
|
|
72
|
+
const records = [...batch.calls.values(), ...batch.sources.values(), ...[...batch.gaps].map(reason => ({ reason }))];
|
|
60
73
|
for (const event of records) {
|
|
61
74
|
const key = JSON.stringify([event.session, event.epoch, event.call_id, event.reason]);
|
|
62
75
|
const encoded = JSON.stringify(event);
|
|
@@ -67,7 +80,9 @@ export function journalToolObservations(file, events, previous) {
|
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
const identity = (harness_id, session_id) => ({ harness_id, session_id });
|
|
70
|
-
const status = value => ["error", "failed", "ERROR"].includes(value) ? "failed" : ["completed", "success", "DONE"].includes(value) ? "completed"
|
|
83
|
+
const status = value => ["error", "failed", "ERROR"].includes(value) ? "failed" : ["completed", "success", "DONE"].includes(value) ? "completed"
|
|
84
|
+
: value === undefined || value === null || ["running", "pending", "in_progress", "inProgress", "RUNNING", "PENDING", "IN_PROGRESS"].includes(value) ? "running" : "unknown";
|
|
85
|
+
const nativeStatus = value => ({ status: status(value), ...(status(value) === "unknown" ? { reason: "tool_status_unsupported" } : {}) });
|
|
71
86
|
export function zcodePhysicalSession(update, rootSessionId) {
|
|
72
87
|
const native = update?._meta?.zcodeRuntime ?? {};
|
|
73
88
|
if (native.source === "subagent" && !native.childSessionId) return null;
|
|
@@ -84,7 +99,7 @@ export function acpToolObservation(message, { harness = "zcode-acp", rootSession
|
|
|
84
99
|
const session = harness === "zcode-acp" ? zcodePhysicalSession(update, root) : harness === "grok-acp" ? transport : root;
|
|
85
100
|
if (!session || !update.toolCallId) return { reason: "tool_identity_missing" };
|
|
86
101
|
return { session: identity(harness, session), parent_session: session !== root ? identity(harness, root) : null,
|
|
87
|
-
transport_session_id: transport, call_id: update.toolCallId, name: update._meta?.["x.ai/tool"]?.name ?? update._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0],
|
|
102
|
+
transport_session_id: transport, call_id: update.toolCallId, name: update._meta?.["x.ai/tool"]?.name ?? update._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0], ...nativeStatus(update.status) };
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
export function droidToolObservations(record, sessionId) {
|
|
@@ -130,15 +145,41 @@ export function codexAppServerObservation(message) {
|
|
|
130
145
|
export function openCodeToolObservation(part, sessionId, messageId) {
|
|
131
146
|
if (part.type !== "tool") return null;
|
|
132
147
|
return { session: identity("opencode-server", part.sessionID ?? sessionId), call_id: part.callID ?? part.id,
|
|
133
|
-
epoch: messageId, name: part.tool,
|
|
148
|
+
epoch: messageId, name: part.tool, ...nativeStatus(part.state?.status) };
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
export function agyToolObservation(step, rootSessionId) {
|
|
137
|
-
if (step.step_type !== "tool") return null;
|
|
152
|
+
if (step.step_type !== "tool" && !(step.step_type === "subagent" && step.tool_name === "invoke_subagent")) return null;
|
|
138
153
|
const sessionId = step.conversation_id ?? rootSessionId;
|
|
139
154
|
return { session: identity("antigravity-cli", sessionId), parent_session: sessionId !== rootSessionId ? identity("antigravity-cli", rootSessionId) : null,
|
|
140
155
|
call_id: step.step_id ?? step.stepId ?? step.id ?? step.tool_call_id ?? step.toolCallId ?? (Number.isInteger(step.step_index) ? String(step.step_index) : null),
|
|
141
|
-
name: step.tool_name ?? step.tool_info?.name,
|
|
156
|
+
name: step.tool_name ?? step.tool_info?.name, ...(step.tool_info?.error ? { status: "failed" } : nativeStatus(step.state)) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Native AGY JSONL records have planner tool invocations, not matching live
|
|
160
|
+
* step IDs. GENERIC result text is deliberately not guessed into success. */
|
|
161
|
+
export function agyTranscriptObservations(text, sessionId, parentSessionId, source = {}) {
|
|
162
|
+
const events = [], lines = text.split("\n"); let recognized = false;
|
|
163
|
+
const session = identity("antigravity-cli", sessionId);
|
|
164
|
+
for (const [line, bytes] of lines.entries()) {
|
|
165
|
+
if (!bytes.trim()) continue;
|
|
166
|
+
let record; try { record = JSON.parse(bytes); } catch { events.push({ session, reason: line === lines.length - 1 ? "agy_transcript_partial_tail" : "agy_transcript_invalid_record" }); continue; }
|
|
167
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); continue; }
|
|
168
|
+
if (record.conversation_id && record.conversation_id !== sessionId) { events.push({ session, reason: "agy_transcript_session_mismatch" }); continue; }
|
|
169
|
+
if (!["SYSTEM_MESSAGE", "USER_INPUT", "PLANNER_RESPONSE", "GENERIC", "TOOL_RESPONSE", "CHECKPOINT"].includes(record.type)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); continue; }
|
|
170
|
+
recognized = true;
|
|
171
|
+
if (record.tool_calls !== undefined && !Array.isArray(record.tool_calls)) events.push({ session, reason: "agy_transcript_shape_unsupported" });
|
|
172
|
+
if (record.truncated || record.truncation || typeof record.content === "string" && /\[.*truncat|<.*truncat/i.test(record.content)) events.push({ session, reason: "agy_transcript_truncated" });
|
|
173
|
+
for (const [index, call] of (Array.isArray(record.tool_calls) ? record.tool_calls : []).entries()) {
|
|
174
|
+
if (!Number.isInteger(record.step_index) || !call || typeof call.name !== "string" || !call.name) { events.push({ session, reason: "agy_transcript_tool_identity_missing" }); continue; }
|
|
175
|
+
const id = call.id ?? call.tool_call_id;
|
|
176
|
+
events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), epoch: "native_transcript", call_id: id ?? `${record.step_index}:${index}`, name: call.name,
|
|
177
|
+
status: "unknown", reason: "agy_transcript_tool_outcome_unproven", source: { ...source, channel: "agy.native_transcript", line: line + 1, step_index: record.step_index } });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!recognized) events.push({ session, reason: "agy_transcript_shape_unsupported" });
|
|
181
|
+
else events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), source_observed: true, source: { ...source, channel: "agy.native_transcript" } });
|
|
182
|
+
return events;
|
|
142
183
|
}
|
|
143
184
|
|
|
144
185
|
/** Read-only replay, shared by accounting and offline diagnosis. A journal
|
|
@@ -150,7 +191,7 @@ export function replayToolJournal(file, options, reducer = new ToolObservations(
|
|
|
150
191
|
for (const [index, line] of lines.entries()) {
|
|
151
192
|
if (!line.trim()) continue;
|
|
152
193
|
let e; try { e = JSON.parse(line); } catch { reducer.gaps.add(index === lines.length - 1 ? "journal_partial_tail" : "journal_invalid_record"); continue; }
|
|
153
|
-
const add = event => { if (event) reducer.add({ ...event, source: { journal: file,
|
|
194
|
+
const add = event => { if (event) reducer.add({ ...event, source: { ...event.source, journal: file, journal_line: index + 1 }, observed_at: e.observed_at ?? e.timestamp ?? e.at ?? e.ts ?? null }); };
|
|
154
195
|
if (e.kind === "tool_observation" && e.schema_id === "dd-flow/tool-observation@1") { add(e.event); continue; }
|
|
155
196
|
if (e.kind === "notification_failure") reducer.gaps.add("notification_failure");
|
|
156
197
|
const m = e.payload ?? e;
|
package/dist/runtime/context.js
CHANGED
|
@@ -12,7 +12,7 @@ export function createContext(env, mode = "initialize", importPermitId) {
|
|
|
12
12
|
const ddFlowHome = resolveDdFlowHome(env);
|
|
13
13
|
return {
|
|
14
14
|
ddFlowHome,
|
|
15
|
-
db: getDatabase(ddFlowHome, mode, importPermitId),
|
|
15
|
+
db: getDatabase(ddFlowHome, mode, importPermitId, mode === "hook" && env.DD_FLOW_HOOK_DEADLINE_AT ? Number(env.DD_FLOW_HOOK_DEADLINE_AT) : undefined),
|
|
16
16
|
env,
|
|
17
17
|
now: () => new Date().toISOString()
|
|
18
18
|
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"project": {"$ref": "#/definitions/identity"},
|
|
13
13
|
"subject": {"$ref": "#/definitions/identity"},
|
|
14
14
|
"flow_kind": {"type": "string", "minLength": 1},
|
|
15
|
+
"verification_matrix_contract": {"const": "dd-flow/verification-matrix@1"},
|
|
15
16
|
"status": {"enum": ["created", "running", "paused", "waiting_for_user", "done", "failed", "blocked", "cancelled", "discarded"]},
|
|
16
17
|
"verdict": {"type": ["string", "null"]},
|
|
17
18
|
"started_at": {"type": "string", "format": "date-time"},
|
|
@@ -1,14 +1,114 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import { terminalCodexOverload } from "../harness-runtime/lib/dd-codex.mjs";
|
|
3
|
-
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
/** Permit acquisition must still belong to the original task, not merely an
|
|
6
|
+
* open RUN. Controls/inspection and ordinary successor prompts are unaffected. */
|
|
7
|
+
export function assertCapacityOwnerAdmission(context, record, operationId) {
|
|
8
|
+
if (!operationId || !record.project_id || !record.run_id)
|
|
9
|
+
return;
|
|
10
|
+
const root = operationId.replace(/:capacity:[12]:session\.prompt$/, "");
|
|
11
|
+
if (root === operationId && !/:child:[a-f0-9]+:followup:[12]$/.test(root))
|
|
12
|
+
return;
|
|
13
|
+
const reject = () => { throw new AppError("capacity_owner_boundary_changed", "Queued continuation no longer belongs to an unfinished task; reconcile its durable state", 1, { operation_id: operationId, work_id: record.work_id, run_id: record.run_id }); };
|
|
14
|
+
if (context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'run_recovery_bindings'")) {
|
|
15
|
+
if (context.db.get("SELECT 1 FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND g.status = 'recovered' AND b.status = 'accepted' AND json_extract(b.runtime_dispatch_json, '$.operation_id') = ?", [record.project_id, record.run_id, root]))
|
|
16
|
+
reject();
|
|
17
|
+
}
|
|
18
|
+
if (record.work_id) {
|
|
19
|
+
const work = context.db.get("SELECT status FROM works WHERE work_id = ? AND project_id = ? AND run_id = ?", [record.work_id, record.project_id, record.run_id]);
|
|
20
|
+
const merge = context.db.get("SELECT status FROM merge_requests WHERE executor_work_id = ? AND project_id = ? AND run_id = ?", [record.work_id, record.project_id, record.run_id]);
|
|
21
|
+
if (merge && !["dispatching", "active"].includes(merge.status))
|
|
22
|
+
reject();
|
|
23
|
+
// MERGE owns the Stage/request boundary: finishing its Work does not
|
|
24
|
+
// necessarily commit stage finish yet. External workers own only Work.
|
|
25
|
+
const mergeOwner = JSON.parse(record.metadata_json).role === "merge";
|
|
26
|
+
if (!work || !(merge && mergeOwner ? ["created", "running", "completed"] : ["created", "running"]).includes(work.status))
|
|
27
|
+
reject();
|
|
28
|
+
}
|
|
29
|
+
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'"))
|
|
30
|
+
return;
|
|
31
|
+
const controller = context.db.get("SELECT state_json FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ?", [record.owner_id, record.project_id, record.run_id]);
|
|
32
|
+
if (!controller)
|
|
33
|
+
return;
|
|
34
|
+
const state = JSON.parse(controller.state_json);
|
|
35
|
+
const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [record.project_id, record.run_id]);
|
|
36
|
+
const index = JSON.parse(run?.index_json ?? "{}");
|
|
37
|
+
if (state.stage && (index.current_stage && index.current_stage !== state.stage || index.stage_runs?.some(stage => stage.stage === state.stage && ["done", "skipped"].includes(stage.status))))
|
|
38
|
+
reject();
|
|
39
|
+
const child = Object.entries(state.child_capacity ?? {}).find(([, intent]) => intent.operation_id === root)?.[0];
|
|
40
|
+
if (child && context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN sessions s ON s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND s.provider_session_id = ? AND w.status <> 'running'", [record.project_id, record.run_id, child]))
|
|
41
|
+
reject();
|
|
42
|
+
}
|
|
43
|
+
export function capacityDelay(error, ordinal, now = Date.now()) {
|
|
44
|
+
const native = error?.details?.provider_error;
|
|
45
|
+
const headers = native?.headers;
|
|
46
|
+
const header = headers && Object.entries(headers).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
|
|
47
|
+
const value = header ?? native?.retryAfter ?? native?.retry_after;
|
|
48
|
+
const seconds = typeof value === "number" || typeof value === "string" && /^\d+(\.\d+)?$/.test(value.trim()) ? Number(value) * 1000 : typeof value === "string" ? Date.parse(value) - now : NaN;
|
|
49
|
+
return Math.max(ordinal === 1 ? 5_000 : 15_000, Number.isFinite(seconds) ? Math.max(0, seconds) : 0);
|
|
50
|
+
}
|
|
51
|
+
/** Continue a confirmed terminal Turn, never replay its completed effects. */
|
|
4
52
|
export async function continueCodexCapacity(input) {
|
|
5
53
|
let { error, operationId } = input;
|
|
6
|
-
|
|
54
|
+
const history = input.retainedIntents?.() ?? [];
|
|
55
|
+
const rootId = input.operationId.replace(/:capacity:[12]:session\.prompt$/, "");
|
|
56
|
+
const text = input.continuationText ?? "Continue the same assigned task in this Session. Check current durable RUN, Work and Stage state first. Never repeat an observed lifecycle command, accepted HITL answer, check or merge apply; use only an exact issued successor. Stop at the assigned boundary.";
|
|
57
|
+
const promptSha = crypto.createHash("sha256").update(text).digest("hex");
|
|
58
|
+
let ordinal = Number(/:capacity:([12]):session\.prompt$/.exec(operationId)?.[1] ?? 0) + 1;
|
|
59
|
+
// Reconcile a retained dispatch before preparing another. Native absence is
|
|
60
|
+
// the only safe way to reuse a prepared intent; a lost reply is not absence.
|
|
61
|
+
for (const intent of history) {
|
|
62
|
+
if (intent.predecessor !== operationId || intent.ordinal !== ordinal || intent.operation_id !== `${rootId}:capacity:${ordinal}:session.prompt`)
|
|
63
|
+
continue;
|
|
64
|
+
if (intent.prompt_sha256 !== promptSha || !intent.turn_id || !Number.isFinite(Date.parse(intent.not_before)))
|
|
65
|
+
throw error;
|
|
66
|
+
let observed;
|
|
67
|
+
try {
|
|
68
|
+
observed = await input.inspectOperation(intent.operation_id);
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
let code = cause;
|
|
72
|
+
for (let depth = 0; depth < 4 && code?.code === "harness_adapter_failed"; depth++)
|
|
73
|
+
code = code.details?.cause;
|
|
74
|
+
if (code?.code !== "operation_not_found")
|
|
75
|
+
throw error;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (observed.operation_id !== intent.operation_id || observed.session_id !== input.sessionId || observed.operation !== "session.prompt" || observed.primary_error)
|
|
79
|
+
throw error;
|
|
80
|
+
if (observed.state === "completed" && observed.result && typeof observed.result === "object") {
|
|
81
|
+
input.signal?.throwIfAborted();
|
|
82
|
+
input.assertCurrent();
|
|
83
|
+
return observed.result;
|
|
84
|
+
}
|
|
85
|
+
if (observed.state !== "failed" || !terminalCodexOverload(observed.error, input.sessionId))
|
|
86
|
+
throw error;
|
|
87
|
+
operationId = intent.operation_id;
|
|
88
|
+
error = observed.error;
|
|
89
|
+
ordinal += 1;
|
|
90
|
+
}
|
|
91
|
+
const decide = async () => {
|
|
92
|
+
input.signal?.throwIfAborted();
|
|
93
|
+
input.assertCurrent();
|
|
94
|
+
const decision = await input.decision?.() ?? "continue";
|
|
95
|
+
input.signal?.throwIfAborted();
|
|
96
|
+
input.assertCurrent();
|
|
97
|
+
if (decision === "reconciliation_blocked")
|
|
98
|
+
throw error;
|
|
99
|
+
if (decision === "continue" && input.deadline !== undefined && Date.now() >= input.deadline)
|
|
100
|
+
throw error;
|
|
101
|
+
return decision === "continue" ? null : { provider_session_id: input.sessionId, capacity_decision: decision };
|
|
102
|
+
};
|
|
103
|
+
for (; ordinal <= 2; ordinal += 1) {
|
|
7
104
|
if (!terminalCodexOverload(error, input.sessionId)) {
|
|
8
105
|
if (ordinal === 1)
|
|
9
106
|
return null;
|
|
10
107
|
throw error;
|
|
11
108
|
}
|
|
109
|
+
const completed = await decide();
|
|
110
|
+
if (completed)
|
|
111
|
+
return completed;
|
|
12
112
|
let prior;
|
|
13
113
|
try {
|
|
14
114
|
prior = await input.inspectOperation(operationId);
|
|
@@ -21,14 +121,16 @@ export async function continueCodexCapacity(input) {
|
|
|
21
121
|
const failure = prior.error;
|
|
22
122
|
const turnId = failure?.details?.turn_id;
|
|
23
123
|
if (prior.operation_id !== operationId || prior.operation !== "session.prompt" || prior.session_id !== input.sessionId
|
|
24
|
-
|| prior.state !== "failed" || prior.primary_error || !turnId || !terminalCodexOverload(failure, input.sessionId, turnId)
|
|
25
|
-
|| failure.details?.native_turn_items?.observed !== true || failure.details.native_turn_items.possible_effects !== false) {
|
|
124
|
+
|| prior.state !== "failed" || prior.primary_error || !turnId || !terminalCodexOverload(failure, input.sessionId, turnId)) {
|
|
26
125
|
if (ordinal === 1)
|
|
27
126
|
return null;
|
|
28
127
|
throw error;
|
|
29
128
|
}
|
|
30
129
|
const settled = async () => {
|
|
31
|
-
const seen = await input.inspectSession();
|
|
130
|
+
const seen = await input.inspectSession(turnId);
|
|
131
|
+
const items = seen.terminal_turn_id === turnId && seen.terminal_status === "failed" ? seen.native_turn_items : failure.details?.native_turn_items;
|
|
132
|
+
if (items?.observed !== true || typeof items.possible_effects !== "boolean" || items.pending === true || items.possible_effects === true && items.pending !== false)
|
|
133
|
+
return false;
|
|
32
134
|
const settlement = seen.settlement;
|
|
33
135
|
const ids = Array.isArray(seen.tree_session_ids) ? seen.tree_session_ids : [];
|
|
34
136
|
const observations = Array.isArray(seen.tree_observations) ? seen.tree_observations : [];
|
|
@@ -38,9 +140,8 @@ export async function continueCodexCapacity(input) {
|
|
|
38
140
|
&& Array.isArray(seen.unsettled_sessions) && seen.unsettled_sessions.length === 0
|
|
39
141
|
: ids.length === 1 && ids[0] === input.sessionId;
|
|
40
142
|
return seen.provider_session_id === input.sessionId && seen.settled === true && treeProven
|
|
41
|
-
&& (!settlement ||
|
|
143
|
+
&& (!settlement || settlement.state === "not_required" || settlement.state === "settled" && settlement.operation_ids?.includes(operationId) === true);
|
|
42
144
|
};
|
|
43
|
-
input.assertCurrent();
|
|
44
145
|
try {
|
|
45
146
|
if (!await settled()) {
|
|
46
147
|
if (ordinal === 1)
|
|
@@ -53,16 +154,34 @@ export async function continueCodexCapacity(input) {
|
|
|
53
154
|
return null;
|
|
54
155
|
throw error;
|
|
55
156
|
}
|
|
56
|
-
const nextId = `${
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
157
|
+
const nextId = `${rootId}:capacity:${ordinal}:session.prompt`;
|
|
158
|
+
const existing = history.find(item => item.operation_id === nextId);
|
|
159
|
+
if (existing && (existing.predecessor !== operationId || existing.turn_id !== turnId || existing.prompt_sha256 !== promptSha || existing.ordinal !== ordinal))
|
|
160
|
+
throw error;
|
|
161
|
+
const wait = existing ? Math.max(0, Date.parse(existing.not_before) - Date.now()) : capacityDelay(failure, ordinal);
|
|
162
|
+
if (!Number.isFinite(wait) || wait > 30_000 || input.deadline !== undefined && Date.now() + wait >= input.deadline)
|
|
163
|
+
throw error;
|
|
164
|
+
if (!existing)
|
|
165
|
+
input.retainIntent({ predecessor: operationId, operation_id: nextId, turn_id: turnId, ordinal, prompt_sha256: promptSha, not_before: new Date(Date.now() + wait).toISOString() });
|
|
166
|
+
await delay(wait, undefined, { signal: input.signal });
|
|
167
|
+
const afterWait = await decide();
|
|
168
|
+
if (afterWait)
|
|
169
|
+
return afterWait;
|
|
60
170
|
if (!await settled().catch(() => false))
|
|
61
171
|
throw error;
|
|
172
|
+
const beforeDispatch = await decide();
|
|
173
|
+
if (beforeDispatch)
|
|
174
|
+
return beforeDispatch;
|
|
62
175
|
try {
|
|
63
|
-
return await input.prompt(nextId,
|
|
176
|
+
return await input.prompt(nextId, text, ordinal);
|
|
64
177
|
}
|
|
65
178
|
catch (nextError) {
|
|
179
|
+
// A task can complete/pause while the daemon queues for its permit.
|
|
180
|
+
// Admission then refuses the stale prompt; accept that owner decision
|
|
181
|
+
// before treating the transport's rejection as another provider failure.
|
|
182
|
+
const finished = await decide();
|
|
183
|
+
if (finished)
|
|
184
|
+
return finished;
|
|
66
185
|
error = nextError;
|
|
67
186
|
operationId = nextId;
|
|
68
187
|
}
|
|
@@ -6,17 +6,21 @@ import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, cont
|
|
|
6
6
|
export async function verifyCodexHookDelivery(env, payload) {
|
|
7
7
|
const command = lifecycleCommandFromPayload(payload);
|
|
8
8
|
const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
|
|
9
|
-
if (parsed.kind === "none" && !(command && continuationCommand(command)))
|
|
9
|
+
if (parsed.kind === "none" && !parsed.unresolved && !(command && continuationCommand(command)))
|
|
10
10
|
return undefined;
|
|
11
11
|
const managed = (parsed.kind !== "none" && commandOption(parsed.invocation, "invocation-id")) || env.DD_FLOW_DAEMON_ID;
|
|
12
12
|
if (!managed)
|
|
13
13
|
return undefined; // Explicit legacy/unmanaged transport.
|
|
14
14
|
if (!env.DD_FLOW_CODEX_STATE_DIR || !env.DD_FLOW_DAEMON_ID)
|
|
15
15
|
throw new AppError("native_hook_unproven", "Managed hook has no owning native daemon", 1, { effect: "no_effect" });
|
|
16
|
+
const deadline = Number(env.DD_FLOW_HOOK_DEADLINE_AT);
|
|
17
|
+
const remaining = Number.isFinite(deadline) && deadline > 0 ? deadline - Date.now() : 5000;
|
|
18
|
+
if (remaining <= 0)
|
|
19
|
+
throw new AppError("native_hook_timeout", "Native hook deadline expired before delivery verification", 1, { phase: "verification", effect: "no_effect", recoverable: false });
|
|
16
20
|
const proof = await callDaemon(env.DD_FLOW_CODEX_STATE_DIR, "hook.verify", {
|
|
17
21
|
daemonId: env.DD_FLOW_DAEMON_ID, rootSessionId: payload.session_id,
|
|
18
22
|
sessionId: payload.agent_id ?? payload.session_id, toolCallId: payload.tool_use_id, turnId: payload.turn_id
|
|
19
|
-
}, 5000);
|
|
23
|
+
}, Math.min(5000, remaining));
|
|
20
24
|
if (typeof proof.projectRoot !== "string"
|
|
21
25
|
|| typeof proof.ddFlowHome !== "string" || !env.DD_FLOW_HOME || path.resolve(env.DD_FLOW_HOME) !== path.resolve(proof.ddFlowHome)
|
|
22
26
|
|| proof.daemonId !== env.DD_FLOW_DAEMON_ID || typeof proof.rootSessionId !== "string" || typeof proof.sessionId !== "string"
|
|
@@ -409,7 +409,12 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
409
409
|
let result, primary, failed = false;
|
|
410
410
|
try {
|
|
411
411
|
const runtimeLineage = recovery ? null : detachSnapshotRuntimeOwners(restored, snapshot, manifest);
|
|
412
|
+
const originalRunRoot = restored.db.get("SELECT run_root FROM runs WHERE id = ?", [manifest.run_id])?.run_root;
|
|
412
413
|
rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome, recovery);
|
|
414
|
+
const publicationRun = restored.db.get("SELECT project_id, run_root FROM runs WHERE id = ?", [manifest.run_id]);
|
|
415
|
+
if (!originalRunRoot || !publicationRun?.run_root || !isInside(manifest.dd_flow_home, originalRunRoot) || !isInside(context.ddFlowHome, publicationRun.run_root))
|
|
416
|
+
throw new AppError("snapshot_invalid", "Restored publication roots are not bound to their runtime", 1);
|
|
417
|
+
restored.db.run("INSERT INTO audit_events (project_id, event_type, forced, payload_json, created_at) VALUES (?, 'flow_run.publication_relocation', 0, ?, ?)", [publicationRun.project_id, JSON.stringify({ run_id: manifest.run_id, from: originalRunRoot, to: publicationRun.run_root, source_manifest_sha256: sourceManifestSha256, import_id: importId }), restored.now()]);
|
|
413
418
|
registerProject(restored, { root: projectRoot });
|
|
414
419
|
requireProjectByRoot(restored, projectRoot);
|
|
415
420
|
if (selectedProfile && routingInput && runtimeLineage) {
|
|
@@ -423,14 +428,6 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
423
428
|
fs.writeFileSync(artifact, routingInput.bytes, { flag: "wx", mode: 0o600 });
|
|
424
429
|
importFlowRunExecutionProfile(restored, { projectRoot, runId: manifest.run_id, stage: manifest.stage_entry, lineageFile: runtimeLineage, profile: selectedProfile });
|
|
425
430
|
}
|
|
426
|
-
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
427
|
-
if (!recovery)
|
|
428
|
-
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
429
|
-
else
|
|
430
|
-
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
431
|
-
if (!status.run.run_root)
|
|
432
|
-
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
433
|
-
const runHome = status.run.run_root;
|
|
434
431
|
const relocation = recovery ? {
|
|
435
432
|
schema_id: "dd-flow/recovery-relocation@1", physical_runtime_id: crypto.randomUUID(), recovery_id: input.recoveryId, run_id: manifest.run_id,
|
|
436
433
|
path_mapping: [{ from: manifest.dd_flow_home, to: context.ddFlowHome }, { from: sourceWorkspaceRoot(manifest), to: workspaceRoot }, { from: manifest.project_root, to: projectRoot }]
|
|
@@ -440,6 +437,14 @@ export function restoreEvalRunSnapshot(context, input, prepared = prepareEvalRun
|
|
|
440
437
|
fs.mkdirSync(path.dirname(relocationFile), { recursive: true });
|
|
441
438
|
writeJson(relocationFile, relocation);
|
|
442
439
|
}
|
|
440
|
+
const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
|
|
441
|
+
if (!recovery)
|
|
442
|
+
assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
|
|
443
|
+
else
|
|
444
|
+
restored.db.run("UPDATE run_recovery_guards SET capture_path = ? WHERE recovery_id = ? AND status = 'sealed'", [snapshot, input.recoveryId]);
|
|
445
|
+
if (!status.run.run_root)
|
|
446
|
+
throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
|
|
447
|
+
const runHome = status.run.run_root;
|
|
443
448
|
const expectedWorkspaceRoot = restoredWorkspaceRoot(manifest, projectRoot, context.ddFlowHome);
|
|
444
449
|
if (physicalPath(workspaceRoot) !== physicalPath(expectedWorkspaceRoot))
|
|
445
450
|
throw new AppError("snapshot_workspace_restore_failed", "Restored workspace differs from the import target", 1, { workspace_root: workspaceRoot, expected_workspace_root: expectedWorkspaceRoot });
|
|
@@ -93,7 +93,7 @@ export async function launchExternalWork(context, input) {
|
|
|
93
93
|
const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", isolatedWorkspace ?? 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.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
|
|
94
94
|
const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
|
|
95
95
|
try {
|
|
96
|
-
const call = (args, timeoutMs, id = `${operationId}:${args[0]}.${args[1]}`) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: id, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
96
|
+
const call = (args, timeoutMs, id = `${operationId}:${args[0]}.${args[1]}`) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: id, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
|
|
97
97
|
await call(["daemon", "start", ...common]);
|
|
98
98
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
99
99
|
const created = await call(["session", "create", ...common], runtimeHarness(resolved.worker) === "zcode-acp" ? 180_000 : undefined);
|
|
@@ -111,6 +111,7 @@ export async function launchExternalWork(context, input) {
|
|
|
111
111
|
const promptFile = path.join(root, "launch.md");
|
|
112
112
|
fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n\n${lifecycleRetryInstruction}${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
|
|
113
113
|
const promptOperationId = `${operationId}:session.prompt`;
|
|
114
|
+
const promptDeadline = Date.now() + 45 * 60_000;
|
|
114
115
|
let prompted;
|
|
115
116
|
try {
|
|
116
117
|
prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
|
|
@@ -118,37 +119,54 @@ export async function launchExternalWork(context, input) {
|
|
|
118
119
|
catch (error) {
|
|
119
120
|
if (runtimeHarness(resolved.worker) !== "codex-desktop")
|
|
120
121
|
throw error;
|
|
121
|
-
const
|
|
122
|
+
const retained = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
|
|
123
|
+
const capacityIntents = retained?.adapter_receipt_json ? JSON.parse(retained.adapter_receipt_json).capacity_intents ?? [] : [];
|
|
124
|
+
const resumed = await continueCodexCapacity({ error, sessionId, operationId: promptOperationId, allowSettledDescendants: true,
|
|
125
|
+
...(input.signal ? { signal: input.signal } : {}), deadline: promptDeadline,
|
|
126
|
+
continuationText: `Continue only Work ${work.work_id} in RUN ${work.run_id}, Stage ${stage ?? "current"}, in the same Session. Inspect its current durable state and the retained Work packet. Never repeat Work start or a completed tool, accepted answer, check or lifecycle command. Use only the current exact issued successor and finish at the original Work boundary.`,
|
|
127
|
+
retainedIntents: () => capacityIntents,
|
|
122
128
|
inspectOperation: id => call(["daemon", "operation", "--state-dir", root, "--operation-id", id, "--json"], 30_000, `${id}:capacity-observe`),
|
|
123
|
-
inspectSession:
|
|
129
|
+
inspectSession: turnId => call(["session", "inspect", "--session-id", sessionId, ...(turnId ? ["--turn-id", turnId] : []), ...common], 30_000, `${operationId}:capacity-inspect`),
|
|
124
130
|
assertCurrent: () => {
|
|
125
131
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
126
132
|
const current = context.db.get("SELECT w.status, l.status AS launch_status FROM works w JOIN work_launches l ON l.work_id = w.work_id WHERE w.work_id = ? AND l.operation_id = ?", [work.work_id, operationId]);
|
|
127
|
-
if (current
|
|
133
|
+
if (!current || !["created", "running", "paused", "completed"].includes(current.status) || current.launch_status !== "launching")
|
|
128
134
|
throw new AppError("capacity_continuation_blocked", "External Work changed before its native continuation", 1, { work_id: work.work_id, state: current });
|
|
129
135
|
if (reviewCopy && reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== reviewCopy.source_sha256)
|
|
130
136
|
throw new AppError("read_only_input_changed", "Authoritative review workspace changed during capacity wait", 2);
|
|
131
137
|
},
|
|
138
|
+
decision: () => {
|
|
139
|
+
const status = context.db.get("SELECT status FROM works WHERE work_id = ?", [work.work_id])?.status;
|
|
140
|
+
return status === "completed" ? "completed" : status === "paused" ? "paused" : "continue";
|
|
141
|
+
},
|
|
132
142
|
retainIntent: intent => {
|
|
133
|
-
|
|
143
|
+
capacityIntents.push(intent);
|
|
144
|
+
const changed = context.db.run("UPDATE work_launches SET adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'launching'", [JSON.stringify({ create: created, capacity_intent: intent, capacity_intents: capacityIntents }), context.now(), operationId]);
|
|
134
145
|
if (changed.changes !== 1)
|
|
135
146
|
throw new AppError("capacity_continuation_blocked", "External Work launch owner changed", 1);
|
|
136
147
|
},
|
|
137
148
|
prompt: (id, text, ordinal) => {
|
|
138
149
|
const file = path.join(root, `capacity-${ordinal}.md`);
|
|
139
|
-
fs.
|
|
140
|
-
|
|
150
|
+
if (fs.existsSync(file)) {
|
|
151
|
+
if (fs.readFileSync(file, "utf8") !== text)
|
|
152
|
+
throw new AppError("capacity_continuation_conflict", "Retained Work continuation prompt differs", 1);
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
fs.writeFileSync(file, text, { flag: "wx", mode: 0o600 });
|
|
156
|
+
return call(["session", "prompt", "--session-id", sessionId, "--prompt-file", file, ...common], Math.max(1, promptDeadline - Date.now()), id);
|
|
141
157
|
} });
|
|
142
158
|
if (!resumed)
|
|
143
159
|
throw error;
|
|
144
160
|
prompted = resumed;
|
|
145
161
|
}
|
|
146
162
|
assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
|
|
147
|
-
context.db.
|
|
163
|
+
const saved = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
|
|
164
|
+
context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ ...(saved?.adapter_receipt_json ? JSON.parse(saved.adapter_receipt_json) : {}), create: created, prompt: prompted }), context.now(), operationId]);
|
|
148
165
|
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 };
|
|
149
166
|
}
|
|
150
167
|
catch (error) {
|
|
151
|
-
context.db.
|
|
168
|
+
const saved = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
|
|
169
|
+
context.db.run("UPDATE work_launches SET status = 'failed', adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ ...(saved?.adapter_receipt_json ? JSON.parse(saved.adapter_receipt_json) : {}), error: errorRecord(error) }), context.now(), operationId]);
|
|
152
170
|
throw error;
|
|
153
171
|
}
|
|
154
172
|
}
|