@llblab/pi-actors 0.51.0 → 0.52.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 (47) hide show
  1. package/AGENTS.md +5 -3
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +3 -3
  5. package/dist/index.js +3 -0
  6. package/dist/lib/async-runs.d.ts +2 -2
  7. package/dist/lib/async-runs.js +42 -19
  8. package/dist/lib/extension-runtime.d.ts +1 -0
  9. package/dist/lib/extension-runtime.js +6 -1
  10. package/dist/lib/limits.d.ts +9 -0
  11. package/dist/lib/limits.js +9 -0
  12. package/dist/lib/observability.d.ts +10 -11
  13. package/dist/lib/observability.js +79 -56
  14. package/dist/lib/pi.d.ts +31 -0
  15. package/dist/lib/pi.js +180 -0
  16. package/dist/lib/run-delivery.d.ts +115 -0
  17. package/dist/lib/run-delivery.js +623 -0
  18. package/dist/lib/run-ui-runtime.d.ts +3 -0
  19. package/dist/lib/run-ui-runtime.js +341 -13
  20. package/dist/lib/runs-trace.d.ts +1 -1
  21. package/dist/lib/runs-trace.js +5 -3
  22. package/dist/lib/session-evidence.d.ts +16 -0
  23. package/dist/lib/session-evidence.js +143 -0
  24. package/dist/lib/temp.js +1 -1
  25. package/dist/lib/tools-inspect.js +3 -1
  26. package/dist/skills/actors/SKILL.md +2 -2
  27. package/dist/skills/actors/references/runs.md +1 -1
  28. package/dist/skills/swarm/SKILL.md +1 -1
  29. package/docs/README.md +1 -0
  30. package/docs/async-runs.md +4 -3
  31. package/docs/coordinator-delivery.md +207 -0
  32. package/index.ts +3 -0
  33. package/lib/async-runs.ts +42 -21
  34. package/lib/extension-runtime.ts +6 -1
  35. package/lib/limits.ts +9 -0
  36. package/lib/observability.ts +96 -78
  37. package/lib/pi.ts +210 -0
  38. package/lib/run-delivery.ts +800 -0
  39. package/lib/run-ui-runtime.ts +370 -18
  40. package/lib/runs-trace.ts +6 -4
  41. package/lib/session-evidence.ts +153 -0
  42. package/lib/temp.ts +1 -1
  43. package/lib/tools-inspect.ts +4 -1
  44. package/package.json +1 -1
  45. package/skills/actors/SKILL.md +2 -2
  46. package/skills/actors/references/runs.md +1 -1
  47. package/skills/swarm/SKILL.md +1 -1
@@ -15,7 +15,7 @@ A swarm can be coordinated without an external gateway. In this model the curren
15
15
 
16
16
  This resembles gateway orchestration in dependency direction but not in ownership: the coordinator is itself an agent instance with inspectable Runs, not an infrastructure service that implicitly creates sessions. Preserve that distinction in prompts, docs, recovery, and target routing.
17
17
 
18
- Once work is delegated, keep the coordinator available for decisions and integration instead of duplicating participant implementation. Wait for terminal follow-up by default; use meaningful attention or evidence-based timers for overdue work rather than a tight inspection loop.
18
+ Once work is delegated, keep the coordinator available for decisions and integration instead of duplicating participant implementation. Wait for the settled completion batch by default; use meaningful attention or evidence-based timers for overdue work rather than a tight inspection loop.
19
19
 
20
20
  ## Reasoning allocation
21
21
 
package/docs/README.md CHANGED
@@ -7,6 +7,7 @@ Living index of all documentation in the `/docs` directory.
7
7
  - [command-templates.md](./command-templates.md) — Portable synchronous command execution standard
8
8
  - [template-recipes.md](./template-recipes.md) — Saved JSON/Markdown recipe standard, imports, and reusable command-template graph composition
9
9
  - [async-runs.md](./async-runs.md) — Run lifecycle, state, Control, Trace, cancellation, and terminal reconciliation
10
+ - [coordinator-delivery.md](./coordinator-delivery.md) — Accepted next-minor design for durable terminal batching and explicit urgent Pi steering
10
11
  - [actor-inspector.md](./actor-inspector.md) — Owner-filtered actor-instance navigation through Recipe, Trace, and Control
11
12
  - [inspection.md](./inspection.md) — Complete `inspect` target/view matrix, authorization boundaries, and diagnostic routes
12
13
  - [tool-registry.md](./tool-registry.md) — Local `pi-actors` registry storage and `register_tool` adaptation
@@ -54,11 +54,12 @@ Trace records strict bounded events:
54
54
  ```json
55
55
  {"id":"…","ts":"…","kind":"command.done","summary":"Command completed","data":{"code":0},"level":"info"}
56
56
  {"id":"…","ts":"…","kind":"checkpoint.ready","summary":"Review needs a decision","level":"info","attention":"followup"}
57
+ {"id":"…","ts":"…","kind":"checkpoint.blocked","summary":"Approval required before migration","level":"warning","attention":"steer"}
57
58
  ```
58
59
 
