@deksden-com/dd-flow-cli 0.9.0-beta.155 → 0.9.0-beta.158

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 (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/error-output.js +11 -1
  4. package/dist/cli/input-preparation.js +32 -4
  5. package/dist/cli/run-cli.js +26 -8
  6. package/dist/harness-runtime/lib/daemon-operations.mjs +46 -11
  7. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +19 -6
  8. package/dist/harness-runtime/lib/dd-agy.mjs +19 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +1 -1
  10. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  11. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +3 -3
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -1
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -2
  16. package/dist/schemas/code-work-batch.schema.json +1 -1
  17. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  18. package/dist/services/codex-capacity.js +367 -0
  19. package/dist/services/controller-child-repair.js +117 -0
  20. package/dist/services/controller-fanout.js +33 -20
  21. package/dist/services/controller-result-authority.js +13 -8
  22. package/dist/services/controller-work-failure.js +198 -0
  23. package/dist/services/lifecycle-invocations.js +117 -25
  24. package/dist/services/managed-daemon-binding.js +40 -0
  25. package/dist/services/retained-native-hook.js +34 -5
  26. package/dist/services/run-control.js +18 -36
  27. package/dist/services/run-controller-process.js +19 -6
  28. package/dist/services/run-controller.js +154 -22
  29. package/dist/services/vnext-code.js +3 -1
  30. package/dist/services/vnext-plan-review.js +2 -2
  31. package/dist/services/vnext-plan.js +3 -3
  32. package/dist/services/work-registry.js +62 -15
  33. package/dist/services/work-retry-context.js +55 -0
  34. package/dist/verification-components.json +3 -0
  35. package/package.json +1 -1
@@ -28,7 +28,7 @@
28
28
  "decision": {"type": "object", "additionalProperties": false, "required": ["id", "decision", "rationale", "affected_surfaces"], "properties": {"id": {"type": "string", "minLength": 1}, "decision": {"type": "string", "minLength": 1}, "rationale": {"type": "string", "minLength": 1}, "affected_surfaces": {"$ref": "#/$defs/strings"}, "durable_ref": {"type": "string", "minLength": 1}}},
29
29
  "documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}},
30
30
  "spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
31
- "execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Semantic contradictions or external blockers that require the worker to stop."}}},
31
+ "execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional action-specific accepted-decision, safety or evidence boundaries. An empty list is valid; a mismatch with current implementation or planned paths alone is not a stop condition."}}},
32
32
  "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
33
33
  "verification": {"type": "object", "additionalProperties": false, "required": ["check_refs"], "properties": {"check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}}},
34
34
  "item": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "details", "depends_on", "requirement_refs", "semantic_spine", "execution_context", "verification"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "requirement_refs": {"$ref": "#/$defs/strings"}, "semantic_spine": {"$ref": "#/$defs/spine"}, "execution_context": {"$ref": "#/$defs/execution"}, "verification": {"$ref": "#/$defs/verification"}}},
