Skip to content

feat: add keep_alive so a background session's future survives its starting turn - #3

Open
jutaz wants to merge 1 commit into
prevalentWare:mainfrom
jutaz:feat/target-parent
Open

jutaz wants to merge 1 commit into
prevalentWare:mainfrom
jutaz:feat/target-parent

Conversation

@jutaz

@jutaz jutaz commented Sep 19, 2026

Copy link
Copy Markdown

The gap

A subagent's session has no future of its own. The turn that spawned it ends, the parent detaches, and nothing ever re-enters that session. So any work that outlives its starting turn — a migration, a long build, anything you'd want a background agent to sit on — dies with the turn, and the agent never gets the chance to report back.

A loop is already the right primitive for this: the scheduler re-prompts a session on a cadence, independent of any parent/child task relationship. What was missing is a way to say this loop is holding a future open, don't end it for me.

The change

create_loop takes keep_alive:

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 until the agent calls stop_loop — because the work resolved, or because it's giving up. The scheduler never makes that call.

Handing the result back to whoever asked stays the agent's job. This flag only keeps the session running long enough to do it; the plugin doesn't message other sessions.

Why this needed a flag rather than a doc note

I audited the paths that can end a loop the agent didn't ask to end, by running the real server against a fake client rather than reading the code. Three could kill a future:

Age expiry. max_loop_age_days (7 by default) stops any loop, interval included. This was the only one that reached interval loops, and the only escape was max_loop_age_days: 0 — which makes every loop immortal, clearly the wrong default. Keep-alive loops are now exempt; ordinary loops are untouched.

Unobserved-session failure. On a prompt failure in a session this process has never observed, runDueLocked returns early without recordRunFailed or scheduleTimer, leaving the loop on nothing but the claimDueRun lease. I confirmed the symptom: lastResult stays null, runCount 0, no timer armed. A detached child emits no events for this process to observe — precisely the keep-alive case. 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 (covered by a test).

Dynamic auto-stop. settleDynamicLoops stops a dynamic loop when a turn ends without schedule_next_run, and rehydrate stops one that restarted with no persisted next run. Both are correct — dynamic means the agent owns the pacing, and that's the documented Claude Code ScheduleWakeup contract. Rather than weaken them, keep_alive requires a fixed interval and is rejected at create time without one. A future whose heartbeat depends on the agent remembering to reschedule every turn isn't durable by definition.

Interval loops already survived the first two of those three via recordRunSent re-arming nextRunAt. So durability was previously an accident of choosing interval mode; this makes it something you declare.

Implementation

One field, keepAlive, using the same Schema.optionalWith(..., { default: () => false }) pattern as agent and maxRuns — existing persisted records decode unchanged as ordinary loops, no migration, no version bump. Both the V1 tool hook and the V2 tool.transform path get the option and both runDueLocked implementations get the two exemptions. 41 lines across src/.

Verification

Full gate green on a clean clone: bun test (62 pass, up from 51 — all 51 pre-existing tests unchanged), bun run lint, bun run typecheck, bun run build. dist/server.js rebuilt per CONTRIBUTING.

New tests cover: survives age expiry that stops an ordinary loop alongside it; retries in a never-observed session; ordinary loops still defer when unobserved; survives a restart and keeps firing; runs indefinitely and ends only on explicit stop_loop; keep_alive without an interval is rejected; V2 schema parity; and a record persisted before this field decodes as an ordinary loop.

End-to-end against a live OpenCode 2.0.5 server, driving the real setup():

real parent spawned a child subagent: ses_f3d17a308ffe... (parentID=ses_f3d17b0a1ffe...)
parent task completed -> DETACHED from child: parent.outcome=succeeded
child transcript at detachment: 3 messages, 1 assistant turn(s)
child created a keep-alive loop AFTER detachment: loop_6m2iu keepAlive=true interval=1s
scheduler drove 3 iterations post-detachment: runCount=3 status=active
child produced NEW assistant turns after detaching: 9 new turn(s)
parent was NOT prompted: 0 iterations reached the parent
agent explicitly stopped the loop: status=stopped reason="work resolved"
future ended: no further iterations after stop (runCount stable at 10)

The child ran nine further assistant turns on its own after its parent had detached, and stopped only when the agent said so.

Notes

Docs updated: a feature bullet and a "Keeping a background session alive" section.

Open to a different name (durable? persist?), and happy to discuss whether the unobserved-session re-arm should apply to all loops rather than just keep-alive ones — I kept it narrow deliberately, since the early return is load-bearing for multi-process setups.

(This PR previously proposed a target: "self" | "parent" option. That was the wrong shape — waking the parent is the agent's responsibility, not the plugin's, and it would have made the plugin a cross-session message bus. It has been dropped entirely and the branch rewritten.)

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.
@jutaz
jutaz force-pushed the feat/target-parent branch from aa86ee8 to 03816da Compare September 21, 2026 07:41
@jutaz jutaz changed the title feat: add target option so a loop can wake the parent session feat: add keep_alive so a background session's future survives its starting turn Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant