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
6 changes: 6 additions & 0 deletions docs/USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ A hybrid trigger combines cron and event delivery with a debounce window:
LoopCreate trigger="cron:5m event:tasks:created" prompt="Process pending tasks" triggerType="hybrid" debounceMs=30000
```

### Inspection

Open `/loop` → `View loops` and select a controller. The detail view shows its kind, resolved storage scope, authority, expiry, reported next fire, fire budget, pause provenance, and supported next action. Workflow and orchestration details remain available below the lifecycle fields. Hybrid detail retains the event source and debounce window, not the event filter.

Inspection does not claim work, resume a controller, or schedule a wake. A live workflow lease is not proof of execution; fire counts are accounting, not delivered-wake counts. Expired, terminal, and orchestration controllers do not offer generic resume. A `controller_limit` pause at an exhausted budget also blocks resume. Custom `PI_LOOP` files are labelled separately from session storage; paths and event filters are not displayed in the lifecycle fields.

### Choosing one controller

Choose one durable owner before decomposing work:
Expand Down
21 changes: 12 additions & 9 deletions src/commands/loop-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import type {
} from "@earendil-works/pi-coding-agent";
import { formatTrigger } from "../loop-format.js";
import { isValidCronExpression, parseInterval } from "../loop-parse.js";
import type { LoopStorageScope } from "../runtime/scope.js";
import type { OrchestrationCancellation } from "../runtime/subagent-orchestration-runtime.js";
import type { DynamicLoopState, LoopEntry, Trigger } from "../types.js";
import { canResumeFromInspection, formatLoopLifecycle } from "../ui/lifecycle-presentation.js";
import { formatOrchestrationInspection, orchestrationCancellationMessage, orchestrationProgressLabel, orchestrationStatusLabel } from "../ui/orchestration-presentation.js";
import { formatWorkflowInspection, workflowActivityLabel } from "../ui/workflow-presentation.js";
import { isTerminalWorkflowRun } from "../workflow-reducer.js";

interface LoopStoreLike {
list(): LoopEntry[];
Expand Down Expand Up @@ -37,6 +38,8 @@ export interface LoopCommandOptions {
pi: ExtensionAPI;
getStore: () => LoopStoreLike;
getTriggerSystem: () => TriggerSystemLike;
getStorageScope?: () => LoopStorageScope;
getNextFire?: (id: string) => number | undefined;
updateWidget: () => void;
maybeBootstrapTaskLoop?: (entry: LoopEntry) => Promise<boolean>;
onDynamicLoopActivated?: (entry: LoopEntry) => void;
Expand Down Expand Up @@ -198,19 +201,19 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
if (entry) {
const actions = ["x Delete"];
if (entry.status === "active") actions.unshift("- Pause");
else if (
entry.status === "paused"
&& Date.now() < entry.expiresAt
&& !entry.orchestration
&& !isTerminalWorkflowRun(entry.workflow)
) actions.unshift("* Resume");
else if (canResumeFromInspection(entry)) actions.unshift("* Resume");
actions.push("< Back");

const detail = entry.workflow
const inspection = entry.workflow
? formatWorkflowInspection(entry)
: entry.orchestration
? formatOrchestrationInspection(entry)
: `#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`;
: `#${entry.id}: ${entry.prompt}`;
const [heading, ...body] = inspection.split("\n");
const detail = [heading, ...formatLoopLifecycle(entry, {
storageScope: options.getStorageScope?.(),
nextFireAt: options.getNextFire?.(entry.id),
}), ...body].join("\n");
const action = await ui.select(detail, actions);

