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.
- package/CHANGELOG.md +154 -80
- package/README.md +54 -25
- package/dist/approval-prompt.d.ts +3 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +1 -1
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval-store.d.ts.map +1 -1
- package/dist/approval-store.js +4 -1
- package/dist/approval-store.js.map +1 -1
- package/dist/approval.d.ts +20 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +26 -6
- package/dist/approval.js.map +1 -1
- package/dist/chain.d.ts +6 -1
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/check-runner.d.ts +61 -0
- package/dist/check-runner.d.ts.map +1 -0
- package/dist/check-runner.js +237 -0
- package/dist/check-runner.js.map +1 -0
- package/dist/correlation.d.ts +90 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +183 -0
- package/dist/correlation.js.map +1 -0
- package/dist/delegate-types.d.ts +140 -0
- package/dist/delegate-types.d.ts.map +1 -0
- package/dist/delegate-types.js +8 -0
- package/dist/delegate-types.js.map +1 -0
- package/dist/delegate.d.ts +4 -128
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +68 -72
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts +36 -0
- package/dist/delegation-approval.d.ts.map +1 -0
- package/dist/delegation-approval.js +67 -0
- package/dist/delegation-approval.js.map +1 -0
- package/dist/git-identity.d.ts +13 -0
- package/dist/git-identity.d.ts.map +1 -0
- package/dist/git-identity.js +44 -0
- package/dist/git-identity.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lease-record.d.ts +78 -0
- package/dist/lease-record.d.ts.map +1 -0
- package/dist/lease-record.js +52 -0
- package/dist/lease-record.js.map +1 -0
- package/dist/ledger-events.d.ts +87 -0
- package/dist/ledger-events.d.ts.map +1 -0
- package/dist/ledger-events.js +45 -0
- package/dist/ledger-events.js.map +1 -0
- package/dist/ledger-report.d.ts +19 -12
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +81 -1
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +44 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +20 -2
- package/dist/ledger.js.map +1 -1
- package/dist/refusals.d.ts +16 -0
- package/dist/refusals.d.ts.map +1 -0
- package/dist/refusals.js +51 -0
- package/dist/refusals.js.map +1 -0
- package/dist/run-child.d.ts +4 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +21 -1
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +7 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +41 -12
- package/dist/run-herdr.js.map +1 -1
- package/dist/workspace-lease.d.ts +28 -0
- package/dist/workspace-lease.d.ts.map +1 -0
- package/dist/workspace-lease.js +276 -0
- package/dist/workspace-lease.js.map +1 -0
- package/dist/workspace.d.ts +32 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +79 -0
- package/dist/workspace.js.map +1 -0
- package/extensions/approval-banking.ts +66 -0
- package/extensions/approvals.ts +95 -7
- package/extensions/chain-approval-facts.ts +51 -0
- package/extensions/chain-ledger.ts +48 -0
- package/extensions/delegate-chain.ts +115 -81
- package/extensions/delegation.ts +81 -23
- package/extensions/execute-child.ts +289 -0
- package/extensions/fanout-outcome.ts +97 -0
- package/extensions/run-delegation.ts +130 -120
- package/extensions/session.ts +4 -0
- package/extensions/workspace-runtime.ts +165 -0
- package/package.json +17 -1
- package/src/approval-prompt.ts +4 -2
- package/src/approval-store.ts +4 -1
- package/src/approval.ts +47 -14
- package/src/chain.ts +3 -1
- package/src/check-runner.ts +341 -0
- package/src/correlation.ts +260 -0
- package/src/delegate-types.ts +144 -0
- package/src/delegate.ts +80 -179
- package/src/delegation-approval.ts +99 -0
- package/src/git-identity.ts +52 -0
- package/src/index.ts +50 -0
- package/src/lease-record.ts +119 -0
- package/src/ledger-events.ts +138 -0
- package/src/ledger-report.ts +90 -2
- package/src/ledger.ts +68 -3
- package/src/refusals.ts +66 -0
- package/src/run-child.ts +23 -1
- package/src/run-herdr.ts +41 -11
- package/src/workspace-lease.ts +303 -0
- 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
|
+
}
|