Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Do not blur these domains:
- `/tasks`, task RPC, `TaskClaim`, and `TaskUpdate` never control workflow work.
- `MonitorManager` owns process-local monitor state; monitor recovery across Pi death is not implemented.
- `pi-subagents` owns worker execution and global concurrency. pi-loop owns only session-scoped orchestration intent, bounded evidence, local capacity, and recovery decisions.
- Ordinary pending notifications are memory-only. Orchestration wake intent is durable until acknowledgement and remains at-least-once across a crash. Persisted controllers recover on resume, not while Pi is absent.
- Ordinary pending notifications are memory-only. Orchestration wake intent survives buffer clearing/restart until valid acknowledgement, unless cancelled or retired by expiry; delivery remains at-least-once across a crash. Persisted controllers recover on resume, not while Pi is absent.

A feature that requires a cross-store workflow/task transaction violates the architecture.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
pi install npm:@trevonistrevon/pi-loop
```

pi-loop runs only while Pi is active. Persisted controller intent is not an always-on worker; ordinary pending wakes and monitor handles/output are memory-only. See the [runtime continuity matrix](./docs/RUNTIME_CONTINUITY.md) before relying on restart or unattended operation.

## Quick start

Create scheduled, event-driven, or self-paced loops:
Expand Down
10 changes: 5 additions & 5 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pi-loop separates controller, standalone-task, worker-execution, and monitor aut

Workflow state work is embedded in `LoopStore` as `WorkflowExecutionRecord`. It never appears in TaskStore, `/tasks`, task RPC, `TaskClaim`, or `TaskUpdate`. Standalone tasks never control workflow transitions. Finite orchestration intent, dispatch reservations, local capacity, and evidence are embedded in LoopStore; they never discover or project tasks or workflow executions. `pi-subagents` owns worker execution and its global queue.

Notification buffers and monitor process handles are memory-only. Orchestration wake intent is durable until delivery acknowledgement, but delivery remains at-least-once across a crash. Persisted controllers recover when Pi resumes; they do not execute while Pi is absent.
Notification buffers and monitor process handles are memory-only. Orchestration wake intent survives buffer clearing/restart until valid acknowledgement, unless cancelled or retired by expiry; delivery remains at-least-once across a crash. Persisted controllers recover when Pi resumes; they do not execute while Pi is absent.

## Runtime map

Expand All @@ -37,7 +37,7 @@ File-backed stores use PID locks, unique temporary snapshots, fsync, atomic snap
- `session` (default): isolated by Pi session ID;
- `project`: shared in the working directory.

Project scope shares durable state but does not yet elect one scheduler owner across concurrent Pi runtimes. Workflow execution leases prevent duplicate phase work; they are separate from the planned project scheduler fence. Subagent orchestration rejects memory, project, disabled, and custom-path stores; it supports only default file-backed session scope.
Project scope shares durable state but does not yet elect one scheduler owner across concurrent Pi runtimes. Workflow execution leases prevent duplicate phase work; they are separate from the planned project scheduler fence. Subagent orchestration rejects memory, project, disabled, and custom-path stores; it supports only default file-backed session scope. See [runtime continuity](./RUNTIME_CONTINUITY.md) for the retention/delivery matrix and safe recovery.

## Loop model

Expand All @@ -50,7 +50,7 @@ Project scope shares durable state but does not yet elect one scheduler owner ac

`LoopCreate` creates ordinary controllers. Use dynamic loops for one evolving goal without named phase/outcome routing; use workflows for ordered phases, conditional outcomes, rework, or durable handoff; use standalone tasks for independently completable backlog items. Free-text `/loop <goal>` controllers have no implicit fire-count cap; finite budgets require an explicit `LoopCreate maxFires`. `LoopUpdate` is only for dynamic controllers that are neither workflow nor orchestration owned and must persist `continue` after empty or unchanged iterations while work remains. `LoopDelete` pauses or removes ordinary/workflow controllers and cancellation-fences orchestration before stopping its workers. New loops expire after seven days by default; `PI_LOOP_EXPIRES_IN` changes that default and each creation tool accepts an `expiresIn` override. Durations are positive integer `s`, `m`, `h`, or `d` values and may exceed seven days. `LoopList` exposes the immutable ISO `expiresAt` boundary. Workflow presentation derives `claimed` (live lease, not observed execution), `waiting` (monitor attached), `paused`, `idle`, or ephemeral `stopped` activity from authoritative workflow fields and reports activity duration, wall-clock age, and state age without persisting duplicate UI state. Expiry and stale event/hybrid retirement during session recovery emit `loops:expired` plus a hidden notification with `deleted` or `paused` disposition. Fire limits bound repeated execution.

Wake delivery is idle-driven. A due timer or event mutates loop state, emits `loop:fire`, buffers a generation-tagged notification, and sends a hidden Pi message when delivery is safe. Immediately before a buffered workflow wake is sent, the runtime re-reads `LoopStore` and requires the controller status, definition revision, state, transition sequence, and execution ID to match the queued snapshot; deleted, transitioned, paused, or reissued work is dropped even within the same session generation. Retirement follows the same generation-fenced notification path after the store mutation and emits a typed `loops:expired` payload whose reason distinguishes `expires_at` from `resume_event_stale`. Pending fire and retirement notifications are memory-only; they are not a durable event ledger across process death. Stale extension contexts are probed before fire mutation.
Wake delivery is idle-driven. A due timer or event mutates loop state, emits `loop:fire`, buffers a generation-tagged notification, and sends a hidden Pi message when delivery is safe. Immediately before a buffered workflow wake is sent, the runtime re-reads `LoopStore` and requires the controller status, state, transition sequence, and execution ID to match the queued snapshot; compatible definition revisions refresh the message from current state; deleted, transitioned, paused, or reissued work is dropped even within the same session generation. Retirement follows the same generation-fenced notification path after the store mutation and emits a typed `loops:expired` payload whose reason distinguishes `expires_at` from `resume_event_stale`. Pending fire and retirement notifications are memory-only; they are not a durable event ledger across process death. Stale extension contexts are probed before fire mutation.

## Subagent orchestration model

Expand All @@ -60,7 +60,7 @@ The parent `agent_end`, existing 30-second heartbeat, session recovery, and dire

Lifecycle callbacks CAS loop revision, owner runtime/generation, work ID, dispatch ID, attempt, and agent ID. Normal terminal evidence is bounded and persisted with `provider_owned` completion status; pi-loop does not call `subagents:rpc:consume`, so the provider retains its native completion surface. Protocol-v2 stop replies and `stopped`/`aborted` lifecycle statuses acknowledge cancellation, not quiescence. They become non-retryable, non-consumable `uncertain` dispatches before stop RPCs run. A late spawn reply may attach its exact cleanup identity without restoring execution. Proved failures retry only within `maxAttempts`; a spawn timeout is ambiguous without upstream status/list or idempotent dispatch keys, so it becomes `uncertain` and is never retried automatically.

Completion bursts refill capacity without a pi-loop parent wake. When every terminal dispatch is provider-owned, pi-loop pauses the controller and acknowledges its aggregate wake internally so only the provider's native completion path runs. Uncertain dispatches and failures without a provider-owned terminal result retain the durable hidden wake until `pi.sendMessage` succeeds; a crash before acknowledgement may duplicate that delivery. Completed/attention controllers pause for `OrchestrationGet` inspection and explicit deletion. Presentation labels controller status as `active`, `needs attention`, `complete`, or `cancelled` while preserving reducer vocabulary in persisted state. Progress separates local `reserved` capacity from pending work and last-reported dispatch observations (`starting`, `queued`, `reported running`); none proves current worker liveness. Paused terminal controllers remain visible in the status line until deletion. Provider-native `SubagentWorkflow` UI and execution remain a separate provider-owned surface.
Completion bursts refill capacity without a pi-loop parent wake. When every terminal dispatch is provider-owned, pi-loop pauses the controller and acknowledges its aggregate wake internally so only the provider's native completion path runs. Uncertain dispatches and failures without a provider-owned terminal result retain the durable hidden wake until `pi.sendMessage` succeeds, unless cancelled or retired by expiry; a crash before acknowledgement may duplicate that delivery. Completed/attention controllers pause for `OrchestrationGet` inspection and explicit deletion. Presentation labels controller status as `active`, `needs attention`, `complete`, or `cancelled` while preserving reducer vocabulary in persisted state. Progress separates local `reserved` capacity from pending work and last-reported dispatch observations (`starting`, `queued`, `reported running`); none proves current worker liveness. Paused terminal controllers remain visible in the status line until deletion. Provider-native `SubagentWorkflow` UI and execution remain a separate provider-owned surface.

Session shutdown/switch invalidates lifecycle callbacks, persists unresolved work as uncertain, and best-effort requests cancellation before store rebinding. Any unresolved dispatch history prevents controller deletion under the Store lock; cancelled batches stay paused and inspectable. Late completion events do not clear this safety latch. Protocol v2 provides no safe automatic clearance or deletion route. Legacy `stopped`/`interrupted` records normalize to non-consumable uncertainty; previously launched retries or consumed evidence cannot be undone, and missing historical stop provenance cannot be reconstructed. A new runtime cannot adopt an unexpired foreign owner lease; after expiry it marks active dispatches uncertain rather than risking duplicates. Project scope, dynamic work addition, dependency graphs, cross-session election, and exact-once dispatch are not implemented.

Expand Down Expand Up @@ -162,7 +162,7 @@ External consumers import only `@trevonistrevon/pi-loop/api`. Deep `src/` import
- no lifetime workflow revision-count cap (retained history has growing storage and processing costs)
- 65,536 bytes per workflow definition

Remaining durability work is tracked separately in tasks: durable wake outbox, project scheduler fencing, and recoverable monitor execution. Current recovery is resume-time reconciliation, not unattended continuity or exactly-once delivery.
Daemon execution, an ordinary durable wake outbox, project scheduler fencing, and recoverable monitor execution are not implemented. Scope any future project from concrete unmet workloads and explicit approval, not persistence alone. Current recovery is resume-time reconciliation, not unattended continuity or exactly-once delivery.

## Security boundary

Expand Down
44 changes: 44 additions & 0 deletions docs/RUNTIME_CONTINUITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Runtime continuity

pi-loop runs inside an active Pi process. Persisted controller intent does not execute while Pi is absent. Resume-time reconciliation is not unattended continuity or exactly-once delivery.

## State and delivery matrix

Storage means the resolved store, not just `PI_LOOP_SCOPE`. `PI_LOOP` file overrides take precedence; `PI_LOOP=off`, memory scope, and an unbound session resolve to process-local controller storage. Native/external task persistence follows its task provider, not this LoopStore matrix.

| Resource | Retention | Resume or inspection boundary |
| --- | --- | --- |
| Memory/off/unbound-session controllers | Process-local | Do not assume state survives process loss or a new session. |
| Default session-file controllers | Isolated by Pi session ID | Resume the same session/store; a different session selects different state. |
| Project/custom-file controllers | Persisted in the resolved file | Shared state is not shared scheduler ownership; no scheduler owner election is implemented. |
| Cron controller intent | File-backed when configured | An active runtime must arm/pump scheduling. Do not assume missed intervals are replayed. |
| Ordinary pending fire/retirement notifications | memory-only | Clearing/switching the runtime discards the buffer, not committed fire accounting. No ordinary wake ledger or outbox. |
| Dynamic progress | Persisted when file-backed | Resume may issue a fresh activation from current state; it does not reconstruct the old buffered message or prove prior work did not run. |
| Event/hybrid subscriptions | Runtime registrations | Session recovery retires stale subscriptions. Recreate explicitly only when still required. |
| Workflow executions/leases | LoopStore records | Inspect current revision/state/execution; claim unowned or expired work. A live foreign lease is not bypassed and is not proof of execution. |
| Orchestration attention wake intent | Persisted until valid acknowledgement, unless cancelled or retired by expiry | Survives buffer clearing/restart; delivery requires a valid owning runtime and may be at-least-once across a crash. Acknowledgement does not prove work execution. Reconcile before action; uncertain work does not retry automatically. |
| Normal subagent completion/output | pi-subagents-owned | Aggregate orchestration wakes do not consume or duplicate normal provider output. Provider-owned completion can acknowledge its wake without a second aggregate message. |
| Monitor handles/output/status | Process-local; terminal state retained 15 minutes | A fresh manager cannot reattach an old process. Clean shutdown/switch drains owned monitors; abrupt death can leave unknown execution. Inspect external effects before authorizing another command. |
| Deleted ordinary/completed workflow controllers | No removal history | An absent ID does not identify whether it expired, completed, was deleted, or belongs to another store. Recovery snapshots are not a removal journal. Do not invent a reason. |

Disk persistence, scheduler activation, message acceptance, execution ownership, and observed work are separate facts. Inspection does not claim work, renew budgets, schedule a wake, or grant consent.

## Safe recovery

1. Resolved storage and the action menu are available through `/loop` → `View loops`. `LoopList` reports IDs, status, triggers, expiry, and recorded next-fire times. Workflow rows also include execution/outcome guidance. `LoopList` does not report resolved storage or the generic action menu. A missing controller is not a tombstone.
2. For workflows, inspect the live state/revision/execution and declared outcomes. Use `WorkflowClaim` when required; use `WorkflowRevise` for an actionable route gap. Terminal and exhausted-budget recovery follows current pause policy, not generic resume.
3. For orchestration, inspect `OrchestrationGet` and provider evidence before cancellation or deletion. Do not replay uncertain dispatches or treat cancellation acknowledgement as proof of worker quiescence.
4. For an unavailable monitor after process loss, inspect logs, process state, and external side effects. No status means no recovered observation, not successful completion or permission to rerun.
5. Recreate expired/retired controllers only with explicit authority. Preserve unfinished progress and the actual evidence; do not silently extend a lifetime.

## Qualifying demand

Proposed, unrun: ask five operators to describe concrete workloads that current boundaries cannot support, and verify that each distinguishes persistence from scheduling, delivery, and execution. Consider a specific durability project only after two independent unmet workloads are documented. Participant recruitment, budget, and acceptance targets require approval; no outcomes or operator commitments are established.

Record the required unattended interval, acceptable wake loss/duplication, scope and competing runtimes, external side effects, restart expectations, and what must happen after ambiguous execution. Then evaluate one bounded daemon, outbox, scheduler-election, or recoverable-monitor proposal. None is implemented or authorized by this guide. Vendor capabilities and historical closed reports do not establish current demand or benefit.

## Test boundary

`test/runtime-continuity.test.ts` uses isolated file-backed stores, controlled notifications/schedulers, and mock process handles. It checks queue clearing versus durable accounting, unarmed scheduling, independent scheduler registrations, durable orchestration acknowledgement, manager-local handles, and absent-controller history. Existing session, notification, index, orchestration, and monitor suites cover generation fences, resume recovery, provider-owned output, and drained shutdown.

These tests do not simulate OS process death, prove orphan cleanup, implement unattended execution, or measure operator comprehension. See [testing](./TESTING.md) for the gate and [reference](./REFERENCE.md) for authority and mutation guarantees.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dist/",
"docs/USAGE_GUIDE.md",
"docs/REFERENCE.md",
"docs/RUNTIME_CONTINUITY.md",
"docs/TESTING.md",
"docs/CONTROLLER_ROUTING_EVAL.md",
"docs/HARNESS_REVIEW_CAMPAIGNS.md",
Expand Down
1 change: 1 addition & 0 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ try {
assert(paths.includes("dist/index.d.ts"), "tarball must include dist/index.d.ts");
assert(paths.includes("dist/api.js"), "tarball must include dist/api.js");
assert(paths.includes("dist/api.d.ts"), "tarball must include dist/api.d.ts");
assert(paths.includes("docs/RUNTIME_CONTINUITY.md"), "tarball must include the linked runtime continuity guide");
assert(!paths.some((path) => path.startsWith("src/")), "tarball must not publish src/");

const root = await import("@trevonistrevon/pi-loop");
Expand Down
19 changes: 19 additions & 0 deletions test/orchestration-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,25 @@ describe("subagent orchestration runtime", () => {
expect(h.store.get(h.entry.id)?.orchestration?.pendingWake).toMatchObject({ reason: "uncertain", sequence: 1 });
});

it("retires an existing attention wake on expiry without replaying uncertain work", async () => {
const rpc = vi.fn(async (channel: string) => {
if (channel === "subagents:rpc:spawn") throw new RpcError(channel, "timed out", true);
return undefined;
});
const h = setup({ workCount: 1, rpc });
await h.runtime.pump();
await h.drain();
expect(h.store.get(h.entry.id)?.orchestration?.pendingWake?.reason).toBe("uncertain");
expect(h.emitWake).toHaveBeenCalledTimes(1);
h.setNow(h.entry.expiresAt);
await h.runtime.pump();
expect(h.store.get(h.entry.id)?.orchestration).toMatchObject({ status: "cancelled", pendingWake: undefined });
expect(h.store.get(h.entry.id)?.status).toBe("paused");
expect(h.emitWake).toHaveBeenCalledTimes(1);
expect(spawnCalls(rpc)).toHaveLength(1);
expect(h.onExpired).toHaveBeenCalledWith(expect.objectContaining({ id: h.entry.id }), "paused");
});

it("does not retry consumption for provider-owned completion", async () => {
const h = setup({ workCount: 1 });
await h.runtime.pump();
Expand Down
Loading
Loading