@llblab/pi-actors 0.21.0 → 0.22.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/BACKLOG.md +39 -0
- package/CHANGELOG.md +13 -1
- package/dist/lib/actor-rooms.js +18 -0
- package/dist/lib/async-runs.d.ts +19 -1
- package/dist/lib/async-runs.js +201 -10
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +13 -4
- package/docs/actor-messages.md +5 -3
- package/docs/async-runs.md +4 -2
- package/docs/recipe-library.md +11 -4
- package/lib/actor-rooms.ts +23 -0
- package/lib/async-runs.ts +261 -15
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +26 -5
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/scripts/coordinator.mjs +227 -114
- package/scripts/locker.mjs +30 -9
- package/scripts/music-player.mjs +401 -94
- package/skills/actors/SKILL.md +1 -1
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
package/dist/lib/tools.js
CHANGED
|
@@ -235,9 +235,9 @@ function compactCommunicationSnapshot(snapshot) {
|
|
|
235
235
|
return "\n(no communication snapshot)";
|
|
236
236
|
return `\nself=${snapshot.self} root=${snapshot.root} rooms=${snapshot.rooms.length} updated_at=${snapshot.updated_at}`;
|
|
237
237
|
}
|
|
238
|
-
function
|
|
238
|
+
function compactInboxMessages(messages, emptyLabel) {
|
|
239
239
|
if (messages.length === 0)
|
|
240
|
-
return
|
|
240
|
+
return `\n(no ${emptyLabel} messages)`;
|
|
241
241
|
return `\n${messages
|
|
242
242
|
.map((message) => [
|
|
243
243
|
...(message.id ? [`id=${String(message.id)}`] : []),
|
|
@@ -246,12 +246,19 @@ function compactBranchInbox(messages) {
|
|
|
246
246
|
`from=${String(message.from ?? "")}`,
|
|
247
247
|
`to=${String(message.to ?? "")}`,
|
|
248
248
|
...(message.queued_at ? [`queued_at=${String(message.queued_at)}`] : []),
|
|
249
|
+
...(message.sent_at ? [`sent_at=${String(message.sent_at)}`] : []),
|
|
249
250
|
...(message.claimed_at ? [`claimed_at=${String(message.claimed_at)}`] : []),
|
|
250
251
|
...(message.handled_at ? [`handled_at=${String(message.handled_at)}`] : []),
|
|
251
252
|
...(message.failed_at ? [`failed_at=${String(message.failed_at)}`] : []),
|
|
252
253
|
].join(" "))
|
|
253
254
|
.join("\n")}`;
|
|
254
255
|
}
|
|
256
|
+
function compactBranchInbox(messages) {
|
|
257
|
+
return compactInboxMessages(messages, "branch inbox");
|
|
258
|
+
}
|
|
259
|
+
function compactRunMailbox(run, mailbox, messages) {
|
|
260
|
+
return `\nrun=${run} accepts=${Array.isArray(mailbox.accepts) ? mailbox.accepts.join(",") : ""} emits=${Array.isArray(mailbox.emits) ? mailbox.emits.join(",") : ""}${compactInboxMessages(messages, "run inbox")}`;
|
|
261
|
+
}
|
|
255
262
|
function compactActorFiles(status) {
|
|
256
263
|
const run = String(status.run ?? "<unknown>");
|
|
257
264
|
const artifacts = asRecord(status.artifacts);
|
|
@@ -726,14 +733,16 @@ export function createInspectToolDefinition(deps = {}) {
|
|
|
726
733
|
case "mailbox": {
|
|
727
734
|
const status = assertRunAccessibleToContext(runId, ctx);
|
|
728
735
|
const mailbox = asRecord(status.mailbox);
|
|
736
|
+
const messages = AsyncRuns.readRunInboxMessages(runId, Number(input.lines || 40));
|
|
737
|
+
const details = { mailbox, messages };
|
|
729
738
|
return {
|
|
730
739
|
content: [
|
|
731
740
|
{
|
|
732
741
|
type: "text",
|
|
733
|
-
text: maybeJsonText(
|
|
742
|
+
text: maybeJsonText(details, input.verbose === true, compactRunMailbox(String(status.run ?? runId), mailbox, messages)),
|
|
734
743
|
},
|
|
735
744
|
],
|
|
736
|
-
details
|
|
745
|
+
details,
|
|
737
746
|
};
|
|
738
747
|
}
|
|
739
748
|
case "communication": {
|
package/docs/actor-messages.md
CHANGED
|
@@ -44,7 +44,7 @@ An alternate implementation shape is a dedicated non-LLM communication actor: a
|
|
|
44
44
|
|
|
45
45
|
That actor-backed shape can also reduce direct file storage. Instead of every protocol feature owning JSON files as primary state, a helper actor can keep live room/roster structures in memory or another local structure and write files only as snapshots, audit logs, artifacts, or recovery checkpoints. The decision boundary is practical: keep files when durability and inspectability are the main value; prefer actor-owned structures when live coordination, subscriptions, fanout, unread state, or mutation consistency becomes the main value.
|
|
46
46
|
|
|
47
|
-
Current backend decision: keep the file-backed adapter for now. The covered workload is append-heavy room coordination plus direct branch inbox queueing/claiming, where durable local files are still the useful source of truth for recovery and `inspect`. A communication helper should be introduced only when a real workflow needs long-lived subscriptions, live fanout policy, or shared mutable room state beyond the current lock/debounce/compaction safeguards.
|
|
47
|
+
Current backend decision: keep the file-backed adapter for now. The covered workload is append-heavy room coordination plus direct branch inbox queueing/claiming, where durable local files are still the useful source of truth for recovery and `inspect`. Live notification is a separate advisory wake layer: actors may subscribe to `wake.jsonl` changes through a cross-platform file notifier and still reconcile canonical mailbox files if a wake is missed. A communication helper should be introduced only when a real workflow needs long-lived subscriptions, live fanout policy, or shared mutable room state beyond the current lock/debounce/compaction safeguards.
|
|
48
48
|
|
|
49
49
|
Package-specific endpoints may still exist, but the envelope stays the same.
|
|
50
50
|
|
|
@@ -106,7 +106,9 @@ Transport is not public API unless a recipe explicitly documents a custom endpoi
|
|
|
106
106
|
|
|
107
107
|
The task room is the discovery and shared-context layer for actors whose spawn-tree positions do not give them each other's addresses. The spawn tree remains the lifecycle/provenance structure; the task room describes the group communication graph. Direct messages and room messages can share the same semantic `type` such as `chat.message`; the route (`to: branch:*` versus `to: room:*`) determines whether delivery is private or group-wide.
|
|
108
108
|
|
|
109
|
-
Use direct branch messages only when the receiving branch is backed by a worker or recipe that reads the parent run mailbox or branch inbox and dispatches branch-targeted envelopes. Room roster contacts are discovery hints, not a guarantee that an independent prompt process is subscribed to its branch address. The current branch inbox records queued
|
|
109
|
+
Use direct branch messages only when the receiving branch is backed by a worker or recipe that reads the parent run mailbox or branch inbox and dispatches branch-targeted envelopes. Room roster contacts are discovery hints, not a guarantee that an independent prompt process is subscribed to its branch address. The current branch inbox records queued mailbox work; runner-side claiming/handling turns direct messages into prompt work for the recipient branch, while room messages remain shared transcript entries. A direct message may ask the recipient to inspect room history when broader shared context is needed. For ad hoc or transcript-driven swarms without such a runner, prefer room-visible replies and mentions so every participant can inspect the shared timeline.
|
|
110
|
+
|
|
111
|
+
Direct branch delivery is prompt steering for worker-backed branches, not a coordinator follow-up. Packaged coordinator flows claim queued branch inbox records immediately before launching the branch's next prompt, append a bounded "direct messages for you" section to that prompt, and then mark the claimed records `handled` or `failed` based on the prompt result. Generic one-shot `pi -p` children do not receive this injection automatically; a recipe must own the runner loop or use the packaged coordinator path for direct messages to become next-prompt work.
|
|
110
112
|
|
|
111
113
|
Selected-recipient multicast stays route-based: send one `to: room:<run>` envelope with `metadata.recipients` set to same-run branch addresses. The room timeline keeps the original room-visible envelope, and the runtime also forwards branch-targeted copies to each listed recipient. This is not a subroom; it is a shared transcript plus explicit direct delivery for actors whose worker protocol consumes branch envelopes.
|
|
112
114
|
|
|
@@ -197,7 +199,7 @@ Recipes can declare their conversational surface:
|
|
|
197
199
|
}
|
|
198
200
|
```
|
|
199
201
|
|
|
200
|
-
The implementation supports `status`, `tail`, `messages`, `artifacts`, `files`, `mailbox`, and `communication` for `run:<id>` actors, `status`, `messages`, `previews`, `roster`, and `contacts` for `room:<run>` actors, `status`/`runs` for `coordinator`, `session:<id>`, and `session:all` actors with optional status filtering, and `status`/`schema` for registered `tool:<name>` actors. Room `status` returns compact message/roster counts plus `last_message_at`, `last_message_from`, `last_message_type`, and `last_message_summary` when available, without parsing the full timeline into actor envelopes. Use `messages` for actor-envelope inspection. `inspect target=coordinator` requires a current coordinator session; use `session:<id>` or `session:all` when the session is intentionally explicit. Direct `run:<id>` and `room:<run>` inspection respects coordinator-session ownership when the current session is known. `inspect` is for decision points and diagnosis only; examples must not teach sleep-then-inspect polling.
|
|
202
|
+
The implementation supports `status`, `tail`, `messages`, `artifacts`, `files`, `mailbox`, and `communication` for `run:<id>` actors, `status`, `messages`, `previews`, `roster`, and `contacts` for `room:<run>` actors, `status`/`runs` for `coordinator`, `session:<id>`, and `session:all` actors with optional status filtering, and `status`/`schema` for registered `tool:<name>` actors. Run mailbox inspection shows recipe-declared mailbox metadata plus recent durable run inbox entries; branch mailbox inspection shows branch-local queued/claimed/handled records. Room `status` returns compact message/roster counts plus `last_message_at`, `last_message_from`, `last_message_type`, and `last_message_summary` when available, without parsing the full timeline into actor envelopes. Use `messages` for actor-envelope inspection. `inspect target=coordinator` requires a current coordinator session; use `session:<id>` or `session:all` when the session is intentionally explicit. Direct `run:<id>` and `room:<run>` inspection respects coordinator-session ownership when the current session is known. `inspect` is for decision points and diagnosis only; examples must not teach sleep-then-inspect polling.
|
|
201
203
|
|
|
202
204
|
## Runtime Direction
|
|
203
205
|
|
package/docs/async-runs.md
CHANGED
|
@@ -156,7 +156,7 @@ The actor-level surface is:
|
|
|
156
156
|
|
|
157
157
|
- `spawn`: start a detached `run:<id>` actor from `file`, `recipe`, or inline `template`.
|
|
158
158
|
- `message`: send one typed envelope to `run:<id>`, `branch:<run>/<branch>`, `room:<run>`, `tool:<name>`, `coordinator`, or `session:<id>`.
|
|
159
|
-
- `inspect`: intentionally read owned `run:<id>` status, tail, messages, artifacts, files, mailbox metadata, or communication snapshot; read `room:<run>` status, messages, previews, roster, or contacts; read current `coordinator` run inventory only when a coordinator session is known; read `session:<id>` or `session:all` run inventory with optional status filtering when the session is explicit; read `tool:<name>` status or schema for registered tool actors.
|
|
159
|
+
- `inspect`: intentionally read owned `run:<id>` status, tail, messages, artifacts, files, mailbox metadata plus recent run inbox entries, or communication snapshot; read `room:<run>` status, messages, previews, roster, or contacts; read current `coordinator` run inventory only when a coordinator session is known; read `session:<id>` or `session:all` run inventory with optional status filtering when the session is explicit; read `tool:<name>` status or schema for registered tool actors.
|
|
160
160
|
|
|
161
161
|
Opt-in supervisor retirement uses `retire_when: "children_terminal"` as lifecycle metadata. Run summaries discover nested child run state dirs under the visible state root so bounded supervisor trees are observable. Candidate detection is conservative: a supervisor is not retirement-ready while command-template progress, descendant `pi -p` worker processes, or nested child async runs under the supervisor state dir are still active. Candidate metadata includes observed child-run counts. When the session watcher observes a ready candidate, it sends a graceful `stop` control message once; if the run has no ready control endpoint, it falls back to owned-run cancellation and records the terminal action through normal run events. Persistent or non-opt-in runs are not retirement candidates.
|
|
162
162
|
|
|
@@ -183,10 +183,12 @@ Some recipes expose a run-local control channel. When present, a caller can send
|
|
|
183
183
|
}
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
For `run:<id>`, `message` adapts the body to the recipe's run-local control channel. For `branch:<run>/<branch>`, it sends the full envelope through the parent run mailbox and records a queued branch-local inbox entry at `branches/<branch>/inbox.jsonl` so the run can dispatch branch-local control. Current consumers are recipe-specific worker protocols that read the parent run mailbox or branch inbox; independent one-shot prompt processes do not automatically consume branch inbox entries. For `tool:<name>`, object bodies become the target tool parameters and primitive bodies are passed as `{ "input": body }`. The generic runtime records control messages but does not interpret arbitrary run mailbox content. For example, a music player may accept `play`, `pause`, `next`, and `stop`, while a collaborative agent recipe may accept `continue`, `revise:<note>`, `approve`, or `abort`. Recipes may treat terminal control messages such as `stop` as synchronously handled so the later process exit does not generate a duplicate async follow-up.
|
|
186
|
+
For `run:<id>`, `message` adapts the body to the recipe's run-local control channel. For `branch:<run>/<branch>`, it sends the full envelope through the parent run mailbox and records a queued branch-local inbox entry at `branches/<branch>/inbox.jsonl` so the run can dispatch branch-local control. Current consumers are recipe-specific worker protocols that read the parent run mailbox or branch inbox; independent one-shot prompt processes do not automatically consume branch inbox entries. In packaged coordinator flows, queued branch inbox records are claimed immediately before the branch's next prompt is launched, appended as direct prompt-steering context, and then marked `handled` or `failed` from the prompt result. This path is not a follow-up notification; it is a runner-owned prompt queue. For `tool:<name>`, object bodies become the target tool parameters and primitive bodies are passed as `{ "input": body }`. The generic runtime records control messages but does not interpret arbitrary run mailbox content. For example, a music player may accept `play`, `pause`, `next`, and `stop`, while a collaborative agent recipe may accept `continue`, `revise:<note>`, `approve`, or `abort`. Recipes may treat terminal control messages such as `stop` as synchronously handled so the later process exit does not generate a duplicate async follow-up.
|
|
187
187
|
|
|
188
188
|
Run-local control uses a platform adapter under the same `message` API. Unix recipes may keep the existing FIFO endpoint, and native Windows recipes can expose a named-pipe endpoint in run state. Recipe authors should document message vocabulary through `mailbox.accepts`, not through transport arguments. Packaged scripts that still create Unix-only endpoints remain WSL/Linux/macOS-only until migrated.
|
|
189
189
|
|
|
190
|
+
Runtime wake notifications are now modeled separately from durable queues. Message handling records canonical state in file-backed mailbox/event files before attempting optional live endpoint delivery. Runs may expose a mailbox-only control endpoint when durable inbox plus wake notification is the intended delivery path; FIFO and named-pipe endpoints remain compatibility/fast-wake paths rather than the durable queue itself. Successful FIFO or named-pipe delivery marks the run inbox entry `sent`; mailbox-only delivery leaves the entry queued for the runtime to claim. `wake.jsonl` is an advisory doorbell that lets a live runtime subscribe through file-system notifications plus explicit initial, wake-triggered, and polling reconciliation callbacks. A missed wake must not lose work because actors can re-read the canonical mailbox state. Runtime loops that consume the file-backed mailbox should claim queued run inbox entries, then mark them `handled` or `failed`; the helper path uses a small lock so concurrent reconciliation callbacks do not process the same entry twice.
|
|
191
|
+
|
|
190
192
|
## Coordinator Notifications
|
|
191
193
|
|
|
192
194
|
The launching coordinator should not busy-poll long-running async runs. The extension watches run state directories and delivers terminal `done`/`failed`/unhandled `killed`/`exited` transitions plus script-authored `notify`/`followup` actor messages back to the owning session. This gives the top-level async task a completion signal on the happy path while still letting recipe-local messages bubble up when scripts need finer-grained notifications. Terminal follow-ups include recipe-level named `artifacts` when declared. The generic runner also emits compact `command.done` actor messages for completed leaf commands; recipe authors declare that capability in `mailbox.emits` rather than configuring a separate delivery policy. Failures and in-flight parallel branch completions can bubble as follow-ups, while successful final leaf completions stay diagnostic to avoid flooding long sequential pipelines. Branch-level `command.done` follow-ups omit artifact manifests because the top-level terminal follow-up carries them once. Intentional `control.stop`, `control.kill`, and recipe-local stop commands stay out of follow-up context because the initiating message already returns synchronously. If a follow-up asks for direction, answer with `message` rather than starting a polling loop. Use explicit `inspect` only when a delivered follow-up requests inspection, a real decision depends on state, or a suspected stuck run needs diagnosis — never merely because a timeout elapsed.
|
package/docs/recipe-library.md
CHANGED
|
@@ -136,12 +136,12 @@ Files:
|
|
|
136
136
|
|
|
137
137
|
Purpose: start a local or URL audio source as an async run so the agent can continue working while playback runs in the background. The running script exposes a run-local mailbox, so addressed `message` calls can control playback without a second recipe.
|
|
138
138
|
|
|
139
|
-
Requirements:
|
|
139
|
+
Requirements: Node.js and one playback backend. Supported backends are `mpv`, macOS `afplay`, `ffplay`, `cvlc`, SoX `play`, or `wmp` on native Windows through the legacy Windows Media Player COM control exposed by `powershell.exe`. The `wmp` backend validates `wmplayer.exe` under `Program Files/Windows Media Player` or `Program Files (x86)/Windows Media Player`; it does not target the newer UWP/Store Media Player. Playback format support depends on the selected player; the actor control path itself uses the portable mailbox/wake runtime layer.
|
|
140
140
|
|
|
141
141
|
The required `source` arg accepts:
|
|
142
142
|
|
|
143
143
|
- A single local file or URL.
|
|
144
|
-
- A directory containing audio files; the wrapper scans `.
|
|
144
|
+
- A directory containing audio files; the wrapper scans `.aac`, `.aif`, `.aiff`, `.flac`, `.m4a`, `.mp3`, `.ogg`, and `.wav` files.
|
|
145
145
|
- An `.m3u`, `.m3u8`, or `.txt` playlist file.
|
|
146
146
|
- A `|`-separated inline list of local files or URLs.
|
|
147
147
|
|
|
@@ -158,7 +158,7 @@ Register playback:
|
|
|
158
158
|
register_tool name=music_player \
|
|
159
159
|
description="Start async music player playback through the Node.js wrapper" \
|
|
160
160
|
template="music-player.json" \
|
|
161
|
-
args="source:string,loop:bool=true,volume:int=70,player:enum(auto,mpv,ffplay,cvlc,play)=auto"
|
|
161
|
+
args="source:string,loop:bool=true,volume:int=70,player:enum(auto,mpv,afplay,ffplay,cvlc,play,wmp)=auto"
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
Start playback:
|
|
@@ -185,7 +185,14 @@ The wrapper also accepts control commands directly when a caller already has the
|
|
|
185
185
|
scripts/music-player.mjs next ~/.pi/agent/tmp/pi-actors/runs/music
|
|
186
186
|
```
|
|
187
187
|
|
|
188
|
-
Message body is
|
|
188
|
+
Message body is queued in the recipe's run-local mailbox and reconciled by the player loop. The loop treats `wake.jsonl` and `fs.watch` as advisory signals, then verifies the durable inbox signature before taking the inbox lock so unchanged mailboxes are not reread on every tick. On Unix-like hosts, child players run in their own process group so pause/resume/next/stop controls can signal the playback subtree directly. The script writes `status.txt`, `player.json`, and track-change actor messages in the same state dir. Track-change messages stay diagnostic by default; interactive recipes should define a small command vocabulary for addressed messages, emit semantic actor messages for decision points, and let the coordinator react to messages rather than sleep-polling state.
|
|
189
|
+
|
|
190
|
+
Cross-platform smoke checklist:
|
|
191
|
+
|
|
192
|
+
- Linux: install one backend such as `mpv` or `ffplay`; start `music_player source="~/Music" run_id=music`, send `pause`, `play`, `next`, and `stop`, then inspect `run:music` status/mailbox.
|
|
193
|
+
- macOS: verify `player=auto` selects `afplay` when no preferred CLI backend is installed, then run the same addressed message controls.
|
|
194
|
+
- Native Windows: verify `player=wmp` detects `wmplayer.exe`, starts playback through Windows Media Player COM, handles `pause`/`play`/`next`/`previous`/`stop`, and leaves handled mailbox records visible through `inspect target=run:music view=mailbox`.
|
|
195
|
+
- All hosts: confirm missed wake resilience by checking that queued mailbox commands are eventually claimed without relying on a transport-specific endpoint.
|
|
189
196
|
|
|
190
197
|
## Safety Notes
|
|
191
198
|
|
package/lib/actor-rooms.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
|
|
11
11
|
import type { ActorMessage } from "./actor-messages.ts";
|
|
12
|
+
import { notifyRuntimeWake } from "./runtime-notifier.ts";
|
|
12
13
|
|
|
13
14
|
const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
14
15
|
const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
@@ -181,6 +182,19 @@ function writeJsonFile(file: string, value: unknown): void {
|
|
|
181
182
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
function notifyActorWake(
|
|
186
|
+
stateDir: string,
|
|
187
|
+
actor: string,
|
|
188
|
+
reason: string,
|
|
189
|
+
metadata: Record<string, unknown> = {},
|
|
190
|
+
): void {
|
|
191
|
+
try {
|
|
192
|
+
notifyRuntimeWake(stateDir, { actor, metadata, reason });
|
|
193
|
+
} catch {
|
|
194
|
+
// Runtime wakes are advisory; durable room/inbox state remains canonical.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
184
198
|
function positiveEnvInt(name: string, fallback: number): number {
|
|
185
199
|
const value = Number(process.env[name] ?? fallback);
|
|
186
200
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
@@ -422,6 +436,10 @@ export function appendBranchInboxMessage(
|
|
|
422
436
|
`${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`,
|
|
423
437
|
{ flag: "a" },
|
|
424
438
|
);
|
|
439
|
+
notifyActorWake(stateDir, address, "branch.message", {
|
|
440
|
+
...(message.from ? { from: message.from } : {}),
|
|
441
|
+
type: message.type,
|
|
442
|
+
});
|
|
425
443
|
} finally {
|
|
426
444
|
releaseLock();
|
|
427
445
|
}
|
|
@@ -451,6 +469,7 @@ export function updateBranchInboxMessageStatus(
|
|
|
451
469
|
if (!changed) return false;
|
|
452
470
|
const compacted = compactBranchInboxMessages(updated);
|
|
453
471
|
fs.writeFileSync(file, `${compacted.map((message) => JSON.stringify(message)).join("\n")}\n`);
|
|
472
|
+
notifyActorWake(stateDir, address, "branch.inbox.status", { id, status });
|
|
454
473
|
return true;
|
|
455
474
|
} finally {
|
|
456
475
|
releaseLock();
|
|
@@ -472,6 +491,10 @@ export function appendRoomMessage(
|
|
|
472
491
|
const run = runFromRoomAddress(message.to);
|
|
473
492
|
if (run) {
|
|
474
493
|
writeCommunicationSnapshot(stateDir, run);
|
|
494
|
+
notifyActorWake(stateDir, message.to, "room.message", {
|
|
495
|
+
...(message.from ? { from: message.from } : {}),
|
|
496
|
+
type: message.type,
|
|
497
|
+
});
|
|
475
498
|
if (message.from && branchIdFromAddress(message.from, run)) {
|
|
476
499
|
writeBranchCommunicationSnapshotDebounced(stateDir, run, message.from);
|
|
477
500
|
}
|
package/lib/async-runs.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Owns detached run state, observation, log tailing, listing, and cancellation safety
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
7
8
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
9
|
import {
|
|
9
10
|
closeSync,
|
|
@@ -33,14 +34,16 @@ import { writeJsonAtomic } from "./file-state.ts";
|
|
|
33
34
|
import * as Paths from "./paths.ts";
|
|
34
35
|
import * as RecipeReferences from "./recipe-references.ts";
|
|
35
36
|
import * as RecipeUsage from "./recipe-usage.ts";
|
|
37
|
+
import { notifyRuntimeWake } from "./runtime-notifier.ts";
|
|
36
38
|
|
|
37
39
|
const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
40
|
+
const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
|
|
38
41
|
|
|
39
42
|
export type AsyncRunLaunchSource = "spawn" | "tool";
|
|
40
43
|
|
|
41
44
|
export interface AsyncRunControlEndpoint {
|
|
42
45
|
path: string;
|
|
43
|
-
type: "fifo" | "named-pipe";
|
|
46
|
+
type: "fifo" | "mailbox" | "named-pipe";
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export interface AsyncRunStartParams {
|
|
@@ -662,6 +665,193 @@ export function readRunEvents(runOrDir: string, lines = 40): RunOutboxEvent[] {
|
|
|
662
665
|
.filter((event): event is RunOutboxEvent => Boolean(event));
|
|
663
666
|
}
|
|
664
667
|
|
|
668
|
+
export type RunInboxStatus =
|
|
669
|
+
| "queued"
|
|
670
|
+
| "sent"
|
|
671
|
+
| "claimed"
|
|
672
|
+
| "handled"
|
|
673
|
+
| "failed";
|
|
674
|
+
|
|
675
|
+
export type RunInboxMessage = Record<string, unknown> & {
|
|
676
|
+
id?: string;
|
|
677
|
+
status?: RunInboxStatus | string;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
export interface ProcessRunInboxResult {
|
|
681
|
+
claimed: number;
|
|
682
|
+
failed: number;
|
|
683
|
+
handled: number;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function runInboxFile(stateDir: string): string {
|
|
687
|
+
return join(stateDir, "inbox.jsonl");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function sleepSync(ms: number): void {
|
|
691
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function acquireRunInboxLock(stateDir: string): () => void {
|
|
695
|
+
const lockDir = join(stateDir, ".inbox.lock");
|
|
696
|
+
const started = Date.now();
|
|
697
|
+
while (true) {
|
|
698
|
+
try {
|
|
699
|
+
mkdirSync(lockDir, { recursive: false });
|
|
700
|
+
writeFileSync(
|
|
701
|
+
join(lockDir, "owner.json"),
|
|
702
|
+
`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
|
|
703
|
+
"utf8",
|
|
704
|
+
);
|
|
705
|
+
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
706
|
+
} catch (error) {
|
|
707
|
+
try {
|
|
708
|
+
const stat = statSync(lockDir);
|
|
709
|
+
if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
|
|
710
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
|
|
717
|
+
throw new Error("Run inbox lock timed out.", { cause: error });
|
|
718
|
+
}
|
|
719
|
+
sleepSync(10);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function parseRunInboxLine(line: string): RunInboxMessage | undefined {
|
|
725
|
+
try {
|
|
726
|
+
return JSON.parse(line) as RunInboxMessage;
|
|
727
|
+
} catch {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
|
|
733
|
+
const file = runInboxFile(stateDir);
|
|
734
|
+
if (!existsSync(file)) return [];
|
|
735
|
+
return readFileSync(file, "utf8")
|
|
736
|
+
.split("\n")
|
|
737
|
+
.filter((line) => line.trim())
|
|
738
|
+
.map(parseRunInboxLine)
|
|
739
|
+
.filter((message): message is RunInboxMessage => Boolean(message));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function writeRunInboxMessages(
|
|
743
|
+
stateDir: string,
|
|
744
|
+
messages: RunInboxMessage[],
|
|
745
|
+
): void {
|
|
746
|
+
writeFileSync(
|
|
747
|
+
runInboxFile(stateDir),
|
|
748
|
+
messages.length
|
|
749
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
750
|
+
: "",
|
|
751
|
+
"utf8",
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function readRunInboxMessages(
|
|
756
|
+
runOrDir: string,
|
|
757
|
+
lines = 40,
|
|
758
|
+
): RunInboxMessage[] {
|
|
759
|
+
const status = getRunStatus(runOrDir);
|
|
760
|
+
const stateDir = String(status.state_dir);
|
|
761
|
+
return tailLines(runInboxFile(stateDir), lines)
|
|
762
|
+
.map(parseRunInboxLine)
|
|
763
|
+
.filter((message): message is RunInboxMessage => Boolean(message));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function updateRunInboxMessageStatus(
|
|
767
|
+
runOrDir: string,
|
|
768
|
+
id: string,
|
|
769
|
+
nextStatus: RunInboxStatus,
|
|
770
|
+
metadata: Record<string, unknown> = {},
|
|
771
|
+
): boolean {
|
|
772
|
+
const status = getRunStatus(runOrDir);
|
|
773
|
+
const stateDir = String(status.state_dir);
|
|
774
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
775
|
+
try {
|
|
776
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
777
|
+
const timestampKey = `${nextStatus}_at`;
|
|
778
|
+
let changed = false;
|
|
779
|
+
const updated = messages.map((message) => {
|
|
780
|
+
if (message.id !== id) return message;
|
|
781
|
+
changed = true;
|
|
782
|
+
return {
|
|
783
|
+
...message,
|
|
784
|
+
...metadata,
|
|
785
|
+
[timestampKey]: new Date().toISOString(),
|
|
786
|
+
status: nextStatus,
|
|
787
|
+
};
|
|
788
|
+
});
|
|
789
|
+
if (changed) writeRunInboxMessages(stateDir, updated);
|
|
790
|
+
return changed;
|
|
791
|
+
} finally {
|
|
792
|
+
releaseLock();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function claimRunInboxMessage(
|
|
797
|
+
runOrDir: string,
|
|
798
|
+
owner = "runtime",
|
|
799
|
+
statuses: string[] = ["queued"],
|
|
800
|
+
): RunInboxMessage | undefined {
|
|
801
|
+
const status = getRunStatus(runOrDir);
|
|
802
|
+
const stateDir = String(status.state_dir);
|
|
803
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
804
|
+
try {
|
|
805
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
806
|
+
const index = messages.findIndex((message) =>
|
|
807
|
+
statuses.includes(String(message.status ?? "queued")),
|
|
808
|
+
);
|
|
809
|
+
if (index < 0) return undefined;
|
|
810
|
+
const claimed = {
|
|
811
|
+
...messages[index],
|
|
812
|
+
claimed_at: new Date().toISOString(),
|
|
813
|
+
claimed_by: owner,
|
|
814
|
+
id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
|
|
815
|
+
status: "claimed",
|
|
816
|
+
} satisfies RunInboxMessage;
|
|
817
|
+
messages[index] = claimed;
|
|
818
|
+
writeRunInboxMessages(stateDir, messages);
|
|
819
|
+
return claimed;
|
|
820
|
+
} finally {
|
|
821
|
+
releaseLock();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export async function processRunInboxMessages(
|
|
826
|
+
runOrDir: string,
|
|
827
|
+
handler: (message: RunInboxMessage) => Promise<void> | void,
|
|
828
|
+
options: { limit?: number; owner?: string; statuses?: string[] } = {},
|
|
829
|
+
): Promise<ProcessRunInboxResult> {
|
|
830
|
+
const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
|
|
831
|
+
const limit = Math.max(1, Number(options.limit ?? 1));
|
|
832
|
+
const owner = options.owner ?? "runtime";
|
|
833
|
+
for (let index = 0; index < limit; index += 1) {
|
|
834
|
+
const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
|
|
835
|
+
if (!message?.id) break;
|
|
836
|
+
result.claimed += 1;
|
|
837
|
+
try {
|
|
838
|
+
await handler(message);
|
|
839
|
+
if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
|
|
840
|
+
result.handled += 1;
|
|
841
|
+
}
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (
|
|
844
|
+
updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
|
|
845
|
+
error: error instanceof Error ? error.message : String(error),
|
|
846
|
+
})
|
|
847
|
+
) {
|
|
848
|
+
result.failed += 1;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return result;
|
|
853
|
+
}
|
|
854
|
+
|
|
665
855
|
export function appendRunOutboxEvent(
|
|
666
856
|
runOrDir: string,
|
|
667
857
|
event: {
|
|
@@ -725,7 +915,9 @@ function getRunControlEndpoint(
|
|
|
725
915
|
if (control && typeof control === "object" && !Array.isArray(control)) {
|
|
726
916
|
const record = control as Record<string, unknown>;
|
|
727
917
|
if (
|
|
728
|
-
(record.type === "fifo" ||
|
|
918
|
+
(record.type === "fifo" ||
|
|
919
|
+
record.type === "mailbox" ||
|
|
920
|
+
record.type === "named-pipe") &&
|
|
729
921
|
typeof record.path === "string" &&
|
|
730
922
|
record.path.trim()
|
|
731
923
|
) {
|
|
@@ -735,10 +927,36 @@ function getRunControlEndpoint(
|
|
|
735
927
|
return { path: join(stateDir, "control.fifo"), type: "fifo" };
|
|
736
928
|
}
|
|
737
929
|
|
|
930
|
+
function appendRunInboxMessage(stateDir: string, message: string): string {
|
|
931
|
+
const id = randomUUID();
|
|
932
|
+
const ts = new Date().toISOString();
|
|
933
|
+
let record: Record<string, unknown>;
|
|
934
|
+
try {
|
|
935
|
+
const parsed = JSON.parse(message) as unknown;
|
|
936
|
+
record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
937
|
+
? (parsed as Record<string, unknown>)
|
|
938
|
+
: { body: parsed, type: "run.message" };
|
|
939
|
+
} catch {
|
|
940
|
+
record = { body: message, type: "run.message" };
|
|
941
|
+
}
|
|
942
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
943
|
+
try {
|
|
944
|
+
writeFileSync(
|
|
945
|
+
runInboxFile(stateDir),
|
|
946
|
+
`${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
|
|
947
|
+
{ flag: "a" },
|
|
948
|
+
);
|
|
949
|
+
} finally {
|
|
950
|
+
releaseLock();
|
|
951
|
+
}
|
|
952
|
+
return id;
|
|
953
|
+
}
|
|
954
|
+
|
|
738
955
|
function writeRunMessageReceipt(
|
|
739
956
|
stateDir: string,
|
|
740
957
|
message: string,
|
|
741
958
|
bytes: number,
|
|
959
|
+
inboxId: string,
|
|
742
960
|
): void {
|
|
743
961
|
const trimmedMessage = message.trim().toLowerCase();
|
|
744
962
|
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
|
|
@@ -747,19 +965,10 @@ function writeRunMessageReceipt(
|
|
|
747
965
|
const ts = new Date().toISOString();
|
|
748
966
|
writeFileSync(
|
|
749
967
|
join(stateDir, "events.jsonl"),
|
|
750
|
-
`${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
|
|
968
|
+
`${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`,
|
|
751
969
|
{ flag: "a" },
|
|
752
970
|
);
|
|
753
|
-
|
|
754
|
-
const envelope = JSON.parse(message) as Record<string, unknown>;
|
|
755
|
-
writeFileSync(
|
|
756
|
-
join(stateDir, "inbox.jsonl"),
|
|
757
|
-
`${JSON.stringify({ ...envelope, received_at: ts })}\n`,
|
|
758
|
-
{ flag: "a" },
|
|
759
|
-
);
|
|
760
|
-
} catch {
|
|
761
|
-
// Plain control lines are already represented in events.jsonl.
|
|
762
|
-
}
|
|
971
|
+
updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
|
|
763
972
|
if (terminalMessage) {
|
|
764
973
|
markTerminalHandled(stateDir, {
|
|
765
974
|
event: "run.message",
|
|
@@ -787,6 +996,25 @@ function sendRunMessageToFifo(
|
|
|
787
996
|
}
|
|
788
997
|
}
|
|
789
998
|
|
|
999
|
+
function notifyRunMessageWake(
|
|
1000
|
+
stateDir: string,
|
|
1001
|
+
run: string,
|
|
1002
|
+
bytes: number,
|
|
1003
|
+
endpoint: AsyncRunControlEndpoint,
|
|
1004
|
+
inboxId: string,
|
|
1005
|
+
): Record<string, unknown> | undefined {
|
|
1006
|
+
try {
|
|
1007
|
+
const event = notifyRuntimeWake(stateDir, {
|
|
1008
|
+
actor: `run:${run}`,
|
|
1009
|
+
metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
|
|
1010
|
+
reason: "run.message",
|
|
1011
|
+
});
|
|
1012
|
+
return { wake: "wake.jsonl", wake_id: event.id };
|
|
1013
|
+
} catch {
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
790
1018
|
function sendRunMessageToNamedPipe(
|
|
791
1019
|
endpoint: AsyncRunControlEndpoint,
|
|
792
1020
|
payload: string,
|
|
@@ -832,7 +1060,23 @@ export async function sendRunMessage(
|
|
|
832
1060
|
throw new Error(`Run pid owner mismatch: ${run}`);
|
|
833
1061
|
const endpoint = getRunControlEndpoint(status, stateDir);
|
|
834
1062
|
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
1063
|
+
const payloadBytes = Buffer.byteLength(payload);
|
|
1064
|
+
const inboxId = appendRunInboxMessage(stateDir, message);
|
|
1065
|
+
const wake = notifyRunMessageWake(stateDir, run, payloadBytes, endpoint, inboxId);
|
|
835
1066
|
const runtimePlatform = options.platform ?? process.platform;
|
|
1067
|
+
if (endpoint.type === "mailbox") {
|
|
1068
|
+
return {
|
|
1069
|
+
...(wake ?? {}),
|
|
1070
|
+
bytes: payloadBytes,
|
|
1071
|
+
control: "inbox.jsonl",
|
|
1072
|
+
control_path: endpoint.path,
|
|
1073
|
+
control_type: endpoint.type,
|
|
1074
|
+
queued: true,
|
|
1075
|
+
run,
|
|
1076
|
+
sent: true,
|
|
1077
|
+
state_dir: stateDir,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
836
1080
|
try {
|
|
837
1081
|
if (endpoint.type === "fifo") {
|
|
838
1082
|
if (runtimePlatform === "win32") {
|
|
@@ -841,8 +1085,9 @@ export async function sendRunMessage(
|
|
|
841
1085
|
);
|
|
842
1086
|
}
|
|
843
1087
|
const bytes = sendRunMessageToFifo(endpoint, payload);
|
|
844
|
-
writeRunMessageReceipt(stateDir, message, bytes);
|
|
1088
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
845
1089
|
return {
|
|
1090
|
+
...(wake ?? {}),
|
|
846
1091
|
bytes,
|
|
847
1092
|
control: "control.fifo",
|
|
848
1093
|
control_path: endpoint.path,
|
|
@@ -857,8 +1102,9 @@ export async function sendRunMessage(
|
|
|
857
1102
|
payload,
|
|
858
1103
|
options.namedPipeSend,
|
|
859
1104
|
);
|
|
860
|
-
writeRunMessageReceipt(stateDir, message, bytes);
|
|
1105
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
861
1106
|
return {
|
|
1107
|
+
...(wake ?? {}),
|
|
862
1108
|
bytes,
|
|
863
1109
|
control: endpoint.path,
|
|
864
1110
|
control_path: endpoint.path,
|