pi-daddy 0.19.0 → 0.20.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 +65 -0
- package/README.md +105 -36
- package/contracts/ledger/v3/README.md +36 -0
- package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
- package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
- package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
- package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
- package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
- package/contracts/ledger/v3/ledger-event.schema.json +930 -0
- package/dist/check-runner.d.ts.map +1 -1
- package/dist/check-runner.js +14 -0
- package/dist/check-runner.js.map +1 -1
- package/dist/cli.js +0 -0
- package/dist/correlation.d.ts.map +1 -1
- package/dist/correlation.js +10 -0
- package/dist/correlation.js.map +1 -1
- package/dist/dashboard-cli.d.ts +18 -0
- package/dist/dashboard-cli.d.ts.map +1 -0
- package/dist/dashboard-cli.js +154 -0
- package/dist/dashboard-cli.js.map +1 -0
- package/dist/dashboard-handshake.d.ts +37 -0
- package/dist/dashboard-handshake.d.ts.map +1 -0
- package/dist/dashboard-handshake.js +127 -0
- package/dist/dashboard-handshake.js.map +1 -0
- package/dist/dashboard-herdr.d.ts +54 -0
- package/dist/dashboard-herdr.d.ts.map +1 -0
- package/dist/dashboard-herdr.js +286 -0
- package/dist/dashboard-herdr.js.map +1 -0
- package/dist/dashboard-projection.d.ts +73 -0
- package/dist/dashboard-projection.d.ts.map +1 -0
- package/dist/dashboard-projection.js +294 -0
- package/dist/dashboard-projection.js.map +1 -0
- package/dist/dashboard-render.d.ts +10 -0
- package/dist/dashboard-render.d.ts.map +1 -0
- package/dist/dashboard-render.js +208 -0
- package/dist/dashboard-render.js.map +1 -0
- package/dist/delegate-types.d.ts +6 -2
- package/dist/delegate-types.d.ts.map +1 -1
- package/dist/delegate-types.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +4 -1
- package/dist/delegate.js.map +1 -1
- package/dist/execution-id.d.ts +6 -0
- package/dist/execution-id.d.ts.map +1 -0
- package/dist/execution-id.js +13 -0
- package/dist/execution-id.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/ledger-events.d.ts +27 -4
- package/dist/ledger-events.d.ts.map +1 -1
- package/dist/ledger-events.js +30 -9
- package/dist/ledger-events.js.map +1 -1
- package/dist/ledger-identifiers.d.ts +7 -0
- package/dist/ledger-identifiers.d.ts.map +1 -0
- package/dist/ledger-identifiers.js +20 -0
- package/dist/ledger-identifiers.js.map +1 -0
- package/dist/ledger-report.d.ts +5 -2
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +38 -14
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger-v3-validation.d.ts +11 -0
- package/dist/ledger-v3-validation.d.ts.map +1 -0
- package/dist/ledger-v3-validation.js +265 -0
- package/dist/ledger-v3-validation.js.map +1 -0
- package/dist/ledger.d.ts +18 -5
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +29 -7
- package/dist/ledger.js.map +1 -1
- package/dist/propagation.d.ts +3 -1
- package/dist/propagation.d.ts.map +1 -1
- package/dist/propagation.js +3 -0
- package/dist/propagation.js.map +1 -1
- package/dist/run-child.d.ts +3 -1
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +30 -2
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +2 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +8 -0
- package/dist/run-herdr.js.map +1 -1
- package/dist/workflow-fact-id.d.ts +4 -0
- package/dist/workflow-fact-id.d.ts.map +1 -0
- package/dist/workflow-fact-id.js +14 -0
- package/dist/workflow-fact-id.js.map +1 -0
- package/dist/workflow-facts.d.ts +34 -0
- package/dist/workflow-facts.d.ts.map +1 -0
- package/dist/workflow-facts.js +43 -0
- package/dist/workflow-facts.js.map +1 -0
- package/extensions/chain-ledger.ts +4 -0
- package/extensions/chain-plan.ts +98 -0
- package/extensions/delegate-chain.ts +63 -122
- package/extensions/delegation-ledger.ts +60 -0
- package/extensions/delegation.ts +13 -13
- package/extensions/execute-child.ts +85 -21
- package/extensions/execution-occurrence.ts +20 -0
- package/extensions/grants-command.ts +22 -3
- package/extensions/grants.ts +44 -0
- package/extensions/run-delegation.ts +36 -56
- package/extensions/session.ts +7 -2
- package/extensions/workspace-runtime.ts +10 -0
- package/herdr-plugin/herdr-plugin.toml +18 -0
- package/package.json +16 -4
- package/src/check-runner.ts +16 -0
- package/src/correlation.ts +11 -0
- package/src/dashboard-cli.ts +171 -0
- package/src/dashboard-handshake.ts +185 -0
- package/src/dashboard-herdr.ts +355 -0
- package/src/dashboard-projection.ts +390 -0
- package/src/dashboard-render.ts +254 -0
- package/src/delegate-types.ts +6 -2
- package/src/delegate.ts +4 -2
- package/src/execution-id.ts +18 -0
- package/src/index.ts +25 -0
- package/src/ledger-events.ts +54 -10
- package/src/ledger-identifiers.ts +21 -0
- package/src/ledger-report.ts +40 -19
- package/src/ledger-v3-validation.ts +266 -0
- package/src/ledger.ts +49 -10
- package/src/propagation.ts +3 -0
- package/src/run-child.ts +35 -3
- package/src/run-herdr.ts +9 -0
- package/src/workflow-fact-id.ts +16 -0
- package/src/workflow-facts.ts +65 -0
|
@@ -19,126 +19,41 @@
|
|
|
19
19
|
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
|
-
import { DELEGATE_SUBJECT
|
|
23
|
-
import { chainStepSpec, PLACEHOLDER
|
|
24
|
-
import { planDelegation } from "../src/delegate.ts";
|
|
22
|
+
import { DELEGATE_SUBJECT } from "../src/approval.ts";
|
|
23
|
+
import { chainStepSpec, PLACEHOLDER } from "../src/chain.ts";
|
|
25
24
|
import { MAX_CHAIN_STEPS, childSpawnId, splitBudget } from "../src/fanout.ts";
|
|
26
25
|
import { PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress } from "../src/progress.ts";
|
|
27
|
-
import type { Capability } from "../src/resolve.ts";
|
|
28
26
|
import { recordChainRefusal } from "./chain-ledger.ts";
|
|
29
|
-
import { obtainApprovals, snapshotOf } from "./approvals.ts";
|
|
27
|
+
import { obtainApprovals, snapshotOf, type ApprovalOutcome } from "./approvals.ts";
|
|
30
28
|
import { runOneDelegation } from "./run-delegation.ts";
|
|
31
29
|
import { isCriticalAssuranceBlock } from "./execute-child.ts";
|
|
32
30
|
import type { GrantsSession } from "./session.ts";
|
|
33
31
|
import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
|
|
34
32
|
import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from "./chain-approval-facts.ts";
|
|
33
|
+
import { newExecutionId } from "../src/execution-id.ts";
|
|
34
|
+
import { planChain, type GateRequest } from "./chain-plan.ts";
|
|
35
35
|
|
|
36
|
-
/** One step
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Either every gate the chain will hit, or the first step that can never run and why. */
|
|
59
|
-
interface ChainPlan {
|
|
60
|
-
requests: GateRequest[];
|
|
61
|
-
/** Exact approval keys each step would spend, including duplicate subjects across steps. */
|
|
62
|
-
uses: Map<number, Set<string>>;
|
|
63
|
-
/** Set when a step is refused for a reason no approval can fix. The chain must refuse before asking anyone. */
|
|
64
|
-
doomed?: { step: number; reason: string; plan: ReturnType<typeof planDelegation>; agent?: string; refusal?: StructuredRefusal };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Plan every step and collect the gates — or find a step that can never run.
|
|
69
|
-
*
|
|
70
|
-
* **`plan.ok` is honoured here, and ignoring it was a privilege path.** The first version read only
|
|
71
|
-
* `gatedBlocked`, so a step refused for an unheld `agent:` id, an unknown definition, an empty task or a universal
|
|
72
|
-
* capability still raised its gate and the answer was still banked. Measured: `delegate({tools:["bash","agent:x"]})`
|
|
73
|
-
* asks nobody and refuses, while the same step inside a chain raised a dialog, took *Allow for this session*, refused
|
|
74
|
-
* anyway — and left `tool:bash` pre-approved for every later delegation in the session. On the `agent:` path it
|
|
75
|
-
* banked a **30-day** entry, including for a step whose task was whitespace, whose dialog therefore read `task:`
|
|
76
|
-
* followed by nothing.
|
|
77
|
-
*
|
|
78
|
-
* `shouldSeekApproval` is the rule every other gate in this package already applies, and its own docstring names
|
|
79
|
-
* this hazard: *"both banked against a spawn that never happened, and both reachable by a model that appends one
|
|
80
|
-
* unheld capability to an otherwise ordinary request."* The chain reimplemented the decision beside it instead of
|
|
81
|
-
* routing through it. So a doomed step now refuses the whole chain **before any dialog**, which is the same
|
|
82
|
-
* principle the executor check follows.
|
|
83
|
-
*
|
|
84
|
-
* Planned with no UI: `planWithApprovals` would open a dialog per step during the very phase whose purpose is to ask
|
|
85
|
-
* upfront. A step's task is unknown for everything after the first, which is fine — an approval key never contains
|
|
86
|
-
* the task (ADR-0021), so the set of gates is fully determined without it.
|
|
87
|
-
*/
|
|
88
|
-
async function planChain(session: GrantsSession, steps: StepSpec[], perStepBudget: number): Promise<ChainPlan> {
|
|
89
|
-
const requests: GateRequest[] = [];
|
|
90
|
-
const uses = new Map<number, Set<string>>();
|
|
91
|
-
const seen = new Set<string>();
|
|
92
|
-
const context = await session.delegationContext();
|
|
93
|
-
|
|
94
|
-
for (const [index, step] of steps.entries()) {
|
|
95
|
-
// The real task, not a placeholder: the planner's own empty-task guard must run here rather than at spawn time,
|
|
96
|
-
// or a step with a whitespace task raises a dialog and is refused afterwards. The handoff is absent at planning
|
|
97
|
-
// time and cannot change which capabilities are gated.
|
|
98
|
-
const plan = planDelegation(
|
|
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
|
-
},
|
|
111
|
-
{ ...context, spawnId: session.ownSpawnId, childSpawnId: childSpawnId(session.ownSpawnId, index) },
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
// A gate is the ONLY refusal an approval can lift. Anything else is doomed, and asking about it banks authority
|
|
115
|
-
// for a spawn that will never happen.
|
|
116
|
-
if (!plan.ok && !shouldSeekApproval(plan.result)) {
|
|
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
|
-
};
|
|
123
|
-
}
|
|
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
|
-
|
|
130
|
-
const subject = step.agent ?? DELEGATE_SUBJECT;
|
|
131
|
-
uses.set(index, new Set(plan.result.gatedBlocked.map((capability) => `${capability}@${subject}`)));
|
|
132
|
-
for (const capability of plan.result.gatedBlocked) {
|
|
133
|
-
const key = `${capability}@${subject}`;
|
|
134
|
-
if (seen.has(key)) continue;
|
|
135
|
-
seen.add(key);
|
|
136
|
-
requests.push({
|
|
137
|
-
subject, path: step.agent ? "definition" : "delegate", capability, task: step.task, stepIndex: index, plan,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return { requests, uses };
|
|
36
|
+
/** One chain step is one execution occurrence, however many capability dialogs contributed to its answer. */
|
|
37
|
+
function mergeGateOutcomes(outcomes: readonly ApprovalOutcome[]): ApprovalOutcome {
|
|
38
|
+
const merged = {
|
|
39
|
+
approved: [...new Set(outcomes.flatMap((outcome) => outcome.approved))],
|
|
40
|
+
sources: Object.assign({}, ...outcomes.map((outcome) => outcome.sources)),
|
|
41
|
+
scopes: Object.assign({}, ...outcomes.map((outcome) => outcome.scopes)),
|
|
42
|
+
recordedScopes: Object.assign({}, ...outcomes.map((outcome) => outcome.recordedScopes)),
|
|
43
|
+
bindings: Object.assign({}, ...outcomes.map((outcome) => outcome.bindings)),
|
|
44
|
+
expiresAt: Object.assign({}, ...outcomes.map((outcome) => outcome.expiresAt)),
|
|
45
|
+
uses: Object.assign({}, ...outcomes.map((outcome) => outcome.uses)),
|
|
46
|
+
humanDenied: outcomes.some((outcome) => outcome.humanDenied),
|
|
47
|
+
} satisfies ApprovalOutcome;
|
|
48
|
+
const last = outcomes.findLast((outcome) => outcome.gateOutcome !== undefined);
|
|
49
|
+
const banked = outcomes.flatMap((outcome) => outcome.banked ?? []);
|
|
50
|
+
return {
|
|
51
|
+
...merged,
|
|
52
|
+
...(last?.gateOutcome ? { gateOutcome: last.gateOutcome } : {}),
|
|
53
|
+
...(last?.refusalCode ? { refusalCode: last.refusalCode } : {}),
|
|
54
|
+
...(last?.reason ? { reason: last.reason } : {}),
|
|
55
|
+
...(banked.length > 0 ? { banked } : {}),
|
|
56
|
+
};
|
|
142
57
|
}
|
|
143
58
|
|
|
144
59
|
export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): void {
|
|
@@ -209,12 +124,15 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
209
124
|
|
|
210
125
|
// Plan every step first. A step that can never run refuses the chain HERE, before anyone is asked — see
|
|
211
126
|
// `planChain`.
|
|
212
|
-
const
|
|
127
|
+
const executionIds = steps.map(() => newExecutionId());
|
|
128
|
+
const parentExecutionId = session.ownExecutionId ?? null;
|
|
129
|
+
const chainPlan = await planChain(session, steps, executionIds);
|
|
213
130
|
if (chainPlan.doomed) {
|
|
214
131
|
const message = `chain refused at step ${chainPlan.doomed.step}: ${chainPlan.doomed.reason} No step ran, and nobody was ` +
|
|
215
132
|
`asked to approve anything — a step that cannot run must not bank authority for a spawn that will never happen.`;
|
|
216
133
|
await recordChainRefusal({
|
|
217
134
|
session, plan: chainPlan.doomed.plan, stepIndex: chainPlan.doomed.step - 1,
|
|
135
|
+
executionId: executionIds[chainPlan.doomed.step - 1], parentExecutionId,
|
|
218
136
|
agent: chainPlan.doomed.agent, reason: message, refusal: chainPlan.doomed.refusal,
|
|
219
137
|
});
|
|
220
138
|
if (chainPlan.doomed.refusal) throw new GovernanceRefusal({ ...chainPlan.doomed.refusal, message });
|
|
@@ -255,16 +173,29 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
255
173
|
const message = `chain refused: ${request.capability} was not approved for ${request.subject}, so no step ran. A chain ` +
|
|
256
174
|
`is gated as a unit — running only its approved steps would return a partial result that reads like a complete one.`;
|
|
257
175
|
const structured = outcome.refusalCode ? refusal(outcome.refusalCode, message) : undefined;
|
|
176
|
+
const decisions = new Map<number, { request: GateRequest; outcomes: ApprovalOutcome[]; refusal?: typeof structured }>();
|
|
258
177
|
for (const approved of approvedDecisions) {
|
|
178
|
+
const decision = decisions.get(approved.request.stepIndex) ?? {
|
|
179
|
+
request: approved.request,
|
|
180
|
+
outcomes: [],
|
|
181
|
+
};
|
|
182
|
+
decision.outcomes.push(approved.outcome);
|
|
183
|
+
decisions.set(approved.request.stepIndex, decision);
|
|
184
|
+
}
|
|
185
|
+
const deniedDecision = decisions.get(request.stepIndex) ?? { request, outcomes: [] };
|
|
186
|
+
deniedDecision.request = request;
|
|
187
|
+
deniedDecision.outcomes.push(outcome);
|
|
188
|
+
deniedDecision.refusal = structured;
|
|
189
|
+
decisions.set(request.stepIndex, deniedDecision);
|
|
190
|
+
|
|
191
|
+
for (const [stepIndex, decision] of [...decisions].sort(([left], [right]) => left - right)) {
|
|
259
192
|
await recordChainRefusal({
|
|
260
|
-
session, plan:
|
|
261
|
-
|
|
193
|
+
session, plan: decision.request.plan, stepIndex,
|
|
194
|
+
executionId: executionIds[stepIndex], parentExecutionId,
|
|
195
|
+
agent: steps[stepIndex]?.agent, reason: message, refusal: decision.refusal,
|
|
196
|
+
approval: mergeGateOutcomes(decision.outcomes),
|
|
262
197
|
});
|
|
263
198
|
}
|
|
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
199
|
if (structured) throw new GovernanceRefusal(structured);
|
|
269
200
|
throw new Error(message);
|
|
270
201
|
}
|
|
@@ -276,9 +207,13 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
276
207
|
tail: emptyTail,
|
|
277
208
|
}));
|
|
278
209
|
const paint = throttle(() => {
|
|
279
|
-
|
|
280
|
-
content:
|
|
281
|
-
|
|
210
|
+
try {
|
|
211
|
+
(onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
|
|
212
|
+
content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
// Display only: a broken partial-result sink must not become a child cancellation mechanism.
|
|
216
|
+
}
|
|
282
217
|
}, PAINT_INTERVAL_MS);
|
|
283
218
|
|
|
284
219
|
const outcomes: Array<{
|
|
@@ -310,7 +245,12 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
310
245
|
const outcome = await runOneDelegation(
|
|
311
246
|
session,
|
|
312
247
|
chainStepSpec(step, previous),
|
|
313
|
-
{
|
|
248
|
+
{
|
|
249
|
+
parentId: session.ownSpawnId,
|
|
250
|
+
childId,
|
|
251
|
+
executionId: executionIds[index],
|
|
252
|
+
parentExecutionId,
|
|
253
|
+
},
|
|
314
254
|
split.perChild,
|
|
315
255
|
ctx,
|
|
316
256
|
signal,
|
|
@@ -331,6 +271,7 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
|
|
|
331
271
|
},
|
|
332
272
|
// Provenance: which child's output composed THIS step's task (ADR-0033). Absent for step 1.
|
|
333
273
|
taskFrom: index === 0 ? undefined : childSpawnId(session.ownSpawnId, index - 1),
|
|
274
|
+
taskFromExecutionId: index === 0 ? undefined : executionIds[index - 1],
|
|
334
275
|
},
|
|
335
276
|
);
|
|
336
277
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Delegation } from "../src/delegate.ts";
|
|
2
|
+
import { appendRecord, buildRecord } from "../src/ledger.ts";
|
|
3
|
+
import type { ApprovalOutcome } from "./approvals.ts";
|
|
4
|
+
import type { ExecutionOccurrenceIds } from "./execution-occurrence.ts";
|
|
5
|
+
import type { GrantsSession } from "./session.ts";
|
|
6
|
+
|
|
7
|
+
export type ApprovalLedgerFacts = Pick<
|
|
8
|
+
ApprovalOutcome,
|
|
9
|
+
"approved" | "sources" | "scopes" | "expiresAt" | "uses" | "humanDenied"
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The load-bearing capability decision append. Kept in one function so every delegation form writes the
|
|
14
|
+
* same unique identity, approval provenance, trusted digests and refusal facts before a child can start.
|
|
15
|
+
*/
|
|
16
|
+
export async function recordDelegationDecision(input: {
|
|
17
|
+
session: GrantsSession;
|
|
18
|
+
plan: Delegation;
|
|
19
|
+
ids: ExecutionOccurrenceIds;
|
|
20
|
+
agent?: string;
|
|
21
|
+
taskFrom?: string;
|
|
22
|
+
taskFromExecutionId?: string;
|
|
23
|
+
approval?: ApprovalOutcome;
|
|
24
|
+
approvalFacts?: ApprovalLedgerFacts;
|
|
25
|
+
}): Promise<void> {
|
|
26
|
+
const ledgerPath = input.session.ledgerPath;
|
|
27
|
+
if (!ledgerPath) return;
|
|
28
|
+
const { session, plan, ids, approval, approvalFacts } = input;
|
|
29
|
+
await appendRecord(
|
|
30
|
+
{ path: ledgerPath, strict: true },
|
|
31
|
+
buildRecord({
|
|
32
|
+
executionId: ids.executionId,
|
|
33
|
+
parentExecutionId: ids.parentExecutionId,
|
|
34
|
+
parentId: ids.parentId,
|
|
35
|
+
childId: ids.childId,
|
|
36
|
+
depth: plan.childDepth,
|
|
37
|
+
agentType: input.agent ?? "delegate",
|
|
38
|
+
executor: session.executor.kind,
|
|
39
|
+
taskFrom: input.taskFrom,
|
|
40
|
+
taskFromExecutionId: input.taskFromExecutionId,
|
|
41
|
+
requested: plan.requested,
|
|
42
|
+
parentGrant: session.ownGrant,
|
|
43
|
+
result: plan.result,
|
|
44
|
+
blocked: !plan.ok,
|
|
45
|
+
reason: plan.reason,
|
|
46
|
+
approved: approval?.approved ?? approvalFacts?.approved,
|
|
47
|
+
approvalSources: approval?.sources ?? approvalFacts?.sources,
|
|
48
|
+
approvalScopes: approval?.recordedScopes ?? approvalFacts?.scopes,
|
|
49
|
+
approvalExpiresAt: approval?.expiresAt ?? approvalFacts?.expiresAt,
|
|
50
|
+
approvalUses: approval?.uses ?? approvalFacts?.uses,
|
|
51
|
+
humanDenied: approval?.humanDenied ?? approvalFacts?.humanDenied,
|
|
52
|
+
gateOutcome: approval?.gateOutcome,
|
|
53
|
+
definitionDigest: plan.definitionDigest,
|
|
54
|
+
taskDigest: plan.taskDigest,
|
|
55
|
+
correlation: plan.correlation,
|
|
56
|
+
refusal: plan.refusal,
|
|
57
|
+
now: new Date(),
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
package/extensions/delegation.ts
CHANGED
|
@@ -12,15 +12,9 @@
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
14
|
import { maySpawnDefinition } from "../src/delegate.ts";
|
|
15
|
-
import { MAX_CHILDREN_PER_CALL,
|
|
15
|
+
import { MAX_CHILDREN_PER_CALL, splitBudget } from "../src/fanout.ts";
|
|
16
16
|
import {
|
|
17
|
-
PAINT_INTERVAL_MS,
|
|
18
|
-
appendTail,
|
|
19
|
-
emptyTail,
|
|
20
|
-
renderProgress,
|
|
21
|
-
replaceTail,
|
|
22
|
-
throttle,
|
|
23
|
-
type ChildProgress,
|
|
17
|
+
PAINT_INTERVAL_MS, appendTail, emptyTail, renderProgress, replaceTail, throttle, type ChildProgress,
|
|
24
18
|
} from "../src/progress.ts";
|
|
25
19
|
import { registerChainTool } from "./delegate-chain.ts";
|
|
26
20
|
import { GovernanceRefusal, refusal } from "../src/refusals.ts";
|
|
@@ -33,6 +27,7 @@ import {
|
|
|
33
27
|
} from "./fanout-outcome.ts";
|
|
34
28
|
import { isCriticalAssuranceBlock, type DelegationOutcome } from "./execute-child.ts";
|
|
35
29
|
import { type GrantsSession } from "./session.ts";
|
|
30
|
+
import { newDelegationOccurrence } from "./execution-occurrence.ts";
|
|
36
31
|
|
|
37
32
|
/**
|
|
38
33
|
* Wire a set of children to pi's partial-result channel — ADR-0032.
|
|
@@ -61,9 +56,14 @@ function progressReporter(
|
|
|
61
56
|
}));
|
|
62
57
|
|
|
63
58
|
const paint = throttle(() => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
try {
|
|
60
|
+
onUpdate?.({
|
|
61
|
+
content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
// Display only. In particular, this callback can run inside runChild's security-sensitive onSpawn;
|
|
65
|
+
// letting it escape makes runChild kill an otherwise healthy governed child.
|
|
66
|
+
}
|
|
67
67
|
}, PAINT_INTERVAL_MS);
|
|
68
68
|
|
|
69
69
|
return {
|
|
@@ -256,7 +256,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
256
256
|
correlation: params.correlation,
|
|
257
257
|
workspace: params.workspace,
|
|
258
258
|
},
|
|
259
|
-
|
|
259
|
+
newDelegationOccurrence(session, 0),
|
|
260
260
|
// A single blocking delegation spends nothing from the subtree budget: cardinality is already
|
|
261
261
|
// bounded to one by the call being blocking, which is the accident fan-out removes. Passing the
|
|
262
262
|
// budget through unchanged means a child can still fan out with what this session was given.
|
|
@@ -338,7 +338,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
|
|
|
338
338
|
try {
|
|
339
339
|
return await runOneDelegation(
|
|
340
340
|
session, child,
|
|
341
|
-
|
|
341
|
+
newDelegationOccurrence(session, index),
|
|
342
342
|
split.perChild, ctx, signal, { onProgress: progress.sink(index) },
|
|
343
343
|
);
|
|
344
344
|
} catch (error) {
|
|
@@ -2,7 +2,7 @@ import type { Delegation } from "../src/delegate.ts";
|
|
|
2
2
|
import { appendLedgerEvent, buildChildLifecycleEvent } from "../src/ledger.ts";
|
|
3
3
|
import { mergeChildEnv } from "../src/propagation.ts";
|
|
4
4
|
import type { Capability } from "../src/resolve.ts";
|
|
5
|
-
import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
|
|
5
|
+
import { DEFAULT_KILL_GRACE_MS, ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
|
|
6
6
|
import { resolveWorkspace } from "../src/herdr-cli.ts";
|
|
7
7
|
import { HerdrWriterCloseError, runHerdrPane } from "../src/run-herdr.ts";
|
|
8
8
|
import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
|
|
@@ -45,12 +45,20 @@ export function isCriticalAssuranceBlock(
|
|
|
45
45
|
return outcome.text.trimStart().startsWith("BLOCKED_CRITICAL_ASSURANCE");
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export async function appendAfterRuntimeRecord<T>(
|
|
49
|
+
runtimeRecord: Promise<void> | undefined,
|
|
50
|
+
appendTerminal: () => Promise<T>,
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
if (runtimeRecord) await runtimeRecord;
|
|
53
|
+
return appendTerminal();
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
export interface ChildProgressUpdate {
|
|
49
57
|
chunk?: string;
|
|
50
58
|
snapshot?: string[];
|
|
51
59
|
paneId?: string;
|
|
52
60
|
agentName?: string;
|
|
53
|
-
state?: "running" | "completed" | "failed";
|
|
61
|
+
state?: "starting" | "running" | "completed" | "failed";
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -68,30 +76,46 @@ export async function executePlannedChild(input: {
|
|
|
68
76
|
plan: Delegation;
|
|
69
77
|
agent?: string;
|
|
70
78
|
childId: string;
|
|
79
|
+
executionId: string;
|
|
80
|
+
parentExecutionId: string | null;
|
|
71
81
|
cwd: string;
|
|
72
82
|
preparedWorkspace?: PreparedWorkspace;
|
|
73
83
|
signal?: AbortSignal;
|
|
74
84
|
onProgress?: (update: ChildProgressUpdate) => void;
|
|
75
85
|
}): Promise<DelegationOutcome> {
|
|
76
|
-
const { session, plan, childId, preparedWorkspace, signal, onProgress } = input;
|
|
77
|
-
|
|
86
|
+
const { session, plan, childId, executionId, parentExecutionId, preparedWorkspace, signal, onProgress } = input;
|
|
87
|
+
const ledgerPath = session.ledgerPath;
|
|
88
|
+
const configuredTimeoutMs = timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]);
|
|
89
|
+
const startedAt = new Date();
|
|
90
|
+
const deadlineAt = new Date(startedAt.getTime() + configuredTimeoutMs).toISOString();
|
|
91
|
+
if (ledgerPath) {
|
|
78
92
|
try {
|
|
79
93
|
await appendLedgerEvent(
|
|
80
|
-
{ path:
|
|
94
|
+
{ path: ledgerPath, strict: true },
|
|
81
95
|
buildChildLifecycleEvent({
|
|
96
|
+
executionId,
|
|
97
|
+
parentExecutionId,
|
|
82
98
|
childId,
|
|
83
99
|
state: "starting",
|
|
84
100
|
executor: session.executor.kind,
|
|
101
|
+
deadlineAt,
|
|
85
102
|
correlation: plan.correlation,
|
|
86
|
-
now:
|
|
103
|
+
now: startedAt,
|
|
87
104
|
}),
|
|
88
105
|
);
|
|
89
106
|
} catch (error) {
|
|
90
|
-
await releaseDelegationWorkspace({
|
|
107
|
+
await releaseDelegationWorkspace({
|
|
108
|
+
prepared: preparedWorkspace, childId, executionId, parentExecutionId, reason: "ledger-failed",
|
|
109
|
+
});
|
|
91
110
|
throw error;
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
113
|
|
|
114
|
+
// The recorded deadline and executor timer are one fact. Waiting for the strict starting append consumes
|
|
115
|
+
// the budget; handing the child a fresh full timeout would leave it live after the dashboard truthfully
|
|
116
|
+
// marked that deadline incomplete.
|
|
117
|
+
const remainingTimeoutMs = Math.max(1, Date.parse(deadlineAt) - Date.now());
|
|
118
|
+
const terminationGraceMs = Math.min(DEFAULT_KILL_GRACE_MS, Math.max(1, Math.floor(remainingTimeoutMs / 10)));
|
|
95
119
|
const cwd = preparedWorkspace?.workspace.root ?? input.cwd;
|
|
96
120
|
const leaseAbort = new AbortController();
|
|
97
121
|
const writerLease = preparedWorkspace?.lease.access === "write" ? preparedWorkspace.lease : undefined;
|
|
@@ -107,6 +131,26 @@ export async function executePlannedChild(input: {
|
|
|
107
131
|
let retainWriterLease = false;
|
|
108
132
|
let terminalAttempted = false;
|
|
109
133
|
const teardownFailures: string[] = [];
|
|
134
|
+
let runtimeRecord: Promise<void> | undefined;
|
|
135
|
+
const recordRunning = (executor: "process" | "herdr", pane?: { id: string; agentName: string }): void => {
|
|
136
|
+
if (!ledgerPath) return;
|
|
137
|
+
try {
|
|
138
|
+
runtimeRecord = appendLedgerEvent(
|
|
139
|
+
{
|
|
140
|
+
path: ledgerPath,
|
|
141
|
+
strict: false,
|
|
142
|
+
onFailure: (cause) => teardownFailures.push(`child runtime identity record failed: ${String(cause)}`),
|
|
143
|
+
},
|
|
144
|
+
buildChildLifecycleEvent({
|
|
145
|
+
executionId, parentExecutionId, childId, state: "running", executor, deadlineAt,
|
|
146
|
+
...(pane ? { herdrPaneId: pane.id, herdrAgentName: pane.agentName } : {}),
|
|
147
|
+
correlation: plan.correlation, now: new Date(),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
teardownFailures.push(`child runtime identity record failed: ${String(error)}`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
110
154
|
try {
|
|
111
155
|
const output = session.executor.kind === "herdr"
|
|
112
156
|
? await runHerdrPane({
|
|
@@ -117,10 +161,16 @@ export async function executePlannedChild(input: {
|
|
|
117
161
|
name: `${input.agent ?? "delegate"}-${childId}`,
|
|
118
162
|
workspace: resolveWorkspace(process.env),
|
|
119
163
|
signal: executionSignal,
|
|
120
|
-
timeoutMs:
|
|
164
|
+
timeoutMs: remainingTimeoutMs,
|
|
121
165
|
keepPane: writerLease ? false : process.env[ENV_HERDR_KEEP_PANE] === "1",
|
|
122
166
|
closeOnSettle: Boolean(writerLease),
|
|
123
|
-
onPane:
|
|
167
|
+
onPane: (paneId, agentName) => onProgress?.({ paneId, agentName, state: "starting" }),
|
|
168
|
+
onRunning: (paneId, agentName) => {
|
|
169
|
+
// Record first: runHerdrPane isolates this display callback, so a renderer exception after the
|
|
170
|
+
// observation cannot suppress the authoritative running event.
|
|
171
|
+
recordRunning("herdr", { id: paneId, agentName });
|
|
172
|
+
onProgress?.({ paneId, agentName, state: "running" });
|
|
173
|
+
},
|
|
124
174
|
onTab: preparedWorkspace ? (tabId) => preparedWorkspace.lease.attachHerdrTab(tabId) : undefined,
|
|
125
175
|
onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
|
|
126
176
|
})
|
|
@@ -130,18 +180,28 @@ export async function executePlannedChild(input: {
|
|
|
130
180
|
env: mergeChildEnv(process.env, plan.env),
|
|
131
181
|
cwd,
|
|
132
182
|
signal: executionSignal,
|
|
133
|
-
|
|
183
|
+
// SIGTERM gets only grace that fits INSIDE the recorded deadline. The independent hard timer
|
|
184
|
+
// prevents a delayed soft-timeout callback from starting a fresh grace period beyond that bound.
|
|
185
|
+
timeoutMs: Math.max(1, remainingTimeoutMs - terminationGraceMs),
|
|
186
|
+
hardDeadlineAt: Date.parse(deadlineAt),
|
|
134
187
|
onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
|
|
135
|
-
onSpawn:
|
|
188
|
+
onSpawn: (pid) => {
|
|
189
|
+
// Lease attachment is a security hook and may fail the spawn. The shared reporters isolate
|
|
190
|
+
// display exceptions before they reach this callback; runChild deliberately kills on any error
|
|
191
|
+
// here, so presentation must never be added directly without that reporter boundary.
|
|
192
|
+
preparedWorkspace?.lease.attachProcess(pid);
|
|
193
|
+
recordRunning("process");
|
|
194
|
+
onProgress?.({ state: "running" });
|
|
195
|
+
},
|
|
136
196
|
});
|
|
137
197
|
|
|
138
198
|
const childFailed = Boolean(output.spawnError || output.aborted || output.timedOut || output.code !== 0);
|
|
139
199
|
releaseReason = output.timedOut ? "timeout" : output.aborted ? "cancelled" : childFailed ? "failed" : "completed";
|
|
140
|
-
if (
|
|
200
|
+
if (ledgerPath) {
|
|
141
201
|
terminalAttempted = true;
|
|
142
|
-
await appendLedgerEvent(
|
|
202
|
+
await appendAfterRuntimeRecord(runtimeRecord, () => appendLedgerEvent(
|
|
143
203
|
{
|
|
144
|
-
path:
|
|
204
|
+
path: ledgerPath,
|
|
145
205
|
// NOT strict, and this line is the whole point of R-99. The child has already run: failing
|
|
146
206
|
// closed here prevents nothing and used to discard a completed child's entire output while
|
|
147
207
|
// blaming "ledger" — under `delegate_all` it discarded every sibling's work too. The docstring
|
|
@@ -151,6 +211,8 @@ export async function executePlannedChild(input: {
|
|
|
151
211
|
onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
|
|
152
212
|
},
|
|
153
213
|
buildChildLifecycleEvent({
|
|
214
|
+
executionId,
|
|
215
|
+
parentExecutionId,
|
|
154
216
|
childId,
|
|
155
217
|
state: childFailed ? "failed" : "completed",
|
|
156
218
|
executor: session.executor.kind,
|
|
@@ -163,7 +225,7 @@ export async function executePlannedChild(input: {
|
|
|
163
225
|
correlation: plan.correlation,
|
|
164
226
|
now: new Date(),
|
|
165
227
|
}),
|
|
166
|
-
);
|
|
228
|
+
));
|
|
167
229
|
}
|
|
168
230
|
|
|
169
231
|
const leaseWasLost = writerLease ? leaseLost : false;
|
|
@@ -218,24 +280,24 @@ export async function executePlannedChild(input: {
|
|
|
218
280
|
return withTeardownNotes(succeeded);
|
|
219
281
|
} catch (error) {
|
|
220
282
|
retainWriterLease = Boolean(writerLease && error instanceof HerdrWriterCloseError);
|
|
221
|
-
if (
|
|
283
|
+
if (ledgerPath && !terminalAttempted) {
|
|
222
284
|
// Best-effort: this records the failure, so it must not REPLACE the failure. A strict append that
|
|
223
285
|
// throws here would discard the original error — including HerdrWriterCloseError, whose whole
|
|
224
286
|
// meaning is "a lease is deliberately retained" (R-108).
|
|
225
|
-
await appendLedgerEvent(
|
|
287
|
+
await appendAfterRuntimeRecord(runtimeRecord, () => appendLedgerEvent(
|
|
226
288
|
{
|
|
227
|
-
path:
|
|
289
|
+
path: ledgerPath,
|
|
228
290
|
strict: false,
|
|
229
291
|
onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
|
|
230
292
|
},
|
|
231
293
|
buildChildLifecycleEvent({
|
|
232
|
-
childId, state: "failed", executor: session.executor.kind,
|
|
294
|
+
executionId, parentExecutionId, childId, state: "failed", executor: session.executor.kind,
|
|
233
295
|
reason: error instanceof GovernanceRefusal
|
|
234
296
|
? error.code
|
|
235
297
|
: error instanceof Error ? error.name : "unknown executor error",
|
|
236
298
|
correlation: plan.correlation, now: new Date(),
|
|
237
299
|
}),
|
|
238
|
-
);
|
|
300
|
+
));
|
|
239
301
|
}
|
|
240
302
|
await teardown();
|
|
241
303
|
// Attached, not dropped. `withTeardownNotes` was applied on both return paths and neither throw path,
|
|
@@ -278,7 +340,9 @@ function errorWithTeardownNotes(error: unknown, notes: readonly string[]): unkno
|
|
|
278
340
|
await releaseDelegationWorkspace({
|
|
279
341
|
prepared: preparedWorkspace,
|
|
280
342
|
childId,
|
|
281
|
-
|
|
343
|
+
executionId,
|
|
344
|
+
parentExecutionId,
|
|
345
|
+
ledgerPath,
|
|
282
346
|
reason: releaseReason,
|
|
283
347
|
retain: retainWriterLease,
|
|
284
348
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { newExecutionId } from "../src/execution-id.ts";
|
|
2
|
+
import { childSpawnId } from "../src/fanout.ts";
|
|
3
|
+
import type { GrantsSession } from "./session.ts";
|
|
4
|
+
|
|
5
|
+
/** Readable logical position plus the unique identity used for every lifecycle join. */
|
|
6
|
+
export interface ExecutionOccurrenceIds {
|
|
7
|
+
parentId: string;
|
|
8
|
+
childId: string;
|
|
9
|
+
executionId: string;
|
|
10
|
+
parentExecutionId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function newDelegationOccurrence(session: GrantsSession, index: number): ExecutionOccurrenceIds {
|
|
14
|
+
return {
|
|
15
|
+
parentId: session.ownSpawnId,
|
|
16
|
+
childId: childSpawnId(session.ownSpawnId, index),
|
|
17
|
+
executionId: newExecutionId(),
|
|
18
|
+
parentExecutionId: session.ownExecutionId ?? null,
|
|
19
|
+
};
|
|
20
|
+
}
|