@llblab/pi-actors 0.19.4 → 0.19.11
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/AGENTS.md +4 -4
- package/BACKLOG.md +41 -11
- package/CHANGELOG.md +51 -0
- package/README.md +3 -1
- package/docs/actor-messages.md +3 -3
- package/docs/async-runs.md +2 -0
- package/index.ts +20 -2
- package/lib/actor-inspector-tui.ts +58 -0
- package/lib/actor-rooms.ts +133 -59
- package/lib/observability.ts +60 -33
- package/package.json +2 -2
- package/scripts/async-runner.mjs +30 -6
- package/scripts/coordinator.mjs +84 -36
- package/scripts/validate-recipe.mjs +25 -4
- package/skills/actors/SKILL.md +3 -3
- package/skills/swarm/SKILL.md +1 -1
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
## Topology
|
|
15
15
|
|
|
16
16
|
- `/index.ts`: Minimal extension coordinator/composition root; it wires live pi ports and should avoid owning domain behavior
|
|
17
|
-
- `/lib/*.ts`: Flat Domain DAG modules for cohesive reusable behavior; `command-templates.ts` mirrors the shared portable command-template standard, `schema.ts` owns tool arg declarations and placeholder-derived tool schemas, `identity.ts` owns names, `config.ts` owns config persistence, `registry.ts` owns registry register/update/delete use-cases, `output.ts` owns result formatting/truncation, `execution.ts` owns registered-tool execution, `recipe-references.ts` owns template recipe reference detection and path resolution, `async-runs.ts` owns async run state, `actor-rooms.ts` owns room timelines/rosters/communication snapshots, `actor-inspector-tui.ts` owns compact terminal previews and selected-message inspection for actor communications, `observability.ts` owns ambient run summaries, `temp.ts` owns pi-agent temp cleanup, `prompts.ts` owns LLM-facing copy, `tools.ts` owns pi-facing tool definitions for both `register_tool`, async run primitives, and generated runtime tools, `runtime.ts` owns load/conflict/registration coordination, and `paths.ts` owns config/tmp path resolution
|
|
17
|
+
- `/lib/*.ts`: Flat Domain DAG modules for cohesive reusable behavior; `command-templates.ts` mirrors the shared portable command-template standard, `schema.ts` owns tool arg declarations and placeholder-derived tool schemas, `identity.ts` owns names, `config.ts` owns config persistence, `registry.ts` owns registry register/update/delete use-cases, `output.ts` owns result formatting/truncation, `execution.ts` owns registered-tool execution, `recipe-references.ts` owns template recipe reference detection and path resolution, `async-runs.ts` owns async run state, `actor-rooms.ts` owns room timelines/rosters/communication snapshots with burst-safe roster and branch snapshot writes, locked branch-local inbox mutations, plus status reads that avoid parsing full timelines, `actor-inspector-tui.ts` owns compact terminal previews, branch-inbox unread/current-branch filtering, and selected-message inspection for actor communications, `observability.ts` owns ambient run summaries, `temp.ts` owns pi-agent temp cleanup, `prompts.ts` owns LLM-facing copy, `tools.ts` owns pi-facing tool definitions for both `register_tool`, async run primitives, and generated runtime tools, `runtime.ts` owns load/conflict/registration coordination, and `paths.ts` owns config/tmp path resolution
|
|
18
18
|
- `index.ts` should import local domains as namespaces (`import * as CommandTemplates from "./lib/command-templates.ts"`) so orchestration reads through domain names instead of flat helper imports
|
|
19
19
|
- `/scripts/*.mjs`: Thin helper processes for detached async run execution; keep policy in registered tool config and reusable logic in `/lib`
|
|
20
20
|
- `/recipes/*.json`: Packaged standard recipe library; keep recipes optional, composable, and policy-light; prefer public args for operator/agent decisions instead of baking project-specific prompts, file names, or concrete model-version defaults into reusable recipes
|
|
@@ -43,17 +43,17 @@
|
|
|
43
43
|
- `Typed arg authoring`: Typed args support `string`, `path`, `int`, `number`, `bool`, and `enum(...)` plus two equivalent readability styles: metadata-first (`args` + `defaults` + simple `{name}` placeholders) for long command lines, and inline-first (`{name:type=default}` placeholders) for compact one-property templates | Trigger: Changing arg parsing, docs, schema generation, or registry serialization | Action: Preserve both styles, keep explicit `args` type declarations higher priority than inline placeholder types, and make breaking cleanup explicit when removing old arg shapes
|
|
44
44
|
- `Template recipe graph`: The valid execution chain is `tool → template → recipe → run → template`; file-backed and co-located recipes are storage variants of that chain | Trigger: Adding registry bindings, recipes, docs, or runtime shortcuts | Action: Keep command templates synchronous and portable, use `async: true` as the detached run switch, require every recipe to own `template` directly, and reject cyclic shortcuts such as recipe-owned `tool`
|
|
45
45
|
- `Layer boundary discipline`: Command-template evolution must be separated from template-recipe configuration and async-run lifecycle configuration | Trigger: Adding syntax, placeholders, imports, async controls, or docs | Action: Put portable execution graph semantics in `docs/command-templates.md`, recipe storage/import/default/reference behavior in `docs/template-recipes.md`, and detached lifecycle/state/IPC behavior in `docs/async-runs.md`; type imported recipes as command-template-shaped recipe definitions, not async-run instances
|
|
46
|
-
- `Executable script recipes`: Recipe templates may point directly at executable helper scripts, including JavaScript `.mjs` files with shebangs; do not prefix such recipes with `node` unless the script is intentionally not executable | Trigger: Adding or editing script-backed recipes and docs | Action: Keep the script executable bit, call `{repo}/scripts/name.mjs ...` directly,
|
|
46
|
+
- `Executable script recipes`: Recipe templates may point directly at executable helper scripts, including JavaScript `.mjs` files with shebangs; do not prefix such recipes with `node` unless the script is intentionally not executable | Trigger: Adding or editing script-backed recipes and docs | Action: Keep the script executable bit, call `{repo}/scripts/name.mjs ...` directly, keep the standard library on one maintained wrapper per capability unless a second wrapper has a concrete platform reason, and ensure installed npm script entrypoints do not import `.ts` files from under `node_modules` through Node native type stripping
|
|
47
47
|
- `Registry safety boundaries`: Tool definitions use `template`, not `script`, and built-in/core tool names must not be shadowed | Trigger: Loading/editing persisted config or registration logic | Action: Reject legacy `script` entries explicitly, avoid silent user-config rewrites outside the repo, and keep conflict checks before persistence/runtime registration
|
|
48
48
|
- `Async run observability`: Ambient triangles count active async work units across the visible run tree: each running async run contributes at least one triangle, reported active parallel command/subagent branches contribute the visible branch count when greater than one, and descendant `pi -p` subagent processes are folded in so coordinator-plus-workers scenarios expand beyond a single coordinator marker. Event-driven terminal/outbox watchers should initiate follow-up for unhandled terminal completion/failure states, failed or in-flight `command.done` branch completions, and coordinator-bound script-authored messages with bounded body previews; actor `message` is the explicit coordinator-to-run command channel paired with these upward events. Do not restore busy-polling loops, sleep-then-status smoke examples, duplicate follow-ups for final successful leaf commands, or duplicate follow-ups for `cancel`, `kill`, or control-stop actions already handled by synchronous tool results. | Trigger: Changing async run UI, notifications, actor-message routing, or smoke-test interpretation | Action: Preserve branch-aware triangles from `progress.activeSubagents`, runtime-inferred branch bubbling for packaged fanout completion, process-tree expansion for coordinator-launched workers, terminal notifications as event-driven behavior, and docs/examples that teach reactive run→coordinator→message loops before sleep-polling patterns.
|
|
49
49
|
- `Communication direction`: The design target is an organic universal message layer across sync tasks, async runs, branches, tools, and coordinators. Breaking changes are allowed to compress concepts, remove accidental duplication, and make duplex communication symmetric where the domain is symmetric. | Trigger: Designing APIs or recipes that communicate | Action: Prefer a concentrated actor/message protocol (`spawn`, `message`, `inspect`, addressed endpoints, typed message envelopes, mailbox accepts/emits) over exposing FIFO/outbox/status mechanics directly; use one envelope for upward, downward, lateral, parent/branch, and branch/parent messages; absorb runtime async primitives into actor API instead of preserving parallel public concepts.
|
|
50
50
|
- `Runtime IO discipline`: Tool stdout and temp state must stay bounded and local | Trigger: Changing execution, formatting, temp files, run state, logs, or artifacts | Action: Keep tail truncation/full-output temp files/failure formatting intact; keep extension-owned temp state under `~/.pi/agent/tmp/pi-actors` unless explicitly overridden
|
|
51
51
|
- `Backlog is planning, not history`: `BACKLOG.md` should contain only completable future work with current task/scope/exit criteria; completed delivery history belongs in `CHANGELOG.md`, and durable or evergreen behavior belongs in `AGENTS.md`, README, docs, or skills | Trigger: Editing backlog or reconciling completed slices | Action: Remove historical progress narratives, version-scoped headings, watch-mode/monitoring principles, open-ended “continue evolving” items, and conditional “if usage proves” notes unless they are framed as a concrete gated task; keep priority order and prefer an 80/20 focus list when many remaining tasks compete for attention
|
|
52
52
|
- `Release artifact hygiene`: PR/release summaries become stale during active branch work and do not belong in the repository documentation tree | Trigger: Preparing release notes or PR bodies | Action: Create temporary/operator-facing artifacts outside the repo only during explicit release finalization; keep durable release evidence in `CHANGELOG.md` and open gates in `BACKLOG.md`
|
|
53
|
-
- `Graceful actor retirement`: Coordinator/helper actors that exist only to supervise a bounded worker tree should have explicit retirement semantics instead of relying on the operator or LLM to remember cleanup | Trigger: Designing coordinator recipes, helper actors, worker fanout, locker-backed swarms, or auto-stop behavior | Action: Make retirement opt-in through recipe/run metadata,
|
|
53
|
+
- `Graceful actor retirement`: Coordinator/helper actors that exist only to supervise a bounded worker tree should have explicit retirement semantics instead of relying on the operator or LLM to remember cleanup | Trigger: Designing coordinator recipes, helper actors, worker fanout, locker-backed swarms, or auto-stop behavior | Action: Make retirement opt-in through recipe/run metadata, keep candidates blocked while active command-template branches or descendant `pi -p` workers are still running, retire only after observed child actors are terminal and outputs are flushed, prefer graceful control messages before process termination, record retirement events, and never infer retirement for persistent services or backlog implementers
|
|
54
54
|
- `Persistent implementer workflows are recipe composition`: Backlog implementer scenarios should be launched through reusable component recipes, not one-off scripts or ad hoc shell orchestration | Trigger: Designing implementer swarms, backlog workers, coordinator-assigned task loops, or related recipes | Action: Compose cells such as `coordinator-locker`, subagent launchers, and actor-message utilities; preserve JSON envelope object shape across handoffs; add missing reusable component recipes only when needed; update the actors skill launcher map with supported scenarios
|
|
55
55
|
- `Modular coordination and separate lock state`: The coordination of multi-agent workflows is split into two cleanly decoupled layers: the active coordinator and the stateful locker. The locker manages task queueing and resource lock leases over Unix FIFO/pipes without project policy. The coordinator script (`scripts/coordinator.mjs`) manages process pools, rooms, and lifecycles, and supports different pluggable mode strategies (`pipeline`, `fanout`, `pool`, `consensus`). | Trigger: Modifying coordination scripts, queues, locking, or parallel worker flows | Action: Keep the locker generic and thin, and implement all orchestration strategy rules inside the multi-mode coordinator.
|
|
56
|
-
- `Active branch inbox queues`: Direct branch messages are active, work-triggering inbox queues rather than passive files. During subagent execution, the coordinator
|
|
56
|
+
- `Active branch inbox queues`: Direct branch messages are active, work-triggering inbox queues rather than passive files. During subagent execution, the coordinator atomically claims (`claimed`), assigns missing message IDs, injects, and handles (`handled`/`failed`) queued branch-local direct messages to allow interactive/resumable worker workflows. | Trigger: Delivering branch messages, executing subagents, or updating branch queues | Action: Ensure direct messages can continue or wake long-lived branch runners, guard branch-local inbox append/status rewrites with the branch inbox lock, and keep the FIFO queue status transitions clean and fully tested.
|
|
57
57
|
- `Recipe library growth is demand-driven`: Packaged recipes should grow from concrete repeated task patterns, not speculative scenario catalogs | Trigger: Adding packaged utilities, pipelines, or component recipes | Action: Prefer existing component composition, keep recipes policy-light with caller-owned prompts/models/paths/knobs, avoid scenario-specific scripts when existing components suffice, and document new reusable launch scenarios in the actors skill only after the recipe exists
|
|
58
58
|
- `Context sync`: Meaningful implementation or docs changes must reconcile `BACKLOG.md`, `CHANGELOG.md`, README, and docs navigation | Trigger: Closing, narrowing, or discovering work | Action: Run the context validator before final status when practical
|
|
59
59
|
- `Public path hygiene`: Published docs must not include machine-local absolute paths | Trigger: Adding validation commands, examples, or local instructions to README/AGENTS/docs/changelog | Action: Use `~/.pi/...`, `<repo>/...`, `${SKILL_DIR}/...`, or relative paths
|
package/BACKLOG.md
CHANGED
|
@@ -8,19 +8,13 @@
|
|
|
8
8
|
- Goal: Continue evolving actor communication without adding a second public messaging model.
|
|
9
9
|
- Direction:
|
|
10
10
|
- Evaluate whether room storage/routing should remain built into the tool adapter or move behind a dedicated non-LLM communication actor recipe/script, possibly singleton-scoped. Preserve the same public `room:<run>` address and envelope either way.
|
|
11
|
+
- Treat the next backend decision as an evidence-backed experiment, not a rewrite: stress a real room/direct-message workload, compare the current file-backed adapter with a thin communication actor/helper, and record the decision.
|
|
11
12
|
- Consider reducing direct file-backed state where it improves coherence: model room/roster state as actor-owned data structures served by helper scripts/actors, with files retained only for durable snapshots, recovery, artifacts, or audit logs.
|
|
12
|
-
-
|
|
13
|
+
- Further storage changes should preserve the current burst/read/concurrency safeguards: branch communication snapshot writes are debounced, root snapshots stay current, roster files are not rewritten during bursts when only `last_seen` changes, room status inspection does not parse full timelines, branch-local inbox append/status rewrites are lock-guarded, and legacy no-ID branch inbox records can be claimed exactly once.
|
|
14
|
+
- Prevent monolith drift: `actor-rooms.ts` may remain a thin adapter, but growing routing policy, subscription loops, fanout policy, or long-lived state ownership should move behind a focused communication helper/actor rather than accumulating in the tool adapter.
|
|
13
15
|
- Exit:
|
|
14
16
|
- Any backend/storage change preserves existing `spawn` / `message` / `inspect` semantics and room address compatibility.
|
|
15
|
-
|
|
16
|
-
### Actor Communication TUI Preview
|
|
17
|
-
|
|
18
|
-
- Priority: High.
|
|
19
|
-
- Goal: Make actor-to-actor communication more navigable in the terminal UI without exposing large payloads by default.
|
|
20
|
-
- Direction:
|
|
21
|
-
- Add current-branch and unread filters after branch read-state semantics are real.
|
|
22
|
-
- Exit:
|
|
23
|
-
- Operators can distinguish unread/current-branch messages while retaining intentional full-body inspection.
|
|
17
|
+
- A short decision note or changelog entry explains why the room backend stayed file-backed or moved behind a communication actor/helper.
|
|
24
18
|
|
|
25
19
|
### Graceful Actor Retirement
|
|
26
20
|
|
|
@@ -29,13 +23,25 @@
|
|
|
29
23
|
- Direction:
|
|
30
24
|
- Build on the existing `retire_when: "children_terminal"` recipe/run metadata contract and observability retirement-candidate detection for ephemeral supervisors.
|
|
31
25
|
- Treat auto-retirement as opt-in only; never infer it for arbitrary long-lived services, user tools, or persistent backlog implementers.
|
|
32
|
-
- Extend candidate detection
|
|
26
|
+
- Extend candidate detection beyond current active command/proc-descendant gating to full observed child async-run state rather than log text: the supervisor may retire only when all launched child async runs are terminal and required artifacts/outbox events have been flushed.
|
|
33
27
|
- Prefer graceful stop (`control.stop` / actor message) before process termination; escalate only after a bounded timeout and record the retirement event in run state.
|
|
34
28
|
- Preserve manual `cancel` / `kill` semantics and make retirement visible through `inspect` / ambient observability.
|
|
35
29
|
- Exit:
|
|
36
30
|
- A packaged coordinator recipe can launch worker actors, complete its coordination duties, and shut itself down automatically after the worker tree reaches terminal state.
|
|
37
31
|
- Persistent services and implementer actors remain alive unless their recipe explicitly opts into retirement.
|
|
38
32
|
|
|
33
|
+
### Coordinator Strategy Boundary
|
|
34
|
+
|
|
35
|
+
- Priority: Medium.
|
|
36
|
+
- Goal: Keep the generic coordinator from becoming a second overloaded monolith as room/direct-message workflows mature.
|
|
37
|
+
- Direction:
|
|
38
|
+
- Split only at real pressure points: branch inbox claim/finalize helpers, participant execution, room transcript synthesis, and mode strategies are likely seams, but avoid cosmetic module churn.
|
|
39
|
+
- Preserve the current principle that the locker stays generic/thin and all orchestration policy stays in coordinator strategy code or recipe composition.
|
|
40
|
+
- Prefer reusable helper modules or small scripts only when at least two packaged workflows need the same behavior.
|
|
41
|
+
- Exit:
|
|
42
|
+
- Adding a new coordinator mode or packaged multi-agent workflow does not require editing unrelated mode logic.
|
|
43
|
+
- Existing room-swarm, locker, and direct-branch-message tests still cover the extracted seams.
|
|
44
|
+
|
|
39
45
|
### Consensus-First Build Recipe
|
|
40
46
|
|
|
41
47
|
- Priority: Medium.
|
|
@@ -51,6 +57,18 @@
|
|
|
51
57
|
- A packaged recipe can reproduce the interactive-music-instrument workflow shape for another single-artifact task without copying the demo script.
|
|
52
58
|
- Docs and skills point agents to the packaged recipe and explain when to choose it over a free-form room swarm.
|
|
53
59
|
|
|
60
|
+
### Actor OS Scenario Smoke Matrix
|
|
61
|
+
|
|
62
|
+
- Priority: Medium.
|
|
63
|
+
- Goal: Convert the 0.19.x actor-communication hardening into repeatable end-to-end scenario checks instead of relying on ad hoc demos.
|
|
64
|
+
- Direction:
|
|
65
|
+
- Cover one scenario each for shared room coordination, direct branch work delivery, branch inbox claim/handle/fail transitions, inspector navigation, recipe context injection, recipe persistence suggestion, and opt-in retirement candidate detection.
|
|
66
|
+
- Keep scenarios local-first and bounded: fake `pi`/models where possible, no external services, no long sleeps, no broad golden transcripts.
|
|
67
|
+
- Prefer packaged recipes and public `spawn` / `message` / `inspect` calls so the smoke matrix exercises the same surface agents use.
|
|
68
|
+
- Exit:
|
|
69
|
+
- A single validation command or documented test group verifies the actor OS behaviors that made 0.19.x production-useful.
|
|
70
|
+
- The smoke matrix catches regressions in actor communication, recipe memory, and observability without requiring a manual swarm demo.
|
|
71
|
+
|
|
54
72
|
### Persistent Backlog Implementer Workflow
|
|
55
73
|
|
|
56
74
|
- Priority: Medium.
|
|
@@ -93,10 +111,22 @@
|
|
|
93
111
|
- Add nested recipe directories only after flat `recipes/*.json` discovery semantics are stable.
|
|
94
112
|
- Keep same-id priority and invalid-blocking behavior explicit if nested ids are introduced.
|
|
95
113
|
|
|
114
|
+
### Actor Recipe Feedback Loop
|
|
115
|
+
|
|
116
|
+
- Priority: Low.
|
|
117
|
+
- Goal: Turn actor recipe-context awareness into a practical improvement loop for packaged recipes and operator-owned recipe memory.
|
|
118
|
+
- Direction:
|
|
119
|
+
- After real multi-agent runs, capture whether child actors report that recipe/import/mailbox/role boundaries fit the task.
|
|
120
|
+
- Keep the loop advisory and operator-gated: feedback may suggest recipe edits or copying into `~/.pi/agent/recipes`, but must not auto-save or rewrite durable recipes without confirmation.
|
|
121
|
+
- Prefer small recipe/readme/skill refinements over adding scenario catalogs; recurring patterns should become packaged recipes only after repeated use.
|
|
122
|
+
- Exit:
|
|
123
|
+
- At least one real run produces recipe-boundary feedback that is either applied to a recipe/docs change or explicitly rejected with rationale.
|
|
124
|
+
|
|
96
125
|
### Recipe Usage Telemetry Evolution
|
|
97
126
|
|
|
98
127
|
- Priority: Low.
|
|
99
128
|
- Goal: Improve long-term operator insight into recipe usefulness without making telemetry noisy.
|
|
100
129
|
- Direction:
|
|
101
130
|
- Consider sidecar stats sync/backup policy after inline user-owned `usage.calls` / `usage.last_called` proves useful.
|
|
131
|
+
- Consider an operator-approved recipe promotion workflow that turns successful package/ad hoc/direct spawn suggestions into a reviewed `~/.pi/agent/recipes` entry with provenance and diff, without auto-saving.
|
|
102
132
|
- Do not add failure counters as primary usefulness evidence unless there is a strong operator-facing need.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.11: Installed Async Runner Hotfix
|
|
4
|
+
|
|
5
|
+
- `[Async Runs]` Fixed installed npm package async recipe launches on Node 22 by avoiding direct runtime imports of raw `.ts` files from under `node_modules` in `scripts/async-runner.mjs`. Installed runners now copy the package `lib` sources into the run state before importing them, keeping Node native type stripping outside the blocked `node_modules` path.
|
|
6
|
+
- `[Scripts]` Applied the same installed-package import guard to `scripts/validate-recipe.mjs`, so the packaged recipe validator works when invoked from an installed `@llblab/pi-actors` package.
|
|
7
|
+
- `[Tests]` Added installed-package script smoke coverage that copies `lib`/`scripts` under a temporary `node_modules/@llblab/pi-actors` path and verifies both async runner execution and recipe validation avoid `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
|
|
8
|
+
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.11` for the hotfix release.
|
|
9
|
+
|
|
10
|
+
## 0.19.10: Legacy Branch Message Claim IDs
|
|
11
|
+
|
|
12
|
+
- `[Branch Messages]` Coordinator claim handling now assigns IDs to older/manual queued branch inbox entries that lack `id`, so injected direct messages can still transition to `handled` or `failed` and do not repeat forever.
|
|
13
|
+
- `[Tests]` Extended direct branch inbox coordinator coverage to include a legacy no-ID message and assert both claimed/handled timestamps are recorded.
|
|
14
|
+
- `[Docs/Context]` Updated actor-message docs, durable project context, package metadata, lockfile metadata, and packaged skill metadata to `0.19.10`.
|
|
15
|
+
|
|
16
|
+
## 0.19.9: Locked Branch Inbox Mutations
|
|
17
|
+
|
|
18
|
+
- `[Branch Messages]` Added lock-guarded append and status rewrites for branch-local direct-message inbox files so concurrent direct delivery and coordinator claim/handle transitions do not overwrite each other.
|
|
19
|
+
- `[Coordinator]` Made room-swarm branch prompt execution atomically claim queued direct messages before injection, then mark claimed messages as `handled` or `failed` after the child prompt exits.
|
|
20
|
+
- `[Tests]` Added concurrent branch inbox append coverage and asserted coordinator direct-message handling records both `claimed_at` and `handled_at`.
|
|
21
|
+
- `[Docs/Context]` Updated actor-message docs, project context, backlog safeguards, package metadata, lockfile metadata, and packaged skill metadata to `0.19.9`.
|
|
22
|
+
|
|
23
|
+
## 0.19.8: Efficient Room Status Reads
|
|
24
|
+
|
|
25
|
+
- `[Rooms]` Changed room status inspection to count JSONL entries and read only the last timeline record instead of parsing the full room timeline into actor-envelope objects.
|
|
26
|
+
- `[Inspector]` Preserved the existing `inspect room:<run> view=status` shape while reducing storage/read amplification for large room transcripts.
|
|
27
|
+
- `[Docs/Context]` Updated actor-message docs, backlog safeguards, project context, package metadata, lockfile metadata, and skill metadata for `0.19.8`.
|
|
28
|
+
- `[Tests]` Added regression coverage that room status preserves message count and last-message metadata across longer timelines.
|
|
29
|
+
|
|
30
|
+
## 0.19.7: Burst-Safe Roster Writes
|
|
31
|
+
|
|
32
|
+
- `[Rooms]` Debounced room roster rewrites when a burst only changes a member's `last_seen`, while still writing semantic roster changes such as role, status, display, caps, claim, or parent immediately.
|
|
33
|
+
- `[Runtime IO]` Added `PI_ACTORS_ROOM_ROSTER_MIN_MS` as the roster-only debounce interval, mirroring the existing communication snapshot debounce approach without changing public `room:<run>` message or inspect semantics.
|
|
34
|
+
- `[Docs/Context]` Updated actor-message docs, project context, and the remaining rooms backlog scope to preserve the new burst-safe roster invariant during future storage/backend changes.
|
|
35
|
+
- `[Tests]` Added regression coverage for roster rewrite debounce and immediate semantic roster updates.
|
|
36
|
+
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.7` for the hotfix release.
|
|
37
|
+
|
|
38
|
+
## 0.19.6: Conservative Retirement Candidates
|
|
39
|
+
|
|
40
|
+
- `[Observability]` Added per-run descendant `pi -p` worker counting and exposes `descendantSubagents` on run observations. Ambient run status still counts active descendant workers, but now retains the per-run attribution needed for supervisor lifecycle decisions.
|
|
41
|
+
- `[Retirement]` Tightened opt-in `retire_when: "children_terminal"` candidate detection so supervisors are not considered retirement-ready while command-template progress or descendant `pi -p` workers are still active.
|
|
42
|
+
- `[Docs/Context]` Updated async-run docs, project context, and the remaining retirement backlog scope to reflect the conservative candidate baseline and the remaining child async-run/output-flush work.
|
|
43
|
+
- `[Tests]` Added regression coverage that blocks retirement candidates with descendant subagents.
|
|
44
|
+
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.6` for the hotfix release.
|
|
45
|
+
|
|
46
|
+
## 0.19.5: Branch Inbox Inspector Filters
|
|
47
|
+
|
|
48
|
+
- `[Actor Inspector]` Added branch-local inbox previews to the compact actor communication table, so queued direct `branch:<run>/<branch>` work is visible alongside room, run inbox, and outbox messages.
|
|
49
|
+
- `[Actor Inspector]` Added `/actors-inspector-filter unread`, `/actors-inspector-filter branch <name>`, and `/actors-inspector-filter current-branch <name>` to focus queued branch inbox work and one branch's room/direct/inbox traffic without exposing full payloads by default.
|
|
50
|
+
- `[Docs/Skills]` Updated README and the packaged actors skill with the new inspector filters and branch-inbox preview behavior.
|
|
51
|
+
- `[Backlog]` Closed the high-priority actor communication TUI preview item now that unread/current-branch navigation is implemented with branch read-state semantics.
|
|
52
|
+
- `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.5` for the hotfix release.
|
|
53
|
+
|
|
3
54
|
## 0.19.4: User Recipe Collection Suggestions
|
|
4
55
|
|
|
5
56
|
- `[Observability]` Broadened recipe persistence suggestions from direct inline spawns to the normal user workflow: any successful actor run backed by a recipe outside `~/.pi/agent/recipes` now asks the launching agent to offer copying/registering it into the user recipe root when it fits this machine's recurring workflow.
|
package/README.md
CHANGED
|
@@ -155,11 +155,13 @@ The terminal actor inspector is hidden by default. When opened without an explic
|
|
|
155
155
|
/actors-inspector-toggle 20
|
|
156
156
|
/actors-inspector-filter room
|
|
157
157
|
/actors-inspector-filter direct
|
|
158
|
+
/actors-inspector-filter unread
|
|
159
|
+
/actors-inspector-filter branch front
|
|
158
160
|
/actors-inspector-filter mention checkpoint
|
|
159
161
|
/actors-inspect 3
|
|
160
162
|
```
|
|
161
163
|
|
|
162
|
-
The table is compact and optimistic by default: bounded route/type/summary/body previews, capped noisy room rows, and an inline roster summary in the form `name/role` that wraps only when needed. Active roster members use the target color; members that sent `actor.leave` remain visible as inactive/muted participants from the current run. `/actors-inspect <number>` opens the selected row as a full-message view; toggle again to return to the table or close it. Actor display names come from room `actor.join` roster metadata or branch addresses, keeping debugger output plain and name-driven.
|
|
164
|
+
The table is compact and optimistic by default: bounded route/type/summary/body previews, capped noisy room rows, branch-local inbox previews, and an inline roster summary in the form `name/role` that wraps only when needed. Active roster members use the target color; members that sent `actor.leave` remain visible as inactive/muted participants from the current run. Use `unread` to focus queued branch inbox work and `branch <name>` / `current-branch <name>` to focus one branch's room/direct/inbox traffic. `/actors-inspect <number>` opens the selected row as a full-message view; toggle again to return to the table or close it. Actor display names come from room `actor.join` roster metadata or branch addresses, keeping debugger output plain and name-driven.
|
|
163
165
|
|
|
164
166
|
## Registry Model
|
|
165
167
|
|
package/docs/actor-messages.md
CHANGED
|
@@ -94,7 +94,7 @@ Transports differ, but the public contract does not:
|
|
|
94
94
|
|
|
95
95
|
- `to: run:<id>` routes through the run-local control channel selected by that recipe or runtime adapter.
|
|
96
96
|
- `to: coordinator` routes to the runtime attention path when `from` names a run actor. `to: session:<id>` uses the same actor-message path only when the sender run is owned by that session, making explicit session-directed checkpoints possible without exposing runtime delivery knobs. Generic async-runner `command.done` messages and explicit coordinator/session-bound messages include the actor envelope fields alongside runtime metadata.
|
|
97
|
-
- `to: branch:<run>/<branch>` currently routes through the parent run mailbox with the full envelope preserved so the run or recipe-specific worker protocol can dispatch branch-local control. It also persists a queued branch-local copy under `branches/<branch>/inbox.jsonl`, inspectable with `inspect branch:<run>/<branch> view=mailbox`; compact inspection includes the inbox message `id`, status, route, type, and timestamps so worker protocols can correlate claims/retries. It is not a broadcast room and it does not make an arbitrary prompt process consume the message automatically. Target direction: direct branch messages should become initiating inbox work for long-lived branch runners, delivered into the recipient's next prompt/context as soon as the runner can accept work.
|
|
97
|
+
- `to: branch:<run>/<branch>` currently routes through the parent run mailbox with the full envelope preserved so the run or recipe-specific worker protocol can dispatch branch-local control. It also persists a queued branch-local copy under `branches/<branch>/inbox.jsonl`, inspectable with `inspect branch:<run>/<branch> view=mailbox`; compact inspection includes the inbox message `id`, status, route, type, and timestamps so worker protocols can correlate claims/retries. Branch-local inbox append and status rewrites are guarded by a small lock so direct delivery and coordinator claims do not overwrite each other during bursts. Coordinator claim handling also assigns an ID to older/manual queued records that do not have one so they can still transition to `handled` or `failed` instead of repeating forever. It is not a broadcast room and it does not make an arbitrary prompt process consume the message automatically. Target direction: direct branch messages should become initiating inbox work for long-lived branch runners, delivered into the recipient's next prompt/context as soon as the runner can accept work.
|
|
98
98
|
- `to: room:<run>` appends the full envelope to the room timeline, updates room state for room-control types such as `actor.join` and `actor.leave`, and can route selected-recipient multicast when `metadata.recipients` contains same-run `branch:<run>/<branch>` addresses.
|
|
99
99
|
- `to: tool:<name>` invokes an executable pi tool by name. Object bodies become tool parameters; primitive bodies are passed as `{ "input": body }`.
|
|
100
100
|
|
|
@@ -182,7 +182,7 @@ Recipes can declare their conversational surface:
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
`spawn` creates detached `run:<id>` actors from a recipe file/name or inline command template. Spawn metadata may include explicit `state_dir` and named `artifacts` for terminal follow-ups and inspection.
|
|
185
|
+
`spawn` creates detached `run:<id>` actors from a recipe file/name or inline command template. Spawn metadata may include explicit `state_dir` and named `artifacts` for terminal follow-ups and inspection. Room rosters are durable but burst-safe: repeated messages that only update `last_seen` may be coalesced briefly, while semantic roster changes such as role/status/display still write immediately.
|
|
186
186
|
|
|
187
187
|
## Inspect
|
|
188
188
|
|
|
@@ -195,7 +195,7 @@ Recipes can declare their conversational surface:
|
|
|
195
195
|
}
|
|
196
196
|
```
|
|
197
197
|
|
|
198
|
-
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. 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.
|
|
198
|
+
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.
|
|
199
199
|
|
|
200
200
|
## Runtime Direction
|
|
201
201
|
|
package/docs/async-runs.md
CHANGED
|
@@ -158,6 +158,8 @@ The actor-level surface is:
|
|
|
158
158
|
- `message`: send one typed envelope to `run:<id>`, `branch:<run>/<branch>`, `room:<run>`, `tool:<name>`, `coordinator`, or `session:<id>`.
|
|
159
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.
|
|
160
160
|
|
|
161
|
+
Opt-in supervisor retirement uses `retire_when: "children_terminal"` as lifecycle metadata. Candidate detection is conservative: a supervisor is not retirement-ready while command-template progress or descendant `pi -p` worker processes are still active; future retirement execution must also verify child async-run state and flushed outputs before stopping the supervisor.
|
|
162
|
+
|
|
161
163
|
Low-level async actions map into the actor surface instead of forming a second public model:
|
|
162
164
|
|
|
163
165
|
- Start → `spawn`
|
package/index.ts
CHANGED
|
@@ -54,6 +54,8 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
54
54
|
| ActorInspectorTui.ActorInspectorPreview["channel"][]
|
|
55
55
|
| undefined;
|
|
56
56
|
let actorInspectorMention: string | undefined;
|
|
57
|
+
let actorInspectorBranch: string | undefined;
|
|
58
|
+
let actorInspectorUnreadOnly = false;
|
|
57
59
|
let actorInspectorRoomLimitPerRun = 12;
|
|
58
60
|
let selectedInspectorSequence: number | undefined;
|
|
59
61
|
let recipeWatcherFailureNotified = false;
|
|
@@ -90,9 +92,11 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
90
92
|
{
|
|
91
93
|
channels: actorInspectorChannels,
|
|
92
94
|
currentRunOnly: true,
|
|
95
|
+
branch: actorInspectorBranch,
|
|
93
96
|
mention: actorInspectorMention,
|
|
94
97
|
ownerId,
|
|
95
98
|
roomLimitPerRun: actorInspectorRoomLimitPerRun,
|
|
99
|
+
unreadOnly: actorInspectorUnreadOnly,
|
|
96
100
|
},
|
|
97
101
|
);
|
|
98
102
|
const rows =
|
|
@@ -337,7 +341,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
337
341
|
});
|
|
338
342
|
pi.registerCommand("actors-inspector-filter", {
|
|
339
343
|
description:
|
|
340
|
-
"Filter actor inspector rows: all, room, direct, broadcast, mention <text>",
|
|
344
|
+
"Filter actor inspector rows: all, room, direct, broadcast, unread, branch <name>, mention <text>",
|
|
341
345
|
handler: async (args, ctx) => {
|
|
342
346
|
const parts = Array.isArray(args)
|
|
343
347
|
? args.map(String)
|
|
@@ -346,9 +350,23 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
346
350
|
if (!mode || mode === "all" || mode === "clear") {
|
|
347
351
|
actorInspectorChannels = undefined;
|
|
348
352
|
actorInspectorMention = undefined;
|
|
353
|
+
actorInspectorBranch = undefined;
|
|
354
|
+
actorInspectorUnreadOnly = false;
|
|
349
355
|
} else if (mode === "room" || mode === "direct" || mode === "broadcast") {
|
|
350
356
|
actorInspectorChannels = [mode];
|
|
351
357
|
actorInspectorMention = undefined;
|
|
358
|
+
} else if (mode === "unread") {
|
|
359
|
+
actorInspectorUnreadOnly = true;
|
|
360
|
+
} else if (mode === "branch" || mode === "current-branch") {
|
|
361
|
+
const branch = parts.slice(1).join(" ").trim();
|
|
362
|
+
if (!branch) {
|
|
363
|
+
ctx.ui.notify(
|
|
364
|
+
`Usage: /actors-inspector-filter ${mode} <branch-name>`,
|
|
365
|
+
"warning",
|
|
366
|
+
);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
actorInspectorBranch = branch;
|
|
352
370
|
} else if (mode === "mention") {
|
|
353
371
|
const mention = parts.slice(1).join(" ").trim();
|
|
354
372
|
if (!mention) {
|
|
@@ -362,7 +380,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
362
380
|
actorInspectorMention = mention;
|
|
363
381
|
} else {
|
|
364
382
|
ctx.ui.notify(
|
|
365
|
-
"Usage: /actors-inspector-filter all|room|direct|broadcast|mention <text>",
|
|
383
|
+
"Usage: /actors-inspector-filter all|room|direct|broadcast|unread|branch <name>|mention <text>",
|
|
366
384
|
"warning",
|
|
367
385
|
);
|
|
368
386
|
return;
|
|
@@ -13,9 +13,12 @@ import * as Paths from "./paths.ts";
|
|
|
13
13
|
|
|
14
14
|
export interface ActorInspectorPreview {
|
|
15
15
|
body_preview?: string;
|
|
16
|
+
branch?: string;
|
|
16
17
|
channel: "broadcast" | "direct" | "room";
|
|
17
18
|
from?: string;
|
|
18
19
|
from_display?: string;
|
|
20
|
+
inbox_status?: string;
|
|
21
|
+
message_id?: string;
|
|
19
22
|
run: string;
|
|
20
23
|
sequence?: number;
|
|
21
24
|
summary?: string;
|
|
@@ -50,10 +53,12 @@ export interface ActorInspectorRosterMember {
|
|
|
50
53
|
|
|
51
54
|
export interface ActorInspectorPreviewReadOptions {
|
|
52
55
|
ownerId?: string;
|
|
56
|
+
branch?: string;
|
|
53
57
|
currentRunOnly?: boolean;
|
|
54
58
|
channels?: ActorInspectorPreview["channel"][];
|
|
55
59
|
mention?: string;
|
|
56
60
|
roomLimitPerRun?: number;
|
|
61
|
+
unreadOnly?: boolean;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
@@ -207,6 +212,39 @@ function readInboxPreviews(
|
|
|
207
212
|
.filter((preview): preview is ActorInspectorPreview => Boolean(preview));
|
|
208
213
|
}
|
|
209
214
|
|
|
215
|
+
function readBranchInboxPreviews(
|
|
216
|
+
run: string,
|
|
217
|
+
stateDir: string,
|
|
218
|
+
): ActorInspectorPreview[] {
|
|
219
|
+
const branchesDir = path.join(stateDir, "branches");
|
|
220
|
+
try {
|
|
221
|
+
return fs
|
|
222
|
+
.readdirSync(branchesDir, { withFileTypes: true })
|
|
223
|
+
.filter((entry) => entry.isDirectory())
|
|
224
|
+
.flatMap((entry) =>
|
|
225
|
+
readJsonLines(path.join(branchesDir, entry.name, "inbox.jsonl"))
|
|
226
|
+
.map((message): ActorInspectorPreview | undefined => {
|
|
227
|
+
const preview = previewFromMessage(
|
|
228
|
+
run,
|
|
229
|
+
message,
|
|
230
|
+
String(message.queued_at ?? message.received_at ?? message.timestamp ?? ""),
|
|
231
|
+
);
|
|
232
|
+
if (!preview) return undefined;
|
|
233
|
+
return {
|
|
234
|
+
...preview,
|
|
235
|
+
branch: entry.name,
|
|
236
|
+
...(typeof message.id === "string" ? { message_id: message.id } : {}),
|
|
237
|
+
...(typeof message.status === "string" ? { inbox_status: message.status } : {}),
|
|
238
|
+
};
|
|
239
|
+
})
|
|
240
|
+
.filter((preview): preview is ActorInspectorPreview => Boolean(preview)),
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
function readOutboxPreviews(
|
|
211
249
|
run: string,
|
|
212
250
|
stateDir: string,
|
|
@@ -238,6 +276,21 @@ function matchesOwner(stateDir: string, ownerId: string | undefined): boolean {
|
|
|
238
276
|
return ownerId === undefined || getRunOwnerId(stateDir) === ownerId;
|
|
239
277
|
}
|
|
240
278
|
|
|
279
|
+
function isUnreadPreview(preview: ActorInspectorPreview): boolean {
|
|
280
|
+
return preview.inbox_status === "queued" || preview.inbox_status === undefined && preview.branch !== undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function matchesBranchFilter(
|
|
284
|
+
preview: ActorInspectorPreview,
|
|
285
|
+
branch: string | undefined,
|
|
286
|
+
): boolean {
|
|
287
|
+
const name = branch?.trim();
|
|
288
|
+
if (!name) return true;
|
|
289
|
+
if (preview.branch !== undefined) return preview.branch === name;
|
|
290
|
+
const address = `branch:${preview.run}/${name}`;
|
|
291
|
+
return preview.from === address || preview.to === address;
|
|
292
|
+
}
|
|
293
|
+
|
|
241
294
|
function matchesPreviewFilter(
|
|
242
295
|
preview: ActorInspectorPreview,
|
|
243
296
|
options: ActorInspectorPreviewReadOptions,
|
|
@@ -245,11 +298,15 @@ function matchesPreviewFilter(
|
|
|
245
298
|
if (options.channels?.length && !options.channels.includes(preview.channel)) {
|
|
246
299
|
return false;
|
|
247
300
|
}
|
|
301
|
+
if (options.unreadOnly && !isUnreadPreview(preview)) return false;
|
|
302
|
+
if (!matchesBranchFilter(preview, options.branch)) return false;
|
|
248
303
|
const mention = options.mention?.trim().toLowerCase();
|
|
249
304
|
if (!mention) return true;
|
|
250
305
|
return [
|
|
306
|
+
preview.branch,
|
|
251
307
|
preview.from,
|
|
252
308
|
preview.from_display,
|
|
309
|
+
preview.inbox_status,
|
|
253
310
|
preview.to,
|
|
254
311
|
preview.type,
|
|
255
312
|
preview.summary,
|
|
@@ -297,6 +354,7 @@ export function readActorInspectorPreviews(
|
|
|
297
354
|
return [
|
|
298
355
|
...readRoomPreviews(entry.name, stateDir),
|
|
299
356
|
...readInboxPreviews(entry.name, stateDir),
|
|
357
|
+
...readBranchInboxPreviews(entry.name, stateDir),
|
|
300
358
|
...readOutboxPreviews(entry.name, stateDir),
|
|
301
359
|
];
|
|
302
360
|
})
|
package/lib/actor-rooms.ts
CHANGED
|
@@ -10,8 +10,8 @@ import * as path from "node:path";
|
|
|
10
10
|
|
|
11
11
|
import type { ActorMessage } from "./actor-messages.ts";
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
13
|
+
const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
14
|
+
const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
15
15
|
const DEFAULT_ROOM_MAX_MESSAGES = 10000;
|
|
16
16
|
const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 250;
|
|
17
17
|
|
|
@@ -117,9 +117,9 @@ function sleepSync(ms: number): void {
|
|
|
117
117
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
const lockDir = path.join(
|
|
120
|
+
function acquireStateLock(parentDir: string, name: string, label: string): () => void {
|
|
121
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
122
|
+
const lockDir = path.join(parentDir, name);
|
|
123
123
|
const started = Date.now();
|
|
124
124
|
while (true) {
|
|
125
125
|
try {
|
|
@@ -132,24 +132,29 @@ function acquireRoomLock(stateDir: string, room: string): () => void {
|
|
|
132
132
|
} catch (error) {
|
|
133
133
|
try {
|
|
134
134
|
const stat = fs.statSync(lockDir);
|
|
135
|
-
if (Date.now() - stat.mtimeMs >
|
|
135
|
+
if (Date.now() - stat.mtimeMs > STATE_LOCK_MAX_AGE_MS) {
|
|
136
136
|
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
137
137
|
continue;
|
|
138
138
|
}
|
|
139
139
|
} catch {
|
|
140
140
|
continue;
|
|
141
141
|
}
|
|
142
|
-
if (Date.now() - started >
|
|
143
|
-
throw new Error(
|
|
144
|
-
`Room append lock timed out for ${room} in ${stateDir}.`,
|
|
145
|
-
{ cause: error },
|
|
146
|
-
);
|
|
142
|
+
if (Date.now() - started > STATE_LOCK_TIMEOUT_MS) {
|
|
143
|
+
throw new Error(`${label} lock timed out.`, { cause: error });
|
|
147
144
|
}
|
|
148
145
|
sleepSync(10);
|
|
149
146
|
}
|
|
150
147
|
}
|
|
151
148
|
}
|
|
152
149
|
|
|
150
|
+
function acquireRoomLock(stateDir: string, room: string): () => void {
|
|
151
|
+
return acquireStateLock(roomDir(stateDir, room), ".append.lock", `Room append ${room}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function acquireBranchInboxLock(stateDir: string, branch: string): () => void {
|
|
155
|
+
return acquireStateLock(path.dirname(branchInboxFile(stateDir, branch)), ".inbox.lock", `Branch inbox ${branch}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
154
159
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
155
160
|
? (value as Record<string, unknown>)
|
|
@@ -191,6 +196,13 @@ function snapshotMinIntervalMs(): number {
|
|
|
191
196
|
);
|
|
192
197
|
}
|
|
193
198
|
|
|
199
|
+
function rosterMinIntervalMs(): number {
|
|
200
|
+
return positiveEnvInt(
|
|
201
|
+
"PI_ACTORS_ROOM_ROSTER_MIN_MS",
|
|
202
|
+
DEFAULT_SNAPSHOT_MIN_INTERVAL_MS,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
194
206
|
function compactRoomMessages(stateDir: string, room: string): void {
|
|
195
207
|
const maxMessages = roomMaxMessages();
|
|
196
208
|
const file = messagesFile(stateDir, room);
|
|
@@ -204,6 +216,31 @@ function compactRoomMessages(stateDir: string, room: string): void {
|
|
|
204
216
|
});
|
|
205
217
|
}
|
|
206
218
|
|
|
219
|
+
function readJsonlLineCount(file: string): number {
|
|
220
|
+
const stat = fs.statSync(file);
|
|
221
|
+
if (stat.size === 0) return 0;
|
|
222
|
+
const fd = fs.openSync(file, "r");
|
|
223
|
+
try {
|
|
224
|
+
const chunkSize = 64 * 1024;
|
|
225
|
+
const chunk = Buffer.allocUnsafe(chunkSize);
|
|
226
|
+
let position = 0;
|
|
227
|
+
let count = 0;
|
|
228
|
+
let lastByte: number | undefined;
|
|
229
|
+
while (position < stat.size) {
|
|
230
|
+
const bytesRead = fs.readSync(fd, chunk, 0, Math.min(chunkSize, stat.size - position), position);
|
|
231
|
+
if (bytesRead <= 0) break;
|
|
232
|
+
position += bytesRead;
|
|
233
|
+
for (let index = 0; index < bytesRead; index += 1) {
|
|
234
|
+
if (chunk[index] === 10) count += 1;
|
|
235
|
+
}
|
|
236
|
+
lastByte = chunk[bytesRead - 1];
|
|
237
|
+
}
|
|
238
|
+
return lastByte === 10 ? count : count + 1;
|
|
239
|
+
} finally {
|
|
240
|
+
fs.closeSync(fd);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
207
244
|
function readJsonlTailLines(file: string, limit: number): string[] {
|
|
208
245
|
const lineLimit = Math.max(1, limit);
|
|
209
246
|
const stat = fs.statSync(file);
|
|
@@ -250,15 +287,38 @@ function writeRoomRoster(
|
|
|
250
287
|
writeJsonFile(rosterFile(stateDir, room), roster);
|
|
251
288
|
}
|
|
252
289
|
|
|
253
|
-
function
|
|
290
|
+
function shouldDebounceFile(file: string, minIntervalMs: number): boolean {
|
|
254
291
|
try {
|
|
255
|
-
return Date.now() - fs.statSync(file).mtimeMs <
|
|
292
|
+
return Date.now() - fs.statSync(file).mtimeMs < minIntervalMs;
|
|
256
293
|
} catch (error) {
|
|
257
294
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
258
295
|
throw error;
|
|
259
296
|
}
|
|
260
297
|
}
|
|
261
298
|
|
|
299
|
+
function shouldDebounceSnapshot(file: string): boolean {
|
|
300
|
+
return shouldDebounceFile(file, snapshotMinIntervalMs());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function comparableRosterMember(member: RoomMember | undefined): string {
|
|
304
|
+
if (!member) return "";
|
|
305
|
+
const { last_seen: _lastSeen, ...semantic } = member;
|
|
306
|
+
return JSON.stringify(semantic);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function shouldWriteRoomRosterMember(
|
|
310
|
+
stateDir: string,
|
|
311
|
+
room: string,
|
|
312
|
+
before: RoomMember | undefined,
|
|
313
|
+
after: RoomMember,
|
|
314
|
+
): boolean {
|
|
315
|
+
if (!before) return true;
|
|
316
|
+
if (comparableRosterMember(before) !== comparableRosterMember(after)) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
return !shouldDebounceFile(rosterFile(stateDir, room), rosterMinIntervalMs());
|
|
320
|
+
}
|
|
321
|
+
|
|
262
322
|
function updateRosterForMessage(
|
|
263
323
|
stateDir: string,
|
|
264
324
|
room: string,
|
|
@@ -269,33 +329,33 @@ function updateRosterForMessage(
|
|
|
269
329
|
if (!message.from) return roster;
|
|
270
330
|
const body = asRecord(message.body);
|
|
271
331
|
const current = roster[message.from];
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
332
|
+
const next = message.type === "actor.leave"
|
|
333
|
+
? {
|
|
334
|
+
address: message.from,
|
|
335
|
+
joined_at: current?.joined_at ?? receivedAt,
|
|
336
|
+
last_seen: receivedAt,
|
|
337
|
+
...(current?.caps !== undefined ? { caps: current.caps } : {}),
|
|
338
|
+
...(current?.claim !== undefined ? { claim: current.claim } : {}),
|
|
339
|
+
...(current?.display !== undefined ? { display: current.display } : {}),
|
|
340
|
+
...(current?.parent !== undefined ? { parent: current.parent } : {}),
|
|
341
|
+
...(current?.role !== undefined ? { role: current.role } : { role: "actor" }),
|
|
342
|
+
status: String(body.status ?? "left"),
|
|
343
|
+
}
|
|
344
|
+
: {
|
|
345
|
+
address: message.from,
|
|
346
|
+
joined_at: current?.joined_at ?? receivedAt,
|
|
347
|
+
last_seen: receivedAt,
|
|
348
|
+
...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
|
|
349
|
+
...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
|
|
350
|
+
...(body.display !== undefined ? { display: body.display } : current?.display !== undefined ? { display: current.display } : {}),
|
|
351
|
+
...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
|
|
352
|
+
...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
|
|
353
|
+
status: String(body.status ?? current?.status ?? "present"),
|
|
354
|
+
};
|
|
355
|
+
roster[message.from] = next;
|
|
356
|
+
if (shouldWriteRoomRosterMember(stateDir, room, current, next)) {
|
|
284
357
|
writeRoomRoster(stateDir, room, roster);
|
|
285
|
-
return roster;
|
|
286
358
|
}
|
|
287
|
-
roster[message.from] = {
|
|
288
|
-
address: message.from,
|
|
289
|
-
joined_at: current?.joined_at ?? receivedAt,
|
|
290
|
-
last_seen: receivedAt,
|
|
291
|
-
...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
|
|
292
|
-
...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
|
|
293
|
-
...(body.display !== undefined ? { display: body.display } : current?.display !== undefined ? { display: current.display } : {}),
|
|
294
|
-
...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
|
|
295
|
-
...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
|
|
296
|
-
status: String(body.status ?? current?.status ?? "present"),
|
|
297
|
-
};
|
|
298
|
-
writeRoomRoster(stateDir, room, roster);
|
|
299
359
|
return roster;
|
|
300
360
|
}
|
|
301
361
|
|
|
@@ -325,12 +385,16 @@ export function appendBranchInboxMessage(
|
|
|
325
385
|
): void {
|
|
326
386
|
const branch = branchIdFromAddress(address, run);
|
|
327
387
|
if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
388
|
+
const releaseLock = acquireBranchInboxLock(stateDir, branch);
|
|
389
|
+
try {
|
|
390
|
+
fs.writeFileSync(
|
|
391
|
+
branchInboxFile(stateDir, branch),
|
|
392
|
+
`${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`,
|
|
393
|
+
{ flag: "a" },
|
|
394
|
+
);
|
|
395
|
+
} finally {
|
|
396
|
+
releaseLock();
|
|
397
|
+
}
|
|
334
398
|
}
|
|
335
399
|
|
|
336
400
|
export function updateBranchInboxMessageStatus(
|
|
@@ -343,18 +407,23 @@ export function updateBranchInboxMessageStatus(
|
|
|
343
407
|
): boolean {
|
|
344
408
|
const branch = branchIdFromAddress(address, run);
|
|
345
409
|
if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
410
|
+
const releaseLock = acquireBranchInboxLock(stateDir, branch);
|
|
411
|
+
try {
|
|
412
|
+
const file = branchInboxFile(stateDir, branch);
|
|
413
|
+
const messages = readBranchInboxMessages(stateDir, run, address, Number.MAX_SAFE_INTEGER);
|
|
414
|
+
let changed = false;
|
|
415
|
+
const timestampKey = `${status}_at`;
|
|
416
|
+
const updated = messages.map((message) => {
|
|
417
|
+
if (message.id !== id) return message;
|
|
418
|
+
changed = true;
|
|
419
|
+
return { ...message, ...metadata, [timestampKey]: new Date().toISOString(), status };
|
|
420
|
+
});
|
|
421
|
+
if (!changed) return false;
|
|
422
|
+
fs.writeFileSync(file, `${updated.map((message) => JSON.stringify(message)).join("\n")}\n`);
|
|
423
|
+
return true;
|
|
424
|
+
} finally {
|
|
425
|
+
releaseLock();
|
|
426
|
+
}
|
|
358
427
|
}
|
|
359
428
|
|
|
360
429
|
export function appendRoomMessage(
|
|
@@ -427,8 +496,13 @@ export function readRoomMessagePreviews(
|
|
|
427
496
|
}
|
|
428
497
|
|
|
429
498
|
export function getRoomStatus(stateDir: string, room: string): RoomStatus {
|
|
430
|
-
|
|
431
|
-
|
|
499
|
+
let messageCount = 0;
|
|
500
|
+
try {
|
|
501
|
+
messageCount = readJsonlLineCount(messagesFile(stateDir, room));
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
504
|
+
}
|
|
505
|
+
const [last] = readRoomMessages(stateDir, room, 1);
|
|
432
506
|
return {
|
|
433
507
|
...(last
|
|
434
508
|
? {
|
|
@@ -438,7 +512,7 @@ export function getRoomStatus(stateDir: string, room: string): RoomStatus {
|
|
|
438
512
|
last_message_type: last.type,
|
|
439
513
|
}
|
|
440
514
|
: {}),
|
|
441
|
-
message_count:
|
|
515
|
+
message_count: messageCount,
|
|
442
516
|
room,
|
|
443
517
|
roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
|
|
444
518
|
};
|
package/lib/observability.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type RunOutboxLevel = "info" | "warning" | "error";
|
|
|
23
23
|
export interface RunObservation {
|
|
24
24
|
activeSubagents?: number;
|
|
25
25
|
completed?: number;
|
|
26
|
+
descendantSubagents?: number;
|
|
26
27
|
failures?: number;
|
|
27
28
|
ownerId?: string;
|
|
28
29
|
artifacts?: Record<string, string>;
|
|
@@ -51,6 +52,7 @@ export interface RunSummary {
|
|
|
51
52
|
|
|
52
53
|
export interface RunRetirementCandidate {
|
|
53
54
|
activeSubagents: number;
|
|
55
|
+
descendantSubagents: number;
|
|
54
56
|
run: string;
|
|
55
57
|
stateDir: string;
|
|
56
58
|
}
|
|
@@ -94,7 +96,7 @@ const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
|
|
|
94
96
|
|
|
95
97
|
const procDescendantScanCache = new Map<
|
|
96
98
|
string,
|
|
97
|
-
{
|
|
99
|
+
{ counts: Map<string, number>; expiresAt: number; signature: string }
|
|
98
100
|
>();
|
|
99
101
|
|
|
100
102
|
function toNumber(value: unknown): number | undefined {
|
|
@@ -182,18 +184,26 @@ export function summarizeRuns(
|
|
|
182
184
|
.filter((run): run is RunObservation => Boolean(run))
|
|
183
185
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
184
186
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
185
|
-
const
|
|
187
|
+
const processSubagentsByRun = countRunningSubagentsByRun(stateRoot, ownerId);
|
|
188
|
+
const runsWithDescendants = runs.map((run) => {
|
|
189
|
+
const descendantSubagents = processSubagentsByRun.get(run.run) ?? 0;
|
|
190
|
+
return descendantSubagents > 0 ? { ...run, descendantSubagents } : run;
|
|
191
|
+
});
|
|
192
|
+
const runningRuns = runsWithDescendants.filter((run) => run.status === "running");
|
|
186
193
|
const running = runningRuns.length;
|
|
187
|
-
const done =
|
|
188
|
-
const exited =
|
|
189
|
-
const failed =
|
|
190
|
-
const cancelled =
|
|
191
|
-
const killed =
|
|
194
|
+
const done = runsWithDescendants.filter((run) => run.status === "done").length;
|
|
195
|
+
const exited = runsWithDescendants.filter((run) => run.status === "exited").length;
|
|
196
|
+
const failed = runsWithDescendants.filter((run) => run.status === "failed").length;
|
|
197
|
+
const cancelled = runsWithDescendants.filter((run) => run.status === "cancelled").length;
|
|
198
|
+
const killed = runsWithDescendants.filter((run) => run.status === "killed").length;
|
|
192
199
|
const progressSubagents = runningRuns.reduce(
|
|
193
200
|
(sum, run) => sum + Math.max(1, Math.floor(run.activeSubagents ?? 0)),
|
|
194
201
|
0,
|
|
195
202
|
);
|
|
196
|
-
const processSubagents =
|
|
203
|
+
const processSubagents = [...processSubagentsByRun.values()].reduce(
|
|
204
|
+
(sum, count) => sum + count,
|
|
205
|
+
0,
|
|
206
|
+
);
|
|
197
207
|
const runningSubagents = Math.max(progressSubagents, running + processSubagents);
|
|
198
208
|
return {
|
|
199
209
|
cancelled,
|
|
@@ -203,8 +213,8 @@ export function summarizeRuns(
|
|
|
203
213
|
killed,
|
|
204
214
|
running,
|
|
205
215
|
runningSubagents,
|
|
206
|
-
runs,
|
|
207
|
-
total:
|
|
216
|
+
runs: runsWithDescendants,
|
|
217
|
+
total: runsWithDescendants.length,
|
|
208
218
|
};
|
|
209
219
|
}
|
|
210
220
|
|
|
@@ -228,13 +238,13 @@ function getProcCommand(pid: string): string {
|
|
|
228
238
|
return (readProcFile(`/proc/${pid}/cmdline`) ?? "").replaceAll("\0", " ");
|
|
229
239
|
}
|
|
230
240
|
|
|
231
|
-
function
|
|
232
|
-
const pids = new
|
|
241
|
+
function getRunningRunPidMap(stateRoot: string, ownerId?: string): Map<string, string> {
|
|
242
|
+
const pids = new Map<string, string>();
|
|
233
243
|
for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
|
|
234
244
|
if (run.status !== "running") continue;
|
|
235
245
|
const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
|
|
236
246
|
const pid = Number(status.pid || 0);
|
|
237
|
-
if (pid > 0) pids.
|
|
247
|
+
if (pid > 0) pids.set(String(pid), run.run);
|
|
238
248
|
}
|
|
239
249
|
return pids;
|
|
240
250
|
}
|
|
@@ -278,18 +288,18 @@ function summarizeRunsWithoutSubagents(
|
|
|
278
288
|
};
|
|
279
289
|
}
|
|
280
290
|
|
|
281
|
-
export function
|
|
291
|
+
export function countRunningSubagentsByRun(
|
|
282
292
|
stateRoot = Paths.getRunStateRoot(),
|
|
283
293
|
ownerId?: string,
|
|
284
|
-
): number {
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
const signature = [...
|
|
294
|
+
): Map<string, number> {
|
|
295
|
+
const runPidMap = getRunningRunPidMap(stateRoot, ownerId);
|
|
296
|
+
if (runPidMap.size === 0 || !existsSync("/proc")) return new Map();
|
|
297
|
+
const signature = [...runPidMap.keys()].sort().join(",");
|
|
288
298
|
const cacheKey = `${stateRoot}\0${ownerId ?? ""}`;
|
|
289
299
|
const cached = procDescendantScanCache.get(cacheKey);
|
|
290
300
|
const now = Date.now();
|
|
291
301
|
if (cached && cached.signature === signature && cached.expiresAt > now) {
|
|
292
|
-
return cached.
|
|
302
|
+
return new Map(cached.counts);
|
|
293
303
|
}
|
|
294
304
|
const parentByPid = new Map<string, string>();
|
|
295
305
|
const commandByPid = new Map<string, string>();
|
|
@@ -297,7 +307,7 @@ export function countRunningSubagents(
|
|
|
297
307
|
try {
|
|
298
308
|
procEntries = readdirSync("/proc", { withFileTypes: true });
|
|
299
309
|
} catch {
|
|
300
|
-
return
|
|
310
|
+
return new Map();
|
|
301
311
|
}
|
|
302
312
|
for (const entry of procEntries) {
|
|
303
313
|
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
@@ -306,27 +316,39 @@ export function countRunningSubagents(
|
|
|
306
316
|
parentByPid.set(entry.name, ppid);
|
|
307
317
|
commandByPid.set(entry.name, getProcCommand(entry.name));
|
|
308
318
|
}
|
|
309
|
-
const
|
|
319
|
+
const runForDescendant = (pid: string): string | undefined => {
|
|
310
320
|
let current = parentByPid.get(pid);
|
|
311
321
|
const seen = new Set<string>();
|
|
312
322
|
while (current && !seen.has(current)) {
|
|
313
|
-
|
|
323
|
+
const run = runPidMap.get(current);
|
|
324
|
+
if (run) return run;
|
|
314
325
|
seen.add(current);
|
|
315
326
|
current = parentByPid.get(current);
|
|
316
327
|
}
|
|
317
|
-
return
|
|
328
|
+
return undefined;
|
|
318
329
|
};
|
|
319
|
-
|
|
330
|
+
const counts = new Map<string, number>();
|
|
320
331
|
for (const [pid, command] of commandByPid.entries()) {
|
|
321
332
|
if (!command.includes("pi -p") && !command.includes("pi\0-p")) continue;
|
|
322
|
-
|
|
333
|
+
const run = runForDescendant(pid);
|
|
334
|
+
if (run) counts.set(run, (counts.get(run) ?? 0) + 1);
|
|
323
335
|
}
|
|
324
336
|
procDescendantScanCache.set(cacheKey, {
|
|
325
|
-
|
|
337
|
+
counts,
|
|
326
338
|
expiresAt: now + PROC_DESCENDANT_SCAN_TTL_MS,
|
|
327
339
|
signature,
|
|
328
340
|
});
|
|
329
|
-
return
|
|
341
|
+
return new Map(counts);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function countRunningSubagents(
|
|
345
|
+
stateRoot = Paths.getRunStateRoot(),
|
|
346
|
+
ownerId?: string,
|
|
347
|
+
): number {
|
|
348
|
+
return [...countRunningSubagentsByRun(stateRoot, ownerId).values()].reduce(
|
|
349
|
+
(sum, count) => sum + count,
|
|
350
|
+
0,
|
|
351
|
+
);
|
|
330
352
|
}
|
|
331
353
|
|
|
332
354
|
export function renderSubagentStatus(
|
|
@@ -352,14 +374,19 @@ export function findRunRetirementCandidates(
|
|
|
352
374
|
summary: RunSummary,
|
|
353
375
|
): RunRetirementCandidate[] {
|
|
354
376
|
return summary.runs
|
|
355
|
-
.filter((run) =>
|
|
356
|
-
run.
|
|
357
|
-
run.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
377
|
+
.filter((run) => {
|
|
378
|
+
const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
|
|
379
|
+
const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
|
|
380
|
+
return (
|
|
381
|
+
run.status === "running" &&
|
|
382
|
+
run.retireWhen === "children_terminal" &&
|
|
383
|
+
run.stateDir &&
|
|
384
|
+
activeSubagents + descendantSubagents <= 0
|
|
385
|
+
);
|
|
386
|
+
})
|
|
361
387
|
.map((run) => ({
|
|
362
388
|
activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
|
|
389
|
+
descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
|
|
363
390
|
run: run.run,
|
|
364
391
|
stateDir: run.stateDir!,
|
|
365
392
|
}));
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-actors",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.11",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Actor
|
|
5
|
+
"description": "Local Actor Kernel for Pi",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
8
8
|
"pi-extension",
|
package/scripts/async-runner.mjs
CHANGED
|
@@ -11,18 +11,42 @@
|
|
|
11
11
|
* Keep orchestration policy out of this file.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { appendFileSync, readFileSync } from "node:fs";
|
|
15
|
-
import { join } from "node:path";
|
|
14
|
+
import { appendFileSync, cpSync, existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
17
|
|
|
17
18
|
const stateDir = process.argv[2];
|
|
18
19
|
if (!stateDir) {
|
|
19
20
|
console.error("missing state dir");
|
|
20
21
|
process.exit(1);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
|
|
24
|
+
function scriptFile() {
|
|
25
|
+
return fileURLToPath(import.meta.url);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isUnderNodeModules(file) {
|
|
29
|
+
return /[/\\]node_modules[/\\]/.test(file);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function prepareTypeStripImportRoot() {
|
|
33
|
+
const packageRoot = dirname(dirname(scriptFile()));
|
|
34
|
+
const sourceLib = join(packageRoot, "lib");
|
|
35
|
+
if (!isUnderNodeModules(packageRoot)) return sourceLib;
|
|
36
|
+
const copiedLib = join(stateDir, ".type-strip-lib");
|
|
37
|
+
if (!existsSync(copiedLib)) cpSync(sourceLib, copiedLib, { recursive: true });
|
|
38
|
+
return copiedLib;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const typeStripImportRoot = prepareTypeStripImportRoot();
|
|
42
|
+
async function importLib(name) {
|
|
43
|
+
return import(pathToFileURL(join(typeStripImportRoot, `${name}.ts`)).href);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { executeRegisteredTool } = await importLib("execution");
|
|
47
|
+
const { execCommandTemplate } = await importLib("command-templates");
|
|
48
|
+
const { appendRecipeContextToPiArgs } = await importLib("actor-recipe-context");
|
|
49
|
+
const { writeJsonAtomic } = await importLib("file-state");
|
|
26
50
|
const runPath = join(stateDir, "run.json");
|
|
27
51
|
const progressPath = join(stateDir, "progress.json");
|
|
28
52
|
const resultPath = join(stateDir, "result.json");
|
package/scripts/coordinator.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
|
|
6
6
|
function arg(name, fallback = "") {
|
|
7
7
|
const prefix = `--${name}=`;
|
|
@@ -60,6 +60,9 @@ async function sleep(ms) {
|
|
|
60
60
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
64
|
+
const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
65
|
+
|
|
63
66
|
async function waitForPath(path, timeoutMs = 5000) {
|
|
64
67
|
const started = Date.now();
|
|
65
68
|
while (!existsSync(path)) {
|
|
@@ -68,6 +71,37 @@ async function waitForPath(path, timeoutMs = 5000) {
|
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
async function acquireStateLock(parentDir, name, label) {
|
|
75
|
+
await mkdir(parentDir, { recursive: true });
|
|
76
|
+
const lockDir = `${parentDir}/${name}`;
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
while (true) {
|
|
79
|
+
try {
|
|
80
|
+
await mkdir(lockDir);
|
|
81
|
+
await writeFile(`${lockDir}/owner.json`, `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf8");
|
|
82
|
+
return async () => rm(lockDir, { recursive: true, force: true });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
try {
|
|
85
|
+
const current = await stat(lockDir);
|
|
86
|
+
if (Date.now() - current.mtimeMs > STATE_LOCK_MAX_AGE_MS) {
|
|
87
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (Date.now() - started > STATE_LOCK_TIMEOUT_MS) {
|
|
94
|
+
throw new Error(`${label} lock timed out.`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
await sleep(10);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function acquireBranchInboxLock(runId, branchName) {
|
|
102
|
+
return acquireStateLock(`${runStateDir(runId)}/branches/${branchName}`, ".inbox.lock", `Branch inbox ${branchName}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
async function writeLockerMessage(locker, message) {
|
|
72
106
|
if (!locker) return;
|
|
73
107
|
await waitForPath(locker.controlPath);
|
|
@@ -186,46 +220,64 @@ async function synthesize(config, locker) {
|
|
|
186
220
|
process.stdout.write(`artifact=${config.artifactPath}\n`);
|
|
187
221
|
}
|
|
188
222
|
|
|
223
|
+
async function readInboxLines(inboxPath) {
|
|
224
|
+
if (!existsSync(inboxPath)) return [];
|
|
225
|
+
const content = await readFile(inboxPath, "utf8");
|
|
226
|
+
return content.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function writeInboxMessages(inboxPath, messages) {
|
|
230
|
+
await writeFile(inboxPath, messages.map((message) => JSON.stringify(message)).join("\n") + "\n", "utf8");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function claimQueuedInboxMessages(runId, branchName) {
|
|
234
|
+
const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
|
|
235
|
+
const releaseLock = await acquireBranchInboxLock(runId, branchName);
|
|
236
|
+
try {
|
|
237
|
+
const messages = await readInboxLines(inboxPath);
|
|
238
|
+
const claimedAt = new Date().toISOString();
|
|
239
|
+
const queuedMessages = [];
|
|
240
|
+
const updated = messages.map((msg, index) => {
|
|
241
|
+
if (msg.status !== "queued" && msg.status) return msg;
|
|
242
|
+
const claimed = {
|
|
243
|
+
...msg,
|
|
244
|
+
claimed_at: claimedAt,
|
|
245
|
+
id: msg.id || `legacy-${Date.now()}-${index}`,
|
|
246
|
+
status: "claimed",
|
|
247
|
+
};
|
|
248
|
+
queuedMessages.push(claimed);
|
|
249
|
+
return claimed;
|
|
250
|
+
});
|
|
251
|
+
if (queuedMessages.length > 0) await writeInboxMessages(inboxPath, updated);
|
|
252
|
+
return queuedMessages;
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
} finally {
|
|
256
|
+
await releaseLock();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
189
260
|
async function updateInboxMessagesStatus(runId, branchName, ids, status) {
|
|
190
261
|
const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
|
|
262
|
+
const releaseLock = await acquireBranchInboxLock(runId, branchName);
|
|
191
263
|
try {
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
msg[`${status}_at`] = new Date().toISOString();
|
|
201
|
-
}
|
|
202
|
-
updatedLines.push(JSON.stringify(msg));
|
|
203
|
-
}
|
|
204
|
-
await writeFile(inboxPath, updatedLines.join("\n") + "\n", "utf8");
|
|
205
|
-
} catch (err) {
|
|
264
|
+
const messages = await readInboxLines(inboxPath);
|
|
265
|
+
const idSet = new Set(ids);
|
|
266
|
+
const updated = messages.map((msg) => {
|
|
267
|
+
if (!msg.id || !idSet.has(msg.id)) return msg;
|
|
268
|
+
return { ...msg, [`${status}_at`]: new Date().toISOString(), status };
|
|
269
|
+
});
|
|
270
|
+
await writeInboxMessages(inboxPath, updated);
|
|
271
|
+
} catch {
|
|
206
272
|
// Best-effort write
|
|
273
|
+
} finally {
|
|
274
|
+
await releaseLock();
|
|
207
275
|
}
|
|
208
276
|
}
|
|
209
277
|
|
|
210
278
|
async function executeParticipantPrompt(role, basePrompt, config) {
|
|
211
279
|
const branchName = role.name;
|
|
212
|
-
const
|
|
213
|
-
const queuedMessages = [];
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
if (existsSync(inboxPath)) {
|
|
217
|
-
const content = await readFile(inboxPath, "utf8");
|
|
218
|
-
const lines = content.split("\n").filter(Boolean);
|
|
219
|
-
for (const line of lines) {
|
|
220
|
-
const msg = JSON.parse(line);
|
|
221
|
-
if (msg.status === "queued" || !msg.status) {
|
|
222
|
-
queuedMessages.push(msg);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} catch (err) {
|
|
227
|
-
// Best-effort read
|
|
228
|
-
}
|
|
280
|
+
const queuedMessages = await claimQueuedInboxMessages(config.runId, branchName);
|
|
229
281
|
|
|
230
282
|
let finalPrompt = basePrompt;
|
|
231
283
|
const claimedIds = [];
|
|
@@ -239,10 +291,6 @@ async function executeParticipantPrompt(role, basePrompt, config) {
|
|
|
239
291
|
}
|
|
240
292
|
inboxSection += "\nPlease acknowledge and address these direct messages in your response.\n";
|
|
241
293
|
finalPrompt += inboxSection;
|
|
242
|
-
|
|
243
|
-
if (claimedIds.length > 0) {
|
|
244
|
-
await updateInboxMessagesStatus(config.runId, branchName, claimedIds, "claimed");
|
|
245
|
-
}
|
|
246
294
|
}
|
|
247
295
|
|
|
248
296
|
const result = await runPi(finalPrompt, config.model, config.thinking);
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --experimental-strip-types
|
|
2
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
function scriptFile() {
|
|
8
|
+
return fileURLToPath(import.meta.url);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isUnderNodeModules(file) {
|
|
12
|
+
return /[/\\]node_modules[/\\]/.test(file);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function prepareTypeStripImportRoot() {
|
|
16
|
+
const packageRoot = dirname(dirname(scriptFile()));
|
|
17
|
+
const sourceLib = join(packageRoot, "lib");
|
|
18
|
+
if (!isUnderNodeModules(packageRoot)) return sourceLib;
|
|
19
|
+
const copiedLib = join(mkdtempSync(join(tmpdir(), "pi-actors-validate-lib-")), "lib");
|
|
20
|
+
cpSync(sourceLib, copiedLib, { recursive: true });
|
|
21
|
+
return copiedLib;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const typeStripImportRoot = prepareTypeStripImportRoot();
|
|
25
|
+
const { readResolvedRecipeConfig } = await import(
|
|
26
|
+
pathToFileURL(join(typeStripImportRoot, "recipe-references.ts")).href
|
|
27
|
+
);
|
|
7
28
|
|
|
8
29
|
function usage() {
|
|
9
30
|
console.error(`Usage:
|
package/skills/actors/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: actors
|
|
3
3
|
description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.19.
|
|
5
|
+
version: 0.19.11
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Actors (pi-actors)
|
|
@@ -122,10 +122,10 @@ Views:
|
|
|
122
122
|
Actor inspector commands:
|
|
123
123
|
|
|
124
124
|
- `/actors-inspector-toggle [rows]`: open/close the compact table or set row count; default is 12 log rows when no size is supplied.
|
|
125
|
-
- `/actors-inspector-filter all|room|direct|broadcast|mention <text>`: narrow table previews without changing room/run state.
|
|
125
|
+
- `/actors-inspector-filter all|room|direct|broadcast|unread|branch <name>|current-branch <name>|mention <text>`: narrow table previews without changing room/run state.
|
|
126
126
|
- `/actors-inspect <number>`: open one visible row as a full-message view.
|
|
127
127
|
|
|
128
|
-
The table is compact and optimistic by default: bounded body previews, capped noisy room rows, and an inline roster summary in the form `name/role` that wraps only when needed. Active roster members use the target color; members that sent `actor.leave` stay visible as inactive/muted participants from the current run. Actor display names come from `actor.join` bodies (`display`) or branch addresses, keeping debugger output plain and name-driven.
|
|
128
|
+
The table is compact and optimistic by default: bounded body previews, capped noisy room rows, branch-local inbox previews, and an inline roster summary in the form `name/role` that wraps only when needed. Use `unread` for queued branch inbox work and `branch <name>` / `current-branch <name>` for one branch's room/direct/inbox traffic. Active roster members use the target color; members that sent `actor.leave` stay visible as inactive/muted participants from the current run. Actor display names come from `actor.join` bodies (`display`) or branch addresses, keeping debugger output plain and name-driven.
|
|
129
129
|
|
|
130
130
|
Let terminal notifications arrive; avoid sleep-poll loops except during diagnosis.
|
|
131
131
|
|
package/skills/swarm/SKILL.md
CHANGED