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.
Files changed (125) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +105 -36
  3. package/contracts/ledger/v3/README.md +36 -0
  4. package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
  5. package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
  6. package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
  7. package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
  8. package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
  9. package/contracts/ledger/v3/ledger-event.schema.json +930 -0
  10. package/dist/check-runner.d.ts.map +1 -1
  11. package/dist/check-runner.js +14 -0
  12. package/dist/check-runner.js.map +1 -1
  13. package/dist/cli.js +0 -0
  14. package/dist/correlation.d.ts.map +1 -1
  15. package/dist/correlation.js +10 -0
  16. package/dist/correlation.js.map +1 -1
  17. package/dist/dashboard-cli.d.ts +18 -0
  18. package/dist/dashboard-cli.d.ts.map +1 -0
  19. package/dist/dashboard-cli.js +154 -0
  20. package/dist/dashboard-cli.js.map +1 -0
  21. package/dist/dashboard-handshake.d.ts +37 -0
  22. package/dist/dashboard-handshake.d.ts.map +1 -0
  23. package/dist/dashboard-handshake.js +127 -0
  24. package/dist/dashboard-handshake.js.map +1 -0
  25. package/dist/dashboard-herdr.d.ts +54 -0
  26. package/dist/dashboard-herdr.d.ts.map +1 -0
  27. package/dist/dashboard-herdr.js +286 -0
  28. package/dist/dashboard-herdr.js.map +1 -0
  29. package/dist/dashboard-projection.d.ts +73 -0
  30. package/dist/dashboard-projection.d.ts.map +1 -0
  31. package/dist/dashboard-projection.js +294 -0
  32. package/dist/dashboard-projection.js.map +1 -0
  33. package/dist/dashboard-render.d.ts +10 -0
  34. package/dist/dashboard-render.d.ts.map +1 -0
  35. package/dist/dashboard-render.js +208 -0
  36. package/dist/dashboard-render.js.map +1 -0
  37. package/dist/delegate-types.d.ts +6 -2
  38. package/dist/delegate-types.d.ts.map +1 -1
  39. package/dist/delegate-types.js.map +1 -1
  40. package/dist/delegate.d.ts.map +1 -1
  41. package/dist/delegate.js +4 -1
  42. package/dist/delegate.js.map +1 -1
  43. package/dist/execution-id.d.ts +6 -0
  44. package/dist/execution-id.d.ts.map +1 -0
  45. package/dist/execution-id.js +13 -0
  46. package/dist/execution-id.js.map +1 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/ledger-events.d.ts +27 -4
  52. package/dist/ledger-events.d.ts.map +1 -1
  53. package/dist/ledger-events.js +30 -9
  54. package/dist/ledger-events.js.map +1 -1
  55. package/dist/ledger-identifiers.d.ts +7 -0
  56. package/dist/ledger-identifiers.d.ts.map +1 -0
  57. package/dist/ledger-identifiers.js +20 -0
  58. package/dist/ledger-identifiers.js.map +1 -0
  59. package/dist/ledger-report.d.ts +5 -2
  60. package/dist/ledger-report.d.ts.map +1 -1
  61. package/dist/ledger-report.js +38 -14
  62. package/dist/ledger-report.js.map +1 -1
  63. package/dist/ledger-v3-validation.d.ts +11 -0
  64. package/dist/ledger-v3-validation.d.ts.map +1 -0
  65. package/dist/ledger-v3-validation.js +265 -0
  66. package/dist/ledger-v3-validation.js.map +1 -0
  67. package/dist/ledger.d.ts +18 -5
  68. package/dist/ledger.d.ts.map +1 -1
  69. package/dist/ledger.js +29 -7
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/propagation.d.ts +3 -1
  72. package/dist/propagation.d.ts.map +1 -1
  73. package/dist/propagation.js +3 -0
  74. package/dist/propagation.js.map +1 -1
  75. package/dist/run-child.d.ts +3 -1
  76. package/dist/run-child.d.ts.map +1 -1
  77. package/dist/run-child.js +30 -2
  78. package/dist/run-child.js.map +1 -1
  79. package/dist/run-herdr.d.ts +2 -0
  80. package/dist/run-herdr.d.ts.map +1 -1
  81. package/dist/run-herdr.js +8 -0
  82. package/dist/run-herdr.js.map +1 -1
  83. package/dist/workflow-fact-id.d.ts +4 -0
  84. package/dist/workflow-fact-id.d.ts.map +1 -0
  85. package/dist/workflow-fact-id.js +14 -0
  86. package/dist/workflow-fact-id.js.map +1 -0
  87. package/dist/workflow-facts.d.ts +34 -0
  88. package/dist/workflow-facts.d.ts.map +1 -0
  89. package/dist/workflow-facts.js +43 -0
  90. package/dist/workflow-facts.js.map +1 -0
  91. package/extensions/chain-ledger.ts +4 -0
  92. package/extensions/chain-plan.ts +98 -0
  93. package/extensions/delegate-chain.ts +63 -122
  94. package/extensions/delegation-ledger.ts +60 -0
  95. package/extensions/delegation.ts +13 -13
  96. package/extensions/execute-child.ts +85 -21
  97. package/extensions/execution-occurrence.ts +20 -0
  98. package/extensions/grants-command.ts +22 -3
  99. package/extensions/grants.ts +44 -0
  100. package/extensions/run-delegation.ts +36 -56
  101. package/extensions/session.ts +7 -2
  102. package/extensions/workspace-runtime.ts +10 -0
  103. package/herdr-plugin/herdr-plugin.toml +18 -0
  104. package/package.json +16 -4
  105. package/src/check-runner.ts +16 -0
  106. package/src/correlation.ts +11 -0
  107. package/src/dashboard-cli.ts +171 -0
  108. package/src/dashboard-handshake.ts +185 -0
  109. package/src/dashboard-herdr.ts +355 -0
  110. package/src/dashboard-projection.ts +390 -0
  111. package/src/dashboard-render.ts +254 -0
  112. package/src/delegate-types.ts +6 -2
  113. package/src/delegate.ts +4 -2
  114. package/src/execution-id.ts +18 -0
  115. package/src/index.ts +25 -0
  116. package/src/ledger-events.ts +54 -10
  117. package/src/ledger-identifiers.ts +21 -0
  118. package/src/ledger-report.ts +40 -19
  119. package/src/ledger-v3-validation.ts +266 -0
  120. package/src/ledger.ts +49 -10
  121. package/src/propagation.ts +3 -0
  122. package/src/run-child.ts +35 -3
  123. package/src/run-herdr.ts +9 -0
  124. package/src/workflow-fact-id.ts +16 -0
  125. 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, shouldSeekApproval } from "../src/approval.ts";
