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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ src/index.ts extension registration and runtime wiring
src/api.ts supported @trevonistrevon/pi-loop/api surface
src/types.ts loop, workflow, revision, monitor contracts
src/store.ts LoopStore workflow/orchestration atomic mutations
src/workflow-admission.ts provider-neutral blocker transition admission
src/task-store.ts standalone native task persistence
src/*-reducer.ts pure state transitions
src/coordinator.ts reducer/effect coordination
Expand All @@ -48,10 +49,12 @@ A workflow is one dynamic `LoopEntry` with a version-1 named-state definition.
- The creator owns the initial execution lease.
- Every destination/retry execution starts unowned and requires `WorkflowClaim`.
- `WorkflowTransition` validates the live owner, settles source work, records evidence, advances state, and creates destination work in one locked write.
- Paused terminal outcomes require a typed blocker claim; trusted providers run outside the LoopStore lock, then the transition uses exact state/revision/execution CAS.
- Machine observations never grant user authority. Rejected, stale, or contradicted claims are state-preserving; restart recovery is explicit resubmission, not a persisted proposal.
- `WorkflowRevise` applies typed additive changes with definition/state/sequence CAS, immutable prior-definition history, and no scheduler or TaskStore effect.
- Current materialized state content is immutable. Current outgoing edges and future state content may be revised.
- Transition CAS includes definition revision so transition/revision races fail closed in either order.
- Terminal completed workflows are deleted; terminal paused workflows remain inspectable.
- Terminal completed workflows are deleted; terminal paused workflows remain inspectable with bounded admission and pause provenance.

## Subagent orchestration contract

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ OrchestrationGet id="1"
| `/loop` | Create or manage scheduled, event, and dynamic goal loops |
| `/tasks` | Manage native fallback tasks when `pi-tasks` is absent |
| `LoopCreate`, `LoopList`, `LoopUpdate`, `LoopDelete` | Create and control ordinary loops |
| `WorkflowCreate`, `WorkflowClaim`, `WorkflowRevise`, `WorkflowTransition` | Create, claim, revise, and advance task-driven workflows; inspect them with `LoopList` |
| `WorkflowCreate`, `WorkflowClaim`, `WorkflowRevise`, `WorkflowTransition` | Create, claim, revise, and advance workflows; paused terminals require trusted blocker admission |
| `OrchestrationCreate`, `OrchestrationGet` | Run and inspect a finite batch of independent async subagent work; cancel with `LoopDelete` |
| `MonitorCreate`, `MonitorList`, `MonitorStop` | Run and inspect background commands |
| `TaskCreate`, `TaskList`, `TaskClaim`, `TaskHeartbeat`, `TaskUpdate`, `TaskDelete` | Native fallback task management |
Expand Down
12 changes: 5 additions & 7 deletions benchmarks/workloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,11 @@ function buildLoopState(): LoopReducerState {
const loopState = buildLoopState();
const loopEvents: LoopReducerEvent[] = Array.from({ length: 1_000 }, (_, index) => {
const id = String((index % 25) + 1);
const type = ["LOOP_FIRED", "LOOP_PAUSED", "LOOP_RESUMED"] as const;
return {
type: type[index % type.length] ?? "LOOP_FIRED",
at: 1_000 + index,
source: "system",
payload: { id },
};
const types = ["LOOP_FIRED", "LOOP_PAUSED", "LOOP_RESUMED"] as const;
const type = types[index % types.length] ?? "LOOP_FIRED";
return type === "LOOP_PAUSED"
? { type, at: 1_000 + index, source: "system", payload: { id, kind: "administrative" } }
: { type, at: 1_000 + index, source: "system", payload: { id } };
});

function buildTaskState(): TaskReducerState {
Expand Down
6 changes: 4 additions & 2 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Project scope shares durable state but does not yet elect one scheduler owner ac

## Loop model

`LoopEntry.status` is `active` or `paused`. Triggers are:
`LoopEntry.status` is `active` or `paused`. New pauses persist optional provenance as `pause:{kind,at,reason?}`. Kinds distinguish `administrative`, `controller_limit`, `semantic_terminal`, and `orchestration_settlement`; older paused snapshots may remain unattributed. Resume clears the record. Triggers are:

- cron: `{type:"cron", schedule}`
- event: `{type:"event", source, filter?}`
Expand Down Expand Up @@ -81,7 +81,9 @@ A run persists current state, transition sequence, attempts, state fire counts,

The initial task execution is leased to the creating runtime. Every destination execution, including self-loop retries, starts unowned. `WorkflowClaim` claims unowned work, renews the same owner, or takes over an expired lease. Live foreign ownership fails closed.

`WorkflowTransition` validates the current lease, declared available outcome, attempt limit, active execution, and definition revision. One locked write settles source work, records evidence, advances state, and creates the destination execution. A missing or exhausted route is handled through `WorkflowRevise`, not a fabricated transition. Completed terminal states delete the controller; paused terminal states preserve it for inspection and represent a declared blocker or required user authority—not a progress notification.
`WorkflowTransition` validates the current lease, declared available outcome, attempt limit, active execution, and definition revision. Ordinary transitions proceed directly. A transition into a `paused` terminal state additionally requires `claim:{class,provider,subject,fact,expected}`. A trusted provider observes the fact outside the LoopStore lock; admission rejects missing, unavailable, stale, expired, conflicting, contradicted, or cross-context observations without writing. The built-in `monitor` provider exposes only `status`, `exitCode`, and `stopReason`; monitor output is never evidence. Admission confirms the exact fact, not whether workflow policy should treat that fact as a blocker—the declared edge remains the workflow author's policy. No user-authority provider is built in, and machine providers cannot grant `user_authority`, so those claims fail closed. After confirmation, the existing state/revision/execution CAS protects the locked transition.

No pending claim or general evidence ledger is persisted. A confirmed transition stores only a bounded admission receipt on `lastTransition` (claim class/provider/subject/fact/expected value, provider versions, and decision time). After restart, callers inspect current state and explicitly resubmit; stale context is rejected. One locked transition write settles source work, records evidence and the receipt, advances state, and creates the destination execution. A missing or exhausted route is handled through `WorkflowRevise`, not a fabricated transition. Completed terminals delete the controller; paused terminals preserve it with `semantic_terminal` pause provenance and represent a declared blocker—not a progress notification.

### Adaptive revision

Expand Down
9 changes: 6 additions & 3 deletions docs/USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ The initial state must be non-terminal. Each wake presents the current state, st
```text
WorkflowTransition id="1" outcome="root_cause_found" evidence="A null config reaches the parser."
WorkflowTransition id="1" outcome="tests_pass" evidence="Targeted and full test suites pass."
WorkflowTransition id="1" outcome="blocked" evidence="Monitor #m1 failed." claim='{"class":"environmental","provider":"monitor","subject":"m1","fact":"status","expected":"error"}'
```

`WorkflowTransition` validates the branch, settles the current execution, records evidence, and activates the next state's execution in the same locked write. Newly entered task phases start unowned so another agent sharing project scope can claim the next phase immediately; whichever agent continues must call `WorkflowClaim id="1"` first. Workflow work is embedded in the loop controller — never call `TaskClaim` or `TaskUpdate` for it. `WorkflowClaim` also renews the current runtime's lease or takes over an expired lease after a restart. A self-loop creates a fresh unowned attempt execution and increments the displayed attempt count. When a target reaches `maxAttempts`, only outcomes leading to that target become unavailable; other declared outcomes remain selectable. Reaching a `completed` terminal state deletes the workflow loop; reaching a `paused` terminal state preserves it in paused state for inspection or deletion. Terminal workflow states cannot be resumed. Task status does not guess an outcome—the model selects one explicitly. LoopList and workflow wakes omit outcomes whose target state has exhausted `maxAttempts`.
`WorkflowTransition` validates the branch, settles the current execution, records evidence, and activates the next state's execution in the same locked write. A `paused` terminal target requires the typed claim shown above. The provider runs before the LoopStore lock, observations are scoped to the current workflow/state/revision/execution/workspace, and the final write uses the existing CAS. The built-in `monitor` provider exposes only `status`, `exitCode`, and `stopReason`; raw output is never admission evidence. Missing, stale, conflicting, contradicted, or unavailable evidence leaves every store unchanged. User-authority claims fail closed because machine evidence cannot manufacture consent.

Newly entered task phases start unowned so another agent sharing project scope can claim the next phase immediately; whichever agent continues must call `WorkflowClaim id="1"` first. Workflow work is embedded in the loop controller — never call `TaskClaim` or `TaskUpdate` for it. `WorkflowClaim` also renews the current runtime's lease or takes over an expired lease after a restart. A self-loop creates a fresh unowned attempt execution and increments the displayed attempt count. When a target reaches `maxAttempts`, only outcomes leading to that target become unavailable; other declared outcomes remain selectable. Reaching a `completed` terminal state deletes the workflow loop; reaching an admitted `paused` terminal state preserves it with `semantic_terminal` pause provenance. Administrative, controller-limit, and orchestration-settlement pauses carry distinct provenance; legacy snapshots may be unattributed. Terminal workflow states cannot be resumed. Task status does not guess an outcome—the model selects one explicitly. LoopList and workflow wakes omit outcomes whose target state has exhausted `maxAttempts`.

When active work discovers a missing prerequisite or supersedes future instructions, inspect `LoopList` and submit one typed revision against its exact definition revision, state, and transition sequence:

Expand All @@ -120,7 +123,7 @@ WorkflowRevise id="1" expectedRevision=1 expectedState="investigate" expectedTra

`WorkflowRevise` stores the prior definition, reason, accepted changes, timestamp, and runtime actor as immutable history. It preserves current execution and lease state, changes only future work or current outgoing edges, and rejects stale revisions or transitions. It never creates standalone tasks. See the [reference](./REFERENCE.md#adaptive-revision) for operation and graph rules.

A missing prerequisite, missing route, or exhausted route is a plan gap—not automatically a blocker. Persist an actionable recovery route with `WorkflowRevise`, then continue through the revised transition and claim while work remains actionable. Call `WorkflowTransition` only when an available declared outcome is supported by evidence; never fabricate one. Do not stop or move the controller to terminal `paused` merely to report progress. Reserve that state for a declared blocker or required user authority.
A missing prerequisite, missing route, or exhausted route is a plan gap—not automatically a blocker. Persist an actionable recovery route with `WorkflowRevise`, then continue through the revised transition and claim while work remains actionable. Call `WorkflowTransition` only when an available declared outcome is supported by evidence; never fabricate one. Do not stop or move the controller to terminal `paused` merely to report progress. Environmental blockers require trusted admission. If user authority is required, report the exact decision needed; machine observations cannot authorize the transition.

To repeat a state until evidence supports an outcome, add a cron-only state policy: `"loop":{"schedule":"0 7 * * *","maxFires":10,"startImmediately":false}`. Only the active state's policy is armed. Scheduled wakes retain the active execution; `WorkflowTransition` remains the only operation that settles it and unlocks the destination execution and cadence. Below the fire cap, a no-change iteration leaves the workflow active; persist material future-plan changes with `WorkflowRevise`. Reaching `maxFires` pauses the workflow and schedules no next cadence. Transition when evidence supports an available outcome; otherwise revise in a bounded recovery state/route, then transition and claim it. State policies do not wake immediately unless `startImmediately` is `true`.

Expand Down Expand Up @@ -300,6 +303,6 @@ Session files live under `.pi/loops/` and `.pi/tasks/`. Keep `session` as the no

## Status line and limits

The TUI status line summarizes ordinary loops, workflows, orchestrations, running monitors, and native tasks. Use `LoopList`, `OrchestrationGet`, `MonitorList`, and `/tasks` for detail. `LoopList` reports active-loop `age` as wall-clock time since creation, including pause and process downtime; paused loops omit the field. The status clears when no work is active.
The TUI status line summarizes ordinary loops, workflows, orchestrations, running monitors, and native tasks. Use `LoopList`, `OrchestrationGet`, `MonitorList`, and `/tasks` for detail. `LoopList` reports active-loop `age` as wall-clock time since creation, including pause and process downtime; paused loops omit age and show available pause provenance. The status clears when no work is active.

The runtime allows at most 25 active loops and 25 running monitors. Each orchestration batch allows up to 32 work items, 8 local workers, and 3 attempts per item.
3 changes: 3 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export type { TaskClaimInput, TaskClaimResult } from "./task-store.js";
export { TaskStore } from "./task-store.js";
export type { TaskClaim, TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
export type {
LoopPauseKind,
LoopPauseRecord,
MonitorOutcome,
OrchestrationActor,
OrchestrationConsumeStatus,
Expand All @@ -55,6 +57,7 @@ export type {
OrchestrationWakeReason,
OrchestrationWorkItem,
OrchestrationWorkStatus,
WorkflowAdmissionRecord,
WorkflowDefinition,
WorkflowDefinitionRevision,
WorkflowMonitorWait,
Expand Down
11 changes: 8 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { isStaleExtensionContextError } from "./runtime/stale-context.js";
import { createSubagentOrchestrationRuntime, type SubagentOrchestrationRuntime } from "./runtime/subagent-orchestration-runtime.js";
import { createTaskBacklogRuntime } from "./runtime/task-backlog-runtime.js";
import { createTaskProviderRuntime, type TaskProviderRuntime } from "./runtime/task-provider-runtime.js";
import { createMonitorWorkflowAdmissionProvider } from "./runtime/workflow-admission-providers.js";
import { CronScheduler } from "./scheduler.js";
import { LoopStore } from "./store.js";
import { registerLoopTools } from "./tools/loop-tools.js";
Expand Down Expand Up @@ -64,6 +65,7 @@ export default function (pi: ExtensionAPI) {
let store = new LoopStore(resolveLoopStorePath(getScopeOptions()));
const memoryLoopStores = new Map<string, LoopStore>();
const monitorManager = new MonitorManager(pi);
const monitorWorkflowAdmissionProvider = createMonitorWorkflowAdmissionProvider((id) => monitorManager.get(id));
let scheduler: CronScheduler;
let triggerSystem: TriggerSystem;
const widget = new LoopWidget(store, monitorManager);
Expand Down Expand Up @@ -256,7 +258,7 @@ export default function (pi: ExtensionAPI) {
if (atMaxFires(current)) {
debug(`loop #${current.id} — reached maxFires ${current.maxFires}, retiring`);
triggerSystem.remove(current.id);
if (current.workflow || current.taskBacklog) store.pause(current.id);
if (current.workflow || current.taskBacklog) store.pause(current.id, "controller_limit", "loop fire cap reached");
else store.delete(current.id);
widget.update();
return;
Expand All @@ -280,14 +282,14 @@ export default function (pi: ExtensionAPI) {

if (atMaxFires(firedEntry)) {
triggerSystem.remove(firedEntry.id);
if (firedEntry.workflow || firedEntry.taskBacklog) store.pause(firedEntry.id);
if (firedEntry.workflow || firedEntry.taskBacklog) store.pause(firedEntry.id, "controller_limit", "loop fire cap reached");
else store.delete(firedEntry.id);
widget.update();
}

if (firedEntry.workflow && atWorkflowStateFireLimit(firedEntry.workflow)) {
triggerSystem.remove(firedEntry.id);
store.pause(firedEntry.id);
store.pause(firedEntry.id, "controller_limit", "workflow state fire cap reached");
widget.update();
}

Expand Down Expand Up @@ -443,6 +445,9 @@ export default function (pi: ExtensionAPI) {
onLoopFire(entry);
},
getActor: () => _sessionId ? { sessionId: _sessionId, runtimeId } : undefined,
getAdmissionContextDigest: () => resolveLoopStorePath(getScopeOptions(), _sessionId)
?? `memory:${process.cwd()}:${_sessionId ?? "unbound"}`,
getAdmissionProviders: () => [monitorWorkflowAdmissionProvider],
});

function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
Expand Down
8 changes: 8 additions & 0 deletions src/loop-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ export function formatLastTransitionLines(lastTransition: WorkflowTransitionReco
const { from, to, outcome, evidence } = lastTransition;
const lines = [`Last transition: ${from} → ${to} via ${outcome}`];
if (evidence) lines.push(`Evidence: ${evidence.replace(/\s+/g, " ")}`);
if (lastTransition.admission) {
const admission = lastTransition.admission;
const provider = admission.provider.replace(/\s+/g, " ");
const subject = admission.subject.replace(/\s+/g, " ");
const fact = admission.fact.replace(/\s+/g, " ");
const observations = admission.observations.map((observation) => observation.replace(/\s+/g, " ")).join(", ");
lines.push(`Admission: ${admission.claimClass} · ${provider}:${subject}.${fact} = ${JSON.stringify(admission.expected)} · ${observations}`);
}
return lines;
}

Expand Down
Loading
Loading