Conversation
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
force-pushed
the
feat/target-parent
branch
from
September 21, 2026 07:41
aa86ee8 to
03816da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_looptakeskeep_alive: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 wasmax_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,
runDueLockedreturns early withoutrecordRunFailedorscheduleTimer, leaving the loop on nothing but theclaimDueRunlease. I confirmed the symptom:lastResultstaysnull,runCount0, 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.claimDueRunstill keeps concurrent processes off the same run, and ordinary loops still defer to their owning process (covered by a test).Dynamic auto-stop.
settleDynamicLoopsstops a dynamic loop when a turn ends withoutschedule_next_run, andrehydratestops one that restarted with no persisted next run. Both are correct — dynamic means the agent owns the pacing, and that's the documented Claude CodeScheduleWakeupcontract. Rather than weaken them,keep_aliverequires 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
recordRunSentre-armingnextRunAt. So durability was previously an accident of choosing interval mode; this makes it something you declare.Implementation
One field,
keepAlive, using the sameSchema.optionalWith(..., { default: () => false })pattern asagentandmaxRuns— existing persisted records decode unchanged as ordinary loops, no migration, no version bump. Both the V1toolhook and the V2tool.transformpath get the option and bothrunDueLockedimplementations get the two exemptions. 41 lines acrosssrc/.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.jsrebuilt 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_alivewithout 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():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.)