The session lifecycle
Every session is in exactly one of five states. The transitions between them are enforced by
AASM in app/models/concerns/session_state_machine.rb, and each one carries side effects that
are as important as the state change itself.
The states
Section titled “The states”| State | DB value | Meaning |
|---|---|---|
waiting | 1 | Queued, or dormant awaiting a scheduled wake-up. The initial state. |
running | 0 | An agent process is alive and a monitoring job owns it. |
needs_input | 2 | The agent’s turn ended, or it’s blocked on an elicitation. This is your to-do list. |
failed | 4 | Terminal error. Resumable. |
archived | 3 | In the trash. Restorable until the clone is reaped. |
The integer values are load-bearing (they’re the existing ActiveRecord enum). A sixth state,
corrupted (5), was removed — sessions now go to failed instead.
The full machine
Section titled “The full machine”The events, and what they actually do
Section titled “The events, and what they actually do”start — waiting → running
Section titled “start — waiting → running”Guarded on git_root being present. Resets the elapsed-time counter and logs.
pause — running → needs_input
Section titled “pause — running → needs_input”Fired when the agent’s turn ends (the process exits normally). This is the workhorse transition, and it does five things beyond changing status:
cleanup_running_job— clearsrunning_job_id.fire_ao_event_triggers("session_needs_input")— wakes anything watching this session.enqueue_debounced_needs_input_push_notification— see below.enqueue_session_inference_if_needed— LLM-generates a title and category if still pending.execute_pending_sleep— if the agent called “wake me up later” while running, the sleep was deferred to here; now it fires.
The debounce is worth understanding. Sessions sometimes flap running → needs_input → running between turns, and without debouncing every flap would push a notification. So the
push job is enqueued with a 60-second delay (NEEDS_INPUT_DEBOUNCE) carrying a monotonic
marker from custom_metadata["needs_input_count"]. If the session churns during the window,
the marker won’t match and the deferred job no-ops.
resume — waiting | needs_input | failed → running
Section titled “resume — waiting | needs_input | failed → running”Unguarded (can_resume? returns true unconditionally — the job handles preconditions).
Clears a pile of stale state: MCP failure flags, the paused_by marker, the
blocked_on_elicitation marker, any pending_sleep, and, importantly, it
cancels pending one-time wake-up triggers targeting this session, so a scheduled wake
doesn’t fire on a session you already resumed by hand.
sleep — needs_input → waiting
Section titled “sleep — needs_input → waiting”The “wake me up later” path. The session goes dormant and a one-time schedule trigger will
resume it. If the agent calls this while running, metadata["pending_sleep"] = true is set
and the actual transition happens on the next pause.
block_on_elicitation / unblock_from_elicitation — running ⇄ needs_input
Section titled “block_on_elicitation / unblock_from_elicitation — running ⇄ needs_input”This pair exists because an elicitation is not a turn ending. An MCP server made a
synchronous request and is blocked waiting for the human; the agent process is still alive.
So block_on_elicitation surfaces the session as needs_input (to get it on your homepage and
into the notification path) but does not call cleanup_running_job — killing
the process would break the round-trip.
A metadata marker (blocked_on_elicitation) distinguishes this from a real pause, and guards
the flip back.
fail — waiting | running | needs_input → failed
Section titled “fail — waiting | running | needs_input → failed”Cleans up the running job, fires session_failed triggers, and enqueues a push notification
that bypasses the per-session opt-in. The reasoning: by the time fail! fires, retries are
already exhausted, so this is a final non-self-resolving event. A silent status flip would be
worse than an unwanted push.
archive — any state → archived
Section titled “archive — any state → archived”Sets archived_at, dismisses notifications, fires session_archived triggers, cleans up
triggers watching this session, and sets a trash expiry.
The clone is not deleted immediately. DeferredCloneCleanupJob runs after a short undo
window and then either deletes the clone (if it’s clean) or preserves unpushed artifacts for
TRASH_RETENTION_PERIOD.
Side effects fail silently, by design
Section titled “Side effects fail silently, by design”Almost every callback in the state machine is wrapped in a bare rescue that logs and swallows:
rescue => e Rails.logger.error "[SessionStateMachine] Failed to ..." # Don't raise - notification failures shouldn't block state transitionsendThis is a deliberate trade: a broken notification service should not be able to wedge a
session in running. The consequence is that cleanup can silently not happen while the state
still advances — an archived session whose trash expiry failed to set, a paused session whose
notification never fired. StaleCloneCleanupJob exists as the safety net for the clone case.
Who else moves sessions around
Section titled “Who else moves sessions around”The state machine is not the only actor:
HeartbeatSweepJob(every 30s) re-nudgesneeds_inputsessions withheartbeat_enabledby injecting a heartbeat prompt and resuming them. It skips sessions blocked on an elicitation or with pending enqueued messages — resuming those would spawn a second process.CleanupOrphanedSessionsJob(every 5 min) catches sessions markedrunningwhose process is gone.ZombieReaperJobreaps dead child processes.SessionRecoveryServicehandles hung and interrupted sessions on a best-effort basis;SigtermRetryServicecovers SIGTERM’d sessions (deploys, OOM) with a bounded retry ladder (MAX_RETRIES = 3).