pi-daddy 0.17.0 → 0.18.0

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 (113) hide show
  1. package/CHANGELOG.md +154 -80
  2. package/README.md +54 -25
  3. package/dist/approval-prompt.d.ts +3 -1
  4. package/dist/approval-prompt.d.ts.map +1 -1
  5. package/dist/approval-prompt.js +1 -1
  6. package/dist/approval-prompt.js.map +1 -1
  7. package/dist/approval-store.d.ts.map +1 -1
  8. package/dist/approval-store.js +4 -1
  9. package/dist/approval-store.js.map +1 -1
  10. package/dist/approval.d.ts +20 -2
  11. package/dist/approval.d.ts.map +1 -1
  12. package/dist/approval.js +26 -6
  13. package/dist/approval.js.map +1 -1
  14. package/dist/chain.d.ts +6 -1
  15. package/dist/chain.d.ts.map +1 -1
  16. package/dist/chain.js +1 -1
  17. package/dist/chain.js.map +1 -1
  18. package/dist/check-runner.d.ts +61 -0
  19. package/dist/check-runner.d.ts.map +1 -0
  20. package/dist/check-runner.js +237 -0
  21. package/dist/check-runner.js.map +1 -0
  22. package/dist/correlation.d.ts +90 -0
  23. package/dist/correlation.d.ts.map +1 -0
  24. package/dist/correlation.js +183 -0
  25. package/dist/correlation.js.map +1 -0
  26. package/dist/delegate-types.d.ts +140 -0
  27. package/dist/delegate-types.d.ts.map +1 -0
  28. package/dist/delegate-types.js +8 -0
  29. package/dist/delegate-types.js.map +1 -0
  30. package/dist/delegate.d.ts +4 -128
  31. package/dist/delegate.d.ts.map +1 -1
  32. package/dist/delegate.js +68 -72
  33. package/dist/delegate.js.map +1 -1
  34. package/dist/delegation-approval.d.ts +36 -0
  35. package/dist/delegation-approval.d.ts.map +1 -0
  36. package/dist/delegation-approval.js +67 -0
  37. package/dist/delegation-approval.js.map +1 -0
  38. package/dist/git-identity.d.ts +13 -0
  39. package/dist/git-identity.d.ts.map +1 -0
  40. package/dist/git-identity.js +44 -0
  41. package/dist/git-identity.js.map +1 -0
  42. package/dist/index.d.ts +5 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/lease-record.d.ts +78 -0
  47. package/dist/lease-record.d.ts.map +1 -0
  48. package/dist/lease-record.js +52 -0
  49. package/dist/lease-record.js.map +1 -0
  50. package/dist/ledger-events.d.ts +87 -0
  51. package/dist/ledger-events.d.ts.map +1 -0
  52. package/dist/ledger-events.js +45 -0
  53. package/dist/ledger-events.js.map +1 -0
  54. package/dist/ledger-report.d.ts +19 -12
  55. package/dist/ledger-report.d.ts.map +1 -1
  56. package/dist/ledger-report.js +81 -1
  57. package/dist/ledger-report.js.map +1 -1
  58. package/dist/ledger.d.ts +44 -1
  59. package/dist/ledger.d.ts.map +1 -1
  60. package/dist/ledger.js +20 -2
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/refusals.d.ts +16 -0
  63. package/dist/refusals.d.ts.map +1 -0
  64. package/dist/refusals.js +51 -0
  65. package/dist/refusals.js.map +1 -0
  66. package/dist/run-child.d.ts +4 -0
  67. package/dist/run-child.d.ts.map +1 -1
  68. package/dist/run-child.js +21 -1
  69. package/dist/run-child.js.map +1 -1
  70. package/dist/run-herdr.d.ts +7 -0
  71. package/dist/run-herdr.d.ts.map +1 -1
  72. package/dist/run-herdr.js +41 -12
  73. package/dist/run-herdr.js.map +1 -1
  74. package/dist/workspace-lease.d.ts +28 -0
  75. package/dist/workspace-lease.d.ts.map +1 -0
  76. package/dist/workspace-lease.js +276 -0
  77. package/dist/workspace-lease.js.map +1 -0
  78. package/dist/workspace.d.ts +32 -0
  79. package/dist/workspace.d.ts.map +1 -0
  80. package/dist/workspace.js +79 -0
  81. package/dist/workspace.js.map +1 -0
  82. package/extensions/approval-banking.ts +66 -0
  83. package/extensions/approvals.ts +95 -7
  84. package/extensions/chain-approval-facts.ts +51 -0
  85. package/extensions/chain-ledger.ts +48 -0
  86. package/extensions/delegate-chain.ts +115 -81
  87. package/extensions/delegation.ts +81 -23
  88. package/extensions/execute-child.ts +289 -0
  89. package/extensions/fanout-outcome.ts +97 -0
  90. package/extensions/run-delegation.ts +130 -120
  91. package/extensions/session.ts +4 -0
  92. package/extensions/workspace-runtime.ts +165 -0
  93. package/package.json +17 -1
  94. package/src/approval-prompt.ts +4 -2
  95. package/src/approval-store.ts +4 -1
  96. package/src/approval.ts +47 -14
  97. package/src/chain.ts +3 -1
  98. package/src/check-runner.ts +341 -0
  99. package/src/correlation.ts +260 -0
  100. package/src/delegate-types.ts +144 -0
  101. package/src/delegate.ts +80 -179
  102. package/src/delegation-approval.ts +99 -0
  103. package/src/git-identity.ts +52 -0
  104. package/src/index.ts +50 -0
  105. package/src/lease-record.ts +119 -0
  106. package/src/ledger-events.ts +138 -0
  107. package/src/ledger-report.ts +90 -2
  108. package/src/ledger.ts +68 -3
  109. package/src/refusals.ts +66 -0
  110. package/src/run-child.ts +23 -1
  111. package/src/run-herdr.ts +41 -11
  112. package/src/workspace-lease.ts +303 -0
  113. package/src/workspace.ts +135 -0
