From 03816daa9a12f58bf3bce7ea33c5e845739af413 Mon Sep 17 00:00:00 2001 From: Justas Brazauskas Date: Mon, 21 Sep 2026 10:40:59 +0300 Subject: [PATCH] feat: add keep_alive so a background session's future survives A subagent's session has no future of its own. The turn that spawned it ends, the parent detaches, and nothing re-enters that session again -- so work that outlives its starting turn dies with it, and the agent never gets the chance to report back. create_loop now takes keep_alive. The loop becomes a durable heartbeat for the session it was created in, and the scheduler stops deciding when that future ends. Only the agent does, by calling stop_loop once the work is resolved or abandoned. Three paths could previously end such a future without the agent asking: - Age expiry stopped any loop past max_loop_age_days (7 by default). This was the only one that also hit interval loops, and the only way to avoid it was max_loop_age_days: 0, which would make every loop immortal. keep-alive loops are now exempt; ordinary loops are unchanged. - A prompt failure in a session this process never observed returned early without recording the failure or re-arming, leaving the loop on nothing but the claimDueRun lease. A detached child emits no events for this process to observe, which is exactly the case keep-alive is for, so those loops now record the failure and re-arm. claimDueRun still keeps concurrent processes off the same run, and ordinary loops still defer to their owning process. - Dynamic loops stop when a turn ends without schedule_next_run, and are stopped on restart when no next run was persisted. Both are correct for dynamic pacing, so rather than weaken them keep_alive requires a fixed interval and is rejected at create time without one. Verified end to end against a live OpenCode 2.0.5 server: a real parent spawned a child subagent, the parent's task completed and detached, and the child then ran nine further assistant turns on its own before the agent stopped the loop. The parent received no iterations -- handing the result back remains the agent's responsibility, not the plugin's. --- README.md | 19 ++++++ dist/server.js | 37 ++++++++---- src/server.ts | 32 +++++++++-- src/state.ts | 9 +++ test/server-v2.test.ts | 50 ++++++++++++++++ test/server.test.ts | 128 ++++++++++++++++++++++++++++++++++++++++- test/state.test.ts | 16 ++++++ 7 files changed, 272 insertions(+), 19 deletions(-) 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/, + ) +})