59
60
  Required fields: `id`, `ts`, `kind`. Optional fields: `summary`, `data`, `level`, `attention`. Trace rejects addressed-envelope fields and malformed or oversized data. It retains a recent suffix within 2,048 events and 4 MiB. When either bound would be exceeded, the canonical lock atomically keeps a newest suffix near the lower targets, the new event, and one cumulative warning-only `runtime.trace_compacted` marker. The marker means older history was discarded; it reports cumulative drop evidence and never requests attention.
60
61
 
61
- Runtime lifecycle, runner progress, command completion, cancellation, kill, parent teardown, and controlled-service observations use Trace. Generic command lifecycle is Trace-only: runner-owned `command.done` records preserve level, captures, session provenance, and execution evidence but never request or project attention. No Recipe field configures command-completion delivery. Semantic checkpoints opt in explicitly with `attention: "notify"` or `attention: "followup"`; they must not infer coordinator intent from a leaf exit code. Bounded reads preserve complete UTF-8 lines and disclose omitted legacy prefixes. `inspect view=trace` reports retained-history completeness and projects events with Controls, owned Pi turns, logs, results, artifacts, and diagnostics newest-first. Equal timestamps use same-source physical order, then fixed source rank and stable id without claiming cross-source causality. Terminal state, `result.json`, `execution.json`, and artifacts remain authoritative even when old Trace has compacted.
62
+ Runtime lifecycle, runner progress, command completion, cancellation, kill, parent teardown, and controlled-service observations use Trace. Generic command lifecycle is Trace-only: runner-owned `command.done` records preserve level, captures, session provenance, and execution evidence but never request or project attention, even if malformed legacy evidence carries `steer`. No Recipe field configures command-completion delivery. Semantic checkpoints opt in explicitly: `attention: "notify"` is visible status, `attention: "followup"` supplies ordinary checkpoint context, and `attention: "steer"` requests urgent delivery at Pi's next safe assistant/tool boundary. Steer is never inferred from exit status; its exact Run generation and event id enter the bounded owner journal before Pi delivery, recover through owned session evidence, and require exact model-bound context acknowledgment. Presentation appends a generation-fenced non-attention `delivery.steer_presented` Trace marker so historical retained steer events cannot replay after bounded owner receipts rotate. The eventual root terminal remains independently eligible for its completion batch. Bounded reads preserve complete UTF-8 lines and disclose omitted legacy prefixes. `inspect view=trace` reports retained-history completeness and projects events with Controls, owned Pi turns, logs, results, artifacts, and diagnostics newest-first. Equal timestamps use same-source physical order, then fixed source rank and stable id without claiming cross-source causality. Terminal state, `result.json`, `execution.json`, and artifacts remain authoritative even when old Trace has compacted.
62
63
 
63
64
  ## Control
64
65
 
