@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/BACKLOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## Open Work
|
|
4
4
|
|
|
5
|
+
### Native Windows Smoke and Runtime Notification Follow-up
|
|
6
|
+
|
|
7
|
+
- Priority: High.
|
|
8
|
+
- Target: Post-0.22.0 validation and hardening.
|
|
9
|
+
- Goal: Validate the cross-platform actor wake/notification layer on native Windows without changing the public actor API or replacing file-backed actor state as the observable source of truth.
|
|
10
|
+
- Decision:
|
|
11
|
+
- Keep files as durable truth: mailbox/state/event/room files remain canonical for `inspect`, observability, crash recovery, and replay.
|
|
12
|
+
- Treat runtime notification as an advisory wake layer, not as the queue itself.
|
|
13
|
+
- Preserve `spawn`, `message`, and `inspect` as the only public actor API; platform transport choices stay internal.
|
|
14
|
+
- Progress:
|
|
15
|
+
- Prepared the cross-platform runtime notification layer for release with a file-backed runtime notifier boundary using `notify(actor)` / `subscribe(actor, onWake)`, persisted `wake.jsonl` records, `fs.watch` subscription, and periodic fallback coverage.
|
|
16
|
+
- Notifier subscriptions can now receive explicit reconciliation callbacks for initial scan, wake-triggered scans, and polling fallback.
|
|
17
|
+
- Run-local `message` delivery now records the durable run inbox entry and advisory wake before attempting the optional live control endpoint; successful endpoint delivery marks the inbox entry `sent`.
|
|
18
|
+
- Run mailbox inspection now shows recent durable inbox entries alongside recipe-declared mailbox metadata.
|
|
19
|
+
- Added run inbox claim/handle/fail helpers for runtime loops, including locked claims so reconciliation callbacks can safely dispatch queued mailbox work once.
|
|
20
|
+
- Added mailbox-only run control endpoints so runtimes can accept `message` through durable inbox/wake state without requiring FIFO or named-pipe delivery.
|
|
21
|
+
- Migrated the packaged music-player control path to queued mailbox commands as the first concrete script using the mailbox-only runtime direction.
|
|
22
|
+
- Added a native Windows `wmp` music-player backend using legacy Windows Media Player COM via `powershell.exe`, with `wmplayer.exe` detection in standard Program Files locations and mailbox-backed controls mapped to WMP play/pause/stop operations.
|
|
23
|
+
- Hardened the music-player mailbox loop to avoid repeated unchanged mailbox reads by combining advisory wake records, `fs.watch`, and inbox signature polling.
|
|
24
|
+
- Improved Unix-like playback support with the macOS-native `afplay` backend, broader audio extension scanning, and process-group signaling for child playback controls.
|
|
25
|
+
- Room timeline appends and branch inbox append/status transitions now emit advisory wake records for the addressed room or branch actor.
|
|
26
|
+
- Direction:
|
|
27
|
+
- Continue wiring mailbox-only endpoints and notifier reconciliation callbacks into concrete packaged actor scripts where file-backed mailbox dispatch should replace transport-specific control loops.
|
|
28
|
+
- Ensure message delivery writes durable file-backed mailbox/state first, then emits a wake notification.
|
|
29
|
+
- Require actor runtimes to reconcile mailbox state on wake and also on a periodic fallback so missed notifications do not lose work.
|
|
30
|
+
- Provide a universal baseline backend using file-system change notification plus periodic reconcile across Linux, macOS, and Windows.
|
|
31
|
+
- Keep FIFO/named-pipe/socket style endpoints as optional fast wake backends or compatibility paths, not as required durable queues.
|
|
32
|
+
- Document the model as "wake, not queue": notification wakes a live actor; files remain the queue and audit trail.
|
|
33
|
+
- Windows smoke focus:
|
|
34
|
+
- Run installed `@llblab/pi-actors@0.22.0` or newer on native Windows.
|
|
35
|
+
- Verify simple `spawn` / `message` / `inspect` actor communication.
|
|
36
|
+
- Verify small room-swarm/subagent communication, branch/direct messages, mailbox claim/handled transitions, graceful stop/cancel behavior, and opt-in retirement.
|
|
37
|
+
- If smoke passes, update docs/release notes from "adapter support" to "Windows smoke-tested subagent communication" for the next release.
|
|
38
|
+
- Exit:
|
|
39
|
+
- Actor communication works through the cross-platform notifier layer with public API unchanged.
|
|
40
|
+
- Inspect/observability continue to read canonical file state and do not depend on a live notifier process.
|
|
41
|
+
- Missed wake notifications are recovered by mailbox reconciliation.
|
|
42
|
+
- Windows subagent communication smoke is documented with results and any remaining limitations.
|
|
43
|
+
|
|
5
44
|
### Consensus-First Build Recipe
|
|
6
45
|
|
|
7
46
|
- Priority: Medium.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.22.0: Cross-Platform Runtime Notification Layer
|
|
6
|
+
|
|
7
|
+
- `[Runtime]` Started the cross-platform notification layer with a file-backed advisory wake notifier (`wake.jsonl`), explicit initial/wake/poll reconciliation callbacks, periodic reconciliation fallback, and run-message/room-message/branch-inbox wake records. Run messages now persist a canonical inbox record before optional endpoint delivery, can accept mailbox-only control endpoints without FIFO/named-pipe transport, mark delivered endpoint messages `sent`, expose recent run inbox entries through `inspect view=mailbox`, and provide locked run-inbox claim/handle/fail helpers for runtime reconciliation loops. Files remain the canonical mailbox/event state for inspection and crash recovery.
|
|
8
|
+
- `[Docs/Tests]` Documented the "wake, not queue" runtime model, added cross-platform music-player smoke guidance, and added coverage for persisted wake events, missed `fs.watch` recovery through polling, and Windows named-pipe message wakes.
|
|
9
|
+
- `[Packaging]` Removed the root JavaScript entrypoint wrapper from packaged files and pointed extension metadata directly at the compiled `dist/index.js` output. Source checkouts keep `index.ts` as the only root entrypoint while installed packages load compiled JavaScript from `dist`.
|
|
10
|
+
- `[Docs/Prompts]` Removed stale FIFO-queue wording from branch-direct message docs and coordinator prompt injection so queued mailbox work is described consistently with the notification/runtime model. Clarified that worker-backed direct branch messages are runner-owned prompt steering, not coordinator follow-ups, while one-shot prompt children do not consume branch inbox records automatically.
|
|
11
|
+
- `[Recipes]` Migrated the packaged music-player control path from Unix FIFO commands to queued mailbox commands, preserving addressed `message` control while making the script align with mailbox-only runtime endpoints.
|
|
12
|
+
- `[Recipes]` Added a native Windows `wmp` music-player backend that drives legacy Windows Media Player through `powershell.exe`/COM, verifies `wmplayer.exe` in the standard Program Files locations, and includes mailbox-backed play, pause, next, previous, and stop controls.
|
|
13
|
+
- `[Recipes]` Reduced music-player mailbox overhead by using advisory wake records, `fs.watch` where available, and inbox file signatures so the loop avoids repeatedly locking and rereading an unchanged mailbox.
|
|
14
|
+
- `[Recipes]` Improved Unix-like playback by adding the macOS-native `afplay` backend, scanning additional common audio extensions, and running child players in their own process group so controls can signal the playback subtree directly.
|
|
15
|
+
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata for the minor release.
|
|
16
|
+
|
|
5
17
|
## 0.21.0: Native Windows Actor Control and Literate Recipes
|
|
6
18
|
|
|
7
19
|
- `[Async Runs]` Added a platform-adapted run-control path: Unix FIFO behavior remains backward-compatible, native Windows can target named-pipe run-control endpoints recorded in run state, and run message receipts still update events and inbox state through the same actor-message path.
|
|
@@ -15,7 +27,7 @@
|
|
|
15
27
|
- `[Coordinator]` Consolidated direct branch inbox claim/finalize rewrites behind one locked mutation helper and moved room-swarm mode dispatch behind an explicit mode registry. Unknown coordinator modes now fail closed, and `pipeline-room-swarm` exposes the supported mode enum.
|
|
16
28
|
- `[Docs]` Documented the local Actor OS smoke matrix covered by `npm test`, spanning room coordination, direct branch delivery, inbox claim/handle transitions, inspector navigation, recipe context injection, persistence suggestions, and opt-in retirement smoke.
|
|
17
29
|
- `[Docs/Tests]` Documented native Windows support scope and added regression coverage for Windows endpoint metadata, mocked named-pipe sends, Windows process-control planning, unchanged Unix FIFO behavior, locker control metadata, branch inbox compaction, mixed room/direct workloads, Markdown recipe loading/discovery/validation, nested child-run retirement gating, and packaged recipe trust diagnostics.
|
|
18
|
-
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata
|
|
30
|
+
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata for the minor release.
|
|
19
31
|
|
|
20
32
|
## 0.20.2: Installed Extension Entrypoint Hotfix
|
|
21
33
|
|
package/dist/lib/actor-rooms.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
+
import { notifyRuntimeWake } from "./runtime-notifier.js";
|
|
9
10
|
const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
10
11
|
const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
11
12
|
const DEFAULT_ROOM_MAX_MESSAGES = 10000;
|
|
@@ -98,6 +99,14 @@ function writeJsonFile(file, value) {
|
|
|
98
99
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
100
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
100
101
|
}
|
|
102
|
+
function notifyActorWake(stateDir, actor, reason, metadata = {}) {
|
|
103
|
+
try {
|
|
104
|
+
notifyRuntimeWake(stateDir, { actor, metadata, reason });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Runtime wakes are advisory; durable room/inbox state remains canonical.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
101
110
|
function positiveEnvInt(name, fallback) {
|
|
102
111
|
const value = Number(process.env[name] ?? fallback);
|
|
103
112
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
@@ -295,6 +304,10 @@ export function appendBranchInboxMessage(stateDir, run, address, message) {
|
|
|
295
304
|
const releaseLock = acquireBranchInboxLock(stateDir, branch);
|
|
296
305
|
try {
|
|
297
306
|
fs.writeFileSync(branchInboxFile(stateDir, branch), `${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`, { flag: "a" });
|
|
307
|
+
notifyActorWake(stateDir, address, "branch.message", {
|
|
308
|
+
...(message.from ? { from: message.from } : {}),
|
|
309
|
+
type: message.type,
|
|
310
|
+
});
|
|
298
311
|
}
|
|
299
312
|
finally {
|
|
300
313
|
releaseLock();
|
|
@@ -320,6 +333,7 @@ export function updateBranchInboxMessageStatus(stateDir, run, address, id, statu
|
|
|
320
333
|
return false;
|
|
321
334
|
const compacted = compactBranchInboxMessages(updated);
|
|
322
335
|
fs.writeFileSync(file, `${compacted.map((message) => JSON.stringify(message)).join("\n")}\n`);
|
|
336
|
+
notifyActorWake(stateDir, address, "branch.inbox.status", { id, status });
|
|
323
337
|
return true;
|
|
324
338
|
}
|
|
325
339
|
finally {
|
|
@@ -337,6 +351,10 @@ export function appendRoomMessage(stateDir, room, message) {
|
|
|
337
351
|
const run = runFromRoomAddress(message.to);
|
|
338
352
|
if (run) {
|
|
339
353
|
writeCommunicationSnapshot(stateDir, run);
|
|
354
|
+
notifyActorWake(stateDir, message.to, "room.message", {
|
|
355
|
+
...(message.from ? { from: message.from } : {}),
|
|
356
|
+
type: message.type,
|
|
357
|
+
});
|
|
340
358
|
if (message.from && branchIdFromAddress(message.from, run)) {
|
|
341
359
|
writeBranchCommunicationSnapshotDebounced(stateDir, run, message.from);
|
|
342
360
|
}
|
package/dist/lib/async-runs.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as RecipeReferences from "./recipe-references.ts";
|
|
|
8
8
|
export type AsyncRunLaunchSource = "spawn" | "tool";
|
|
9
9
|
export interface AsyncRunControlEndpoint {
|
|
10
10
|
path: string;
|
|
11
|
-
type: "fifo" | "named-pipe";
|
|
11
|
+
type: "fifo" | "mailbox" | "named-pipe";
|
|
12
12
|
}
|
|
13
13
|
export interface AsyncRunStartParams {
|
|
14
14
|
async?: boolean;
|
|
@@ -88,6 +88,24 @@ export declare function getRunStatus(runOrDir: string): Record<string, unknown>;
|
|
|
88
88
|
export declare function listRuns(stateRoot?: string, statusFilter?: string): Array<Record<string, unknown>>;
|
|
89
89
|
export declare function tailRun(runOrDir: string, lines?: number): string;
|
|
90
90
|
export declare function readRunEvents(runOrDir: string, lines?: number): RunOutboxEvent[];
|
|
91
|
+
export type RunInboxStatus = "queued" | "sent" | "claimed" | "handled" | "failed";
|
|
92
|
+
export type RunInboxMessage = Record<string, unknown> & {
|
|
93
|
+
id?: string;
|
|
94
|
+
status?: RunInboxStatus | string;
|
|
95
|
+
};
|
|
96
|
+
export interface ProcessRunInboxResult {
|
|
97
|
+
claimed: number;
|
|
98
|
+
failed: number;
|
|
99
|
+
handled: number;
|
|
100
|
+
}
|
|
101
|
+
export declare function readRunInboxMessages(runOrDir: string, lines?: number): RunInboxMessage[];
|
|
102
|
+
export declare function updateRunInboxMessageStatus(runOrDir: string, id: string, nextStatus: RunInboxStatus, metadata?: Record<string, unknown>): boolean;
|
|
103
|
+
export declare function claimRunInboxMessage(runOrDir: string, owner?: string, statuses?: string[]): RunInboxMessage | undefined;
|
|
104
|
+
export declare function processRunInboxMessages(runOrDir: string, handler: (message: RunInboxMessage) => Promise<void> | void, options?: {
|
|
105
|
+
limit?: number;
|
|
106
|
+
owner?: string;
|
|
107
|
+
statuses?: string[];
|
|
108
|
+
}): Promise<ProcessRunInboxResult>;
|
|
91
109
|
export declare function appendRunOutboxEvent(runOrDir: string, event: {
|
|
92
110
|
body?: unknown;
|
|
93
111
|
correlation_id?: string;
|
package/dist/lib/async-runs.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Zones: async runtime, lifecycle, state files
|
|
4
4
|
* Owns detached run state, observation, log tailing, listing, and cancellation safety
|
|
5
5
|
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
8
|
import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
|
|
8
9
|
import { createConnection } from "node:net";
|
|
@@ -14,7 +15,9 @@ import { writeJsonAtomic } from "./file-state.js";
|
|
|
14
15
|
import * as Paths from "./paths.js";
|
|
15
16
|
import * as RecipeReferences from "./recipe-references.js";
|
|
16
17
|
import * as RecipeUsage from "./recipe-usage.js";
|
|
18
|
+
import { notifyRuntimeWake } from "./runtime-notifier.js";
|
|
17
19
|
const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
20
|
+
const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
|
|
18
21
|
const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
|
|
19
22
|
const DEFAULT_RECIPE_ROOT = Paths.getRecipeRoot();
|
|
20
23
|
function packageRoot() {
|
|
@@ -479,6 +482,145 @@ export function readRunEvents(runOrDir, lines = 40) {
|
|
|
479
482
|
.map((line, index) => parseRunOutboxEventLine(line, run, stateDir, index))
|
|
480
483
|
.filter((event) => Boolean(event));
|
|
481
484
|
}
|
|
485
|
+
function runInboxFile(stateDir) {
|
|
486
|
+
return join(stateDir, "inbox.jsonl");
|
|
487
|
+
}
|
|
488
|
+
function sleepSync(ms) {
|
|
489
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
490
|
+
}
|
|
491
|
+
function acquireRunInboxLock(stateDir) {
|
|
492
|
+
const lockDir = join(stateDir, ".inbox.lock");
|
|
493
|
+
const started = Date.now();
|
|
494
|
+
while (true) {
|
|
495
|
+
try {
|
|
496
|
+
mkdirSync(lockDir, { recursive: false });
|
|
497
|
+
writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf8");
|
|
498
|
+
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
try {
|
|
502
|
+
const stat = statSync(lockDir);
|
|
503
|
+
if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
|
|
504
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
|
|
512
|
+
throw new Error("Run inbox lock timed out.", { cause: error });
|
|
513
|
+
}
|
|
514
|
+
sleepSync(10);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function parseRunInboxLine(line) {
|
|
519
|
+
try {
|
|
520
|
+
return JSON.parse(line);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function readRunInboxMessagesFromStateDir(stateDir) {
|
|
527
|
+
const file = runInboxFile(stateDir);
|
|
528
|
+
if (!existsSync(file))
|
|
529
|
+
return [];
|
|
530
|
+
return readFileSync(file, "utf8")
|
|
531
|
+
.split("\n")
|
|
532
|
+
.filter((line) => line.trim())
|
|
533
|
+
.map(parseRunInboxLine)
|
|
534
|
+
.filter((message) => Boolean(message));
|
|
535
|
+
}
|
|
536
|
+
function writeRunInboxMessages(stateDir, messages) {
|
|
537
|
+
writeFileSync(runInboxFile(stateDir), messages.length
|
|
538
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
539
|
+
: "", "utf8");
|
|
540
|
+
}
|
|
541
|
+
export function readRunInboxMessages(runOrDir, lines = 40) {
|
|
542
|
+
const status = getRunStatus(runOrDir);
|
|
543
|
+
const stateDir = String(status.state_dir);
|
|
544
|
+
return tailLines(runInboxFile(stateDir), lines)
|
|
545
|
+
.map(parseRunInboxLine)
|
|
546
|
+
.filter((message) => Boolean(message));
|
|
547
|
+
}
|
|
548
|
+
export function updateRunInboxMessageStatus(runOrDir, id, nextStatus, metadata = {}) {
|
|
549
|
+
const status = getRunStatus(runOrDir);
|
|
550
|
+
const stateDir = String(status.state_dir);
|
|
551
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
552
|
+
try {
|
|
553
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
554
|
+
const timestampKey = `${nextStatus}_at`;
|
|
555
|
+
let changed = false;
|
|
556
|
+
const updated = messages.map((message) => {
|
|
557
|
+
if (message.id !== id)
|
|
558
|
+
return message;
|
|
559
|
+
changed = true;
|
|
560
|
+
return {
|
|
561
|
+
...message,
|
|
562
|
+
...metadata,
|
|
563
|
+
[timestampKey]: new Date().toISOString(),
|
|
564
|
+
status: nextStatus,
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
if (changed)
|
|
568
|
+
writeRunInboxMessages(stateDir, updated);
|
|
569
|
+
return changed;
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
releaseLock();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
export function claimRunInboxMessage(runOrDir, owner = "runtime", statuses = ["queued"]) {
|
|
576
|
+
const status = getRunStatus(runOrDir);
|
|
577
|
+
const stateDir = String(status.state_dir);
|
|
578
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
579
|
+
try {
|
|
580
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
581
|
+
const index = messages.findIndex((message) => statuses.includes(String(message.status ?? "queued")));
|
|
582
|
+
if (index < 0)
|
|
583
|
+
return undefined;
|
|
584
|
+
const claimed = {
|
|
585
|
+
...messages[index],
|
|
586
|
+
claimed_at: new Date().toISOString(),
|
|
587
|
+
claimed_by: owner,
|
|
588
|
+
id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
|
|
589
|
+
status: "claimed",
|
|
590
|
+
};
|
|
591
|
+
messages[index] = claimed;
|
|
592
|
+
writeRunInboxMessages(stateDir, messages);
|
|
593
|
+
return claimed;
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
releaseLock();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
export async function processRunInboxMessages(runOrDir, handler, options = {}) {
|
|
600
|
+
const result = { claimed: 0, failed: 0, handled: 0 };
|
|
601
|
+
const limit = Math.max(1, Number(options.limit ?? 1));
|
|
602
|
+
const owner = options.owner ?? "runtime";
|
|
603
|
+
for (let index = 0; index < limit; index += 1) {
|
|
604
|
+
const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
|
|
605
|
+
if (!message?.id)
|
|
606
|
+
break;
|
|
607
|
+
result.claimed += 1;
|
|
608
|
+
try {
|
|
609
|
+
await handler(message);
|
|
610
|
+
if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
|
|
611
|
+
result.handled += 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
|
|
616
|
+
error: error instanceof Error ? error.message : String(error),
|
|
617
|
+
})) {
|
|
618
|
+
result.failed += 1;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
482
624
|
export function appendRunOutboxEvent(runOrDir, event) {
|
|
483
625
|
const status = getRunStatus(runOrDir);
|
|
484
626
|
const stateDir = String(status.state_dir);
|
|
@@ -514,7 +656,9 @@ function getRunControlEndpoint(status, stateDir) {
|
|
|
514
656
|
const control = status.control;
|
|
515
657
|
if (control && typeof control === "object" && !Array.isArray(control)) {
|
|
516
658
|
const record = control;
|
|
517
|
-
if ((record.type === "fifo" ||
|
|
659
|
+
if ((record.type === "fifo" ||
|
|
660
|
+
record.type === "mailbox" ||
|
|
661
|
+
record.type === "named-pipe") &&
|
|
518
662
|
typeof record.path === "string" &&
|
|
519
663
|
record.path.trim()) {
|
|
520
664
|
return { path: record.path, type: record.type };
|
|
@@ -522,18 +666,34 @@ function getRunControlEndpoint(status, stateDir) {
|
|
|
522
666
|
}
|
|
523
667
|
return { path: join(stateDir, "control.fifo"), type: "fifo" };
|
|
524
668
|
}
|
|
525
|
-
function
|
|
526
|
-
const
|
|
527
|
-
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
|
|
669
|
+
function appendRunInboxMessage(stateDir, message) {
|
|
670
|
+
const id = randomUUID();
|
|
528
671
|
const ts = new Date().toISOString();
|
|
529
|
-
|
|
672
|
+
let record;
|
|
530
673
|
try {
|
|
531
|
-
const
|
|
532
|
-
|
|
674
|
+
const parsed = JSON.parse(message);
|
|
675
|
+
record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
676
|
+
? parsed
|
|
677
|
+
: { body: parsed, type: "run.message" };
|
|
533
678
|
}
|
|
534
679
|
catch {
|
|
535
|
-
|
|
680
|
+
record = { body: message, type: "run.message" };
|
|
681
|
+
}
|
|
682
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
683
|
+
try {
|
|
684
|
+
writeFileSync(runInboxFile(stateDir), `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`, { flag: "a" });
|
|
536
685
|
}
|
|
686
|
+
finally {
|
|
687
|
+
releaseLock();
|
|
688
|
+
}
|
|
689
|
+
return id;
|
|
690
|
+
}
|
|
691
|
+
function writeRunMessageReceipt(stateDir, message, bytes, inboxId) {
|
|
692
|
+
const trimmedMessage = message.trim().toLowerCase();
|
|
693
|
+
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
|
|
694
|
+
const ts = new Date().toISOString();
|
|
695
|
+
writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`, { flag: "a" });
|
|
696
|
+
updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
|
|
537
697
|
if (terminalMessage) {
|
|
538
698
|
markTerminalHandled(stateDir, {
|
|
539
699
|
event: "run.message",
|
|
@@ -558,6 +718,19 @@ function sendRunMessageToFifo(endpoint, payload) {
|
|
|
558
718
|
closeSync(fd);
|
|
559
719
|
}
|
|
560
720
|
}
|
|
721
|
+
function notifyRunMessageWake(stateDir, run, bytes, endpoint, inboxId) {
|
|
722
|
+
try {
|
|
723
|
+
const event = notifyRuntimeWake(stateDir, {
|
|
724
|
+
actor: `run:${run}`,
|
|
725
|
+
metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
|
|
726
|
+
reason: "run.message",
|
|
727
|
+
});
|
|
728
|
+
return { wake: "wake.jsonl", wake_id: event.id };
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
561
734
|
function sendRunMessageToNamedPipe(endpoint, payload, send) {
|
|
562
735
|
if (send)
|
|
563
736
|
return send(endpoint.path, payload);
|
|
@@ -600,15 +773,32 @@ export async function sendRunMessage(runOrDir, message, options = {}) {
|
|
|
600
773
|
throw new Error(`Run pid owner mismatch: ${run}`);
|
|
601
774
|
const endpoint = getRunControlEndpoint(status, stateDir);
|
|
602
775
|
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
776
|
+
const payloadBytes = Buffer.byteLength(payload);
|
|
777
|
+
const inboxId = appendRunInboxMessage(stateDir, message);
|
|
778
|
+
const wake = notifyRunMessageWake(stateDir, run, payloadBytes, endpoint, inboxId);
|
|
603
779
|
const runtimePlatform = options.platform ?? process.platform;
|
|
780
|
+
if (endpoint.type === "mailbox") {
|
|
781
|
+
return {
|
|
782
|
+
...(wake ?? {}),
|
|
783
|
+
bytes: payloadBytes,
|
|
784
|
+
control: "inbox.jsonl",
|
|
785
|
+
control_path: endpoint.path,
|
|
786
|
+
control_type: endpoint.type,
|
|
787
|
+
queued: true,
|
|
788
|
+
run,
|
|
789
|
+
sent: true,
|
|
790
|
+
state_dir: stateDir,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
604
793
|
try {
|
|
605
794
|
if (endpoint.type === "fifo") {
|
|
606
795
|
if (runtimePlatform === "win32") {
|
|
607
796
|
throw new Error("run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.");
|
|
608
797
|
}
|
|
609
798
|
const bytes = sendRunMessageToFifo(endpoint, payload);
|
|
610
|
-
writeRunMessageReceipt(stateDir, message, bytes);
|
|
799
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
611
800
|
return {
|
|
801
|
+
...(wake ?? {}),
|
|
612
802
|
bytes,
|
|
613
803
|
control: "control.fifo",
|
|
614
804
|
control_path: endpoint.path,
|
|
@@ -619,8 +809,9 @@ export async function sendRunMessage(runOrDir, message, options = {}) {
|
|
|
619
809
|
};
|
|
620
810
|
}
|
|
621
811
|
const bytes = await sendRunMessageToNamedPipe(endpoint, payload, options.namedPipeSend);
|
|
622
|
-
writeRunMessageReceipt(stateDir, message, bytes);
|
|
812
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
623
813
|
return {
|
|
814
|
+
...(wake ?? {}),
|
|
624
815
|
bytes,
|
|
625
816
|
control: endpoint.path,
|
|
626
817
|
control_path: endpoint.path,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime wake notifications for actor state.
|
|
3
|
+
* Zones: advisory wake layer, file-backed runtime state, cross-platform notification boundary
|
|
4
|
+
* Owns best-effort live wake signals while durable mailbox/state files remain canonical.
|
|
5
|
+
*/
|
|
6
|
+
export interface RuntimeWakeEvent {
|
|
7
|
+
actor: string;
|
|
8
|
+
id: string;
|
|
9
|
+
metadata?: Record<string, unknown>;
|
|
10
|
+
reason: string;
|
|
11
|
+
state_dir: string;
|
|
12
|
+
ts: string;
|
|
13
|
+
}
|
|
14
|
+
export interface RuntimeNotifierSubscription {
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
export type RuntimeReconcileReason = "initial" | "poll" | "wake";
|
|
18
|
+
export interface RuntimeReconcileEvent {
|
|
19
|
+
actor: string;
|
|
20
|
+
reason: RuntimeReconcileReason;
|
|
21
|
+
state_dir: string;
|
|
22
|
+
ts: string;
|
|
23
|
+
}
|
|
24
|
+
export interface RuntimeNotifierSubscribeOptions {
|
|
25
|
+
onReconcile?: (event: RuntimeReconcileEvent) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface FileRuntimeNotifierOptions {
|
|
28
|
+
pollIntervalMs?: number;
|
|
29
|
+
replay?: boolean;
|
|
30
|
+
watch?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface RuntimeNotifier {
|
|
33
|
+
notify(event: {
|
|
34
|
+
actor: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
reason: string;
|
|
37
|
+
}): RuntimeWakeEvent;
|
|
38
|
+
subscribe(actor: string, onWake: (event: RuntimeWakeEvent) => void, options?: RuntimeNotifierSubscribeOptions): RuntimeNotifierSubscription;
|
|
39
|
+
}
|
|
40
|
+
export declare function runtimeWakeFile(stateDir: string): string;
|
|
41
|
+
export declare function notifyRuntimeWake(stateDir: string, event: {
|
|
42
|
+
actor: string;
|
|
43
|
+
metadata?: Record<string, unknown>;
|
|
44
|
+
reason: string;
|
|
45
|
+
}): RuntimeWakeEvent;
|
|
46
|
+
export declare function parseRuntimeWakeEventLine(line: string): RuntimeWakeEvent | undefined;
|
|
47
|
+
export declare function readRuntimeWakeEvents(stateDir: string): RuntimeWakeEvent[];
|
|
48
|
+
export declare function createFileRuntimeNotifier(stateDir: string, options?: FileRuntimeNotifierOptions): RuntimeNotifier;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime wake notifications for actor state.
|
|
3
|
+
* Zones: advisory wake layer, file-backed runtime state, cross-platform notification boundary
|
|
4
|
+
* Owns best-effort live wake signals while durable mailbox/state files remain canonical.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, watch, } from "node:fs";
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
9
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
10
|
+
export function runtimeWakeFile(stateDir) {
|
|
11
|
+
return join(stateDir, "wake.jsonl");
|
|
12
|
+
}
|
|
13
|
+
function normalizeWakeEvent(stateDir, event) {
|
|
14
|
+
const actor = event.actor.trim();
|
|
15
|
+
const reason = event.reason.trim();
|
|
16
|
+
if (!actor)
|
|
17
|
+
throw new Error("Runtime wake event requires actor.");
|
|
18
|
+
if (!reason)
|
|
19
|
+
throw new Error("Runtime wake event requires reason.");
|
|
20
|
+
return {
|
|
21
|
+
actor,
|
|
22
|
+
id: randomUUID(),
|
|
23
|
+
...(event.metadata ? { metadata: event.metadata } : {}),
|
|
24
|
+
reason,
|
|
25
|
+
state_dir: stateDir,
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function notifyRuntimeWake(stateDir, event) {
|
|
30
|
+
const normalized = normalizeWakeEvent(stateDir, event);
|
|
31
|
+
const file = runtimeWakeFile(stateDir);
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
appendFileSync(file, `${JSON.stringify(normalized)}\n`, "utf8");
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
export function parseRuntimeWakeEventLine(line) {
|
|
37
|
+
try {
|
|
38
|
+
const record = JSON.parse(line);
|
|
39
|
+
if (typeof record.actor !== "string" ||
|
|
40
|
+
typeof record.id !== "string" ||
|
|
41
|
+
typeof record.reason !== "string" ||
|
|
42
|
+
typeof record.state_dir !== "string" ||
|
|
43
|
+
typeof record.ts !== "string") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
actor: record.actor,
|
|
48
|
+
id: record.id,
|
|
49
|
+
...(record.metadata &&
|
|
50
|
+
typeof record.metadata === "object" &&
|
|
51
|
+
!Array.isArray(record.metadata)
|
|
52
|
+
? { metadata: record.metadata }
|
|
53
|
+
: {}),
|
|
54
|
+
reason: record.reason,
|
|
55
|
+
state_dir: record.state_dir,
|
|
56
|
+
ts: record.ts,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function readRuntimeWakeEvents(stateDir) {
|
|
64
|
+
const file = runtimeWakeFile(stateDir);
|
|
65
|
+
if (!existsSync(file))
|
|
66
|
+
return [];
|
|
67
|
+
return readFileSync(file, "utf8")
|
|
68
|
+
.split("\n")
|
|
69
|
+
.filter((line) => line.trim())
|
|
70
|
+
.map(parseRuntimeWakeEventLine)
|
|
71
|
+
.filter((event) => Boolean(event));
|
|
72
|
+
}
|
|
73
|
+
export function createFileRuntimeNotifier(stateDir, options = {}) {
|
|
74
|
+
const file = runtimeWakeFile(stateDir);
|
|
75
|
+
const pollIntervalMs = Math.max(25, Number(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
76
|
+
return {
|
|
77
|
+
notify: (event) => notifyRuntimeWake(stateDir, event),
|
|
78
|
+
subscribe: (actor, onWake, subscribeOptions = {}) => {
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
let position = options.replay || !existsSync(file) ? 0 : statSync(file).size;
|
|
81
|
+
let closed = false;
|
|
82
|
+
const reconcile = (reason) => {
|
|
83
|
+
if (closed)
|
|
84
|
+
return;
|
|
85
|
+
subscribeOptions.onReconcile?.({
|
|
86
|
+
actor,
|
|
87
|
+
reason,
|
|
88
|
+
state_dir: stateDir,
|
|
89
|
+
ts: new Date().toISOString(),
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
const drain = () => {
|
|
93
|
+
if (closed || !existsSync(file))
|
|
94
|
+
return;
|
|
95
|
+
const buffer = readFileSync(file);
|
|
96
|
+
if (position > buffer.length)
|
|
97
|
+
position = 0;
|
|
98
|
+
const chunk = buffer.subarray(position).toString("utf8");
|
|
99
|
+
position = buffer.length;
|
|
100
|
+
for (const line of chunk.split("\n")) {
|
|
101
|
+
if (!line.trim())
|
|
102
|
+
continue;
|
|
103
|
+
const event = parseRuntimeWakeEventLine(line);
|
|
104
|
+
if (event && event.actor === actor) {
|
|
105
|
+
onWake(event);
|
|
106
|
+
reconcile("wake");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
let watcher;
|
|
111
|
+
if (options.watch !== false) {
|
|
112
|
+
try {
|
|
113
|
+
watcher = watch(dirname(file), { persistent: false }, (_eventType, changedFile) => {
|
|
114
|
+
if (!changedFile || String(changedFile) === basename(file))
|
|
115
|
+
drain();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// fs.watch availability varies by platform/filesystem; polling below is the fallback.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
reconcile("initial");
|
|
123
|
+
const timer = setInterval(() => {
|
|
124
|
+
drain();
|
|
125
|
+
reconcile("poll");
|
|
126
|
+
}, pollIntervalMs);
|
|
127
|
+
timer.unref?.();
|
|
128
|
+
return {
|
|
129
|
+
close: () => {
|
|
130
|
+
closed = true;
|
|
131
|
+
clearInterval(timer);
|
|
132
|
+
watcher?.close();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|