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
|
@@ -20,23 +20,21 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import type { InheritableApproval } from "../src/approval.ts";
|
|
22
22
|
import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
|
|
23
|
-
import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
|
|
23
|
+
import { chainStepSpec, PLACEHOLDER, type ChainStep } from "../src/chain.ts";
|
|
24
24
|
import { planDelegation } from "../src/delegate.ts";
|
|
25
25
|
import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
|
|
26
26
|
import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
|
|
27
27
|
import type { Capability } from "../src/resolve.ts";
|
|
28
|
-
import {
|
|
28
|
+
import { recordChainRefusal } from "./chain-ledger.ts";
|
|
29
29
|
import { obtainApprovals, snapshotOf } from "./approvals.ts";
|
|
30
30
|
import { runOneDelegation } from "./run-delegation.ts";
|
|
31
|
+
import { isCriticalAssuranceBlock } from "./execute-child.ts";
|
|
31
32
|
import type { GrantsSession } from "./session.ts";
|
|
33
|
+
import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
|
|
34
|
+
import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from "./chain-approval-facts.ts";
|
|
32
35
|
|
|
33
36
|
/** One step of a chain, as the model describes it. */
|
|
34
|
-
|
|
35
|
-
task: string;
|
|
36
|
-
agent?: string;
|
|
37
|
-
tools?: string[];
|
|
38
|
-
model?: string;
|
|
39
|
-
}
|
|
37
|
+
type StepSpec = ChainStep;
|
|
40
38
|
|
|
41
39
|
/**
|
|
42
40
|
* One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
|
|
@@ -53,13 +51,17 @@ interface GateRequest {
|
|
|
53
51
|
capability: Capability;
|
|
54
52
|
/** The task of the step that needs this capability. */
|
|
55
53
|
task: string;
|
|
54
|
+
stepIndex: number;
|
|
55
|
+
plan: ReturnType<typeof planDelegation>;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Either every gate the chain will hit, or the first step that can never run and why. */
|
|
59
59
|
interface ChainPlan {
|
|
60
60
|
requests: GateRequest[];
|
|
61
|
+
/** Exact approval keys each step would spend, including duplicate subjects across steps. */
|
|
62
|
+
uses: Map<number, Set<string>>;
|
|
61
63
|
/** Set when a step is refused for a reason no approval can fix. The chain must refuse before asking anyone. */
|
|
62
|
-
doomed?: { step: number; reason: string };
|
|
64
|
+
doomed?: { step: number; reason: string; plan: ReturnType<typeof planDelegation>; agent?: string; refusal?: StructuredRefusal };
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -85,6 +87,7 @@ interface ChainPlan {
|
|
|
85
87
|
*/
|
|
86
88
|
async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
|
|
87
89
|
const requests: GateRequest[] = [];
|
|
90
|
+
const uses = new Map<number, Set<string>>();
|
|
88
91
|
const seen = new Set<string>();
|
|
89
92
|
const context = await session.delegationContext();
|
|
90
93
|
|
|
@@ -93,25 +96,49 @@ async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudge
|
|
|
93
96
|
// or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
|
|
94
97
|
// time and cannot change which capabilities are gated.
|
|
95
98
|
const plan = planDelegation(
|
|
96
|
-
{
|
|
99
|
+
{
|
|
100
|
+
task: step.task,
|
|
101
|
+
agent: step.agent,
|
|
102
|
+
tools: step.tools,
|
|
103
|
+
model: step.model,
|
|
104
|
+
correlation: step.workspace
|
|
105
|
+
? { ...(step.correlation ?? {}), workspace_id: step.workspace.workspace_id }
|
|
106
|
+
: step.correlation,
|
|
107
|
+
// Routing spec, not the model's correlation claim — see R-110 in `src/correlation.ts`.
|
|
108
|
+
boundWorkspaceId: step.workspace?.workspace_id,
|
|
109
|
+
boundContextId: step.correlation?.context_id,
|
|
110
|
+
},
|
|
97
111
|
{ ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
|
|
98
112
|
);
|
|
99
113
|
|
|
100
114
|
// A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
|
|
101
115
|
// for a spawn that will never happen.
|
|
102
116
|
if (!plan.ok && !shouldSeekApproval(plan.result)) {
|
|
103
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
requests: [], uses, doomed: {
|
|
119
|
+
step: index + 1, reason: plan.reason ?? "this step cannot run", plan, agent: step.agent,
|
|
120
|
+
...(plan.refusal ? { refusal: plan.refusal } : {}),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
// A correlated approval is bound to the exact task. Step N's final task does not exist until step N-1
|
|
126
|
+
// finishes, so asking upfront would bind the template and then spend it on different instructions.
|
|
127
|
+
// Legacy chains keep their upfront dialogs; correlated steps gate when their composed task exists.
|
|
128
|
+
if (plan.approvalBinding) continue;
|
|
129
|
+
|
|
106
130
|
const subject = step.agent ?? DELEGATE_SUBJECT;
|
|
131
|
+
uses.set(index, new Set(plan.result.gatedBlocked.map((capability) => `${capability}@${subject}`)));
|
|
107
132
|
for (const capability of plan.result.gatedBlocked) {
|
|
108
133
|
const key = `${capability}@${subject}`;
|
|
109
134
|
if (seen.has(key)) continue;
|
|
110
135
|
seen.add(key);
|
|
111
|
-
requests.push({
|
|
136
|
+
requests.push({
|
|
137
|
+
subject, path: step.agent ? "definition" : "delegate", capability, task: step.task, stepIndex: index, plan,
|
|
138
|
+
});
|
|
112
139
|
}
|
|
113
140
|
}
|
|
114
|
-
return { requests };
|
|
141
|
+
return { requests, uses };
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
|
|
@@ -127,6 +154,23 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
127
154
|
agent: Type.Optional(Type.String({ description: "Definition to spawn for this step." })),
|
|
128
155
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
|
|
129
156
|
model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
|
|
157
|
+
correlation: Type.Optional(Type.Object({
|
|
158
|
+
schema_version: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()),
|
|
159
|
+
task_id: Type.Optional(Type.String()), workspace_id: Type.Optional(Type.String()),
|
|
160
|
+
context_id: Type.Optional(Type.String()), phase: Type.Optional(Type.String()),
|
|
161
|
+
assurance: Type.Optional(Type.String()), assurance_effective: Type.Optional(Type.String()),
|
|
162
|
+
policy_label: Type.Optional(Type.String()), assurance_source: Type.Optional(Type.String()),
|
|
163
|
+
assurance_scope: Type.Optional(Type.Any()), activated_at: Type.Optional(Type.String()),
|
|
164
|
+
plan_digest: Type.Optional(Type.String()), definition_digest: Type.Optional(Type.String()),
|
|
165
|
+
task_digest: Type.Optional(Type.String()), base_sha: Type.Optional(Type.String()),
|
|
166
|
+
head_sha: Type.Optional(Type.String()), tree_sha: Type.Optional(Type.String()),
|
|
167
|
+
event_seq: Type.Optional(Type.Number()), last_change_seq: Type.Optional(Type.Number()),
|
|
168
|
+
last_authority_seq: Type.Optional(Type.Number()), check_receipt_id: Type.Optional(Type.String()),
|
|
169
|
+
})),
|
|
170
|
+
workspace: Type.Optional(Type.Object({
|
|
171
|
+
workspace_id: Type.String(),
|
|
172
|
+
access: Type.Union([Type.Literal("read"), Type.Literal("write")]),
|
|
173
|
+
})),
|
|
130
174
|
});
|
|
131
175
|
|
|
132
176
|
const params = Type.Object({
|
|
@@ -156,37 +200,41 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
156
200
|
// written, and the delegation was then refused anyway. `runOneDelegation` checks the executor before its own
|
|
157
201
|
// gate; a chain hoists its gate above `runOneDelegation`, so the check has to be repeated here or that
|
|
158
202
|
// ordering is simply bypassed.
|
|
159
|
-
if (session.executor.refusal) throw new
|
|
203
|
+
if (session.executor.refusal) throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", `chain refused: ${session.executor.refusal}`));
|
|
160
204
|
|
|
161
205
|
// Cardinality next, still before the gate. `splitBudget` is reused rather than re-derived, so a chain and a
|
|
162
206
|
// fan-out cannot disagree about what the budget means.
|
|
163
207
|
const split = splitBudget(session.fanoutBudget, steps.length);
|
|
164
|
-
if (!split.ok) throw new
|
|
208
|
+
if (!split.ok) throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `chain refused: ${split.reason}`));
|
|
165
209
|
|
|
166
210
|
// Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
|
|
167
211
|
// `planChain`.
|
|
168
212
|
const chainPlan = await planChain(session, steps, split.perChild);
|
|
169
213
|
if (chainPlan.doomed) {
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
214
|
+
const message = `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
|
|
215
|
+
`asked to approve anything — a step that cannot run must not bank authority for a spawn that will never happen.`;
|
|
216
|
+
await recordChainRefusal({
|
|
217
|
+
session, plan: chainPlan.doomed.plan, stepIndex: chainPlan.doomed.step - 1,
|
|
218
|
+
agent: chainPlan.doomed.agent, reason: message, refusal: chainPlan.doomed.refusal,
|
|
219
|
+
});
|
|
220
|
+
if (chainPlan.doomed.refusal) throw new GovernanceRefusal({ ...chainPlan.doomed.refusal, message });
|
|
221
|
+
throw new Error(message);
|
|
175
222
|
}
|
|
176
223
|
|
|
177
224
|
// One dialog per `capability@subject`, each naming the step that needs it, all before the first step runs.
|
|
178
225
|
const preApproved: InheritableApproval[] = [];
|
|
179
|
-
|
|
180
|
-
|
|
226
|
+
const approvalAudit = newChainApprovalAudit();
|
|
227
|
+
const approvalStep = new Map<string, number>();
|
|
228
|
+
const approvedDecisions: Array<{ request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> }> = [];
|
|
229
|
+
let declined: { request: GateRequest; outcome: Awaited<ReturnType<typeof obtainApprovals>> } | undefined;
|
|
181
230
|
|
|
182
231
|
for (const request of chainPlan.requests) {
|
|
183
232
|
const outcome = await obtainApprovals(session, [request.capability], request.subject, request.path, ctx, request.task, signal);
|
|
184
|
-
humanDenied = humanDenied || outcome.humanDenied;
|
|
185
233
|
if (!outcome.approved.includes(request.capability)) {
|
|
186
234
|
// **Stop asking.** The chain's outcome is already fixed, and every further dialog banks authority — a
|
|
187
235
|
// `session` yes into `sessionApprovals` and an `always` yes onto disk for 30 days — for a chain that will
|
|
188
236
|
// not run. The single-delegate path breaks on the first no for the same reason.
|
|
189
|
-
declined = {
|
|
237
|
+
declined = { request, outcome };
|
|
190
238
|
break;
|
|
191
239
|
}
|
|
192
240
|
preApproved.push({
|
|
@@ -197,47 +245,28 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
197
245
|
// against one definition's instructions while the capability was spent on another's.
|
|
198
246
|
bodySha256: snapshotOf(session, request.subject)?.bodySha256,
|
|
199
247
|
});
|
|
248
|
+
rememberChainApproval(approvalAudit, request.capability, request.subject, outcome);
|
|
249
|
+
approvalStep.set(`${request.capability}@${request.subject}`, request.stepIndex);
|
|
250
|
+
approvedDecisions.push({ request, outcome });
|
|
200
251
|
}
|
|
201
252
|
|
|
202
253
|
if (declined) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
buildRecord({
|
|
212
|
-
parentId: session.ownSpawnId,
|
|
213
|
-
childId: childSpawnId(session.ownSpawnId, Math.max(0, at)),
|
|
214
|
-
depth: session.depth + 1,
|
|
215
|
-
agentType: declined.subject === DELEGATE_SUBJECT ? "delegate" : declined.subject,
|
|
216
|
-
requested: [declined.capability],
|
|
217
|
-
parentGrant: session.ownGrant,
|
|
218
|
-
result: { effective: [], denied: [], clipped: [], gatedBlocked: [declined.capability], universal: [], subsumedBy: [] },
|
|
219
|
-
blocked: true,
|
|
220
|
-
humanDenied,
|
|
221
|
-
reason: `chain refused: ${declined.capability} not approved for ${declined.subject}; no step ran`,
|
|
222
|
-
executor: session.executor.kind,
|
|
223
|
-
now: new Date(),
|
|
224
|
-
}),
|
|
225
|
-
).catch((error) => {
|
|
226
|
-
// Not swallowed. Everywhere else a `strict` ledger failure fails closed, and losing the one line that
|
|
227
|
-
// records a human's refusal is the direction rule 8 forbids — the chain refuses either way, so saying so
|
|
228
|
-
// costs nothing.
|
|
229
|
-
ctx.ui?.notify?.(
|
|
230
|
-
`grants: the chain was refused AND its ledger line could not be written (${String(error)}) — the ` +
|
|
231
|
-
`refusal happened, but this audit trail does not show it.`,
|
|
232
|
-
"error",
|
|
233
|
-
);
|
|
254
|
+
const { request, outcome } = declined;
|
|
255
|
+
const message = `chain refused: ${request.capability} was not approved for ${request.subject}, so no step ran. A chain ` +
|
|
256
|
+
`is gated as a unit — running only its approved steps would return a partial result that reads like a complete one.`;
|
|
257
|
+
const structured = outcome.refusalCode ? refusal(outcome.refusalCode, message) : undefined;
|
|
258
|
+
for (const approved of approvedDecisions) {
|
|
259
|
+
await recordChainRefusal({
|
|
260
|
+
session, plan: approved.request.plan, stepIndex: approved.request.stepIndex,
|
|
261
|
+
agent: steps[approved.request.stepIndex]?.agent, reason: message, approval: approved.outcome,
|
|
234
262
|
});
|
|
235
263
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
264
|
+
await recordChainRefusal({
|
|
265
|
+
session, plan: request.plan, stepIndex: request.stepIndex, agent: steps[request.stepIndex]?.agent,
|
|
266
|
+
reason: message, refusal: structured, approval: outcome,
|
|
267
|
+
});
|
|
268
|
+
if (structured) throw new GovernanceRefusal(structured);
|
|
269
|
+
throw new Error(message);
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
const children: ChildProgress[] = steps.map((step) => ({
|
|
@@ -252,7 +281,9 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
252
281
|
});
|
|
253
282
|
}, PAINT_INTERVAL_MS);
|
|
254
283
|
|
|
255
|
-
const outcomes: Array<{
|
|
284
|
+
const outcomes: Array<{
|
|
285
|
+
ok: boolean; text: string; reason?: string; refusal?: StructuredRefusal; step: number; agent?: string;
|
|
286
|
+
}> = [];
|
|
256
287
|
let previous: string | undefined;
|
|
257
288
|
let aborted = false;
|
|
258
289
|
/**
|
|
@@ -271,6 +302,11 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
271
302
|
|
|
272
303
|
for (const [index, step] of steps.entries()) {
|
|
273
304
|
const childId = childSpawnId(session.ownSpawnId, index);
|
|
305
|
+
const availableForStep = available.filter((approval) => {
|
|
306
|
+
const key = `${approval.capability}@${approval.subject}`;
|
|
307
|
+
return chainPlan.uses.get(index)?.has(key) &&
|
|
308
|
+
(approval.scope !== "once" || approvalStep.get(key) === index);
|
|
309
|
+
});
|
|
274
310
|
const outcome = await runOneDelegation(
|
|
275
311
|
session,
|
|
276
312
|
chainStepSpec(step, previous),
|
|
@@ -279,21 +315,9 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
279
315
|
ctx,
|
|
280
316
|
signal,
|
|
281
317
|
{
|
|
282
|
-
preApproved:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// chain as a whole.
|
|
286
|
-
approved: available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => a.capability),
|
|
287
|
-
sources: Object.fromEntries(
|
|
288
|
-
available
|
|
289
|
-
.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT))
|
|
290
|
-
.map((a) => [a.capability, "prompt" as const]),
|
|
291
|
-
),
|
|
292
|
-
scopes: Object.fromEntries(
|
|
293
|
-
available.filter((a) => a.subject === (step.agent ?? DELEGATE_SUBJECT)).map((a) => [a.capability, a.scope]),
|
|
294
|
-
),
|
|
295
|
-
humanDenied: false,
|
|
296
|
-
},
|
|
318
|
+
preApproved: availableForStep,
|
|
319
|
+
// Only approvals actually offered to this step are attributed or consumed here.
|
|
320
|
+
approvalFacts: chainApprovalFacts(approvalAudit, availableForStep, step.agent ?? DELEGATE_SUBJECT),
|
|
297
321
|
onProgress: (update) => {
|
|
298
322
|
const child = children[index];
|
|
299
323
|
if (!child) return;
|
|
@@ -312,11 +336,16 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
312
336
|
|
|
313
337
|
children[index].state = outcome.ok ? "completed" : "failed";
|
|
314
338
|
children[index].settledAt = Date.now();
|
|
315
|
-
outcomes.push({
|
|
339
|
+
outcomes.push({
|
|
340
|
+
ok: outcome.ok, text: outcome.text, reason: outcome.reason, refusal: outcome.refusal,
|
|
341
|
+
step: index + 1, agent: step.agent,
|
|
342
|
+
});
|
|
343
|
+
if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
|
|
316
344
|
|
|
317
345
|
// Spend any `once` this step was handed, before the next step sees the list.
|
|
318
|
-
|
|
319
|
-
|
|
346
|
+
available = available.filter((approval) =>
|
|
347
|
+
approval.scope !== "once" || approvalStep.get(`${approval.capability}@${approval.subject}`) !== index,
|
|
348
|
+
);
|
|
320
349
|
|
|
321
350
|
if (!outcome.ok) {
|
|
322
351
|
// **Abort, and mark the rest.** Continuing would make the next step's task an error message, which is
|
|
@@ -345,12 +374,17 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
345
374
|
if (outcomes.length === 1 && !outcomes[0].ok) {
|
|
346
375
|
// Nothing completed at all, so there is no partial result to hand back — and a tool that returns text when
|
|
347
376
|
// nothing ran is how a wrong summary gets written.
|
|
348
|
-
|
|
377
|
+
const message = `chain failed at its first step.\n\n${report}`;
|
|
378
|
+
if (outcomes[0].refusal) throw new GovernanceRefusal({ ...outcomes[0].refusal, message });
|
|
379
|
+
throw new Error(message);
|
|
349
380
|
}
|
|
350
381
|
|
|
351
382
|
return {
|
|
352
383
|
content: [{ type: "text", text: `${report}${tail}` }],
|
|
353
|
-
details: {
|
|
384
|
+
details: {
|
|
385
|
+
steps: steps.length, completed: outcomes.filter((o) => o.ok).length, aborted,
|
|
386
|
+
budgetPerStep: split.perChild, refusals: outcomes.map((outcome) => outcome.refusal ?? null),
|
|
387
|
+
},
|
|
354
388
|
};
|
|
355
389
|
},
|
|
356
390
|
});
|
package/extensions/delegation.ts
CHANGED
|
@@ -23,7 +23,15 @@ import {
|
|
|
23
23
|
type ChildProgress,
|
|
24
24
|
} from "../src/progress.ts";
|
|
25
25
|
import { registerChainTool } from "./delegate-chain.ts";
|
|
26
|
+
import { GovernanceRefusal, refusal } from "../src/refusals.ts";
|
|
26
27
|
import { runOneDelegation } from "./run-delegation.ts";
|
|
28
|
+
import {
|
|
29
|
+
buildFanoutReport,
|
|
30
|
+
childFailureOutcome,
|
|
31
|
+
throwFanoutInfrastructure,
|
|
32
|
+
totalFanoutFailure,
|
|
33
|
+
} from "./fanout-outcome.ts";
|
|
34
|
+
import { isCriticalAssuranceBlock, type DelegationOutcome } from "./execute-child.ts";
|
|
27
35
|
import { type GrantsSession } from "./session.ts";
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -150,11 +158,42 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
150
158
|
`Name of a definition to spawn — its allowed-tools become the grant and its instructions ` +
|
|
151
159
|
`become the sub-agent's system prompt. Available: ${names.join(", ") || "none"}.`;
|
|
152
160
|
|
|
161
|
+
const correlationShape = Type.Object({
|
|
162
|
+
schema_version: Type.Optional(Type.String()),
|
|
163
|
+
run_id: Type.Optional(Type.String()),
|
|
164
|
+
task_id: Type.Optional(Type.String()),
|
|
165
|
+
workspace_id: Type.Optional(Type.String()),
|
|
166
|
+
context_id: Type.Optional(Type.String()),
|
|
167
|
+
phase: Type.Optional(Type.String()),
|
|
168
|
+
assurance: Type.Optional(Type.String()),
|
|
169
|
+
assurance_effective: Type.Optional(Type.String()),
|
|
170
|
+
policy_label: Type.Optional(Type.String()),
|
|
171
|
+
assurance_source: Type.Optional(Type.String()),
|
|
172
|
+
assurance_scope: Type.Optional(Type.Any()),
|
|
173
|
+
activated_at: Type.Optional(Type.String()),
|
|
174
|
+
plan_digest: Type.Optional(Type.String()),
|
|
175
|
+
definition_digest: Type.Optional(Type.String()),
|
|
176
|
+
task_digest: Type.Optional(Type.String()),
|
|
177
|
+
base_sha: Type.Optional(Type.String()),
|
|
178
|
+
head_sha: Type.Optional(Type.String()),
|
|
179
|
+
tree_sha: Type.Optional(Type.String()),
|
|
180
|
+
event_seq: Type.Optional(Type.Number()),
|
|
181
|
+
last_change_seq: Type.Optional(Type.Number()),
|
|
182
|
+
last_authority_seq: Type.Optional(Type.Number()),
|
|
183
|
+
check_receipt_id: Type.Optional(Type.String()),
|
|
184
|
+
});
|
|
185
|
+
const workspaceShape = Type.Object({
|
|
186
|
+
workspace_id: Type.String({ description: "ID from the operator-owned workspace registry." }),
|
|
187
|
+
access: Type.Union([Type.Literal("read"), Type.Literal("write")]),
|
|
188
|
+
});
|
|
189
|
+
|
|
153
190
|
const childShape = Type.Object({
|
|
154
191
|
task: Type.String({ description: "The task for this sub-agent. It receives only this." }),
|
|
155
192
|
agent: Type.Optional(Type.String({ description: describeAgent(spawnable()) })),
|
|
156
193
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
|
|
157
194
|
model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
|
|
195
|
+
correlation: Type.Optional(correlationShape),
|
|
196
|
+
workspace: Type.Optional(workspaceShape),
|
|
158
197
|
});
|
|
159
198
|
|
|
160
199
|
const delegateAllParams = Type.Object({
|
|
@@ -184,6 +223,8 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
184
223
|
"Defaults to this session's model, already provider-qualified.",
|
|
185
224
|
}),
|
|
186
225
|
),
|
|
226
|
+
correlation: Type.Optional(correlationShape),
|
|
227
|
+
workspace: Type.Optional(workspaceShape),
|
|
187
228
|
});
|
|
188
229
|
|
|
189
230
|
pi.registerTool({
|
|
@@ -201,7 +242,14 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
201
242
|
const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
|
|
202
243
|
const outcome = await runOneDelegation(
|
|
203
244
|
session,
|
|
204
|
-
{
|
|
245
|
+
{
|
|
246
|
+
task: params.task,
|
|
247
|
+
agent: params.agent,
|
|
248
|
+
tools: params.tools,
|
|
249
|
+
model: params.model,
|
|
250
|
+
correlation: params.correlation,
|
|
251
|
+
workspace: params.workspace,
|
|
252
|
+
},
|
|
205
253
|
{ parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, 0) },
|
|
206
254
|
// A single blocking delegation spends nothing from the subtree budget: cardinality is already
|
|
207
255
|
// bounded to one by the call being blocking, which is the accident fan-out removes. Passing the
|
|
@@ -214,12 +262,15 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
214
262
|
progress.settle([outcome]);
|
|
215
263
|
|
|
216
264
|
if (!outcome.ok) {
|
|
265
|
+
if (isCriticalAssuranceBlock(outcome)) throw new Error(outcome.text);
|
|
217
266
|
// THROW, do not return. `AgentToolResult` has no `isError` field: pi sets it only when `execute`
|
|
218
267
|
// throws (`pi-agent-core/dist/agent-loop.js` — a normal return is hardcoded `isError: false`).
|
|
219
268
|
// Returning `isError: true` was silently discarded, so every refusal this package made was
|
|
220
269
|
// recorded by pi as a SUCCESSFUL tool call. Found by the integration suite on its first run.
|
|
221
270
|
const detail = outcome.text ? `\n\n${outcome.text}` : "";
|
|
222
|
-
|
|
271
|
+
const message = `delegation refused: ${outcome.reason}${detail}`;
|
|
272
|
+
if (outcome.refusal) throw new GovernanceRefusal({ ...outcome.refusal, message });
|
|
273
|
+
throw new Error(message);
|
|
223
274
|
}
|
|
224
275
|
|
|
225
276
|
return {
|
|
@@ -261,7 +312,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
261
312
|
if (!split.ok) {
|
|
262
313
|
// Thrown, not returned: a returned `isError` is discarded by pi, so a refusal that came back as a
|
|
263
314
|
// normal result would read to the orchestrator as a successful fan-out of zero children.
|
|
264
|
-
throw new
|
|
315
|
+
throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `fan-out refused: ${split.reason}`));
|
|
265
316
|
}
|
|
266
317
|
|
|
267
318
|
// ADR-0032: ONE status block covering every child. `onUpdate` replaces the tool's rendered result, so a
|
|
@@ -270,38 +321,44 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
270
321
|
|
|
271
322
|
// Concurrent by construction. Each child gets its own budget share and its own ledger id, so the
|
|
272
323
|
// records form a tree and two siblings can never be confused for one another.
|
|
324
|
+
// EVERY sibling's error, not just the first. `??=` discarded the rest with no log anywhere, and one
|
|
325
|
+
// of the discardable ones is `HerdrWriterCloseError`, whose entire meaning is "a pane may still be
|
|
326
|
+
// live and its writer lease is deliberately retained" — a resource-retention notice, not a per-child
|
|
327
|
+
// failure. Losing it meant nobody was told a lease is held with no owner until the process exits
|
|
328
|
+
// (R-116).
|
|
329
|
+
const infrastructureErrors: unknown[] = [];
|
|
273
330
|
const outcomes = await Promise.all(
|
|
274
|
-
children.map((child, index) =>
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
331
|
+
children.map(async (child, index): Promise<DelegationOutcome> => {
|
|
332
|
+
try {
|
|
333
|
+
return await runOneDelegation(
|
|
334
|
+
session, child,
|
|
335
|
+
{ parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, index) },
|
|
336
|
+
split.perChild, ctx, signal, { onProgress: progress.sink(index) },
|
|
337
|
+
);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
infrastructureErrors.push(error);
|
|
340
|
+
return childFailureOutcome(error, session.depth + 1);
|
|
341
|
+
}
|
|
342
|
+
}),
|
|
285
343
|
);
|
|
286
344
|
progress.settle(outcomes);
|
|
345
|
+
// The upstream controller's verdict outranks our own infrastructure noise — it is the answer the
|
|
346
|
+
// caller is waiting for, and ADR-0034 requires it to pass through unchanged. But an infrastructure
|
|
347
|
+
// failure must not VANISH behind it, which is what happened when a retained-lease error and a
|
|
348
|
+
// critical block landed in the same fan-out (R-117).
|
|
349
|
+
throwFanoutInfrastructure(outcomes, infrastructureErrors);
|
|
287
350
|
|
|
288
351
|
const failed = outcomes.filter((o) => !o.ok);
|
|
289
352
|
// Every child is reported, including the ones that failed. R-03's rule: a missing result must never
|
|
290
353
|
// be indistinguishable from an empty one, and a fan-out that hid its refusals would let an
|
|
291
354
|
// orchestrator summarise four reviews when only three happened.
|
|
292
|
-
const report = outcomes
|
|
293
|
-
.map((outcome, index) => {
|
|
294
|
-
const label = `### child ${index + 1}${children[index].agent ? ` (${children[index].agent})` : ""}`;
|
|
295
|
-
return outcome.ok
|
|
296
|
-
? `${label} — completed\n\n${outcome.text || "(no output)"}`
|
|
297
|
-
: `${label} — FAILED: ${outcome.reason}${outcome.text ? `\n\n${outcome.text}` : ""}`;
|
|
298
|
-
})
|
|
299
|
-
.join("\n\n---\n\n");
|
|
355
|
+
const report = buildFanoutReport(outcomes, children);
|
|
300
356
|
|
|
301
357
|
if (failed.length === children.length) {
|
|
302
358
|
// All of them failed, so there is no partial result to hand back — and a tool that returns text
|
|
303
359
|
// when nothing ran is exactly how a wrong summary gets written.
|
|
304
|
-
|
|
360
|
+
const message = `fan-out failed: every child was refused or failed.\n\n${report}`;
|
|
361
|
+
throw totalFanoutFailure(failed, message);
|
|
305
362
|
}
|
|
306
363
|
|
|
307
364
|
return {
|
|
@@ -311,6 +368,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
311
368
|
failed: failed.length,
|
|
312
369
|
budgetPerChild: split.perChild,
|
|
313
370
|
granted: outcomes.map((o) => o.granted),
|
|
371
|
+
refusals: outcomes.map((o) => o.refusal ?? null),
|
|
314
372
|
},
|
|
315
373
|
};
|
|
316
374
|
},
|