@@ -0,0 +1,289 @@
1
+ import type { Delegation } from "../src/delegate.ts";
2
+ import { appendLedgerEvent, buildChildLifecycleEvent } from "../src/ledger.ts";
3
+ import { mergeChildEnv } from "../src/propagation.ts";
4
+ import type { Capability } from "../src/resolve.ts";
5
+ import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
6
+ import { resolveWorkspace } from "../src/herdr-cli.ts";
7
+ import { HerdrWriterCloseError, runHerdrPane } from "../src/run-herdr.ts";
8
+ import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
9
+ import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
10
+ import { releaseDelegationWorkspace, type PreparedWorkspace } from "./workspace-runtime.ts";
11
+
12
+ export interface DelegationOutcome {
13
+ ok: boolean;
14
+ text: string;
15
+ reason?: string;
16
+ granted: Capability[];
17
+ depth: number;
18
+ exitCode: number | null;
19
+ refusal?: StructuredRefusal;
20
+ /** Why the child stopped. Load-bearing for `isCriticalAssuranceBlock`, which must not trust text alone. */
21
+ timedOut?: boolean;
22
+ aborted?: boolean;
23
+ truncated?: boolean;
24
+ spawnFailed?: boolean;
25
+ }
26
+
27
+ /**
28
+ * The upstream controller's token, honoured ONLY when the child otherwise exited cleanly non-zero.
29
+ *
30
+ * `text` is the child's own captured output, and under ADR-0012 it can carry content the child merely
31
+ * *read* from a repository. Matching on text alone let a timeout, a cancellation, a lost writer lease or
32
+ * a truncated answer all be reported as a clean upstream veto — with the governance-authored reason and
33
+ * refusal code discarded on the way (R-106). A child that is killed mid-sentence has not been assessed
34
+ * by anybody's gate, so the token cannot be taken at its word there.
35
+ */
36
+ export function isCriticalAssuranceBlock(
37
+ outcome: Pick<DelegationOutcome, "ok" | "text" | "timedOut" | "aborted" | "truncated" | "spawnFailed">,
38
+ ): boolean {
39
+ // `truncated` is deliberately NOT here. The process executor keeps the HEAD of the output
40
+ // (`run-child.ts` slices to the cap and stops appending) and the token is matched at byte 0, so a
41
+ // genuine veto with a rationale over the output cap is still a genuine veto — rejecting it broke the
42
+ // pass-through ADR-0034 pins, in the fix that was supposed to protect it. What must be rejected is a
43
+ // child that never finished speaking: killed, cancelled, or never started.
44
+ if (outcome.ok || outcome.timedOut || outcome.aborted || outcome.spawnFailed) return false;
45
+ return outcome.text.trimStart().startsWith("BLOCKED_CRITICAL_ASSURANCE");
46
+ }
47
+
48
+ export interface ChildProgressUpdate {
49
+ chunk?: string;
50
+ snapshot?: string[];
51
+ paneId?: string;
52
+ agentName?: string;
53
+ state?: "running" | "completed" | "failed";
54
+ }
55
+
56
+ /**
57
+ * Execute one already-approved plan, record lifecycle, and release any workspace lease on every path
58
+ * EXCEPT a failed herdr writer-tab close, which deliberately retains the lease because the pane may
59
+ * still be live and promptable — that retention is recorded as `retained` rather than left as a silent
60
+ * gap in the trail (R-104).
61
+ *
62
+ * Teardown never destroys the result. A terminal ledger append is an OBSERVATION written after the child
63
+ * has already run, so failing closed there prevents nothing and used to discard completed work while
64
+ * blaming "ledger" (R-99). The failure is reported alongside the outcome instead of replacing it.
65
+ */
66
+ export async function executePlannedChild(input: {
67
+ session: GrantsSession;
68
+ plan: Delegation;
69
+ agent?: string;
70
+ childId: string;
71
+ cwd: string;
72
+ preparedWorkspace?: PreparedWorkspace;
73
+ signal?: AbortSignal;
74
+ onProgress?: (update: ChildProgressUpdate) => void;
75
+ }): Promise<DelegationOutcome> {
76
+ const { session, plan, childId, preparedWorkspace, signal, onProgress } = input;
77
+ if (session.ledgerPath) {
78
+ try {
79
+ await appendLedgerEvent(
80
+ { path: session.ledgerPath, strict: true },
81
+ buildChildLifecycleEvent({
82
+ childId,
83
+ state: "starting",
84
+ executor: session.executor.kind,
85
+ correlation: plan.correlation,
86
+ now: new Date(),
87
+ }),
88
+ );
89
+ } catch (error) {
90
+ await releaseDelegationWorkspace({ prepared: preparedWorkspace, childId, reason: "ledger-failed" });
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ const cwd = preparedWorkspace?.workspace.root ?? input.cwd;
96
+ const leaseAbort = new AbortController();
97
+ const writerLease = preparedWorkspace?.lease.access === "write" ? preparedWorkspace.lease : undefined;
98
+ // Tracked as a FACT, not only as an abort: "the kernel lock protecting this workspace evaporated under
99
+ // a live governed writer" and "the operator pressed stop" produced byte-identical reasons before, and a
100
+ // count of lost leases is exactly the number an operator auditing this feature needs (R-103).
101
+ let leaseLost = false;
102
+ writerLease?.lost.then(() => { leaseLost = true; leaseAbort.abort(); });
103
+ const executionSignal = signal
104
+ ? AbortSignal.any([signal, leaseAbort.signal])
105
+ : writerLease ? leaseAbort.signal : undefined;
106
+ let releaseReason = "failed";
107
+ let retainWriterLease = false;
108
+ let terminalAttempted = false;
109
+ const teardownFailures: string[] = [];
110
+ try {
111
+ const output = session.executor.kind === "herdr"
112
+ ? await runHerdrPane({
113
+ args: plan.args.slice(0, -1),
114
+ prompt: plan.args[plan.args.length - 1].trimStart(),
115
+ env: plan.env,
116
+ cwd,
117
+ name: `${input.agent ?? "delegate"}-${childId}`,
118
+ workspace: resolveWorkspace(process.env),
119
+ signal: executionSignal,
120
+ timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
121
+ keepPane: writerLease ? false : process.env[ENV_HERDR_KEEP_PANE] === "1",
122
+ closeOnSettle: Boolean(writerLease),
123
+ onPane: onProgress ? (paneId, agentName) => onProgress({ paneId, agentName, state: "running" }) : undefined,
124
+ onTab: preparedWorkspace ? (tabId) => preparedWorkspace.lease.attachHerdrTab(tabId) : undefined,
125
+ onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
126
+ })
127
+ : await runChild({
128
+ command: writerLease ? "setpriv" : "pi",
129
+ args: writerLease ? ["--pdeathsig", "KILL", "--", "pi", ...plan.args] : plan.args,
130
+ env: mergeChildEnv(process.env, plan.env),
131
+ cwd,
132
+ signal: executionSignal,
133
+ timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
134
+ onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
135
+ onSpawn: preparedWorkspace ? (pid) => preparedWorkspace.lease.attachProcess(pid) : undefined,
136
+ });
137
+
138
+ const childFailed = Boolean(output.spawnError || output.aborted || output.timedOut || output.code !== 0);
139
+ releaseReason = output.timedOut ? "timeout" : output.aborted ? "cancelled" : childFailed ? "failed" : "completed";
140
+ if (session.ledgerPath) {
141
+ terminalAttempted = true;
142
+ await appendLedgerEvent(
143
+ {
144
+ path: session.ledgerPath,
145
+ // NOT strict, and this line is the whole point of R-99. The child has already run: failing
146
+ // closed here prevents nothing and used to discard a completed child's entire output while
147
+ // blaming "ledger" — under `delegate_all` it discarded every sibling's work too. The docstring
148
+ // above, `docs/SPEC.md` and the ADR-0034 amendment all promised this; only the comment changed.
149
+ // `capability_decision`, which PROVISIONS, still fails closed.
150
+ strict: false,
151
+ onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
152
+ },
153
+ buildChildLifecycleEvent({
154
+ childId,
155
+ state: childFailed ? "failed" : "completed",
156
+ executor: session.executor.kind,
157
+ exitCode: output.code,
158
+ signal: output.signal ?? null,
159
+ timedOut: output.timedOut,
160
+ aborted: output.aborted,
161
+ truncated: output.truncated,
162
+ reason: output.spawnError,
163
+ correlation: plan.correlation,
164
+ now: new Date(),
165
+ }),
166
+ );
167
+ }
168
+
169
+ const leaseWasLost = writerLease ? leaseLost : false;
170
+ if (childFailed) {
171
+ const why = output.spawnError
172
+ ? `could not be started: ${output.spawnError}`
173
+ : output.aborted
174
+ ? leaseWasLost
175
+ ? "lost the exclusive writer lease protecting its workspace and was stopped"
176
+ : "was cancelled"
177
+ : output.timedOut
178
+ ? "exceeded its time limit and was killed"
179
+ : `exited with code ${output.code}`;
180
+ // A stable code for every execution failure. Without these an external controller could tell a
181
+ // policy refusal from an internal error, but not a lost writer lease from a user pressing stop
182
+ // (R-103), and not a missing `setpriv` from an ordinary crash (R-107).
183
+ const code = output.spawnError
184
+ ? "EXECUTOR_UNAVAILABLE"
185
+ : leaseWasLost
186
+ ? "WORKSPACE_LEASE_STALE"
187
+ : output.timedOut
188
+ ? "CHILD_TIMED_OUT"
189
+ : output.aborted
190
+ ? "CHILD_CANCELLED"
191
+ : "CHILD_EXIT_NONZERO";
192
+ const failed: DelegationOutcome = {
193
+ ok: false,
194
+ text: output.text.trim(),
195
+ reason: `the sub-agent ${why}`,
196
+ granted: plan.effective,
197
+ depth: plan.childDepth,
198
+ exitCode: output.code,
199
+ refusal: refusal(code, `the sub-agent ${why}`, { child_id: childId }),
200
+ timedOut: output.timedOut,
201
+ aborted: output.aborted,
202
+ truncated: output.truncated,
203
+ spawnFailed: Boolean(output.spawnError),
204
+ };
205
+ await teardown();
206
+ return withTeardownNotes(failed);
207
+ }
208
+
209
+ const succeeded: DelegationOutcome = {
210
+ ok: true,
211
+ text: output.text.trim(),
212
+ granted: plan.effective,
213
+ depth: plan.childDepth,
214
+ exitCode: output.code,
215
+ truncated: output.truncated,
216
+ };
217
+ await teardown();
218
+ return withTeardownNotes(succeeded);
219
+ } catch (error) {
220
+ retainWriterLease = Boolean(writerLease && error instanceof HerdrWriterCloseError);
221
+ if (session.ledgerPath && !terminalAttempted) {
222
+ // Best-effort: this records the failure, so it must not REPLACE the failure. A strict append that
223
+ // throws here would discard the original error — including HerdrWriterCloseError, whose whole
224
+ // meaning is "a lease is deliberately retained" (R-108).
225
+ await appendLedgerEvent(
226
+ {
227
+ path: session.ledgerPath,
228
+ strict: false,
229
+ onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
230
+ },
231
+ buildChildLifecycleEvent({
232
+ childId, state: "failed", executor: session.executor.kind,
233
+ reason: error instanceof GovernanceRefusal
234
+ ? error.code
235
+ : error instanceof Error ? error.name : "unknown executor error",
236
+ correlation: plan.correlation, now: new Date(),
237
+ }),
238
+ );
239
+ }
240
+ await teardown();
241
+ // Attached, not dropped. `withTeardownNotes` was applied on both return paths and neither throw path,
242
+ // so a failed lease-release record — the thing that makes the NEXT owner report a phantom crash — was
243
+ // collected into an array nothing read.
244
+ throw errorWithTeardownNotes(error, teardownFailures);
245
+ }
246
+
247
+ /**
248
+ * Surfaces a teardown failure WITHOUT discarding the result. The child already ran; telling the
249
+ * orchestrator "ledger write failed" and nothing else made a completed delegation indistinguishable
250
+ * from one that never happened, which is the one confusion this package must never create (R-99).
251
+ */
252
+ /**
253
+ * Surfaces a failed best-effort RECORD without displacing the failure it was recording. A
254
+ * `GovernanceRefusal` keeps its `code`, so a controller switching on it still sees the real refusal
255
+ * rather than a ledger complaint. pi renders only `error.message` (its `createErrorToolResult` drops
256
+ * everything else), so the notes go INTO the message — an `AggregateError.errors` array would be invisible.
257
+ */
258
+ function errorWithTeardownNotes(error: unknown, notes: readonly string[]): unknown {
259
+ if (notes.length === 0) return error;
260
+ if (error instanceof GovernanceRefusal) {
261
+ return new GovernanceRefusal({
262
+ code: error.code,
263
+ message: [error.message, ...notes].join("; "),
264
+ ...(error.details ? { details: error.details } : {}),
265
+ });
266
+ }
267
+ if (error instanceof Error) return new Error([error.message, ...notes].join("; "), { cause: error });
268
+ return error;
269
+ }
270
+
271
+ function withTeardownNotes(outcome: DelegationOutcome): DelegationOutcome {
272
+ if (teardownFailures.length === 0) return outcome;
273
+ return { ...outcome, reason: [outcome.reason, ...teardownFailures].filter(Boolean).join("; ") };
274
+ }
275
+
276
+ async function teardown(): Promise<void> {
277
+ try {
278
+ await releaseDelegationWorkspace({
279
+ prepared: preparedWorkspace,
280
+ childId,
281
+ ledgerPath: session.ledgerPath,
282
+ reason: releaseReason,
283
+ retain: retainWriterLease,
284
+ });
285
+ } catch (error) {
286
+ teardownFailures.push(`workspace lease record failed: ${String(error)}`);
287
+ }
288
+ }
289
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * How `delegate_all` aggregates its children into one answer.
3
+ *
4
+ * Pure, and split out of `./delegation.ts` for two reasons: the 400-line module ceiling this project
5
+ * enforces mechanically, and the fact that three of the four functions here exist because the aggregate
6
+ * path had its own copies of defects already fixed on the single-delegation path. That is the R-96/R-97
7
+ * shape — a fix applied where it was found and not where it was duplicated — so the aggregation is now one
8
+ * testable place rather than four inline blocks.
9
+ */
10
+ import { GovernanceRefusal, refusal } from "../src/refusals.ts";
11
+ import { isCriticalAssuranceBlock, type DelegationOutcome } from "./execute-child.ts";
12
+
13
+ /**
14
+ * The outcome recorded for a child whose delegation threw rather than returning.
15
+ *
16
+ * Every swallowed child used to report the same contentless `"delegation infrastructure failed"` with no
17
+ * code and no error identity, so a fan-out could report four indistinguishable failures with four different
18
+ * causes (R-116).
19
+ */
20
+ export function childFailureOutcome(error: unknown, depth: number): DelegationOutcome {
21
+ return {
22
+ ok: false,
23
+ text: "",
24
+ reason: `delegation infrastructure failed: ${String(error instanceof Error ? error.message : error)}`,
25
+ ...(error instanceof GovernanceRefusal
26
+ ? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
27
+ : {}),
28
+ granted: [],
29
+ depth,
30
+ exitCode: null,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Every child is reported, including the ones that failed. R-03's rule: a missing result must never be
36
+ * indistinguishable from an empty one, and a fan-out that hid its refusals would let an orchestrator
37
+ * summarise four reviews when only three happened.
38
+ */
39
+ export function buildFanoutReport(
40
+ outcomes: readonly DelegationOutcome[],
41
+ children: readonly { agent?: string }[],
42
+ ): string {
43
+ return outcomes
44
+ .map((outcome, index) => {
45
+ const label = `### child ${index + 1}${children[index]?.agent ? ` (${children[index].agent})` : ""}`;
46
+ return outcome.ok
47
+ ? `${label} — completed\n\n${outcome.text || "(no output)"}`
48
+ : `${label} — FAILED: ${outcome.reason}${outcome.text ? `\n\n${outcome.text}` : ""}`;
49
+ })
50
+ .join("\n\n---\n\n");
51
+ }
52
+
53
+ /**
54
+ * Raises whatever the fan-out cannot report as a result, losing nothing on the way.
55
+ *
56
+ * The upstream controller's verdict outranks our own infrastructure noise — it is the answer the caller is
57
+ * waiting for, and ADR-0034 requires the token to pass through unchanged. But an infrastructure failure
58
+ * must not VANISH behind it, which is what happened when a retained-lease error and a critical block landed
59
+ * in the same fan-out: `??=` kept the first error, the critical token was checked first, and the retained
60
+ * lease was never mentioned to anyone (R-117).
61
+ */
62
+ export function throwFanoutInfrastructure(
63
+ outcomes: readonly DelegationOutcome[],
64
+ infrastructureErrors: readonly unknown[],
65
+ ): void {
66
+ const criticalBlock = outcomes.find(isCriticalAssuranceBlock);
67
+ if (criticalBlock) {
68
+ if (infrastructureErrors.length > 0) throw new AggregateError(infrastructureErrors, criticalBlock.text);
69
+ throw new Error(criticalBlock.text);
70
+ }
71
+ if (infrastructureErrors.length === 1) throw infrastructureErrors[0];
72
+ if (infrastructureErrors.length > 1) {
73
+ throw new AggregateError(
74
+ infrastructureErrors,
75
+ `fan-out hit ${infrastructureErrors.length} infrastructure failures: ` +
76
+ infrastructureErrors.map((error) => String(error instanceof Error ? error.message : error)).join("; "),
77
+ );
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The refusal for a fan-out where every child failed.
83
+ *
84
+ * Mixed codes used to drop EVERY code and throw a bare `Error`, so on total failure the machine-readable
85
+ * half existed nowhere — `details.refusals` is only returned on the partial-success path. Naming them in
86
+ * `details` keeps the codes reachable without inventing one false aggregate code.
87
+ */
88
+ export function totalFanoutFailure(failed: readonly DelegationOutcome[], message: string): GovernanceRefusal {
89
+ const codes = [...new Set(failed.flatMap((outcome) => outcome.refusal?.code ?? []))];
90
+ if (codes.length === 1 && failed.every((outcome) => outcome.refusal)) {
91
+ return new GovernanceRefusal(refusal(codes[0], message, { failed: failed.length }));
92
+ }
93
+ return new GovernanceRefusal(refusal(
94
+ "FANOUT_FAILED", message,
95
+ { failed: failed.length, codes: codes.length > 0 ? codes.sort().join(",") : "none" },
96
+ ));
97
+ }