23
- import { chainStepSpec, PLACEHOLDER, type ChainStep } from "../src/chain.ts";
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 of a chain, as the model describes it. */
37
- type StepSpec = ChainStep;
38
-
39
- /**
40
- * One dialog's worth of gate: a single capability, for a single subject, described by the step that needs it.
41
- *
42
- * **One request per `capability@subject`, not per subject.** Grouping by subject alone merged every `tools:`-only
43
- * step under the constant `<delegate>` and froze the *first* step's task into the dialog — so an operator approved
44
- * `tool:write` while reading a task that only listed files. The dialog is the one place a human learns what they are
45
- * authorising, so it names the step that actually needs the capability.
46
- */
47
- interface GateRequest {
48
- subject: string;
49
- /** `"definition"` offers `always`; `"delegate"` must not (ADR-0019). Derived from the subject, never assumed. */
50
- path: "definition" | "delegate";
51
- capability: Capability;
52
- /** The task of the step that needs this capability. */
53
- task: string;
54
- stepIndex: number;
55
- plan: ReturnType<typeof planDelegation>;
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 chainPlan = await planChain(session, steps, split.perChild);
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: approved.request.plan, stepIndex: approved.request.stepIndex,
261
- agent: steps[approved.request.stepIndex]?.agent, reason: message, approval: approved.outcome,
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
- (onUpdate as ((partial: { content: Array<{ type: "text"; text: string }> }) => void) | undefined)?.({
280
- content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
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
- { parentId: session.ownSpawnId, childId },
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
+ }
@@ -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, childSpawnId, splitBudget } from "../src/fanout.ts";
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
- onUpdate?.({
65
- content: [{ type: "text", text: renderProgress(children, session.executor.kind, Date.now()) }],
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
- { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, 0) },
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
- { parentId: session.ownSpawnId, childId: childSpawnId(session.ownSpawnId, index) },
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
- if (session.ledgerPath) {
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: session.ledgerPath, strict: true },
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: new Date(),
103
+ now: startedAt,
87
104
  }),
88
105
  );
89
106
  } catch (error) {
90
- await releaseDelegationWorkspace({ prepared: preparedWorkspace, childId, reason: "ledger-failed" });
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: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
164
+ timeoutMs: remainingTimeoutMs,
121
165
  keepPane: writerLease ? false : process.env[ENV_HERDR_KEEP_PANE] === "1",
122
166
  closeOnSettle: Boolean(writerLease),
123
- onPane: onProgress ? (paneId, agentName) => onProgress({ paneId, agentName, state: "running" }) : undefined,
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
- timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
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: preparedWorkspace ? (pid) => preparedWorkspace.lease.attachProcess(pid) : undefined,
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 (session.ledgerPath) {
200
+ if (ledgerPath) {
141
201
  terminalAttempted = true;
142
- await appendLedgerEvent(
202
+ await appendAfterRuntimeRecord(runtimeRecord, () => appendLedgerEvent(
143
203
  {
144
- path: session.ledgerPath,
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 (session.ledgerPath && !terminalAttempted) {
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: session.ledgerPath,
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
- ledgerPath: session.ledgerPath,
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
+ }