diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index b2d4f14..55a471c 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -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: diff --git a/src/commands/loop-command.ts b/src/commands/loop-command.ts index 32b7bd3..cd9170e 100644 --- a/src/commands/loop-command.ts +++ b/src/commands/loop-command.ts @@ -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[]; @@ -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; onDynamicLoopActivated?: (entry: LoopEntry) => void; @@ -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")) { diff --git a/src/index.ts b/src/index.ts index 2e415fb..70f6094 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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(); }, diff --git a/src/runtime/scope.ts b/src/runtime/scope.ts index 527b2c1..cf65c34 100644 --- a/src/runtime/scope.ts +++ b/src/runtime/scope.ts @@ -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; @@ -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; diff --git a/src/ui/lifecycle-presentation.ts b/src/ui/lifecycle-presentation.ts new file mode 100644 index 0000000..0c3da12 --- /dev/null +++ b/src/ui/lifecycle-presentation.ts @@ -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; + +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; +} diff --git a/test/lifecycle-presentation.test.ts b/test/lifecycle-presentation.test.ts new file mode 100644 index 0000000..14223b1 --- /dev/null +++ b/test/lifecycle-presentation.test.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LoopStore } from "../src/store.js"; +import type { LoopEntry } from "../src/types.js"; +import { canResumeFromInspection, formatLoopLifecycle } from "../src/ui/lifecycle-presentation.js"; + +const NOW = Date.UTC(2026, 9, 6, 12); + +function workflow(store: LoopStore, stateCap = false, maxFires?: number): LoopEntry { + return store.create({ type: "dynamic" }, "Review", { + recurring: true, maxFires, + workflow: { + version: 1, initialState: "work", + states: { + work: { + prompt: "Review.", task: { subject: "Review", description: "Collect evidence." }, + on: { done: "done" }, + ...(stateCap ? { loop: { schedule: "*/5 * * * *", maxFires: 1 } } : {}), + }, + done: { prompt: "Report.", terminal: "completed" }, + }, + }, + }); +} + +function freeze(entry: LoopEntry): LoopEntry { + function visit(value: unknown): void { + if (!value || typeof value !== "object") return; + for (const child of Object.values(value)) visit(child); + Object.freeze(value); + } + visit(entry); + return entry; +} + +function text(entry: LoopEntry): string { + return formatLoopLifecycle(entry, { storageScope: "memory", now: NOW }).join("\n"); +} + +describe("lifecycle inspection expectations and invariants", () => { + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); + afterEach(() => vi.useRealTimers()); + + it.each([ + ["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)"], + ] as const)("reports %s storage without a filesystem path", (storageScope, expected) => { + const entry = new LoopStore().create({ type: "event", source: "deploy:done", filter: '{"token":"secret"}' }, "Inspect", { recurring: true }); + const output = formatLoopLifecycle(entry, { storageScope, now: NOW }).join("\n"); + expect(output).toContain(`Storage: ${expected}`); + expect(output).toContain("Kind: event loop"); + expect(output).toContain("Trigger: event: deploy:done"); + expect(output).not.toContain("secret"); + expect(output).not.toContain("token"); + expect(output).toContain("Next fire: not reported"); + }); + + it("does not invent a persistence boundary when inspection context is unavailable", () => { + const entry = new LoopStore().create({ type: "dynamic" }, "Inspect", { recurring: true }); + expect(formatLoopLifecycle(entry, { now: NOW }).join("\n")).toContain("Storage: unknown"); + }); + + it("distinguishes live lease ownership from execution and preserves frozen current state", () => { + const store = new LoopStore(); + const entry = workflow(store); + expect(store.claimWorkflowExecution(entry.id, { sessionId: "private-session", runtimeId: "private-runtime" }).claimed).toBe(true); + const current = freeze(structuredClone(store.get(entry.id)!)); + const before = structuredClone(current); + const output = text(current); + expect(output).toContain("Authority: LoopStore (embedded workflow work; not TaskStore)"); + expect(output).toContain("Respect the live lease"); + expect(output).not.toContain("private-session"); + expect(output).not.toContain("private-runtime"); + expect(output).not.toContain("work is running"); + expect(current).toEqual(before); + }); + + it.each(["unowned", "expired"])("directs %s work to WorkflowClaim without claiming it", (kind) => { + const store = new LoopStore(); + if (kind === "expired") vi.setSystemTime(NOW - 3_600_000); + const entry = workflow(store); + if (kind === "expired") { + expect(store.claimWorkflowExecution(entry.id, { sessionId: "s", runtimeId: "r" }).claimed).toBe(true); + vi.setSystemTime(NOW); + } + const current = freeze(structuredClone(store.get(entry.id)!)); + expect(text(current)).toContain("WorkflowClaim"); + expect(current.workflow?.activeExecution?.lease?.expiresAt ?? 0).toBeLessThanOrEqual(NOW); + }); + + it("directs monitor waits to outcome inspection without exposing output or recommending replay", () => { + const entry = workflow(new LoopStore()); + entry.workflow!.waitingMonitor = { monitorId: "7", stateId: "work", transitionSeq: 0, attachedAt: NOW }; + const output = text(freeze(entry)); + expect(output).toContain("Inspect MonitorList"); + expect(output).toContain("do not re-run the command"); + expect(output).not.toContain("WorkflowClaim"); + }); + + it.each(["administrative", undefined] as const)("allows explicit authorized resume for %s pause without doing it", (kind) => { + const entry = new LoopStore().create({ type: "dynamic" }, "Observe", { recurring: true }); + entry.status = "paused"; + entry.pause = kind ? { kind, at: NOW } : undefined; + expect(canResumeFromInspection(entry, NOW)).toBe(true); + expect(text(entry)).toContain(kind ? "Pause: administrative" : "Pause: legacy (unattributed)"); + expect(text(entry)).toContain("Resume explicitly when authorized"); + expect(entry.status).toBe("paused"); + }); + + it.each(["administrative", undefined] as const)("matches Store.resume for %s provenance at a controller cap", (kind) => { + const store = new LoopStore(); + const entry = workflow(store, false, 1); + store.fire(entry.id); + const current = store.get(entry.id)!; + expect(current.fireCount).toBe(1); + // Model persisted provenance that is not created by automatic cap settlement. + current.pause = kind ? { kind, at: NOW } : undefined; + const before = structuredClone(current); + const eligible = canResumeFromInspection(freeze(structuredClone(current)), NOW); + expect(store.get(entry.id)).toEqual(before); + expect(eligible).toBe(Boolean(store.resume(entry.id))); + expect(eligible).toBe(true); + expect(store.get(entry.id)?.fireCount).toBe(1); + }); + + it("matches Store.resume for an exhausted task-backlog pause", () => { + const store = new LoopStore(); + const entry = store.create({ type: "event", source: "tasks:created" }, "Adopt", { + recurring: true, taskBacklog: true, maxFires: 1, + }); + store.fire(entry.id); + const current = freeze(structuredClone(store.get(entry.id)!)); + const eligible = canResumeFromInspection(current, NOW); + expect(eligible).toBe(Boolean(store.resume(entry.id))); + expect(eligible).toBe(false); + expect(text(current)).toContain("do not resume an exhausted controller"); + expect(store.get(entry.id)).toEqual(current); + }); + + it("retains hybrid event and debounce information without leaking its filter", () => { + const entry = new LoopStore().create({ + type: "hybrid", cron: "*/5 * * * *", debounceMs: 12_000, + event: { source: "deploy:done", filter: '{"token":"secret"}' }, + }, "Inspect", { recurring: true }); + const output = text(freeze(entry)); + expect(output).toContain("Hybrid event: deploy:done · debounce: 12000ms"); + expect(output).not.toContain("secret"); + expect(output).not.toContain("token"); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, 9e15])("does not invent a deadline from invalid scheduler value %s", (nextFireAt) => { + const entry = new LoopStore().create({ type: "dynamic" }, "Inspect", { recurring: true }); + expect(formatLoopLifecycle(freeze(entry), { now: NOW, nextFireAt }).join("\n")).toContain("Next fire: not reported"); + }); + + it("does not offer resume for a workflow state cap and preserves the budget", () => { + const store = new LoopStore(); + const entry = workflow(store, true); + store.fire(entry.id); + const current = freeze(structuredClone(store.get(entry.id)!)); + expect(canResumeFromInspection(current, NOW)).toBe(false); + expect(text(current)).toContain("leave the exhausted state with an evidenced transition"); + expect(text(current)).toContain("State fires: 1/1"); + expect(current.workflow?.stateFireCounts.work).toBe(1); + }); + + it("rejects resume exactly at expiry, even if a scheduler boundary is supplied", () => { + const entry = new LoopStore().create({ type: "dynamic" }, "Observe", { recurring: true }); + entry.status = "paused"; + entry.expiresAt = NOW; + expect(canResumeFromInspection(entry, NOW)).toBe(false); + expect(formatLoopLifecycle(entry, { now: NOW, nextFireAt: NOW + 10 }).join("\n")).toContain("expired controllers cannot resume"); + }); + + it("does not recommend resume for a semantic terminal", () => { + const entry = workflow(new LoopStore()); + entry.workflow!.currentState = "done"; + entry.status = "paused"; + expect(canResumeFromInspection(entry, NOW)).toBe(false); + expect(text(entry)).toContain("Inspect the terminal outcome; do not resume"); + }); + + it("keeps orchestration intent and execution authorities distinct", () => { + const entry = new LoopStore().create({ type: "dynamic" }, "Parallel review", { + recurring: true, + orchestration: { owner: { sessionId: "s", runtimeId: "r", generation: 1 }, definition: { goal: "Review", work: [{ prompt: "Inspect" }] } }, + }); + entry.status = "paused"; + const output = text(freeze(entry)); + expect(output).toContain("Authority: LoopStore intent; pi-subagents execution"); + expect(output).toContain("OrchestrationGet"); + expect(canResumeFromInspection(entry, NOW)).toBe(false); + }); + + it("keeps task-backlog work owned by the task provider", () => { + const entry = new LoopStore().create({ type: "event", source: "tasks:created" }, "Adopt tasks", { + recurring: true, taskBacklog: true, + }); + expect(text(entry)).toContain("Authority: LoopStore scheduling; standalone task provider work"); + }); + + it("directs a pending dynamic update to continue instead of deleting the controller", () => { + const entry = new LoopStore().create({ type: "dynamic" }, "Observe", { recurring: true, dynamic: { goal: "Observe", iteration: 1, awaitingUpdate: true } }); + expect(text(freeze(entry))).toContain("LoopUpdate"); + expect(text(entry)).toContain("continue while work remains"); + expect(text(entry)).not.toContain("LoopDelete"); + }); + + it("preserves both file-backed snapshots during pure inspection", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-loop-inspection-")); + try { + const path = join(dir, "loops.json"); + const store = new LoopStore(path); + const entry = workflow(store); + store.pause(entry.id); + const primary = readFileSync(path); + const previous = readFileSync(`${path}.prev`); + const current = freeze(structuredClone(store.get(entry.id)!)); + text(current); + canResumeFromInspection(current, NOW); + expect(readFileSync(path)).toEqual(primary); + expect(readFileSync(`${path}.prev`)).toEqual(previous); + expect(store.get(entry.id)?.status).toBe("paused"); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); diff --git a/test/loop-command.test.ts b/test/loop-command.test.ts index 75cea69..6fa2ddc 100644 --- a/test/loop-command.test.ts +++ b/test/loop-command.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerLoopCommand } from "../src/commands/loop-command.js"; +import { CronScheduler } from "../src/scheduler.js"; import { LoopStore } from "../src/store.js"; import { createCtx, createMockPi } from "./helpers/mock-pi.js"; import { currentWorkflowIdentity } from "./helpers/workflow-identity.js"; -function setup() { +function setup(inspectionOptions: Record = {}) { const { pi, commandMap } = createMockPi(); const store = new LoopStore(); // memory mode, no file I/O const triggerSystem = { add: vi.fn(), remove: vi.fn() }; @@ -23,6 +24,7 @@ function setup() { maybeBootstrapTaskLoop, onDynamicLoopActivated, cancelOrchestration, + ...inspectionOptions, }); const command = commandMap.get("loop")!; return { store, triggerSystem, updateWidget, maybeBootstrapTaskLoop, onDynamicLoopActivated, cancelOrchestration, command }; @@ -201,6 +203,133 @@ describe("registerLoopCommand", () => { expect(detailTitle).toContain("Unavailable: retry (blocked exhausted 1)"); }); + it("feature: shows resolved storage, lifetime, next fire and accounting without mutating the controller", async () => { + const next = Date.now() + 300_000; + const getNextFire = vi.fn(() => next); + h = setup({ getStorageScope: () => "project", getNextFire }); + const entry = h.store.create({ type: "cron", schedule: "*/5 * * * *" }, "Observe the build", { + recurring: true, maxFires: 4, + }); + const before = structuredClone(h.store.list()); + const pause = vi.spyOn(h.store, "pause"); + const resume = vi.spyOn(h.store, "resume"); + const remove = vi.spyOn(h.store, "delete"); + const fire = vi.spyOn(h.store, "fire"); + let visits = 0; + let detail = ""; + const ui = { + select: vi.fn(async (title: string, choices: string[]) => { + if (title === "Loop") return "View loops"; + if (title === "Loops") return visits++ === 0 ? choices[0] : "< Back"; + detail = title; + return "< Back"; + }), input: vi.fn(), notify: vi.fn(), + }; + + await h.command.handler!("", { ui } as any); + + expect(detail).toContain("Kind: scheduled loop"); + expect(detail).toContain("Storage: project file (shared; no scheduler owner election)"); + expect(detail).toContain(`Expires at: ${new Date(entry.expiresAt).toISOString()}`); + expect(detail).toContain(`Next fire: ${new Date(next).toISOString()}`); + expect(detail).toContain("Fires: 0/4 (accounted, not delivered wakes)"); + expect(detail).toContain("Next action:"); + expect(getNextFire).toHaveBeenCalledWith(entry.id); + expect(h.store.list()).toEqual(before); + for (const fn of [pause, resume, remove, fire, h.triggerSystem.add, h.triggerSystem.remove, h.updateWidget, h.onDynamicLoopActivated]) { + expect(fn).not.toHaveBeenCalled(); + } + }); + + it("reads a real scheduler deadline without pumping, rearming, or changing state", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:01Z")); + h = setup({ getNextFire: (id: string) => scheduler.nextFire(id) }); + const onFire = vi.fn(() => true); + const scheduler = new CronScheduler(h.store, onFire); + try { + const entry = h.store.create({ type: "cron", schedule: "*/5 * * * *" }, "Read deadline", { recurring: true }); + scheduler.add(entry); + const deadline = scheduler.nextFire(entry.id)!; + expect(Number.isFinite(deadline)).toBe(true); + const snapshot = structuredClone(h.store.list()); + const add = vi.spyOn(scheduler, "add"); + const remove = vi.spyOn(scheduler, "remove"); + const pump = vi.spyOn(scheduler, "pump"); + let visits = 0; + let detail = ""; + const ui = { + select: vi.fn(async (title: string, choices: string[]) => { + if (title === "Loop") return "View loops"; + if (title === "Loops") return visits++ === 0 ? choices[0] : "< Back"; + detail = title; + return "< Back"; + }), input: vi.fn(), notify: vi.fn(), + }; + await h.command.handler!("", { ui } as any); + expect(detail).toContain(`Next fire: ${new Date(deadline).toISOString()}`); + expect(scheduler.nextFire(entry.id)).toBe(deadline); + expect(h.store.list()).toEqual(snapshot); + for (const fn of [pump, add, remove, onFire, h.triggerSystem.add, h.triggerSystem.remove, h.updateWidget]) { + expect(fn).not.toHaveBeenCalled(); + } + } finally { + scheduler.stop(); + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it.each([false, true])("bug: does not offer unsupported resume after a workflow %s fire cap", async (stateCap) => { + h.store.create({ type: "dynamic" }, "Bounded review", { + recurring: true, maxFires: stateCap ? 10 : 1, + workflow: { + version: 1, initialState: "work", + states: { + work: { prompt: "Review.", on: { done: "done" }, ...(stateCap ? { loop: { schedule: "*/5 * * * *", maxFires: 1 } } : {}) }, + done: { prompt: "Report.", terminal: "completed" }, + }, + }, + }); + h.store.fire("1"); + expect(h.store.get("1")?.pause?.kind).toBe("controller_limit"); + expect(h.store.resume("1")).toBeUndefined(); + const before = structuredClone(h.store.get("1")); + let visits = 0; + let actions: string[] = []; + const ui = { + select: vi.fn(async (title: string, choices: string[]) => { + if (title === "Loop") return "View loops"; + if (title === "Loops") return visits++ === 0 ? choices[0] : "< Back"; + actions = choices; + return "< Back"; + }), input: vi.fn(), notify: vi.fn(), + }; + await h.command.handler!("", { ui } as any); + expect(actions).not.toContain("* Resume"); + expect(h.store.get("1")).toEqual(before); + expect(h.triggerSystem.add).not.toHaveBeenCalled(); + }); + + it("control: still offers explicit resume for an administrative pause below its budget", async () => { + h.store.create({ type: "dynamic" }, "Continue review", { recurring: true, maxFires: 3 }); + h.store.pause("1"); + let visits = 0; + let actions: string[] = []; + const ui = { + select: vi.fn(async (title: string, choices: string[]) => { + if (title === "Loop") return "View loops"; + if (title === "Loops") return visits++ === 0 ? choices[0] : "< Back"; + actions = choices; + return "< Back"; + }), input: vi.fn(), notify: vi.fn(), + }; + await h.command.handler!("", { ui } as any); + expect(actions).toContain("* Resume"); + expect(h.store.get("1")?.status).toBe("paused"); + expect(h.triggerSystem.add).not.toHaveBeenCalled(); + }); + it("no-args invocation with 'Create scheduled loop' prompts for prompt + interval and creates a loop", async () => { const ui = { select: vi.fn(async () => "Create scheduled loop"), diff --git a/test/scope.test.ts b/test/scope.test.ts index 870d1f4..672fdaa 100644 --- a/test/scope.test.ts +++ b/test/scope.test.ts @@ -1,6 +1,6 @@ import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveLoopStorePath, resolveTaskStorePath, type ScopeOptions } from "../src/runtime/scope.js"; +import { resolveLoopStorageScope, resolveLoopStorePath, resolveTaskStorePath, type ScopeOptions } from "../src/runtime/scope.js"; const CWD = "/tmp/pi-loop-scope"; @@ -50,6 +50,26 @@ describe("resolveLoopStorePath", () => { }); }); +describe("resolveLoopStorageScope", () => { + it.each([ + [opts({ loopScope: "memory" }), "s1", "memory"], + [opts(), undefined, "memory"], + [opts(), "s1", "session"], + [opts({ loopScope: "project" }), undefined, "project"], + [opts({ loopScope: "project", piLoopEnv: "off" }), "s1", "memory"], + [opts({ piLoopEnv: "/custom/private.json" }), "s1", "custom"], + [opts({ piLoopEnv: "./private.json" }), undefined, "custom"], + [opts({ loopScope: "memory", piLoopEnv: "named-store" }), "s1", "custom"], + [opts({ piLoopEnv: "" }), "s1", "session"], + ] as const)("classifies the resolved store for %j and session %s as %s", (options, session, expected) => { + const before = structuredClone(options); + const scope = resolveLoopStorageScope(options, session); + expect(scope).toBe(expected); + expect(scope === "memory").toBe(resolveLoopStorePath(options, session) === undefined); + expect(options).toEqual(before); + }); +}); + describe("resolveTaskStorePath", () => { it("returns undefined for memory scope", () => { expect(resolveTaskStorePath(opts({ loopScope: "memory" }), "s1")).toBeUndefined();