@@ -109,9 +110,9 @@ Statuses include `running`, `done`, `failed`, `exited`, `cancelled`, and `killed
109
110
 
110
111
  Ambient observation detects root terminal transitions and explicit retained Trace attention. Terminal transitions reconcile before semantic attention. Canonical attention is an in-memory wake hint, not a durable queue: observers prime retained ids at startup, deliver each later retained unseen id once, and bound memory to the current retained set across compaction. Persist durable recovery state or an artifact before emitting attention; compaction may discard older hints and its marker makes that history loss explicit. Terminal follow-up delivery persists handled/failure evidence so reloads retry unhandled transitions without duplicating completed notifications.
111
112
 
112
- By default, one normal finite Run produces exactly one automatic agent turn from its root terminal result. Sequence, parallel, repeat, and imported branches are internal execution topology and never own branch-level turns. Each separately launched Run owns its own generation and terminal lifecycle; compatible singleton reuse is not a new launch. Explicit semantic attention may intentionally add a checkpoint turn; Runs marked silent suppress automatic projection.
113
+ Ordinary finite Runs project root terminal results through one completion scheduler. Eligible terminals remain authoritative in Run state while Pi is active; after `agent_settled`, session recovery, or an idle debounce, the scheduler snapshots at most 256 exact generations into one owner-fenced immutable batch. One batch causes one automatic agent turn, exposes at most 64 bounded model-facing rows, and marks member terminals handled only after the exact batch id and content appear in model-bound Pi context. Pending send failures retain bounded retry evidence. On restart, queued recovery inspects only a bounded active Pi session parent chain: exact message evidence waits for presentation without resend, proven absence returns the same batch to pending, and incomplete or conflicting evidence stays queued with a diagnostic. Duplicate exact context envelopes collapse before presentation.
113
114
 
114
- Large semantic results stay outside compact visible follow-up text and remain available in structured details, execution captures, or artifacts.
115
+ Sequence, parallel, repeat, and imported branches are internal execution topology and never own branch-level turns. Each separately launched Run owns its own generation and terminal lifecycle; compatible singleton reuse is not a new launch. Explicit semantic attention may intentionally add a checkpoint turn; Runs marked silent and synchronously acknowledged stop outcomes suppress automatic projection. Large semantic results stay outside compact completion rows and remain available in structured details, execution captures, or artifacts.
115
116
 
116
117
  ## Cancellation and Kill
117
118
 
@@ -0,0 +1,207 @@
1
+ # Coordinator Delivery Scheduler
2
+
3
+ Status: Accepted next-minor design. Implementation is in progress; public delivery behavior remains unchanged until the complete acceptance boundary passes.
4
+
5
+ ## Goal
6
+
7
+ Separate durable Run completion truth from the scheduling of model turns:
8
+
9
+ ```text
10
+ one Run generation -> one root terminal record
11
+ one bounded completion epoch -> one coordinator turn
12
+ ```
13
+
14
+ Ordinary root terminals accumulate while Pi is active and reach the coordinator in one bounded batch after Pi settles. Only an explicitly actor-authored urgent semantic checkpoint may steer an active agent loop.
15
+
16
+ ## Non-Goals
17
+
18
+ - Do not interrupt generation mid-token.
19
+ - Do not stream generic progress or Command lifecycle into model context.
20
+ - Do not infer urgency from exit codes, failure status, artifacts, branch position, or active subagent counts.
21
+ - Do not change Run, Recipe, Trace, Control, artifact, generation, ownership, or Inspect authority.
22
+ - Do not add transport-specific behavior or restore Recipe-level Command delivery grammar.
23
+
24
+ ## Delivery Classes
25
+
26
+ - Generic Trace and runner-owned `command.done` remain Trace-only.
27
+ - `attention: "notify"` remains visible UI status without a model turn.
28
+ - `attention: "followup"` retains its existing explicit semantic follow-up behavior.
29
+ - New `attention: "steer"` requests urgent semantic delivery at Pi's next safe assistant/tool boundary.
30
+ - Root terminal transitions enter durable completion batching instead of sending one follow-up per Run.
31
+
32
+ `command.done` remains non-projectable even if malformed or legacy Trace attaches any attention value.
33
+
34
+ ## Ownership
35
+
36
+ - Run terminal state remains completion truth; the absence of `terminal-handled.json` makes that generation eligible for projection.
37
+ - `runs-trace.ts` owns admission of the new `steer` attention value.
38
+ - `observability.ts` discovers terminal candidates and explicit semantic attention without deciding Pi delivery timing.
39
+ - A new `run-delivery.ts` domain owns the owner-scoped delivery journal, batch construction, bounds, phases, formatting, recovery, and acknowledgments.
40
+ - `run-ui-runtime.ts` owns idle detection, debounce, reconciliation, flushing, and stale-context containment.
41
+ - `extension-runtime.ts` orders completion flushes before automatic Recipe review.
42
+ - `index.ts` remains a thin registration root and adds only the required Pi lifecycle adapter.
43
+ - `pi.ts` exposes narrow ports for batched follow-up and urgent steer delivery.
44
+
45
+ The local TypeScript dependency graph must remain acyclic. No public tool, target, view, Recipe field, or transport contract is added by default.
46
+
47
+ ## Durable Delivery State
48
+
49
+ Store one journal per exact coordinator owner under an internal path derived from a safe owner hash:
50
+
51
+ ```text
52
+ <extension-temp>/delivery/<owner-hash>/projection.json
53
+ ```
54
+
55
+ The journal records the exact internal owner and contains at most one active completion batch plus a bounded set of unpresented urgent steer envelopes.
56
+
57
+ Completion batch shape:
58
+
59
+ ```json
60
+ {
61
+ "batch_id": "uuid",
62
+ "owner_id": "exact internal owner",
63
+ "phase": "pending",
64
+ "members": [
65
+ {
66
+ "run": "review-a",
67
+ "run_instance_id": "generation-id",
68
+ "status": "done",
69
+ "state_dir": "internal path"
70
+ }
71
+ ],
72
+ "created_at": "timestamp"
73
+ }
74
+ ```
75
+
76
+ Phases are monotonic:
77
+
78
+ 1. `pending`: The exact member snapshot is durable but no Pi message has been accepted.
79
+ 2. `queued`: Pi accepted a custom message carrying the exact batch or steer ID.
80
+ 3. `presented`: A Pi `context` event observed that ID in messages being supplied to an LLM call.
81
+
82
+ Every journal mutation uses the canonical token-owned lock, expected-phase fencing, owner and generation validation, and atomic replacement. Repeated transitions are idempotent. Corrupt, oversized, foreign-owner, or stale-generation state fails closed with bounded diagnostics.
83
+
84
+ A queued envelope is not treated as presented merely because `sendMessage()` returned. Only presentation marks completion members through their existing terminal-handled authority. If a member was synchronously archived or pruned after queueing, the bounded delivery snapshot remains sufficient and the missing state write becomes a diagnostic rather than invalidating the batch.
85
+
86
+ ## Completion Collection
87
+
88
+ Reconciliation admits unhandled root terminal generations with status `done`, `failed`, `killed`, or `exited`.
89
+
90
+ It excludes:
91
+
92
+ - Runs with silent notification policy;
93
+ - synchronous stop or cancel outcomes already acknowledged by their caller;
94
+ - handled terminal generations;
95
+ - internal composition branches;
96
+ - every Command lifecycle event.
97
+
98
+ Candidates sort by terminal timestamp, then stable Run identity, then `run_instance_id`. Replacement generations with the same logical Run id remain distinct internal members.
99
+
100
+ While `ctx.isIdle()` is false, candidates remain durable in their Run state and no terminal follow-up is sent. A flush snapshots eligible candidates into one immutable batch. While that batch remains unpresented, newer terminals stay unhandled for the next bounded completion epoch.
101
+
102
+ ## Batch Flush
103
+
104
+ Flush one batch when:
105
+
106
+ 1. `agent_settled` fires for the still-active context and `ctx.isIdle()` remains true;
107
+ 2. terminals arrive while Pi is already idle and survive one short debounce window;
108
+ 3. session restoration discovers unhandled terminal generations or recoverable queued delivery state.
109
+
110
+ The model-facing custom message uses `customType: "pi-actors-run-batch"`, `deliverAs: "followUp"`, and `triggerTurn: true`. It includes:
111
+
112
+ - batch ID and completion window;
113
+ - counts by terminal status;
114
+ - stable Run, status, compact semantic summary, and bounded artifact rows;
115
+ - explicit overflow evidence and the canonical runtime Inspect route.
116
+
117
+ The journal may retain at most 256 members and 1 MiB. Model-facing content lists at most 64 exact rows within the centralized model-output bound. Additional members remain represented by exact status counts and supported Inspect guidance. More than 256 unhandled generations form a later batch rather than being discarded.
118
+
119
+ Completion member details remain redacted through existing terminal projection rules: no raw model policy, secrets, private Recipe paths, or machine-local source paths enter the message.
120
+
121
+ ## Presentation Acknowledgment And Recovery
122
+
123
+ Register a `context` lifecycle adapter that scans model-bound messages for exact pi-actors batch and steer IDs. On a matching active-owner envelope it atomically:
124
+
125
+ 1. moves the envelope to `presented`;
126
+ 2. marks every still-present member generation terminal-handled;
127
+ 3. records a non-attention `delivery.steer_presented` marker in the exact Run generation for a presented steer;
128
+ 4. retains a bounded owner receipt sufficient for near-term deduplication and diagnostics.
129
+
130
+ The generation-fenced Trace marker prevents a retained historical steer from replaying after bounded owner receipts rotate: suffix compaction cannot retain the older steer while discarding its newer presentation marker. Missing, archived, pruned, or replaced Run state needs no marker because it can no longer replay that original generation.
131
+
132
+ Recovery rules:
133
+
134
+ - Send failure: keep `pending`, record failure evidence, and retry.
135
+ - Crash after queueing: inspect existing owned Pi session evidence for the exact custom message ID.
136
+ - Queued message exists: do not resend; wait for `context` presentation.
137
+ - Queued message is absent: return the envelope to `pending`.
138
+ - Presented envelope: never resend.
139
+ - Session or context replacement: close timers and callbacks; never deliver through stale context.
140
+ - Owner mismatch: do not inspect, acknowledge, or deliver the envelope.
141
+
142
+ Session evidence inspection must reuse the existing bounded owned-session readers rather than adding raw unbounded session parsing.
143
+
144
+ ## Explicit Urgent Steer
145
+
146
+ Extend canonical Trace attention with `"steer"`:
147
+
148
+ ```json
149
+ {
150
+ "kind": "checkpoint.blocked",
151
+ "summary": "Approval required before destructive migration",
152
+ "attention": "steer"
153
+ }
154
+ ```
155
+
156
+ A steer event is:
157
+
158
+ - explicit and actor-authored;
159
+ - admitted to the durable owner delivery journal before Pi delivery;
160
+ - sent through `deliverAs: "steer"` with `triggerTurn: true`;
161
+ - presented only when its exact event ID appears in model-bound `context`;
162
+ - retried after delivery failure without duplicate presentation;
163
+ - independent from the eventual root-terminal batch.
164
+
165
+ Pi steering is a safe-boundary continuation, not token-level interruption: while streaming, Pi delivers it after the current assistant turn finishes its tool calls and before the next LLM call. If Pi is idle, it triggers a new turn immediately.
166
+
167
+ Urgent steer capacity is bounded to 64 unpresented envelopes within the same 1 MiB owner journal. Capacity pressure remains visible and retryable; it never degrades into generic follow-up or drops an admitted envelope silently.
168
+
169
+ ## Settled Lifecycle Ordering
170
+
171
+ `onAgentSettled` must use this order:
172
+
173
+ ```text
174
+ active-context and exact-owner check
175
+ -> completion flush
176
+ -> if a batch was sent, defer automatic Recipe review
177
+ -> batch-triggered model run settles
178
+ -> schedule automatic review only when no batch remains
179
+ ```
180
+
181
+ Another extension may start work during `agent_settled`; recheck `ctx.isIdle()` immediately before sending. A completion/settled race places each generation in either the current immutable batch or the next batch, never both.
182
+
183
+ ## Validation Contract
184
+
185
+ Implementation is complete only when source and packed-extension tests prove:
186
+
187
+ 1. Multiple Runs finishing during one agent run cause no immediate terminal turns and one settled batch.
188
+ 2. Idle completions inside the debounce window form one batch.
189
+ 3. Completion/settled races project every generation exactly once.
190
+ 4. Send failure and restart before queueing retry without a handled marker.
191
+ 5. Restart after queueing but before presentation neither loses nor duplicates the batch.
192
+ 6. Exact `context` presentation acknowledges members atomically and idempotently.
193
+ 7. Replacement generations sharing a Run id remain distinct.
194
+ 8. Silent, stopped, cancelled, handled, and foreign-owner Runs remain excluded.
195
+ 9. Legacy or malformed `command.done` attention, including `steer`, remains non-projectable.
196
+ 10. Explicit steer reaches the next safe Pi boundary once and root terminal still batches later.
197
+ 11. Overflow, corruption, journal backpressure, archive/prune races, and stale contexts fail safely.
198
+ 12. Completion flushing precedes automatic Recipe review.
199
+ 13. Pi 0.84.4 remains the exact minimum source and packed lifecycle baseline.
200
+
201
+ Focused observability and delivery tests precede TypeScript/build/import checks. The acceptance checkpoint then runs full product validation, dependency audit, package dry-run, and ABCd context validation.
202
+
203
+ ## Rollout
204
+
205
+ This is one minor release because durable batching, presentation acknowledgment, lifecycle ordering, and explicit steer share one model-delivery invariant. Do not ship partial batching that marks terminals handled at `sendMessage()` acceptance, and do not ship steer before its durable deduplication path exists.
206
+
207
+ Update README and Run documentation only when implementation establishes the new public behavior. Move the accepted outcome from BACKLOG to CHANGELOG only after complete validation.
package/index.ts CHANGED
@@ -15,6 +15,9 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
15
15
  );
16
16
  pi.on("session_start", async (_event, ctx) => runtime.onSessionStart(ctx));
17
17
  pi.on("agent_settled", async (_event, ctx) => runtime.onAgentSettled(ctx));
18
+ pi.on("context", async (event, ctx) => ({
19
+ messages: runtime.onContext(event.messages, ctx) as typeof event.messages,
20
+ }));
18
21
  pi.on("session_shutdown", async (event, ctx) =>
19
22
  runtime.onSessionShutdown(event.reason, ctx),
20
23
  );
package/lib/async-runs.ts CHANGED
@@ -1136,30 +1136,51 @@ function stopRun(
1136
1136
  export function markRunTerminalNotificationHandled(
1137
1137
  stateDir: string,
1138
1138
  status: string,
1139
- ): void {
1140
- markTerminalHandled(stateDir, {
1141
- event: "run.notification",
1142
- status,
1143
- });
1139
+ expectedRunInstanceId: string,
1140
+ ): boolean {
1141
+ const releaseLock = RunsStart.acquireStateStartLock(stateDir);
1142
+ try {
1143
+ if (!existsSync(join(stateDir, "run.json"))) return false;
1144
+ const current = getRunStatus(stateDir);
1145
+ if (
1146
+ current.run_instance_id !== expectedRunInstanceId ||
1147
+ current.status !== status
1148
+ ) return false;
1149
+ markTerminalHandled(stateDir, {
1150
+ event: "run.notification",
1151
+ run_instance_id: expectedRunInstanceId,
1152
+ status,
1153
+ });
1154
+ rmSync(join(stateDir, "terminal-delivery-failure.json"), { force: true });
1155
+ return true;
1156
+ } finally {
1157
+ releaseLock();
1158
+ }
1144
1159
  }
1145
1160
 
1146
- export function recordRunTerminalDeliveryFailure(
1161
+ export function markRunSteerPresentationHandled(
1147
1162
  stateDir: string,
1148
- status: string,
1149
- error: unknown,
1150
- ): void {
1151
- const path = join(stateDir, "terminal-delivery-failure.json");
1152
- const previous = readJson(path);
1153
- const message = (error instanceof Error ? error.message : String(error))
1154
- .replaceAll(/\s+/g, " ")
1155
- .trim()
1156
- .slice(0, 500);
1157
- writeJsonAtomic(path, {
1158
- attempts: Math.max(0, Number(previous?.attempts ?? 0)) + 1,
1159
- error: message || "unknown delivery failure",
1160
- status,
1161
- ts: new Date().toISOString(),
1162
- });
1163
+ expectedRunInstanceId: string,
1164
+ eventId: string,
1165
+ steerId: string,
1166
+ ): boolean {
1167
+ const releaseLock = RunsStart.acquireStateStartLock(stateDir);
1168
+ try {
1169
+ if (!existsSync(join(stateDir, "run.json"))) return false;
1170
+ const current = getRunStatus(stateDir);
1171
+ if (current.run_instance_id !== expectedRunInstanceId) return false;
1172
+ appendRunTraceEvent(stateDir, {
1173
+ data: {
1174
+ event_id: eventId,
1175
+ run_instance_id: expectedRunInstanceId,
1176
+ steer_id: steerId,
1177
+ },
1178
+ kind: "delivery.steer_presented",
1179
+ });
1180
+ return true;
1181
+ } finally {
1182
+ releaseLock();
1183
+ }
1163
1184
  }
1164
1185
 
1165
1186
  export function cancelRun(
@@ -26,6 +26,7 @@ export interface ActorExtensionRuntime {
26
26
  discoverResources(metaUrl: string): { skillPaths: string[] } | undefined;
27
27
  getRunOwnerId(ctx: Pi.ExtensionContext): string;
28
28
  onAgentSettled(ctx: Pi.ExtensionContext): void;
29
+ onContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
29
30
  onSessionShutdown(reason: string, ctx: Pi.ExtensionContext): void;
30
31
  onSessionStart(ctx: Pi.ExtensionContext): Promise<void>;
31
32
  registerCoreTools(): void;
@@ -142,7 +143,11 @@ export function createActorExtensionRuntime(
142
143
  },
143
144
  getRunOwnerId,
144
145
  onAgentSettled(ctx) {
145
- if (activeRunContext === ctx) automaticReview.schedule();
146
+ if (activeRunContext !== ctx) return;
147
+ if (!runUiRuntime.flushCompletionBatch(ctx)) automaticReview.schedule();
148
+ },
149
+ onContext(messages, ctx) {
150
+ return runUiRuntime.projectContext(messages, ctx);
146
151
  },
147
152
  onSessionShutdown(reason, ctx) {
148
153
  const ownerId = runOwnerIdsByContext.get(ctx);
package/lib/limits.ts CHANGED
@@ -28,3 +28,12 @@ export const RUN_CONTROL_ERROR_MAX_BYTES = 4 * 1024;
28
28
  export const RUN_CONTROL_JOURNAL_MAX_BYTES = 1024 * 1024;
29
29
  export const RUN_RETENTION_MAX_RECORDS = 256;
30
30
  export const RUN_RETENTION_MAX_BYTES = 1024 * 1024;
31
+ export const RUN_DELIVERY_BATCH_MAX_MEMBERS = 256;
32
+ export const RUN_DELIVERY_MODEL_MAX_MEMBERS = 64;
33
+ export const RUN_DELIVERY_MODEL_MAX_BYTES = 16 * 1024;
34
+ export const RUN_DELIVERY_RECEIPT_LIMIT = 128;
35
+ export const RUN_DELIVERY_SESSION_MAX_ENTRIES = 256;
36
+ export const RUN_DELIVERY_SESSION_MAX_BYTES = 1024 * 1024;
37
+ export const RUN_DELIVERY_STEER_MAX_ENVELOPES = 64;
38
+ export const RUN_DELIVERY_STEER_MAX_BYTES = 16 * 1024;
39
+ export const RUN_DELIVERY_JOURNAL_MAX_BYTES = 1024 * 1024;
@@ -26,6 +26,7 @@ import {
26
26
  import * as AsyncRuns from "./async-runs.ts";
27
27
  import * as Paths from "./paths.ts";
28
28
  import * as RunsTrace from "./runs-trace.ts";
29
+ import type { RunCompletionBatchMember } from "./run-delivery.ts";
29
30
  import { readJsonlFileResilient } from "./state-readers.ts";
30
31
 
31
32
  export type RunObservedStatus =
@@ -35,7 +36,7 @@ export type RunObservedStatus =
35
36
  | "exited"
36
37
  | "cancelled"
37
38
  | "killed";
38
- export type RunTraceAttention = "log" | "notify" | "followup";
39
+ export type RunTraceAttention = "log" | "notify" | "followup" | "steer";
39
40
  export type RunTraceLevel = "info" | "warning" | "error";
40
41
 
41
42
  export interface RunObservation {
@@ -53,6 +54,7 @@ export interface RunObservation {
53
54
  terminalHandled?: boolean;
54
55
  retireWhen?: string;
55
56
  run: string;
57
+ runInstanceId?: string;
56
58
  semanticResult?: RunTerminalSemanticResult;
57
59
  tool?: string;
58
60
  stateDir?: string;
@@ -149,70 +151,6 @@ export function pruneRunUiObservationState(
149
151
  );
150
152
  }
151
153
 
152
- export function deliverRunTransitionNotifications(
153
- transitions: RunTransition[],
154
- sink: RunUiNotificationSink,
155
- inFlight: Set<string> = new Set(),
156
- ): void {
157
- for (const transition of transitions) {
158
- if (!shouldNotifyRunTransition(transition)) continue;
159
- const key = transition.stateDir ?? transition.run;
160
- if (inFlight.has(key)) continue;
161
- inFlight.add(key);
162
- try {
163
- const text = formatRunTransitionMessage(transition);
164
- sink.notify(text, getRunTransitionNotificationType(transition));
165
- if (!shouldSendRunTransitionFollowUp(transition)) continue;
166
- sink.sendFollowUp({
167
- customType: "pi-actors-run",
168
- content: text,
169
- display: false,
170
- details: transition,
171
- });
172
- if (transition.stateDir) {
173
- AsyncRuns.markRunTerminalNotificationHandled(
174
- transition.stateDir,
175
- transition.to,
176
- );
177
- }
178
- } catch (error) {
179
- if (transition.stateDir) {
180
- AsyncRuns.recordRunTerminalDeliveryFailure(
181
- transition.stateDir,
182
- transition.to,
183
- error,
184
- );
185
- }
186
- const message = error instanceof Error ? error.message : String(error);
187
- sink.notify(
188
- `Actor terminal delivery failed for run:${transition.run}: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`,
189
- "error",
190
- );
191
- } finally {
192
- inFlight.delete(key);
193
- }
194
- }
195
- }
196
-
197
- export function reconcileRunTerminalNotifications(input: {
198
- inFlight?: Set<string>;
199
- ownerId: string;
200
- sink: RunUiNotificationSink;
201
- state: RunUiObservationState;
202
- stateRoot?: string;
203
- includeAttention?: boolean;
204
- }): RunUiSnapshot {
205
- const snapshot = readRunUiSnapshot(input.state, input.ownerId, {
206
- includeAttention: input.includeAttention,
207
- stateRoot: input.stateRoot,
208
- });
209
- deliverRunTransitionNotifications(snapshot.transitions, input.sink, input.inFlight);
210
- if (input.includeAttention)
211
- deliverRunAttentionNotifications(snapshot.attentionEvents, input.sink);
212
- pruneRunUiObservationState(input.state, snapshot);
213
- return snapshot;
214
- }
215
-
216
154
  export function deliverRunAttentionNotifications(
217
155
  events: RunAttentionEvent[],
218
156
  sink: RunUiNotificationSink,
@@ -447,7 +385,9 @@ export interface RunRetirementExecutorOptions {
447
385
  export interface RunTransition {
448
386
  from: RunObservedStatus;
449
387
  run: string;
388
+ runInstanceId?: string;
450
389
  stateDir?: string;
390
+ terminalAt?: string;
451
391
  artifacts?: Record<string, string>;
452
392
  launchCorrelation?: Record<string, string>;
453
393
  launchSource?: AsyncRuns.AsyncRunLaunchSource;
@@ -477,6 +417,7 @@ export interface RunAttentionEvent {
477
417
  level: RunTraceLevel;
478
418
  metadata?: Record<string, unknown>;
479
419
  run: string;
420
+ runInstanceId?: string;
480
421
  stateDir: string;
481
422
  summary: string;
482
423
  ts: string;
@@ -513,11 +454,18 @@ function getProgress(status: Record<string, unknown>): Record<string, unknown> {
513
454
 
514
455
  function getUpdatedAt(status: Record<string, unknown>): string | undefined {
515
456
  const progress = getProgress(status);
516
- return typeof progress.updatedAt === "string"
517
- ? progress.updatedAt
518
- : typeof status.createdAt === "string"
519
- ? status.createdAt
520
- : undefined;
457
+ const result = status.result &&
458
+ typeof status.result === "object" &&
459
+ !Array.isArray(status.result)
460
+ ? status.result as Record<string, unknown>
461
+ : {};
462
+ return typeof result.completed_at === "string"
463
+ ? result.completed_at
464
+ : typeof progress.updatedAt === "string"
465
+ ? progress.updatedAt
466
+ : typeof status.createdAt === "string"
467
+ ? status.createdAt
468
+ : undefined;
521
469
  }
522
470
 
523
471
  function scanRunStateDirs(
@@ -658,6 +606,9 @@ function observeRun(stateDir: string): RunObservation | undefined {
658
606
  ...(typeof status.recipe_file === "string"
659
607
  ? { recipeFile: status.recipe_file }
660
608
  : {}),
609
+ ...(typeof status.run_instance_id === "string"
610
+ ? { runInstanceId: status.run_instance_id }
611
+ : {}),
661
612
  ...(status.terminal_handled ? { terminalHandled: true } : {}),
662
613
  ...(typeof status.retire_when === "string"
663
614
  ? { retireWhen: status.retire_when }
@@ -1060,8 +1011,10 @@ export function detectRunTransitions(
1060
1011
  ...(run.launchSource ? { launchSource: run.launchSource } : {}),
1061
1012
  ...(run.modelPolicy ? { modelPolicy: run.modelPolicy } : {}),
1062
1013
  ...(run.recipeFile ? { recipeFile: run.recipeFile } : {}),
1014
+ ...(run.runInstanceId ? { runInstanceId: run.runInstanceId } : {}),
1063
1015
  ...(run.semanticResult ? { semanticResult: run.semanticResult } : {}),
1064
1016
  ...(run.terminalHandled ? { terminalHandled: true } : {}),
1017
+ ...(run.updatedAt ? { terminalAt: run.updatedAt } : {}),
1065
1018
  to: run.status,
1066
1019
  ...(run.tool ? { tool: run.tool } : {}),
1067
1020
  });
@@ -1107,7 +1060,9 @@ function parseAttentionRecord(
1107
1060
  ...(raw.body !== undefined ? { body: raw.body } : {}),
1108
1061
  ...(raw.data !== undefined ? { data: raw.data } : {}),
1109
1062
  attention:
1110
- raw.attention === "notify" || raw.attention === "followup"
1063
+ raw.attention === "notify" ||
1064
+ raw.attention === "followup" ||
1065
+ raw.attention === "steer"
1111
1066
  ? raw.attention
1112
1067
  : normalizeTraceAttention(raw.delivery),
1113
1068
  id,
@@ -1119,6 +1074,7 @@ function parseAttentionRecord(
1119
1074
  ? { metadata: raw.metadata as Record<string, unknown> }
1120
1075
  : {}),
1121
1076
  run: run.run,
1077
+ ...(run.runInstanceId ? { runInstanceId: run.runInstanceId } : {}),
1122
1078
  stateDir: run.stateDir,
1123
1079
  summary,
1124
1080
  ts,
@@ -1167,6 +1123,16 @@ export function detectRunAttentionEvents(
1167
1123
  const read = readTraceAttentionRecords(run);
1168
1124
  const retained = new Set<string>();
1169
1125
  const seen = seenEventIds.get(key) ?? new Set<string>();
1126
+ const presentedSteerIds = new Set(read.records.flatMap((record) => {
1127
+ if (
1128
+ record.kind !== "delivery.steer_presented" ||
1129
+ !record.data ||
1130
+ typeof record.data !== "object" ||
1131
+ Array.isArray(record.data)
1132
+ ) return [];
1133
+ const eventId = (record.data as Record<string, unknown>).event_id;
1134
+ return typeof eventId === "string" && eventId ? [eventId] : [];
1135
+ }));
1170
1136
  const start = read.canonical ? 0
1171
1137
  : Math.min(legacyLineCounts.get(key) ?? 0, read.records.length);
1172
1138
  for (const [index, record] of read.records.entries()) {
@@ -1174,8 +1140,14 @@ export function detectRunAttentionEvents(
1174
1140
  if (!event || !shouldNotifyRunAttentionEvent(event) ||
1175
1141
  event.kind === "runtime.trace_compacted") continue;
1176
1142
  retained.add(event.id);
1177
- if (prime || run.notificationPolicy === "silent") seen.add(event.id);
1178
- else if (index >= start && !seen.has(event.id)) {
1143
+ if (isRunSteerAttentionEvent(event) && presentedSteerIds.has(event.id)) {
1144
+ seen.add(event.id);
1145
+ continue;
1146
+ }
1147
+ if (run.notificationPolicy === "silent") seen.add(event.id);
1148
+ else if (prime) {
1149
+ if (!isRunSteerAttentionEvent(event)) seen.add(event.id);
1150
+ } else if (index >= start && !seen.has(event.id)) {
1179
1151
  events.push(event); seen.add(event.id);
1180
1152
  }
1181
1153
  }
@@ -1194,7 +1166,20 @@ export function getRunAttentionNotificationType(
1194
1166
 
1195
1167
  export function shouldNotifyRunAttentionEvent(event: RunAttentionEvent): boolean {
1196
1168
  if (event.kind === "command.done") return false;
1197
- return event.attention === "notify" || event.attention === "followup";
1169
+ return event.attention === "notify" ||
1170
+ event.attention === "followup" ||
1171
+ event.attention === "steer";
1172
+ }
1173
+
1174
+ export function isRunSteerAttentionEvent(event: RunAttentionEvent): boolean {
1175
+ return event.kind !== "command.done" && event.attention === "steer";
1176
+ }
1177
+
1178
+ export function retryRunAttentionEvent(
1179
+ state: RunUiObservationState,
1180
+ event: Pick<RunAttentionEvent, "id" | "stateDir">,
1181
+ ): void {
1182
+ state.attentionEventIds.get(event.stateDir)?.delete(event.id);
1198
1183
  }
1199
1184
 
1200
1185
  export function shouldSendRunAttentionFollowUp(event: RunAttentionEvent): boolean {
@@ -1297,10 +1282,43 @@ export function shouldNotifyRunTransition(transition: RunTransition): boolean {
1297
1282
  );
1298
1283
  }
1299
1284
 
1300
- export function shouldSendRunTransitionFollowUp(
1301
- transition: RunTransition,
1302
- ): boolean {
1303
- return shouldNotifyRunTransition(transition);
1285
+ /** Build exact immutable generation members for owner-journal admission. */
1286
+ export function collectRunCompletionBatchMembers(
1287
+ transitions: RunTransition[],
1288
+ ): RunCompletionBatchMember[] {
1289
+ return transitions.flatMap((transition) => {
1290
+ if (
1291
+ !shouldNotifyRunTransition(transition) ||
1292
+ !transition.stateDir ||
1293
+ !transition.runInstanceId ||
1294
+ !transition.terminalAt ||
1295
+ Number.isNaN(Date.parse(transition.terminalAt))
1296
+ ) return [];
1297
+ const artifactEntries = Object.entries(transition.artifacts ?? {})
1298
+ .filter((entry): entry is [string, string] =>
1299
+ typeof entry[1] === "string" && Boolean(entry[1]))
1300
+ .slice(0, 4);
1301
+ const rawSummary = transition.semanticResult?.summary.trim() ||
1302
+ `Run ${transition.to}.`;
1303
+ return [{
1304
+ ...(artifactEntries.length > 0
1305
+ ? { artifacts: Object.fromEntries(artifactEntries) }
1306
+ : {}),
1307
+ run: transition.run,
1308
+ run_instance_id: transition.runInstanceId,
1309
+ state_dir: transition.stateDir,
1310
+ status: transition.to as RunCompletionBatchMember["status"],
1311
+ summary: rawSummary.length > 1_000
1312
+ ? `${rawSummary.slice(0, 999)}…`
1313
+ : rawSummary,
1314
+ terminal_at: transition.terminalAt,
1315
+ }];
1316
+ }).sort((left, right) =>
1317
+ left.terminal_at.localeCompare(right.terminal_at) ||
1318
+ left.run.localeCompare(right.run) ||
1319
+ left.run_instance_id.localeCompare(right.run_instance_id) ||
1320
+ left.state_dir.localeCompare(right.state_dir)
1321
+ );
1304
1322
  }
1305
1323
 
1306
1324
  const TERMINAL_FOLLOW_UP_ARTIFACT_LIMIT = 4;