@@ -0,0 +1,367 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import crypto from "node:crypto";
3
+ import { CAPACITY_POLICY, capacityBackoff, nativeItemsSettled, normalizeCodexFailure, overloadBurst, parseCapacityOperation, refusalObservation, terminalCodexOverload } from "../harness-runtime/lib/codex-capacity-policy.mjs";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { isObservationLoss } from "../harness-runtime/lib/operation-errors.mjs";
6
+ export function eligibleCodexChildOverload(child, parentSessionId) {
7
+ return typeof child.session_id === "string" && child.session_id !== parentSessionId && child.parent_session_id === parentSessionId && child.provenance !== "parent_mismatch" && child.status === "failed" && typeof child.terminal_turn_id === "string" && nativeItemsSettled(child.native_turn_items)
8
+ && terminalCodexOverload({ code: "turn_interrupted", details: { provider_session_id: child.session_id, turn_id: child.terminal_turn_id, native_turn_id: child.terminal_turn_id, terminal_status: "failed", provider_error: child.provider_error } }, child.session_id, child.terminal_turn_id);
9
+ }
10
+ /** A terminal root is not a settled tree. Only the daemon's exact owned tree
11
+ * with a settled root and positively live descendants permits waiting. */
12
+ export function capacityReadiness(seen, sessionId, operationId, turnId, items, allowDescendants = false) {
13
+ if (seen.provider_session_id !== sessionId || seen.terminal_turn_id !== undefined && seen.terminal_turn_id !== turnId || !nativeItemsSettled(items))
14
+ return "unproven";
15
+ const ids = Array.isArray(seen.tree_session_ids) ? seen.tree_session_ids : [];
16
+ if (!ids.length || ids[0] !== sessionId || ids.some(id => typeof id !== "string" || !id) || new Set(ids).size !== ids.length)
17
+ return "unproven";
18
+ const observations = Array.isArray(seen.tree_observations) ? seen.tree_observations : [];
19
+ const exactTree = observations.length === ids.length && ids.every(id => observations.filter(item => item.provider_session_id === id).length === 1);
20
+ const settlement = seen.settlement;
21
+ const budgetSettled = !settlement || settlement.state === "not_required" || settlement.state === "settled" && settlement.operation_ids?.includes(operationId) === true;
22
+ const treeSettled = allowDescendants ? exactTree && observations.every(item => item.settled === true) && Array.isArray(seen.unsettled_sessions) && seen.unsettled_sessions.length === 0 : ids.length === 1;
23
+ if (seen.settled === true && treeSettled && budgetSettled)
24
+ return "ready";
25
+ if (!allowDescendants || seen.settled !== false || !exactTree || ids.length < 2 || observations[0]?.provider_session_id !== sessionId || observations[0].settled !== true
26
+ || settlement && settlement.state !== "not_required" && !(settlement.state === "pending" && settlement.reason === "tree_unsettled"))
27
+ return "unproven";
28
+ const live = observations.filter(item => item.settled === false);
29
+ const unsettled = seen.unsettled_sessions;
30
+ const knownLive = live.length > 0 && live.every(item => item.provider_session_id !== sessionId && ["active", "running", "inProgress"].includes(String(typeof item.status === "object" && item.status ? item.status.type : item.status)));
31
+ return knownLive && observations.every(item => typeof item.settled === "boolean") && Array.isArray(unsettled) && unsettled.length === live.length && live.every(item => unsettled.includes(item.provider_session_id)) ? "owned_children_live" : "unproven";
32
+ }
33
+ /** Permit acquisition must still belong to the original task, not merely an
34
+ * open RUN. Controls/inspection and ordinary successor prompts are unaffected. */
35
+ export function assertCapacityOwnerAdmission(context, record, operationId, binding) {
36
+ if (!operationId || !record.project_id || !record.run_id)
37
+ return;
38
+ const root = parseCapacityOperation(operationId).root;
39
+ const childOrdinal = /:child:[a-f0-9]+:followup:(\d+)$/.exec(root)?.[1];
40
+ if (childOrdinal && (!Number.isSafeInteger(Number(childOrdinal)) || Number(childOrdinal) < 1 || String(Number(childOrdinal)) !== childOrdinal))
41
+ throw new AppError("capacity_continuation_conflict", "Invalid native child continuation ordinal", 1);
42
+ if (root === operationId && !childOrdinal)
43
+ return;
44
+ 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 }); };
45
+ const metadata = JSON.parse(record.metadata_json);
46
+ const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
47
+ const parsed = parseCapacityOperation(operationId);
48
+ if (parsed.ordinal > 0 && !["judge", "probe"].includes(metadata.role ?? "")) {
49
+ if (!binding?.nativeSessionId || !binding.promptSha256)
50
+ reject();
51
+ let intent;
52
+ const predecessor = parsed.ordinal === 1 ? root : `${root}:capacity:${parsed.ordinal - 1}:session.prompt`;
53
+ if (hasTable("run_controller_operations")) {
54
+ const saved = context.db.get("SELECT receipt_json FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation = ? AND status = 'failed'", [predecessor, record.owner_id, metadata.run_generation ?? 0]);
55
+ if (saved)
56
+ intent = JSON.parse(saved.receipt_json).capacity_intent;
57
+ }
58
+ if (!intent && record.work_id)
59
+ for (const table of ["work_launches", "merge_requests"]) {
60
+ if (!hasTable(table))
61
+ continue;
62
+ const rows = table === "work_launches"
63
+ ? context.db.all("SELECT l.adapter_receipt_json FROM work_launches l JOIN works w ON w.work_id = l.work_id WHERE l.work_id = ? AND w.project_id = ? AND w.run_id = ?", [record.work_id, record.project_id, record.run_id])
64
+ : context.db.all("SELECT adapter_receipt_json FROM merge_requests WHERE executor_work_id = ? AND project_id = ? AND run_id = ?", [record.work_id, record.project_id, record.run_id]);
65
+ for (const saved of rows) {
66
+ const retained = JSON.parse(saved.adapter_receipt_json ?? "null");
67
+ intent = retained?.capacity_intents?.find((item) => item.operation_id === operationId);
68
+ if (intent)
69
+ break;
70
+ }
71
+ if (intent)
72
+ break;
73
+ }
74
+ if (!intent || intent.policy !== CAPACITY_POLICY || intent.root_operation_id !== root || intent.ordinal !== parsed.ordinal
75
+ || intent.predecessor !== predecessor || intent.operation_id !== operationId || intent.refusal?.turn_id !== intent.turn_id
76
+ || intent.refusal?.ordinal !== parsed.ordinal - 1 || intent.refusal?.session_id !== intent.session_id
77
+ || !Number.isFinite(Date.parse(intent.not_before)) || Date.parse(intent.not_before) > Date.parse(context.now())
78
+ || intent.deadline !== null && (typeof intent.deadline !== "number" || intent.deadline <= Date.parse(context.now()))
79
+ || binding?.nativeSessionId !== undefined && intent.session_id !== binding.nativeSessionId
80
+ || binding?.promptSha256 !== undefined && intent.prompt_sha256 !== binding.promptSha256)
81
+ reject();
82
+ }
83
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'run_recovery_bindings'")) {
84
+ 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]))
85
+ reject();
86
+ }
87
+ if (record.work_id) {
88
+ 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]);
89
+ 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]);
90
+ if (merge && !["dispatching", "active"].includes(merge.status))
91
+ reject();
92
+ // MERGE owns the Stage/request boundary: finishing its Work does not
93
+ // necessarily commit stage finish yet. External workers own only Work.
94
+ const mergeOwner = metadata.role === "merge";
95
+ if (!work || !(merge && mergeOwner ? ["created", "running", "completed"] : ["created", "running"]).includes(work.status))
96
+ reject();
97
+ }
98
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'run_controllers'"))
99
+ return;
100
+ 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]);
101
+ if (!controller)
102
+ return;
103
+ const state = JSON.parse(controller.state_json);
104
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [record.project_id, record.run_id]);
105
+ const index = JSON.parse(run?.index_json ?? "{}");
106
+ 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))))
107
+ reject();
108
+ const child = Object.entries(state.child_capacity ?? {}).find(([, intent]) => intent.operation_id === root)?.[0];
109
+ if (childOrdinal && !child)
110
+ reject();
111
+ 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]))
112
+ reject();
113
+ }
114
+ export function capacityDelay(error, ordinal, now = Date.now()) {
115
+ return capacityBackoff(error, ordinal, now);
116
+ }
117
+ /** Continue a confirmed terminal Turn, never replay its completed effects. */
118
+ export async function continueCodexCapacity(input) {
119
+ let { error, operationId } = input;
120
+ const history = [...(input.retainedIntents?.() ?? [])];
121
+ const parsed = parseCapacityOperation(input.operationId);
122
+ const rootId = parsed.root;
123
+ 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.";
124
+ const promptSha = crypto.createHash("sha256").update(text).digest("hex");
125
+ let ordinal = parsed.ordinal + 1;
126
+ const conflict = (message) => { throw new AppError("capacity_continuation_conflict", message, 1, { operation_id: operationId, root_operation_id: rootId, session_id: input.sessionId }); };
127
+ const blocked = (message) => new AppError("capacity_continuation_blocked", message, 1, { capacity_uncertainty: true, operation_id: operationId, root_operation_id: rootId, provider_session_id: input.sessionId, primary_error: error });
128
+ const observe = async (action, observedOperationId = operationId) => {
129
+ try {
130
+ return await action();
131
+ }
132
+ catch (cause) {
133
+ let primary = cause;
134
+ for (let depth = 0; depth < 4 && primary?.code === "harness_adapter_failed"; depth++)
135
+ primary = (primary.cause ?? primary.details?.cause);
136
+ if (isObservationLoss(primary))
137
+ throw new AppError("operation_observation_lost", "Capacity native observation was lost; do not cancel or resend", 1, { capacity_uncertainty: true, root_operation_id: rootId, operation_id: observedOperationId, provider_session_id: input.sessionId, predecessor_error: error, cause });
138
+ throw cause;
139
+ }
140
+ };
141
+ const refusals = [...(input.retainedFailures?.() ?? [])];
142
+ if (parsed.ordinal > history.length)
143
+ conflict("Continuation has no complete retained predecessor chain");
144
+ for (let index = 0; index < history.length; index++) {
145
+ const intent = history[index];
146
+ const expected = index + 1;
147
+ if (intent.ordinal !== expected || intent.operation_id !== `${rootId}:capacity:${expected}:session.prompt`
148
+ || intent.predecessor !== (expected === 1 ? rootId : `${rootId}:capacity:${expected - 1}:session.prompt`)
149
+ || intent.prompt_sha256 !== promptSha || intent.policy !== CAPACITY_POLICY || intent.root_operation_id !== rootId || intent.session_id !== input.sessionId
150
+ || intent.deadline !== (input.deadline ?? null) || !intent.refusal || intent.refusal.ordinal !== expected - 1 || intent.refusal.turn_id !== intent.turn_id
151
+ || !Number.isFinite(Date.parse(intent.not_before)))
152
+ conflict("Retained capacity chain is incomplete or belongs to another policy/task");
153
+ const previous = refusals.find(item => item.ordinal === intent.refusal.ordinal);
154
+ if (previous && JSON.stringify(previous) !== JSON.stringify(intent.refusal))
155
+ conflict("Retained failure observation changed");
156
+ if (!previous)
157
+ refusals.push(intent.refusal);
158
+ }
159
+ refusals.sort((a, b) => a.ordinal - b.ordinal);
160
+ for (let index = 0; index < refusals.length; index++) {
161
+ const refusal = refusals[index];
162
+ if (refusal.ordinal !== index || refusal.policy !== CAPACITY_POLICY || refusal.session_id !== input.sessionId || !refusal.turn_id
163
+ || refusals.slice(0, index).some(item => item.turn_id === refusal.turn_id) || !Number.isFinite(Date.parse(refusal.first_observed_at)))
164
+ conflict("Retained refusal chain is invalid");
165
+ }
166
+ // Reconcile a retained dispatch before preparing another. Native absence is
167
+ // the only safe way to reuse a prepared intent; a lost reply is not absence.
168
+ for (const intent of history) {
169
+ if (intent.ordinal < ordinal)
170
+ continue;
171
+ if (intent.predecessor !== operationId || intent.ordinal !== ordinal || intent.operation_id !== `${rootId}:capacity:${ordinal}:session.prompt`)
172
+ conflict("Retained successor skips its predecessor");
173
+ if (intent.prompt_sha256 !== promptSha || !intent.turn_id || !Number.isFinite(Date.parse(intent.not_before)))
174
+ throw error;
175
+ let observed;
176
+ try {
177
+ observed = await observe(() => input.inspectOperation(intent.operation_id), intent.operation_id);
178
+ }
179
+ catch (cause) {
180
+ let code = cause;
181
+ for (let depth = 0; depth < 4 && code?.code === "harness_adapter_failed"; depth++)
182
+ code = code.details?.cause;
183
+ if (code?.code !== "operation_not_found")
184
+ throw cause;
185
+ break;
186
+ }
187
+ if (observed.operation_id !== intent.operation_id || observed.session_id !== input.sessionId || observed.operation !== "session.prompt")
188
+ conflict("Retained native successor identity changed");
189
+ if (observed.primary_error)
190
+ throw observed.primary_error;
191
+ while (["prepared", "dispatching", "running", "awaiting_provider", "outcome_unknown"].includes(String(observed.state))) {
192
+ input.signal?.throwIfAborted();
193
+ input.assertCurrent();
194
+ const boundary = await observe(async () => await input.decision?.() ?? "continue", intent.operation_id);
195
+ if (boundary === "completed" || boundary === "paused")
196
+ return { provider_session_id: input.sessionId, capacity_decision: boundary };
197
+ if (boundary === "reconciliation_blocked")
198
+ throw new AppError("capacity_continuation_blocked", "Retained native successor has an unresolved owner boundary", 1, { operation_id: intent.operation_id, primary_error: error });
199
+ if (input.deadline !== undefined && Date.now() >= input.deadline)
200
+ throw new AppError("capacity_owner_deadline_exceeded", "Retained successor exhausted its original deadline", 1);
201
+ await delay(250, undefined, { signal: input.signal });
202
+ observed = await observe(() => input.inspectOperation(intent.operation_id), intent.operation_id);
203
+ input.signal?.throwIfAborted();
204
+ input.assertCurrent();
205
+ if (observed.operation_id !== intent.operation_id || observed.session_id !== input.sessionId || observed.operation !== "session.prompt")
206
+ conflict("Retained native successor identity changed");
207
+ if (observed.primary_error)
208
+ throw observed.primary_error;
209
+ }
210
+ if (observed.state === "completed" && observed.result && typeof observed.result === "object") {
211
+ input.signal?.throwIfAborted();
212
+ input.assertCurrent();
213
+ const boundary = await observe(async () => await input.decision?.() ?? "continue", intent.operation_id);
214
+ input.signal?.throwIfAborted();
215
+ input.assertCurrent();
216
+ if (boundary === "reconciliation_blocked")
217
+ throw new AppError("capacity_continuation_blocked", "Retained completion cannot be reconciled with its owner boundary", 1);
218
+ return boundary === "paused" || boundary === "completed" ? { provider_session_id: input.sessionId, capacity_decision: boundary } : observed.result;
219
+ }
220
+ if (observed.state === "failed" && observed.error && !terminalCodexOverload(observed.error, input.sessionId))
221
+ throw observed.error;
222
+ if (observed.state !== "failed" || !terminalCodexOverload(observed.error, input.sessionId))
223
+ throw new AppError("operation_observation_lost", "Retained successor outcome is not proven; do not resend", 1, { operation_id: intent.operation_id, primary_error: error, observed });
224
+ operationId = intent.operation_id;
225
+ error = observed.error;
226
+ ordinal += 1;
227
+ }
228
+ const decide = async (observedOperationId = operationId) => {
229
+ input.signal?.throwIfAborted();
230
+ input.assertCurrent();
231
+ const decision = await observe(async () => await input.decision?.() ?? "continue", observedOperationId);
232
+ input.signal?.throwIfAborted();
233
+ input.assertCurrent();
234
+ if (decision === "reconciliation_blocked")
235
+ throw new AppError("capacity_continuation_blocked", "Native refusal cannot be reconciled with its durable owner boundary", 1, { primary_error: error, root_operation_id: rootId, provider_session_id: input.sessionId });
236
+ if (decision === "continue" && input.deadline !== undefined && Date.now() >= input.deadline)
237
+ throw new AppError("capacity_owner_deadline_exceeded", "Continuation exhausted its original owner deadline", 1, { primary_error: error, deadline: input.deadline, root_operation_id: rootId, provider_session_id: input.sessionId });
238
+ return decision === "continue" ? null : { provider_session_id: input.sessionId, capacity_decision: decision };
239
+ };
240
+ for (;; ordinal += 1) {
241
+ if (!Number.isSafeInteger(ordinal))
242
+ conflict("Continuation ordinal overflow");
243
+ if (!terminalCodexOverload(error, input.sessionId)) {
244
+ if (ordinal === 1)
245
+ return null;
246
+ throw error;
247
+ }
248
+ const completed = await decide();
249
+ if (completed)
250
+ return completed;
251
+ const prior = await observe(() => input.inspectOperation(operationId));
252
+ const failure = prior.error;
253
+ const confirmed = normalizeCodexFailure(failure, input.sessionId);
254
+ const turnId = confirmed?.turn_id;
255
+ input.signal?.throwIfAborted();
256
+ input.assertCurrent();
257
+ if (prior.primary_error)
258
+ throw prior.primary_error;
259
+ if (prior.operation_id !== operationId || prior.operation !== "session.prompt" || prior.session_id !== input.sessionId
260
+ || prior.state !== "failed" || prior.primary_error || !turnId || !terminalCodexOverload(failure, input.sessionId, turnId)) {
261
+ throw blocked("Retained predecessor does not prove the exact terminal overloaded Turn");
262
+ }
263
+ const readiness = async () => {
264
+ input.signal?.throwIfAborted();
265
+ input.assertCurrent();
266
+ const seen = await observe(() => input.inspectSession(turnId));
267
+ input.signal?.throwIfAborted();
268
+ input.assertCurrent();
269
+ const items = (seen.terminal_turn_id === turnId && seen.terminal_status === "failed" ? seen.native_turn_items : confirmed?.native_turn_items);
270
+ return capacityReadiness(seen, input.sessionId, operationId, turnId, items, input.allowSettledDescendants);
271
+ };
272
+ const ready = await readiness();
273
+ if (ready === "unproven")
274
+ throw blocked("Native predecessor readiness is unproven; do not cancel or replay to manufacture proof");
275
+ const native = normalizeCodexFailure(failure, input.sessionId, turnId);
276
+ let refusal = refusals.find(item => item.ordinal === ordinal - 1);
277
+ if (refusal && (refusal.turn_id !== turnId || refusal.session_id !== input.sessionId || refusal.policy !== CAPACITY_POLICY))
278
+ conflict("Failure ordinal identifies a different native Turn");
279
+ if (refusal) {
280
+ const expected = refusalObservation(failure, ordinal - 1, refusal.first_observed_at);
281
+ if (refusal.native_terminal_at !== expected.native_terminal_at || refusal.native_terminal_basis !== expected.native_terminal_basis)
282
+ conflict("Retained refusal time differs from its native receipt");
283
+ }
284
+ if (!refusal) {
285
+ if (refusals.some(item => item.turn_id === turnId))
286
+ conflict("A cached native Turn cannot be a second refusal");
287
+ // A cached legacy failure cannot be assigned a new time during reattach.
288
+ if (ordinal > 1 && parsed.ordinal > 0 && !native.observed_at)
289
+ conflict("Retained continuation has no original failure time");
290
+ const observedAt = typeof native.observed_at === "string" && Number.isFinite(Date.parse(native.observed_at)) ? native.observed_at : new Date().toISOString();
291
+ refusal = refusalObservation(failure, ordinal - 1, observedAt);
292
+ if (!input.atomicIntent)
293
+ input.retainFailure?.(operationId, refusal);
294
+ refusals.push(refusal);
295
+ }
296
+ const burst = overloadBurst(refusals);
297
+ if (burst) {
298
+ if (input.atomicIntent)
299
+ input.retainFailure?.(operationId, refusal);
300
+ throw new AppError("provider_overload_burst", "Two consecutive continuation Turns were overloaded within two minutes", 1, { ...burst, root_operation_id: rootId, provider_session_id: input.sessionId, continuation_count: ordinal - 1, primary_error: failure });
301
+ }
302
+ const nextId = `${rootId}:capacity:${ordinal}:session.prompt`;
303
+ const existing = history.find(item => item.operation_id === nextId);
304
+ if (existing && (existing.predecessor !== operationId || existing.turn_id !== turnId || existing.prompt_sha256 !== promptSha || existing.ordinal !== ordinal))
305
+ throw error;
306
+ const wait = existing ? Math.max(0, Date.parse(existing.not_before) - Date.now()) : capacityDelay(failure, ordinal);
307
+ if (!Number.isFinite(wait) || Date.now() + wait > 8.64e15 || input.deadline !== undefined && Date.now() + wait >= input.deadline) {
308
+ if (input.atomicIntent)
309
+ input.retainFailure?.(operationId, refusal);
310
+ throw new AppError("capacity_owner_deadline_exceeded", "Continuation wait exceeds its original owner deadline", 1, { primary_error: failure, deadline: input.deadline ?? null });
311
+ }
312
+ if (!existing) {
313
+ const intent = { predecessor: operationId, operation_id: nextId, turn_id: turnId, ordinal, prompt_sha256: promptSha, not_before: new Date(Date.now() + wait).toISOString(), policy: CAPACITY_POLICY, root_operation_id: rootId, session_id: input.sessionId, deadline: input.deadline ?? null, refusal };
314
+ input.retainIntent(intent);
315
+ history.push(intent);
316
+ }
317
+ // Intent/refusal are durable before the first wait; no permit or writer is
318
+ // held here, and live siblings are never cancelled as a settlement proof.
319
+ const waitForOwnedChildren = async (initial) => {
320
+ let current = initial;
321
+ while (current === "owned_children_live") {
322
+ await delay(250, undefined, { signal: input.signal });
323
+ const boundary = await decide();
324
+ if (boundary)
325
+ return boundary;
326
+ current = await readiness();
327
+ }
328
+ if (current !== "ready")
329
+ throw blocked("Owned native child readiness became unproven");
330
+ return null;
331
+ };
332
+ const childBoundary = await waitForOwnedChildren(ready);
333
+ if (childBoundary)
334
+ return childBoundary;
335
+ // Node timers truncate large delays. Bounded slices preserve cancellation
336
+ // and owner checks; monotonic elapsed time does not renew a Retry-After.
337
+ const until = performance.now() + Math.max(0, Date.parse(history.find(item => item.operation_id === nextId).not_before) - Date.now());
338
+ while (performance.now() < until) {
339
+ await delay(Math.min(250, Math.max(0, until - performance.now())), undefined, { signal: input.signal });
340
+ const boundary = await decide();
341
+ if (boundary)
342
+ return boundary;
343
+ }
344
+ const afterWait = await decide();
345
+ if (afterWait)
346
+ return afterWait;
347
+ const refreshedBoundary = await waitForOwnedChildren(await readiness());
348
+ if (refreshedBoundary)
349
+ return refreshedBoundary;
350
+ const beforeDispatch = await decide();
351
+ if (beforeDispatch)
352
+ return beforeDispatch;
353
+ try {
354
+ return await observe(() => input.prompt(nextId, text, ordinal), nextId);
355
+ }
356
+ catch (nextError) {
357
+ // A task can complete/pause while the daemon queues for its permit.
358
+ // Admission then refuses the stale prompt; accept that owner decision
359
+ // before treating the transport's rejection as another provider failure.
360
+ const finished = await decide(nextId);
361
+ if (finished)
362
+ return finished;
363
+ error = nextError;
364
+ operationId = nextId;
365
+ }
366
+ }
367
+ }
@@ -0,0 +1,117 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { renderNativeChildFollowupInstruction, renderWaitInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
3
+ import { nativeItemsSettled } from "../harness-runtime/lib/provider-overload-policy.mjs";
4
+ import { parseLifecycleCommand } from "./lifecycle-command.js";
5
+ import { storageSessionId } from "./session-identity.js";
6
+ import { stageWorkIds } from "./stage-work-graph.js";
7
+ import { appendEvent, digest } from "./run-controller-state.js";
8
+ const blocked = (workId, reason, diagnostic = {}) => new AppError("child_work_repair_blocked", "Completed child has no safe automatic Work correction", 1, { work_id: workId, reason, reconciliation_required: true, effect: "no_effect", ...diagnostic });
9
+ function progressKey(error) {
10
+ const failures = error.details.failures;
11
+ if (Array.isArray(failures) && failures.length) {
12
+ const inputs = failures.map((receipt) => typeof receipt.declaration_id === "string" && typeof receipt.command === "string" && typeof receipt.input_hash === "string"
13
+ ? [receipt.declaration_id, receipt.command, receipt.input_hash] : null);
14
+ return inputs.every(Boolean) ? digest([error.code, inputs.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))]) : null;
15
+ }
16
+ return typeof error.details.input_key === "string" && typeof error.details.rejection_signature === "string"
17
+ ? digest([error.details.input_key, error.details.rejection_signature]) : null;
18
+ }
19
+ /** One durable correction per refusal/successor, not a provider retry or a new worker. */
20
+ export function completedChildRepair(context, row, input) {
21
+ const members = stageWorkIds(context, row.project_id, row.run_id, input.stage);
22
+ for (const child of input.children) {
23
+ if (child.status !== "completed")
24
+ continue;
25
+ const links = context.db.all(`SELECT w.work_id,w.status FROM work_sessions ws
26
+ JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.session_id=ws.session_id AND s.project_id=w.project_id
27
+ WHERE w.project_id=? AND w.run_id=? AND ws.session_id=? AND s.parent_session_id=?`, [row.project_id, row.run_id, storageSessionId({ harness_id: input.harness, session_id: child.session_id }), storageSessionId({ harness_id: input.harness, session_id: input.parentId })])
28
+ .filter(link => members.has(link.work_id));
29
+ if (!links.some(link => link.status === "running"))
30
+ continue;
31
+ if (links.length !== 1 || child.parent_session_id !== input.parentId || child.provenance === "parent_mismatch"
32
+ || !child.terminal_turn_id || !nativeItemsSettled(child.native_turn_items))
33
+ throw blocked(links[0].work_id, "native_identity_unconfirmed");
34
+ const workId = links[0].work_id;
35
+ if (context.db.get("SELECT 1 FROM check_receipts WHERE project_id=? AND run_id=? AND work_id=? AND status='running' LIMIT 1", [row.project_id, row.run_id, workId]))
36
+ continue;
37
+ if (context.db.get("SELECT 1 FROM works WHERE parent_work_id=? AND status IN ('created','running','paused') LIMIT 1", [workId]))
38
+ throw blocked(workId, "descendants_unsettled");
39
+ const reservations = context.db.all(`SELECT e.controller_id,e.data_json FROM run_controller_events e
40
+ JOIN run_controllers c ON c.controller_id=e.controller_id WHERE c.project_id=? AND c.run_id=?
41
+ AND e.event_type='work_repair_reserved' AND json_extract(e.data_json,'$.work_id')=? ORDER BY e.sequence DESC`, [row.project_id, row.run_id, workId]);
42
+ const refusals = context.db.all("SELECT id,command,scope_json,identity_json,outcome_json FROM lifecycle_invocations WHERE status='settled' AND json_extract(outcome_json,'$.error.details.work_id')=? ORDER BY rowid DESC", [workId]);
43
+ const refusal = refusals.find(attempt => {
44
+ try {
45
+ const scope = JSON.parse(attempt.scope_json), identity = JSON.parse(attempt.identity_json ?? "null");
46
+ const parsed = parseLifecycleCommand(attempt.command);
47
+ return attempt.scope_json === context.env.DD_FLOW_INVOCATION_SCOPE && parsed.kind === "standalone" && parsed.invocation.operation === "work_finish" && scope.runId === row.run_id
48
+ && scope.generation === row.generation && scope.rootSessionId === input.parentId
49
+ && Array.isArray(identity) && identity[2] === child.session_id && identity[3] === input.parentId;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ });
55
+ if (!refusal)
56
+ throw blocked(workId, "repair_refusal_missing");
57
+ const outcome = JSON.parse(refusal.outcome_json), error = outcome.error, details = error?.details;
58
+ const diagnostic = { refusal_invocation_id: refusal.id, successor_invocation_id: outcome.successor_invocation_id, primary_error: { code: error?.code, message: error?.message } };
59
+ // Reattach the exact durable episode before admitting a new one. A new
60
+ // terminal Turn/UUID alone never authorizes a second delivery.
61
+ for (const previous of reservations) {
62
+ const retained = JSON.parse(previous.data_json);
63
+ const operation = context.db.get("SELECT status FROM run_controller_operations WHERE operation_id=?", [retained.operation_id]);
64
+ const sameEpisode = retained.refusal_invocation_id === refusal.id && retained.successor_invocation_id === outcome.successor_invocation_id;
65
+ if (operation?.status !== "completed" && operation?.status !== "failed") {
66
+ if (previous.controller_id !== row.controller_id || retained.generation !== row.generation || retained.child_session_id !== child.session_id)
67
+ throw blocked(workId, "followup_owner_unconfirmed", { ...diagnostic, operation_id: retained.operation_id });
68
+ return retained;
69
+ }
70
+ if (sameEpisode)
71
+ throw blocked(workId, "followup_admission_unconfirmed", { ...diagnostic, operation_id: retained.operation_id });
72
+ }
73
+ if (details?.effect !== "no_effect" || details.recoverable !== true || typeof details.retry_command !== "string"
74
+ || typeof outcome.successor_invocation_id !== "string")
75
+ throw blocked(workId, "repair_successor_unproven", diagnostic);
76
+ const successor = context.db.get("SELECT scope_json,status FROM lifecycle_invocations WHERE id=?", [outcome.successor_invocation_id]);
77
+ if (successor?.status !== "issued" || successor.scope_json !== refusal.scope_json)
78
+ throw blocked(workId, "repair_successor_not_issued", diagnostic);
79
+ const progress = progressKey(error);
80
+ if (!progress)
81
+ throw blocked(workId, "repair_progress_unproven", diagnostic);
82
+ if (reservations.some(previous => {
83
+ const retained = JSON.parse(previous.data_json);
84
+ // Legacy episodes without a binding cannot prove a new correction.
85
+ return !retained.progress_key || retained.progress_key === progress;
86
+ }))
87
+ throw blocked(workId, "repeated_without_progress", diagnostic);
88
+ const instruction = renderNativeChildFollowupInstruction(input.harness, child, { purpose: "work_repair", context: JSON.stringify({ work_id: workId, code: error.code, message: error.message, retry_command: details.retry_command }) });
89
+ if (!instruction)
90
+ throw blocked(workId, "same_child_repair_unsupported", diagnostic);
91
+ return { work_id: workId, generation: row.generation, child_session_id: child.session_id, terminal_turn_id: child.terminal_turn_id, refusal_invocation_id: refusal.id,
92
+ successor_invocation_id: outcome.successor_invocation_id, progress_key: progress,
93
+ operation_id: `${row.controller_id}:work-repair:${digest([row.run_id, workId, row.generation, refusal.id, outcome.successor_invocation_id])}`,
94
+ prompt: `Continue only the existing child for this unfinished Work. This is one bound correction, not a new fanout wave.\n${instruction}\n\nA follow-up submission acknowledgement or the child's previous completed Turn is not completion of this correction. Wait for this same child's follow-up; never resend the message to poll it.\n${renderWaitInstructions({ harness: input.harness, stage: input.stage })}` };
95
+ }
96
+ return null;
97
+ }
98
+ export function reserveChildRepair(context, row, repair, assertOwner) {
99
+ context.db.writeTransaction(() => {
100
+ assertOwner();
101
+ if (repair.generation !== row.generation)
102
+ throw blocked(repair.work_id, "generation_changed_before_followup");
103
+ const work = context.db.get("SELECT status FROM works WHERE project_id=? AND run_id=? AND work_id=?", [row.project_id, row.run_id, repair.work_id]);
104
+ if (work?.status !== "running")
105
+ throw blocked(repair.work_id, "work_changed_before_followup");
106
+ const reserved = context.db.get(`SELECT e.controller_id,e.data_json FROM run_controller_events e JOIN run_controllers c ON c.controller_id=e.controller_id
107
+ WHERE c.project_id=? AND c.run_id=? AND e.event_type='work_repair_reserved' AND json_extract(e.data_json,'$.successor_invocation_id')=?`, [row.project_id, row.run_id, repair.successor_invocation_id]);
108
+ if (reserved && (reserved.controller_id !== row.controller_id || reserved.data_json !== JSON.stringify(repair)))
109
+ throw blocked(repair.work_id, "episode_reserved_by_another_dispatch");
110
+ if (!reserved) {
111
+ const successor = context.db.get("SELECT status FROM lifecycle_invocations WHERE id=?", [repair.successor_invocation_id]);
112
+ if (successor?.status !== "issued")
113
+ throw blocked(repair.work_id, "successor_changed_before_followup");
114
+ appendEvent(context, row.controller_id, "work_repair_reserved", repair);
115
+ }
116
+ });
117
+ }
@@ -12,8 +12,9 @@ import { requireProjectByRoot } from "./projects.js";
12
12
  import { stageAttempt, stageWorkIds } from "./stage-work-graph.js";
13
13
  import { runtimeCommand } from "./runtime-command.js";
14
14
  import { promptDeliveryInstruction } from "./prompt-delivery.js";
15
- import { currentStageFinishCommand } from "./lifecycle-invocations.js";
15
+ import { currentStageFinishAuthority } from "./lifecycle-invocations.js";
16
16
  import { acceptedChildWork } from "./controller-result-authority.js";
17
+ import { explicitWorkFailure } from "./controller-work-failure.js";
17
18
  function semanticCompletionPrompt(stage, completed, command) {
18
19
  const task = stage === "plan-review"
19
20
  ? "Classify the reviewer findings, apply every accepted correction in the current PLAN-REVIEW Work, and write the authoritative review decision."
@@ -72,7 +73,10 @@ export async function nextControllerFanout(context, input) {
72
73
  throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
73
74
  if (children.length) {
74
75
  const reconciled = await reconcileVnextFanout(context, { ...input, observations });
75
- const issues = reconciled.issues.filter(issue => !["child_check_in_progress", "child_capacity_pending"].includes(issue.code));
76
+ // A known settled child's Work blocker does not consume an independent
77
+ // ready branch. It remains running, so this cannot manufacture Stage success.
78
+ const issues = reconciled.issues.filter(issue => !["child_check_in_progress", "child_capacity_pending"].includes(issue.code)
79
+ && !(issue.code === "execution_ended_without_work_result" && issue.work_id && input.repairBlockedWorks?.includes(issue.work_id)));
76
80
  if (issues.length)
77
81
  throw new AppError("fanout_reconciliation_required", "Native child outcome requires reconciliation, not replay", 1, { issues });
78
82
  const unsettled = children.filter(child => child.status === "running" || child.status === "unknown");
@@ -96,21 +100,28 @@ export async function nextControllerFanout(context, input) {
96
100
  if (fanout.works.failed || fanout.works.cancelled) {
97
101
  const projectId = requireProjectByRoot(context, input.projectRoot).id;
98
102
  const members = stageWorkIds(context, projectId, input.runId, input.stage);
99
- const failures = context.db.all("SELECT work_id,status,result FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND status IN ('failed','cancelled')", [projectId, input.runId, fanout.parent_work_id]).filter(work => members.has(work.work_id)).map(work => {
100
- let receipt = {};
101
- try {
102
- const value = work.result ? JSON.parse(work.result) : null;
103
- if (value && typeof value === "object" && !Array.isArray(value))
104
- receipt = value;
105
- }
106
- catch { /* Missing legacy metadata is unknown. */ }
107
- const providerError = safeNativeFailure(receipt.provider_error);
108
- const nativeBinding = verifiedNativeWorkFailure(context, { projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, parentSessionId: input.sessionId, parentWorkId: fanout.parent_work_id, workId: work.work_id, receipt });
109
- return { work_id: work.work_id, status: work.status, ...(providerError ? { provider_error: providerError } : {}),
110
- ...(nativeBinding ? { native_binding: nativeBinding } : {}),
111
- ...Object.fromEntries(["terminal_turn_id", "terminal_observed_at"].filter(key => typeof receipt[key] === "string" && receipt[key].length <= 256).map(key => [key, receipt[key]])) };
112
- });
113
- throw new AppError("fanout_child_failed", "A child Work failed or was cancelled; no retry authority has been issued", 1, { stage: input.stage, run_id: input.runId, parent_work_id: fanout.parent_work_id, failed: fanout.works.failed, cancelled: fanout.works.cancelled, failures, effect: "unknown" });
103
+ const failedIds = context.db.all("SELECT work_id FROM works WHERE project_id=? AND run_id=? AND parent_work_id=? AND status='failed' ORDER BY work_id", [projectId, input.runId, fanout.parent_work_id]).filter(work => members.has(work.work_id));
104
+ const explicit = !input.dispatchFence && !fanout.works.cancelled ? failedIds.map(work => explicitWorkFailure(context, projectId, input.runId, work.work_id)) : [];
105
+ const failure = explicit.length === failedIds.length && explicit.length ? explicit[0] : null;
106
+ if (failure && !(input.deferredFailure === failure.work_id && fanout.works.ready.length))
107
+ return { kind: "failure", failure, ready: fanout.works.ready.length > 0 };
108
+ if (!failure) {
109
+ const failures = context.db.all("SELECT work_id,status,result FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND status IN ('failed','cancelled')", [projectId, input.runId, fanout.parent_work_id]).filter(work => members.has(work.work_id)).map(work => {
110
+ let receipt = {};
111
+ try {
112
+ const value = work.result ? JSON.parse(work.result) : null;
113
+ if (value && typeof value === "object" && !Array.isArray(value))
114
+ receipt = value;
115
+ }
116
+ catch { /* Missing legacy metadata is unknown. */ }
117
+ const providerError = safeNativeFailure(receipt.provider_error);
118
+ const nativeBinding = verifiedNativeWorkFailure(context, { projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, parentSessionId: input.sessionId, parentWorkId: fanout.parent_work_id, workId: work.work_id, receipt });
119
+ return { work_id: work.work_id, status: work.status, ...(providerError ? { provider_error: providerError } : {}),
120
+ ...(nativeBinding ? { native_binding: nativeBinding } : {}),
121
+ ...Object.fromEntries(["terminal_turn_id", "terminal_observed_at"].filter(key => typeof receipt[key] === "string" && receipt[key].length <= 256).map(key => [key, receipt[key]])) };
122
+ });
123
+ throw new AppError("fanout_child_failed", "A child Work failed or was cancelled; no retry authority has been issued", 1, { stage: input.stage, run_id: input.runId, parent_work_id: fanout.parent_work_id, failed: fanout.works.failed, cancelled: fanout.works.cancelled, failures, effect: "unknown" });
124
+ }
114
125
  }
115
126
  if (fanout.works.ready.length) {
116
127
  const choices = fanout.works.ready.map(work => {
@@ -214,10 +225,12 @@ export async function nextControllerFanout(context, input) {
214
225
  ].join("\n\n"));
215
226
  }
216
227
  }
217
- const finish = currentStageFinishCommand(context, input.stage);
228
+ const finish = currentStageFinishAuthority(context, input.stage);
218
229
  if (!finish)
219
- throw new AppError("controller_finish_authority_missing", "Settled child Work has no current issued Stage finish; reconcile lifecycle authority", 1, { stage: input.stage });
220
- return prompt("semantic_completion", semanticCompletionPrompt(input.stage, fanout.works.completed, finish));
230
+ throw new AppError("controller_finish_authority_missing", "Settled child Work has no current Stage finish authority", 1, { stage: input.stage, reconciliation_required: true });
231
+ if (finish.status !== "issued" || !finish.command)
232
+ throw new AppError("controller_finish_authority_pending", "Observe the original Stage finish before another productive dispatch", 1, { stage: input.stage, invocation_id: finish.id, status: finish.status, reconciliation_required: true });
233
+ return prompt("semantic_completion", semanticCompletionPrompt(input.stage, fanout.works.completed, finish.command));
221
234
  }
222
235
  /** Shared pinned runtime normalization; preserve structured errors at the TS boundary. */
223
236
  export function controllerNativeChildren(receipt, rootSessionId) {