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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ OrchestrationGet id="1"

- Cron, event, hybrid, dynamic goal, and opt-in workflow loops
- Idle-safe agent re-wakes with dynamic-loop restart/session-switch recovery
- Observable seven-day recurring-loop expiry through `LoopList`, `loops:expired`, and a hidden Pi notification
- Background command monitoring with buffered output, `onDone` wakes, and renewable inactivity alerts
- Optional `pi-tasks` integration and a native task fallback
- Session-scoped, bounded async subagent orchestration through protocol-v2 `pi-subagents`
Expand Down
4 changes: 2 additions & 2 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ Project scope shares durable state but does not yet elect one scheduler owner ac
- hybrid: `{type:"hybrid", cron, event, debounceMs}`
- dynamic: `{type:"dynamic"}`

`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. `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. Recurring loops expire after seven days unless recreated; fire limits bound repeated execution.
`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. `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. Recurring loops expire after seven days unless explicitly recreated; `LoopList` exposes the ISO `expiresAt` boundary. Seven-day 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. 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. 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 Down
8 changes: 6 additions & 2 deletions docs/USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ LoopCreate trigger="0 9 * * 1-5" prompt="Review weekday alerts" maxFires=10

Intervals such as `5m`, `2h`, and `1d` are converted to cron expressions. Full five-field cron expressions are also accepted. Cron and hybrid loops track their next fire time and deliver only when the agent is idle.

Use `maxFires` for polling or other bounded work so a loop cannot run indefinitely. Recurring loops expire after seven days.
Use `maxFires` for polling or other bounded work so a loop cannot run indefinitely. Recurring loops expire after seven days. `LoopList` exposes each controller's exact `expiresAt` boundary. Seven-day expiry and stale event/hybrid retirement during session recovery emit `loops:expired` and a hidden Pi wake that reports whether the controller was deleted or paused. Renewal is explicit: recreate the loop only when its schedule is still required.

### Event loops

Expand Down Expand Up @@ -244,6 +244,11 @@ Monitor events:
- `monitor:done`
- `monitor:error`

Loop lifecycle events:

- `loops:expired` — `{loopId, prompt, trigger, recurring, createdAt, expiresAt, expiredAt, disposition, source, reason}` where `reason` is `expires_at` or `resume_event_stale`
- `loops:autodeleted`

Native task lifecycle events:

- `tasks:created`
Expand All @@ -254,7 +259,6 @@ Native task lifecycle events:
- `tasks:updated`
- `tasks:deleted`
- `tasks:backlog_empty`
- `loops:autodeleted`

Task event payloads include `previousStatus`. Transition events report the status before the transition; details-only `tasks:updated` events report the status current at edit time.