if (entry.orchestration && (action === "x Delete" || action === "- Pause")) {
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
createNotificationRuntime,
type LoopFireEvent,
} from "./runtime/notification-runtime.js";
import { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
import { resolveLoopStorageScope, resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
import { registerSessionRuntimeHooks } from "./runtime/session-runtime.js";
import { isStaleExtensionContextError } from "./runtime/stale-context.js";
import { createSubagentOrchestrationRuntime, type SubagentOrchestrationRuntime } from "./runtime/subagent-orchestration-runtime.js";
Expand Down Expand Up @@ -599,6 +599,8 @@ export default function (pi: ExtensionAPI) {
pi,
getStore: () => store,
getTriggerSystem: () => triggerSystem,
getStorageScope: () => resolveLoopStorageScope(getScopeOptions(), _sessionId),
getNextFire: (id) => scheduler.nextFire(id),
updateWidget: () => {
widget.update();
},
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/scope.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join, resolve } from "node:path";

export type LoopScope = "memory" | "session" | "project";
export type LoopStorageScope = LoopScope | "custom";

export interface ScopeOptions {
piLoopEnv?: string;
Expand All @@ -24,6 +25,12 @@ export function resolveLoopStorePath(options: ScopeOptions, sessionId?: string):
return join(cwd, ".pi", "loops", "loops.json");
}

export function resolveLoopStorageScope(options: ScopeOptions, sessionId?: string): LoopStorageScope {
if (resolveLoopStorePath(options, sessionId) === undefined) return "memory";
if (options.piLoopEnv) return "custom";
return options.loopScope === "session" ? "session" : "project";
}

export function resolveTaskStorePath(options: ScopeOptions, sessionId?: string): string | undefined {
const cwd = options.cwd ?? process.cwd();
const { loopScope } = options;
Expand Down
94 changes: 94 additions & 0 deletions src/ui/lifecycle-presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { formatTrigger } from "../loop-format.js";
import { atMaxFires } from "../loop-reducer.js";
import type { LoopStorageScope } from "../runtime/scope.js";
import type { LoopEntry } from "../types.js";
import { atWorkflowStateFireLimit, getActiveWorkflowStateLoop, isTerminalWorkflowRun } from "../workflow-reducer.js";

interface LifecycleInspectionContext {
storageScope?: LoopStorageScope;
nextFireAt?: number;
now?: number;
}

const STORAGE_LABELS = {
memory: "memory (process-local)",
session: "session file (isolated by session ID)",
project: "project file (shared; no scheduler owner election)",
custom: "custom file (PI_LOOP override)",
} satisfies Record<LoopStorageScope, string>;

export function canResumeFromInspection(entry: LoopEntry, now = Date.now()): boolean {
if (entry.status !== "paused" || now >= entry.expiresAt || entry.orchestration || isTerminalWorkflowRun(entry.workflow)) return false;
return !(entry.pause?.kind === "controller_limit"
&& (atMaxFires(entry) || Boolean(entry.workflow && atWorkflowStateFireLimit(entry.workflow))));
}

function controllerKind(entry: LoopEntry): string {
if (entry.workflow) return "workflow";
if (entry.orchestration) return "orchestration";
if (entry.taskBacklog) return "task-backlog loop";
if (entry.trigger.type === "dynamic") return "dynamic loop";
if (entry.trigger.type === "cron") return "scheduled loop";
return `${entry.trigger.type} loop`;
}

function controllerAuthority(entry: LoopEntry): string {
if (entry.workflow) return "LoopStore (embedded workflow work; not TaskStore)";
if (entry.orchestration) return "LoopStore intent; pi-subagents execution";
if (entry.autoTask || entry.taskBacklog) return "LoopStore scheduling; standalone task provider work";
return "LoopStore";
}

function nextAction(entry: LoopEntry, now: number): string {
if (now >= entry.expiresAt) return "Inspect retained work; expired controllers cannot resume. Recreate only if authorized.";
if (entry.orchestration) return "Inspect OrchestrationGet before cancellation or deletion; do not replay uncertain work or use generic resume.";
if (isTerminalWorkflowRun(entry.workflow)) return "Inspect the terminal outcome; do not resume.";
if (entry.status === "paused") {
if (!canResumeFromInspection(entry, now)) {
if (entry.workflow && !atMaxFires(entry) && atWorkflowStateFireLimit(entry.workflow)) {
return "Inspect LoopList outcomes; leave the exhausted state with an evidenced transition, not resume.";
}
return entry.workflow
? "Inspect available terminal outcomes with LoopList; do not resume or retry the same state."
: "Inspect the fire budget; do not resume an exhausted controller.";
}
return "Resume explicitly when authorized; inspection does not resume work.";
}
if (entry.workflow?.waitingMonitor) return "Inspect MonitorList; wait for the terminal outcome, do not re-run the command.";
if (entry.workflow) {
const execution = entry.workflow.activeExecution;
if (execution?.status === "active") {
return execution.lease && execution.lease.expiresAt > now
? "Respect the live lease; inspect outcomes with LoopList. A claim is not proof of execution."
: "Claim current work with WorkflowClaim before execution.";
}
return "Inspect available outcomes with LoopList; transition only with evidence.";
}
if (entry.dynamic?.awaitingUpdate) return "Persist progress with LoopUpdate; use continue while work remains.";
if (entry.trigger.type === "event") return "Await the declared event; registration is not proof of wake delivery.";
return "Observe the next fire; wakes deliver when Pi is idle.";
}

export function formatLoopLifecycle(entry: LoopEntry, context: LifecycleInspectionContext = {}): string[] {
const now = context.now ?? Date.now();
const cadence = entry.workflow && getActiveWorkflowStateLoop(entry.workflow);
const trigger = cadence ? `workflow cron: ${cadence.schedule}` : formatTrigger(entry.trigger, "command");
const nextDate = context.nextFireAt === undefined ? undefined : new Date(context.nextFireAt);
const nextFire = nextDate && Number.isFinite(nextDate.getTime()) ? nextDate.toISOString() : "not reported";
const lines = [
`Kind: ${controllerKind(entry)} · status: ${entry.status}`,
`Storage: ${context.storageScope ? STORAGE_LABELS[context.storageScope] : "unknown (inspection context unavailable)"}`,
`Authority: ${controllerAuthority(entry)}`,
`Trigger: ${trigger}`,
`Expires at: ${new Date(entry.expiresAt).toISOString()}${now >= entry.expiresAt ? " (expired)" : ""}`,
`Next fire: ${nextFire}`,
`Fires: ${entry.fireCount ?? 0}/${entry.maxFires ?? "unbounded"} (accounted, not delivered wakes)`,
];
if (entry.trigger.type === "hybrid") {
lines.push(`Hybrid event: ${entry.trigger.event.source} · debounce: ${entry.trigger.debounceMs}ms`);
}
if (cadence) lines.push(`State fires: ${entry.workflow?.stateFireCounts?.[entry.workflow.currentState] ?? 0}/${cadence.maxFires ?? "unbounded"}`);
if (entry.status === "paused") lines.push(`Pause: ${entry.pause?.kind ?? "legacy (unattributed)"}`);
lines.push(`Next action: ${nextAction(entry, now)}`);
return lines;
}
Loading
Loading