diff --git a/README.md b/README.md index e8a5375..88c3c33 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The OpenCode Loop Plugin adds: - `/loop ` and `/loop ` (dynamic pacing) as an OpenCode command for TUI, desktop, and web. - A server-side scheduler with per-loop timers that injects a synthetic iteration prompt only when the session is idle, with busy backoff. - Dynamic loops where the agent itself picks the delay before each next iteration via `schedule_next_run`, mirroring Claude Code's self-paced `/loop`. +- Keep-alive loops: `create_loop({ keep_alive: true })` holds a background or subagent session running across turns until the agent stops it, so work that outlives the turn that started it is never ended by the scheduler. - Agent tools: `create_loop`, `list_loops`, `stop_loop`, `pause_loop`, `resume_loop`, `run_loop`, `schedule_next_run`, and `clear_loops`. - Persistent loop state that survives OpenCode restarts, with atomic writes and owner-only file permissions. - A TUI sidebar with live countdowns and a `Loops` command-palette entry to run, pause, resume, or stop loops. @@ -120,6 +121,24 @@ After creating a loop, the agent immediately performs the first iteration in the A dynamic loop mirrors Claude Code's self-paced `/loop`: at the end of each iteration the agent calls `schedule_next_run` with a delay in seconds and a one-sentence reason ("watching CI run"), or calls `stop_loop` to end the loop. If an iteration ends without doing either, the loop ends — exactly like omitting `ScheduleWakeup` in Claude Code. +### Keeping a background session alive + +A subagent's session normally has no future of its own: the turn that spawned it ends, the parent detaches, and nothing re-enters that session again. `keep_alive` gives it one. + +``` +create_loop( + instruction: "Check whether the migration finished. If it has, report the result and call stop_loop.", + interval: "5m", + keep_alive: true +) +``` + +The session keeps being re-prompted on the cadence until the agent calls `stop_loop` — because the work is resolved, or because it is giving up. The scheduler never ends a keep-alive loop on its own: it is exempt from the age expiry that stops ordinary loops, it survives an OpenCode restart, and it keeps retrying in a detached session this process never observed instead of quietly stalling. + +`keep_alive` requires a fixed interval. Dynamic pacing hands the next run to the agent every turn, so a single missed `schedule_next_run` would end the very future the flag exists to hold open; that combination is rejected when the loop is created. + +Handing a result back to whoever asked for the work stays the agent's job. This flag only keeps the session running long enough to do it. + ### How iterations are scheduled - Iterations only run while the session is idle. If a loop comes due while the session is busy, it is deferred with a short backoff and retried when the session goes idle. diff --git a/dist/server.js b/dist/server.js index 72bc224..da8c427 100644 --- a/dist/server.js +++ b/dist/server.js @@ -27,6 +27,7 @@ var LoopSchema = Schema.Struct({ sessionID: Schema.String, prompt: Schema.String, mode: Schema.optionalWith(Schema.Literal("interval", "dynamic"), { default: () => "interval" }), + keepAlive: Schema.optionalWith(Schema.Boolean, { default: () => false }), intervalMs: NullableNumber, status: Schema.Literal("active", "paused", "stopped", "completed"), createdAt: Schema.Number, @@ -217,6 +218,9 @@ function requireLoop(state, loopID) { async function createLoop(sessionID, options) { const prompt = validatePrompt(options.prompt); const mode = options.mode === "dynamic" ? "dynamic" : "interval"; + const keepAlive = options.keepAlive === true; + if (keepAlive && mode !== "interval") + throw new Error("keep-alive loops require a fixed interval"); const intervalMs = mode === "interval" ? positiveIntegerOrNull(options.intervalMs) : null; if (mode === "interval" && intervalMs == null) throw new Error("interval loops require a positive interval"); @@ -224,7 +228,7 @@ async function createLoop(sessionID, options) { const maxLoops = positiveIntegerOrNull(options.maxLoopsPerSession) ?? DEFAULT_MAX_LOOPS_PER_SESSION; const agent = typeof options.agent === "string" && options.agent.trim() ? options.agent.trim() : null; return mutate((state) => { - const open = Object.values(state.loops).filter((loop2) => loop2.sessionID === sessionID && isOpen(loop2.status)); + const open = Object.values(state.loops).filter((loop) => loop.sessionID === sessionID && isOpen(loop.status)); if (open.length >= maxLoops) { throw new Error(`this session already has ${open.length} open loop(s); stop one before creating another (limit ${maxLoops})`); } @@ -237,6 +241,7 @@ async function createLoop(sessionID, options) { sessionID, prompt, mode, + keepAlive, intervalMs, status: "active", createdAt: timestamp, @@ -510,6 +515,7 @@ Preserve each loop's id, cadence, instruction, and status in the compacted conte } // src/server.ts +var KEEP_ALIVE_DESCRIPTION = "Keep this session alive across turns until the agent explicitly stops the loop. Requires a fixed interval. Use it in a background or subagent session whose work outlives the turn that started it: the scheduler keeps re-prompting the session on the cadence, and the loop is never ended automatically by age expiry. You remain responsible for calling stop_loop once the work is resolved or abandoned."; var DEFAULT_COMMAND_NAME = "loop"; var DEFAULT_BUSY_BACKOFF_SECONDS = 60; var DEFAULT_FAILURE_BACKOFF_SECONDS = 60; @@ -642,7 +648,7 @@ var server = async ({ client }, options) => { return; } loop = claimed; - if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { + if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`); return; } @@ -669,7 +675,7 @@ var server = async ({ client }, options) => { }); } catch (error) { dynamicPending.delete(loopID); - if (!observedSessions.has(loop.sessionID)) { + if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) { await log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID }); return; } @@ -687,9 +693,9 @@ var server = async ({ client }, options) => { } async function runDueForSession(sessionID) { const loops = await activeLoops(sessionID); - const now2 = Date.now(); + const now = Date.now(); for (const loop of loops) { - if (loop.nextRunAt == null || loop.nextRunAt > now2) + if (loop.nextRunAt == null || loop.nextRunAt > now) continue; await runDue(loop.id); if (busySessions.has(sessionID)) @@ -751,7 +757,8 @@ var server = async ({ client }, options) => { args: { instruction: z.string().min(1).max(MAX_PROMPT_CHARS).describe("The instruction to perform on each iteration."), interval: z.string().optional().describe('Fixed cadence like "30s", "10m", "2h", or "1d". Omit for a dynamically paced loop.'), - max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes.") + max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes."), + keep_alive: z.boolean().optional().describe(KEEP_ALIVE_DESCRIPTION) }, async execute(args, context) { const input = args; @@ -763,7 +770,8 @@ var server = async ({ client }, options) => { intervalMs: dynamic ? null : parseInterval(input.interval, minIntervalSeconds), maxRuns: input.max_runs ?? null, agent: typeof context.agent === "string" ? context.agent : null, - maxLoopsPerSession + maxLoopsPerSession, + keepAlive: input.keep_alive === true }); if (loop.mode === "dynamic") { dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true }); @@ -1041,7 +1049,7 @@ async function setupV2(context) { return; } loop = claimed; - if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { + if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86400000)} days`); return; } @@ -1066,7 +1074,7 @@ async function setupV2(context) { }); } catch (error) { dynamicPending.delete(loopID); - if (!observedSessions.has(loop.sessionID)) { + if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) { v2Log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID }); return; } @@ -1084,9 +1092,9 @@ async function setupV2(context) { } async function runDueForSession(sessionID) { const loops = await activeLoops(sessionID); - const now2 = Date.now(); + const now = Date.now(); for (const loop of loops) { - if (loop.nextRunAt == null || loop.nextRunAt > now2) + if (loop.nextRunAt == null || loop.nextRunAt > now) continue; await runDue(loop.id); if (busySessions.has(sessionID)) @@ -1279,6 +1287,10 @@ function loopToolsV2(services) { type: "integer", minimum: 1, description: "Optional maximum number of iterations before the loop completes." + }, + keep_alive: { + type: "boolean", + description: KEEP_ALIVE_DESCRIPTION } }, ["instruction"]), options: { codemode: false }, @@ -1292,7 +1304,8 @@ function loopToolsV2(services) { intervalMs: dynamic ? null : parseInterval(input.interval, services.minIntervalSeconds), maxRuns: input.max_runs ?? null, agent: typeof toolContext.agent === "string" ? toolContext.agent : null, - maxLoopsPerSession: services.maxLoopsPerSession + maxLoopsPerSession: services.maxLoopsPerSession, + keepAlive: input.keep_alive === true }); if (loop.mode === "dynamic") { services.dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true }); diff --git a/src/server.ts b/src/server.ts index 69dd28d..ed30ffc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,9 @@ type Options = { restricted_agents?: string[] } +const KEEP_ALIVE_DESCRIPTION = + "Keep this session alive across turns until the agent explicitly stops the loop. Requires a fixed interval. Use it in a background or subagent session whose work outlives the turn that started it: the scheduler keeps re-prompting the session on the cadence, and the loop is never ended automatically by age expiry. You remain responsible for calling stop_loop once the work is resolved or abandoned." + const DEFAULT_COMMAND_NAME = "loop" const DEFAULT_BUSY_BACKOFF_SECONDS = 60 const DEFAULT_FAILURE_BACKOFF_SECONDS = 60 @@ -185,7 +188,9 @@ const server: Plugin = async ({ client }, options?: Options) => { return } loop = claimed - if (maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { + // A keep-alive loop ends only when the agent says so, so age expiry and the + // other automatic stops do not apply to it. + if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86_400_000)} days`) return } @@ -216,7 +221,10 @@ const server: Plugin = async ({ client }, options?: Options) => { }) } catch (error) { dynamicPending.delete(loopID) - if (!observedSessions.has(loop.sessionID)) { + // A keep-alive loop is expected to run in a detached session this process + // may never observe, so it re-arms instead of being left to the claim + // lease. claimDueRun still keeps concurrent processes off the same run. + if (!observedSessions.has(loop.sessionID) && !loop.keepAlive) { // Likely a session owned by another OpenCode process sharing the state // file: leave its record alone and stop driving it from this process. await log("info", "Skipping loop for a session this process has not observed", { loopID, sessionID: loop.sessionID }) @@ -302,9 +310,10 @@ const server: Plugin = async ({ client }, options?: Options) => { .optional() .describe('Fixed cadence like "30s", "10m", "2h", or "1d". Omit for a dynamically paced loop.'), max_runs: z.number().int().positive().optional().describe("Optional maximum number of iterations before the loop completes."), + keep_alive: z.boolean().optional().describe(KEEP_ALIVE_DESCRIPTION), }, async execute(args, context) { - const input = args as { instruction: string; interval?: string; max_runs?: number } + const input = args as { instruction: string; interval?: string; max_runs?: number; keep_alive?: boolean } observedSessions.add(context.sessionID) const dynamic = !input.interval?.trim() const loop = await createLoop(context.sessionID, { @@ -314,6 +323,7 @@ const server: Plugin = async ({ client }, options?: Options) => { maxRuns: input.max_runs ?? null, agent: typeof context.agent === "string" ? context.agent : null, maxLoopsPerSession, + keepAlive: input.keep_alive === true, }) if (loop.mode === "dynamic") { dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true }) @@ -617,7 +627,9 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { + // A keep-alive loop ends only when the agent says so, so age expiry and the + // other automatic stops do not apply to it. + if (!loop.keepAlive && maxLoopAgeMs > 0 && Date.now() - loop.createdAt >= maxLoopAgeMs) { await stopLoop(loopID, `expired after ${Math.round(maxLoopAgeMs / 86_400_000)} days`) return } @@ -646,7 +658,10 @@ async function setupV2(context: PluginV2.Plugin.Context): Promise { - const input = args as { instruction: string; interval?: string; max_runs?: number } + const input = args as { instruction: string; interval?: string; max_runs?: number; keep_alive?: boolean } services.observedSessions.add(toolContext.sessionID) const dynamic = !input.interval?.trim() const loop = await createLoop(toolContext.sessionID, { @@ -885,6 +904,7 @@ function loopToolsV2(services: LoopServices): ToolV2Info[] { maxRuns: input.max_runs ?? null, agent: typeof toolContext.agent === "string" ? toolContext.agent : null, maxLoopsPerSession: services.maxLoopsPerSession, + keepAlive: input.keep_alive === true, }) if (loop.mode === "dynamic") { services.dynamicPending.set(loop.id, { sessionID: loop.sessionID, sawBusy: true }) diff --git a/src/state.ts b/src/state.ts index 9c74675..4f87f3a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -14,6 +14,7 @@ export type CreateLoopOptions = { maxRuns?: number | null agent?: string | null maxLoopsPerSession?: number | null + keepAlive?: boolean } export type Loop = { @@ -21,6 +22,7 @@ export type Loop = { sessionID: string prompt: string mode: LoopMode + keepAlive: boolean intervalMs: number | null status: LoopStatus createdAt: number @@ -65,6 +67,7 @@ const LoopSchema = Schema.Struct({ sessionID: Schema.String, prompt: Schema.String, mode: Schema.optionalWith(Schema.Literal("interval", "dynamic"), { default: () => "interval" as const }), + keepAlive: Schema.optionalWith(Schema.Boolean, { default: () => false }), intervalMs: NullableNumber, status: Schema.Literal("active", "paused", "stopped", "completed"), createdAt: Schema.Number, @@ -293,6 +296,11 @@ function requireLoop(state: State, loopID: string) { export async function createLoop(sessionID: string, options: CreateLoopOptions) { const prompt = validatePrompt(options.prompt) const mode: LoopMode = options.mode === "dynamic" ? "dynamic" : "interval" + const keepAlive = options.keepAlive === true + // A keep-alive future needs a self-sufficient heartbeat. Dynamic pacing puts + // the next run in the agent's hands every turn, so one missed reschedule + // would end the very future the flag promises to hold open. + if (keepAlive && mode !== "interval") throw new Error("keep-alive loops require a fixed interval") const intervalMs = mode === "interval" ? positiveIntegerOrNull(options.intervalMs) : null if (mode === "interval" && intervalMs == null) throw new Error("interval loops require a positive interval") const maxRuns = positiveIntegerOrNull(options.maxRuns) @@ -311,6 +319,7 @@ export async function createLoop(sessionID: string, options: CreateLoopOptions) sessionID, prompt, mode, + keepAlive, intervalMs, status: "active", createdAt: timestamp, diff --git a/test/server-v2.test.ts b/test/server-v2.test.ts index a8f7149..13a20d0 100644 --- a/test/server-v2.test.ts +++ b/test/server-v2.test.ts @@ -513,3 +513,53 @@ test("V2 cleanup disposes registrations, clears timers, and stops the event cons expect(mock.promptCalls).toHaveLength(0) expect((await getLoop(created.created))?.status).toBe("active") }) + +test("V2 create_loop advertises keep_alive without making it required", async () => { + const mock = makeMockContext() + const cleanup = await plugin.setup(mock as never) + + const input = loopTool(mock, "create_loop").input as { + properties: Record + required: string[] + } + expect(input.properties.keep_alive?.type).toBe("boolean") + expect(input.required).toEqual(["instruction"]) + + mock.stream.end() + await cleanup() +}) + +test("V2 keep-alive loop survives age expiry and keeps its future open", async () => { + const mock = makeMockContext({ max_loop_age_days: 7 }) + const cleanup = await plugin.setup(mock as never) + + const created = JSON.parse( + contentOf( + await loopTool(mock, "create_loop").execute( + { instruction: "hold the future open", interval: "1s", keep_alive: true }, + toolContext("ses_keep"), + ), + ), + ) as { created: string; loop: { keepAlive: boolean } } + expect(created.loop.keepAlive).toBe(true) + + await waitFor(() => mock.promptCalls.length >= 1) + expect(mock.promptCalls[0]?.sessionID).toBe("ses_keep") + expect((await getLoop(created.created))?.status).toBe("active") + + await waitFor(async () => (await getLoop(created.created))?.runCount === 1) + mock.stream.end() + await cleanup() +}) + +test("V2 keep_alive requires a fixed interval", async () => { + const mock = makeMockContext() + const cleanup = await plugin.setup(mock as never) + + await expect( + loopTool(mock, "create_loop").execute({ instruction: "no cadence", keep_alive: true }, toolContext("ses_bad")), + ).rejects.toThrow(/keep-alive loops require a fixed interval/) + + mock.stream.end() + await cleanup() +}) diff --git a/test/server.test.ts b/test/server.test.ts index 3437773..f442f6e 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import serverModule from "../src/server" @@ -292,3 +292,129 @@ test("rehydrates persisted active loops on startup", async () => { expect(sent[0]?.text).toContain(created.created) await second.dispose?.() }) + +test("a keep-alive loop survives age expiry that would stop an ordinary loop", async () => { + const sent: SentPrompt[] = [] + const first = await makeServer(sent) + const durable = JSON.parse( + await tool(first, "create_loop").execute( + { instruction: "hold the future open", interval: "1s", keep_alive: true }, + { sessionID: "ses_keep" }, + ), + ) as { created: string } + const ordinary = JSON.parse( + await tool(first, "create_loop").execute({ instruction: "ordinary", interval: "1s" }, { sessionID: "ses_keep" }), + ) as { created: string } + await first.dispose?.() + + // Backdate both well past the expiry window. + const statePath = process.env.OPENCODE_LOOP_STATE_PATH! + const state = JSON.parse(await readFile(statePath, "utf8")) as { + loops: Record + } + for (const id of [durable.created, ordinary.created]) { + state.loops[id]!.createdAt = Date.now() - 400 * 24 * 60 * 60 * 1000 + state.loops[id]!.nextRunAt = Date.now() - 1000 + } + await writeFile(statePath, JSON.stringify(state)) + + const second = await makeServer(sent, { max_loop_age_days: 7 }) + await sleep(600) + + expect((await getLoop(durable.created))?.status).toBe("active") + expect((await getLoop(ordinary.created))?.status).toBe("stopped") + await second.dispose?.() +}) + +test("a keep-alive loop retries in a session this process never observed", async () => { + const sent: SentPrompt[] = [] + const first = await makeServer(sent) + const durable = JSON.parse( + await tool(first, "create_loop").execute( + { instruction: "hold the future open", interval: "1s", keep_alive: true }, + { sessionID: "ses_detached" }, + ), + ) as { created: string } + await first.dispose?.() + + // A fresh process never sees an event for the detached session, and the + // prompt fails: the loop must record the failure and re-arm, not stall. + const second = await makeServer(sent, undefined, { failPrompts: true }) + await sleep(1300) + + const loop = await getLoop(durable.created) + expect(loop?.status).toBe("active") + expect(loop?.lastResult).toBe("failed") + expect(loop?.nextRunAt).not.toBeNull() + await second.dispose?.() +}) + +test("an ordinary loop still defers to the owning process when unobserved", async () => { + const sent: SentPrompt[] = [] + const first = await makeServer(sent) + const ordinary = JSON.parse( + await tool(first, "create_loop").execute({ instruction: "ordinary", interval: "1s" }, { sessionID: "ses_other_proc" }), + ) as { created: string } + await first.dispose?.() + + const second = await makeServer(sent, undefined, { failPrompts: true }) + await sleep(1300) + + // Left alone for the process that owns it: no failure recorded. + expect((await getLoop(ordinary.created))?.lastResult).toBeNull() + await second.dispose?.() +}) + +test("a keep-alive loop survives a restart and keeps firing", async () => { + const sent: SentPrompt[] = [] + const first = await makeServer(sent) + const durable = JSON.parse( + await tool(first, "create_loop").execute( + { instruction: "hold the future open", interval: "1s", keep_alive: true }, + { sessionID: "ses_restart" }, + ), + ) as { created: string } + await first.dispose?.() + + const second = await makeServer(sent) + await sleep(1300) + + expect(sent.map((entry) => entry.sessionID)).toContain("ses_restart") + expect((await getLoop(durable.created))?.status).toBe("active") + await second.dispose?.() +}) + +test("a keep-alive future ends only when the agent stops it", async () => { + const sent: SentPrompt[] = [] + const hooks = await makeServer(sent) + const durable = JSON.parse( + await tool(hooks, "create_loop").execute( + { instruction: "hold the future open", interval: "1s", keep_alive: true }, + { sessionID: "ses_stop" }, + ), + ) as { created: string } + + // Several iterations pass without the agent ever stopping the loop. The + // injected turn marks the session busy, so each iteration needs its idle. + for (let iteration = 0; iteration < 3; iteration += 1) { + await sleep(1200) + await idle(hooks, "ses_stop") + } + expect((await getLoop(durable.created))?.status).toBe("active") + expect((await getLoop(durable.created))?.runCount).toBeGreaterThanOrEqual(3) + + await tool(hooks, "stop_loop").execute({ loop_id: durable.created }, { sessionID: "ses_stop" }) + expect((await getLoop(durable.created))?.status).toBe("stopped") + await hooks.dispose?.() +}) + +test("keep_alive requires a fixed interval", async () => { + const sent: SentPrompt[] = [] + const hooks = await makeServer(sent) + + await expect( + tool(hooks, "create_loop").execute({ instruction: "no cadence", keep_alive: true }, { sessionID: "ses_bad" }), + ).rejects.toThrow(/keep-alive loops require a fixed interval/) + + await hooks.dispose?.() +}) diff --git a/test/state.test.ts b/test/state.test.ts index fb259ec..9e5457b 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -194,6 +194,8 @@ test("decodes persisted state with optional fields omitted", async () => { expect(loop?.lastResult).toBeNull() expect(loop?.maxRuns).toBeNull() expect(loop?.agent).toBeNull() + // Loops persisted before keep-alive existed are ordinary loops. + expect(loop?.keepAlive).toBe(false) }) test("writes state with owner-only file permissions", async () => { @@ -216,3 +218,17 @@ test("formats loop lists for reports", async () => { expect(report).toContain("every 10m") expect(report).toContain("check the deploy") }) + +test("createLoop records keep-alive futures and defaults them off", async () => { + const ordinary = await createLoop("ses_1", { prompt: "tick", intervalMs: 60_000 }) + expect(ordinary.keepAlive).toBe(false) + + const durable = await createLoop("ses_1", { prompt: "tick", intervalMs: 60_000, keepAlive: true }) + expect(durable.keepAlive).toBe(true) +}) + +test("keep-alive loops cannot delegate pacing to the agent", async () => { + await expect(createLoop("ses_1", { prompt: "tick", mode: "dynamic", keepAlive: true })).rejects.toThrow( + /keep-alive loops require a fixed interval/, + ) +})