Bilans — always-on (overlap: keepalive) + examples/keepalive
The keepalive/always-on scheduling feature and its demo bot (examples/keepalive/main.bot, a tool-only heartbeat). See docs/scheduling.md#always-on-agents--overlap-keepalive.
2026-07-20 — feature dogfood: always-on end-to-end (local studio)
- Status: validated
- Versions: feature branch
feat/keepalive-always-on· iterion @ worktree head (post c31a52f) - Method:
iterion studio --store-dir "$PWD/.iterion" --port 7801withITERION_SCHEDULER_INTERVAL=3s; two keepalive bots auto-seeded from manifests —heartbeat(tool-only, instant,interval:15sstale_after:2m) and a throwawayslowbeat(sleep 60,interval:15sstale_after:10s). No API keys (tool-only). In-processtrigger.Scheduler. - Result: converged behavior on all three keepalive invariants —
- Relaunch loop:
heartbeatproduced a stream of freshfinishedruns at ~15–20s spacing (7 over ~2min), all stampedsource.kind=schedule+ sameschedule_id. - At-most-one-live:
slowbeat(60s runs) kept exactly one live at a time — no stacking. - Staleness + reap: a
slowbeatrun silent paststale_after=10swas CAS-flippedrunning → failed_resumableand a fresh one relaunched, on every tick — log:scheduler: … (slowbeat) reaping 1 stale keepalive run(s) […]+server: keepalive reaped stale run ….
- Relaunch loop:
- Value: proved the whole local path (studio → in-process scheduler → schedgate keepalive gate → runview launcher → run store → reaper) works as designed: an agent stays continuously alive as a stream of short, individually-budgeted runs, self-recovering from a stuck run within one tick — without fighting
max_duration/GC. - Findings / misses: the FIRST live attempt showed
slowbeatstacking (3runningat once, no reap) — surfaced a real wiring bug, fixed below. - Engine hardening (bugs found → fixes, same branch):
- Local scheduler ran gate-less.
Server.scheduleGate()returned nil whenevercfg.Store == nil, which is always true in local/studio mode (runview owns the store). So the in-process keepalive scheduler had no overlap/staleness/reap gate and every tick fired unconditionally. Fix: fall back tos.runs.RunStore()whencfg.Storeis nil (pkg/server/trigger_coordinator.go). This is what made at-most-one-live- reaping actually engage.
- Keepalive subs weren't auto-loaded.
buildLocalTriggerStore(pkg/cli/trigger.go) seeded board + schedule invocations but not keepalive, so akind: keepalivebot never activated out of the box. Fix: seedFromKeepaliveInvocationtoo. - Bundle pairing needs
main.bot. The demo'sheartbeat.botwas read as a loose file (manifest ignored →invocations: null); renamed tomain.botso the manifest pairs and the keepalive invocation loads.
- Local scheduler ran gate-less.
- Lessons for next run:
- Sub-minute keepalive requires the resident scheduler (studio/server); host crontab floors at 1 minute.
ITERION_SCHEDULER_INTERVALsets the in-process tick resolution (default 15s). - Scheduled runs create a worktree per run even for tool-only bots (a launcher default, not keepalive-specific) — noted, not blocking; watch disk under a fast always-on cadence.
- Test staleness with a separate store dir / fresh process —
rm -rf .iterion/runswhile a prior studio's runs are in flight triggers a drain and "run not found" noise. Kill alliterion studioprocs first.
- Sub-minute keepalive requires the resident scheduler (studio/server); host crontab floors at 1 minute.
- Follow-ups (found by the /simplify cleanup pass, deferred as out-of-diff):
- Unbounded per-tick liveness scan.
schedgate.LiveAndStaleRunsForSchedulecallsListRunsBySchedule+LoadRunfor every run ever stamped with the schedule id, every tick. Reaped runs stayfailed_resumablebut keep thesource.schedule_idedge, so the scan grows O(lifetime runs) — acute for a 15s keepalive (~5.7k runs/day). Fix lives in the store layer (filter terminal runs inListRunsByScheduleacross mongo/fs/memory + conformance), outside this feature's diff. Worth doing before enabling a long-lived keepalive in production. - Studio hides the guard under always-on. The always-on toggle hides the whole
SchedulePolicyEditor, so a guard command (which the CLI/API allow withoverlap=keepalive) can't be set from studio. Fix = let the editor own the third overlap mode / keep the guard portion visible under always-on.
- Unbounded per-tick liveness scan.
