@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/hook-ingress.js +4 -3
  4. package/dist/cli/native-hook-ingress.js +34 -2
  5. package/dist/cli/run-cli.js +3 -0
  6. package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
  7. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +68 -6
  8. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +21 -5
  9. package/dist/harness-runtime/lib/dd-codex.mjs +5 -1
  10. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
  11. package/dist/harness-runtime/lib/dd-zcode.mjs +40 -16
  12. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  13. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  14. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  15. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  16. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  17. package/dist/harness-runtime/lib/managed-daemon.mjs +3 -2
  18. package/dist/harness-runtime/lib/model-observations.mjs +16 -9
  19. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  20. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  21. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  22. package/dist/harness-runtime/lib/tool-observations.mjs +52 -11
  23. package/dist/runtime/context.js +1 -1
  24. package/dist/schemas/flow-run.schema.json +1 -0
  25. package/dist/services/codex-capacity.js +131 -12
  26. package/dist/services/codex-hook-delivery.js +6 -2
  27. package/dist/services/eval-snapshots.js +13 -8
  28. package/dist/services/external-work-launch.js +27 -9
  29. package/dist/services/hooks.js +51 -59
  30. package/dist/services/lifecycle-command.js +1 -383
  31. package/dist/services/lifecycle-contract.js +1 -30
  32. package/dist/services/lifecycle-invocations.js +34 -13
  33. package/dist/services/merge-server.js +26 -9
  34. package/dist/services/review-copy.js +21 -0
  35. package/dist/services/run-controller-adapter.js +25 -3
  36. package/dist/services/run-controller-recovery.js +9 -2
  37. package/dist/services/run-controller.js +59 -20
  38. package/dist/services/run-observations.js +7 -2
  39. package/dist/services/runs.js +60 -47
  40. package/dist/services/stage-report-renderer.js +44 -2
  41. package/dist/services/verification-matrix.js +345 -0
  42. package/dist/services/vnext-code-review.js +30 -11
  43. package/dist/services/vnext-code.js +31 -10
  44. package/dist/services/vnext-merge.js +30 -7
  45. package/dist/services/vnext-plan-review.js +28 -7
  46. package/dist/services/vnext-plan.js +16 -3
  47. package/dist/services/work-registry.js +15 -1
  48. package/dist/storage/database.js +43 -9
  49. 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
- /** Native command hooks have a 30s outer limit; leave room for transport and cleanup. */
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) => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(cleanupTimer); error ? reject(error) : resolve(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) { this.gaps.add(event.reason); return; }
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 = {}, sessions = new Map(), reasons = new Set(this.gaps); let total = 0, failures = 0, pending = 0;
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" : "running";
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], status: status(update.status) };
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, status: status(part.state?.status) };
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, status: step.tool_info?.error ? "failed" : status(step.state) };
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, line: index + 1 }, observed_at: e.observed_at ?? e.timestamp ?? e.at ?? e.ts ?? null }); };
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;
@@ -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
- /** Only a terminal, effect-free native Turn may get another productive Turn. */
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
- for (let ordinal = 1; ordinal <= 2; ordinal += 1) {
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 || ["settled", "not_required"].includes(settlement.state ?? ""));
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 = `${input.operationId}:capacity:${ordinal}:session.prompt`;
57
- input.retainIntent({ predecessor: operationId, operation_id: nextId, turn_id: turnId, ordinal, not_before: new Date(Date.now() + (ordinal === 1 ? 5_000 : 15_000)).toISOString() });
58
- await delay(ordinal === 1 ? 5_000 : 15_000);
59
- input.assertCurrent();
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, 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.", ordinal);
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 resumed = await continueCodexCapacity({ error, sessionId, operationId: promptOperationId,
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: () => call(["session", "inspect", "--session-id", sessionId, ...common], 30_000, `${operationId}:capacity-inspect`),
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?.status !== "created" || current.launch_status !== "launching")
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
- 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 }), context.now(), operationId]);
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.writeFileSync(file, `${text}\n`, { flag: "wx", mode: 0o600 });
140
- return call(["session", "prompt", "--session-id", sessionId, "--prompt-file", file, ...common], 45 * 60_000, id);
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.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]);
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.run("UPDATE work_launches SET status = 'failed', adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ error: errorRecord(error) }), context.now(), operationId]);
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
  }