Expand Down
4 changes: 4 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ export {
rpcCall,
rpcProbe,
} from "./rpc/cross-extension-rpc.js";
export type { LoopExpiredPayload } from "./runtime/loop-events.js";
export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
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 {
LoopExpiryDisposition,
LoopExpiryReason,
LoopExpirySource,
LoopPauseKind,
LoopPauseRecord,
MonitorOutcome,
Expand Down
7 changes: 6 additions & 1 deletion src/commands/loop-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,12 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
if (entry) {
const actions = ["x Delete"];
if (entry.status === "active") actions.unshift("- Pause");
else if (entry.status === "paused" && !entry.orchestration && !isTerminalWorkflowRun(entry.workflow)) actions.unshift("* Resume");
else if (
entry.status === "paused"
&& Date.now() < entry.expiresAt
&& !entry.orchestration
&& !isTerminalWorkflowRun(entry.workflow)
) actions.unshift("* Resume");
actions.push("< Back");

const detail = entry.workflow
Expand Down
39 changes: 36 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { registerLoopCommand } from "./commands/loop-command.js";
import { atMaxFires } from "./loop-reducer.js";
import { MonitorManager } from "./monitor-manager.js";
import { rpcCall, rpcProbe } from "./rpc/cross-extension-rpc.js";
import { buildLoopExpiredPayload } from "./runtime/loop-events.js";
import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
import {
createNotificationRuntime,
Expand All @@ -40,7 +41,7 @@ import { registerMonitorTools } from "./tools/monitor-tools.js";
import { registerSubagentOrchestrationTools } from "./tools/subagent-orchestration-tools.js";
import { registerWorkflowTools } from "./tools/workflow-tools.js";
import { TriggerSystem } from "./trigger-system.js";
import type { LoopEntry, LoopFireOrigin, MonitorEntry, Trigger } from "./types.js";
import type { LoopEntry, LoopExpiryDisposition, LoopExpiryReason, LoopExpirySource, LoopFireOrigin, MonitorEntry, Trigger } from "./types.js";
import { LoopWidget } from "./ui/widget.js";
import { atWorkflowStateFireLimit, getActiveWorkflowStateLoop, isTerminalWorkflowRun } from "./workflow-reducer.js";

Expand Down Expand Up @@ -73,7 +74,16 @@ export default function (pi: ExtensionAPI) {
// call), so stale monitors don't linger in the count between turns.
monitorManager.setOnChange(() => widget.update());

scheduler = new CronScheduler(store, (entry, origin) => onLoopFire(entry, undefined, origin));
function createScheduler(loopStore: LoopStore): CronScheduler {
return new CronScheduler(
loopStore,
(entry, origin) => onLoopFire(entry, undefined, origin),
(entry, disposition) => emitLoopExpired(entry, disposition, "scheduler", "expires_at"),
isCurrentExtensionContext,
);
}

scheduler = createScheduler(store);
triggerSystem = new TriggerSystem(pi, scheduler, store, (entry, origin) => onLoopFire(entry, undefined, origin));

let taskProvider: TaskProviderRuntime | undefined;
Expand Down Expand Up @@ -202,6 +212,25 @@ export default function (pi: ExtensionAPI) {
}
}

function emitLoopExpired(
entry: LoopEntry,
disposition: LoopExpiryDisposition,
source: LoopExpirySource,
reason: LoopExpiryReason,
generation = sessionGeneration,
): void {
if (generation !== sessionGeneration || !isCurrentExtensionContext()) return;
triggerSystem.remove(entry.id);
const payload = buildLoopExpiredPayload(entry, disposition, source, reason, Date.now());
try {
pi.events.emit("loops:expired", payload);
} catch (error) {
debug(`loops:expired #${entry.id} — event listener failed`, error);
}
void notificationRuntime.queueOrDeliverLoopExpired({ ...payload, sessionGeneration: generation })
.catch((error) => debug(`loops:expired #${entry.id} — notification failed`, error));
}

function emitLoopFire(entry: LoopEntry, monitor?: MonitorEntry, orchestrationWakeSequence?: number): void {
pi.events.emit("loop:fire", {
loopId: entry.id,
Expand Down Expand Up @@ -332,7 +361,7 @@ export default function (pi: ExtensionAPI) {
memoryLoopStores.set(sessionId, store);
}
widget.setStore(store);
scheduler = new CronScheduler(store, (entry, origin) => onLoopFire(entry, undefined, origin));
scheduler = createScheduler(store);
triggerSystem = new TriggerSystem(pi, scheduler, store, (entry, origin) => onLoopFire(entry, undefined, origin));
},
clearAllLoops: () => {
Expand Down Expand Up @@ -365,6 +394,10 @@ export default function (pi: ExtensionAPI) {
shutdownMonitors: () => monitorManager.shutdown(),
hasPendingTasks,
cleanDoneTasks,
isContextCurrent: isCurrentExtensionContext,
emitLoopExpired: (entry, disposition, reason, generation) => {
emitLoopExpired(entry, disposition, "session_recovery", reason, generation);
},
});

// ── Loop fire handler — queues an in-memory notification, then injects a custom message when delivery is safe ──
Expand Down
36 changes: 35 additions & 1 deletion src/runtime/loop-events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LoopEntry } from "../types.js";
import type { LoopEntry, LoopExpiryDisposition, LoopExpiryReason, LoopExpirySource } from "../types.js";

export type LoopAutoDeleteReason = "task_backlog_empty";

Expand All @@ -20,6 +20,19 @@ export interface LoopAutodeletedPayload {
pendingCount: number;
}

export interface LoopExpiredPayload {
loopId: string;
prompt: string;
trigger: LoopEntry["trigger"];
recurring: boolean;
createdAt: number;
expiresAt: number;
expiredAt: number;
disposition: LoopExpiryDisposition;
source: LoopExpirySource;
reason: LoopExpiryReason;
}

export interface TaskBacklogEmptyPayload {
pendingCount: 0;
deletedLoopIds: string[];
Expand Down Expand Up @@ -49,6 +62,27 @@ export function buildLoopAutodeletedPayload(
};
}

export function buildLoopExpiredPayload(
entry: LoopEntry,
disposition: LoopExpiryDisposition,
source: LoopExpirySource,
reason: LoopExpiryReason,
expiredAt: number,
): LoopExpiredPayload {
return {
loopId: entry.id,
prompt: entry.prompt,
trigger: entry.trigger,
recurring: entry.recurring,
createdAt: entry.createdAt,
expiresAt: entry.expiresAt,
expiredAt,
disposition,
source,
reason,
};
}

export function buildTaskBacklogEmptyPayload(
deletedLoopIds: string[],
): TaskBacklogEmptyPayload {
Expand Down
47 changes: 47 additions & 0 deletions src/runtime/notification-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { getOrchestrationCounts } from "../orchestration-reducer.js";
import type { DynamicLoopState, MonitorOutcome, OrchestrationState, Trigger, WorkflowRunState } from "../types.js";
import { getWorkflowOutcomeAvailability } from "../workflow-reducer.js";
import type { LoopExpiredPayload } from "./loop-events.js";
import { TASK_BACKLOG_ACTION_CONTRACT } from "./task-backlog-runtime.js";

const MAX_ORCHESTRATION_WAKE_CHARS = 12_288;
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface NotificationRuntimeOptions {
export interface NotificationRuntime {
syncRuntimeState(options?: { agentRunning?: boolean; hasPendingMessages?: boolean }): void;
queueOrDeliverNotification(data: LoopFireEvent): Promise<void>;
queueOrDeliverLoopExpired(data: LoopExpiredPayload & { sessionGeneration?: number }): Promise<void>;
queueOrDeliverMonitorStarted(data: MonitorStartedEvent): Promise<void>;
discardMonitorStarted(monitorId: string): void;
flushPendingNotifications(options?: { ignorePendingMessages?: boolean }): Promise<void>;
Expand Down Expand Up @@ -294,6 +296,31 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
};
}

function buildLoopExpiredNotification(
data: LoopExpiredPayload & { sessionGeneration?: number },
): PendingNotification {
const isStaleEvent = data.reason === "resume_event_stale";
return {
sessionGeneration: data.sessionGeneration ?? sessionGeneration,
loopId: data.loopId,
prompt: data.prompt,
trigger: data.trigger,
timestamp: data.expiredAt,
recurring: data.recurring,
key: `loop:${data.loopId}:expired:${data.expiresAt}`,
message: [
isStaleEvent
? `[pi-loop] Loop #${data.loopId} retired during session recovery and was ${data.disposition}.`
: `[pi-loop] Loop #${data.loopId} expired and was ${data.disposition}.`,
data.prompt,
isStaleEvent
? "Event and hybrid subscriptions do not resume across sessions."
: `Expiry boundary: ${new Date(data.expiresAt).toISOString()}`,
"Recreate it explicitly if this controller is still required; retirement does not imply consent to renew indefinitely.",
].join("\n"),
};
}

function buildMonitorStartedNotification(data: MonitorStartedEvent): PendingNotification {
const label = data.description ?? data.command.slice(0, 80);
return {
Expand Down Expand Up @@ -402,6 +429,25 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
await flushPendingNotifications();
}

async function queueOrDeliverLoopExpired(
data: LoopExpiredPayload & { sessionGeneration?: number },
): Promise<void> {
if (data.sessionGeneration !== undefined && data.sessionGeneration !== sessionGeneration) {
debug?.(`loops:expired #${data.loopId} — stale session generation, dropping wake`);
return;
}
const notification = buildLoopExpiredNotification(data);
applyNotificationEvent({
type: "NOTIFICATION_QUEUED",
at: notification.timestamp,
source: "system",
entityType: "notification",
entityId: notification.key,
payload: { notification },
});
await flushPendingNotifications();
}

async function queueOrDeliverMonitorStarted(data: MonitorStartedEvent): Promise<void> {
if (data.sessionGeneration !== undefined && data.sessionGeneration !== sessionGeneration) {
debug?.(`monitor:started #${data.monitorId} — stale session generation, dropping wake`);
Expand Down Expand Up @@ -455,6 +501,7 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
return {
syncRuntimeState,
queueOrDeliverNotification,
queueOrDeliverLoopExpired,
queueOrDeliverMonitorStarted,
discardMonitorStarted,
flushPendingNotifications,
Expand Down
24 changes: 22 additions & 2 deletions src/runtime/session-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { LoopStore } from "../store.js";
import type { LoopEntry, LoopExpiryDisposition, LoopExpiryReason } from "../types.js";
import type { NotificationRuntime } from "./notification-runtime.js";
import type { LoopScope } from "./scope.js";

Expand Down Expand Up @@ -38,6 +39,13 @@ export interface SessionRuntimeOptions {
shutdownMonitors: () => Promise<void>;
hasPendingTasks: () => Promise<number>;
cleanDoneTasks: () => Promise<void>;
isContextCurrent: () => boolean;
emitLoopExpired: (
entry: LoopEntry,
disposition: LoopExpiryDisposition,
reason: LoopExpiryReason,
generation: number,
) => void;
}

export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): void {
Expand Down Expand Up @@ -68,6 +76,8 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
shutdownMonitors,
hasPendingTasks,
cleanDoneTasks,
isContextCurrent,
emitLoopExpired,
} = options;

let storeUpgraded = false;
Expand Down Expand Up @@ -116,9 +126,19 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
migrateTaskBacklogLoops();
if (!isCurrentGeneration(generation)) return;
clearWorkflowMonitorWaits();
if (!isContextCurrent()) return;
const store = getStore();
store.clearExpired();
store.expireEventLoops(sessionStartedAt);
const expired = store.expireEntries(sessionStartedAt);
for (const record of expired) {
if (!isCurrentGeneration(generation)) return;
emitLoopExpired(record.entry, record.disposition, record.reason, generation);
}
if (!isCurrentGeneration(generation) || !isContextCurrent()) return;
const staleEventLoops = store.expireEventLoopEntries(sessionStartedAt);
for (const record of staleEventLoops) {
if (!isCurrentGeneration(generation)) return;
emitLoopExpired(record.entry, record.disposition, record.reason, generation);
}
await recoverOrchestrations();
if (!isCurrentGeneration(generation)) return;
const triggerSystem = getTriggerSystem();
Expand Down
Loading
Loading