Skip to content

Goals and stop conditions

A goal is the session’s definition of done. It’s the mechanism behind closed-loop autonomy: an agent should verify its work before it comes back to you.

From config/goals.json:

IDWhat it demands
codebase-questionResearch and answer inline. Do not create files, PRs, or branches. Stop in needs_input if a human asked; report back to the parent and archive if a session did.
open-reviewed-green-prOpen the PR through the open-pr skill, block until CI is green, run an independent fresh-eyes review, address all its feedback, re-check CI, write a ## Verification section with checked boxes and proof, then apply the ready to merge label. Then stop, and archive when the PR merges. The default for most roots.
open-reviewed-green-pr-with-version-bumpSame, plus a mandatory version bump when server source changed.
e2e-verified-green-prSame, plus: state the critical path up front, spin up a real dev server, drive it with browser automation, record video and screenshots, embed them in the PR.

All three PR goals name the open-pr skill as the canonical way to commit, push, open, and finalize the PR — agents are told not to hand-roll their own commit/push/PR sequence when that skill is available. The skill’s terminal act is applying the ready to merge label, and the goal text makes that label part of “done.”

The label is deliberately disambiguated in the goal text, because its name collides with the “do not merge your own PR” instruction. Applying ready to merge does not merge the PR and does not claim a human has reviewed it — it is the agent’s own claim that self-review, fresh-eyes review, and green CI are complete. It is fully compatible with leaving the PR unmerged; “do not merge” is not a reason to skip the label.

The three PR goals then end by telling the agent to stop in needs_input, and to archive when the PR merges. The session that opened a PR is the session holding the work’s context, so it is the one a human comes back to while the PR’s disposition is unsettled.

Nothing has to watch for the merge. GitHubPullRequestPollerJob sweeps unarchived sessions with recorded PR URLs, and on the open → merged transition it delivers AutomatedPrompts.pr_merged_message to the session. That message is the archive signal, and it makes the queue self-draining:

Both outcomes are correct by construction, and neither needs a human to tidy up. A held PR keeps its session in the queue, which is the point — and when that human merges it, the same signal releases the session. A merged PR drains its own session out. A PR closed without merging ends the work, and the session archives on that too.

The stop is conditional, and the condition is the merge message rather than a person’s attention. A goal that makes a human the only thing able to release a session is what leaves sessions in needs_input for weeks after their PR has landed.

Three cases stop the message arriving at all — an unrecorded PR URL, a merge the poller never saw open, and a swallowed delivery — and a session that hits one waits forever. The goal text tells the agent to check get_session for a recorded URL before settling in. See Limitations.

codebase-question stops for a different reason, and only when a human invoked the session directly. A research session a parent spawned reports its answer back to the parent and archives.

That is the entire mechanism. AgentSessionJob#build_prompt_with_goal resolves the goal id to its description (or passes an unknown string through verbatim as free text) and appends it to the prompt.

A blank base prompt short-circuits the whole thing — a guard against spawning an agent whose entire prompt is a bare goal string.

Precedence, in order:

  1. Explicit goal param at session creation (UI form or POST /api/v1/sessions).
  2. The agent root’s default_goal from roots.json.
  3. Nothing — the column is nullable, and the goal suffix is simply not appended.

It can be changed after the fact: PATCH /api/v1/sessions/:id accepts goal, and a follow-up prompt can carry a new one — through POST /api/v1/sessions/:id/follow_up, the MCP action_session follow_up action, or the enqueued-message editor, the one web surface with a goal field on a message. A follow-up goal is applied whether the prompt is sent straight through, queued behind a running turn, or interrupted in; a blank one preserves the goal the session already has rather than clearing it (see the REST API reference).

The column is validated on length only (GOAL_MAX_LENGTH). Any string is a legal goal. Tracked in #88.

A session can have heartbeat_enabled with an interval (30–86,400 seconds). HeartbeatSweepJob runs every 30 seconds and, for each due needs_input session with a heartbeat, injects an automated nudge prompt and resumes it — the “keep working toward your goal” loop.

It deliberately skips sessions that are blocked on an elicitation or that have pending enqueued messages, because resuming those would spawn a second process against the same clone.

The nudge goes out through Session#deliver_follow_up!. Five entry points share it: the web follow-up form, triggers, the GitHub comment and merge-conflict pollers, and this sweep. The method clears the stale per-turn metadata, transitions the session to running, stamps the prompt where the recovery paths look for it, enqueues AgentSessionJob, and records running_job_id so the session is never “running with no job.”

Two direct-delivery paths deliberately stay outside it: the REST API’s follow_up (which never stamped a pending prompt, and would change behaviour if it started) and EnqueuedMessageProcessorService (which delivers a message it has already claimed from a queue, under different locking).

The heartbeat is the one caller that passes stamp_pending_prompt: false. A user’s message is worth replaying after a SIGTERM retry; a drumbeat is not — replaying one would deliver a beat for a moment that has already passed.

This trips people up. Mechanically, a session reaching needs_input just means the agent finished a turn, and a session reaching archived means someone (or something) explicitly archived it:

  • You archived it in the UI.
  • The agent called action_session with archive through Zimmer’s self-session MCP server.
  • A health monitor archived it.

Intentionally, they mean more than that. archived is what a completed session looks like: the agent is told, in OrchestratorSystemPromptBuilder and in the goal text, to archive itself as its last act. needs_input is a deliberate signal that a human is required, and there are exactly four sanctioned reasons to send it:

  1. The agent lacked the authorization scope or tools to finish, with no parent session to report back to.
  2. The session opened a PR whose merge disposition is unsettled. It archives when the PR merges; a PR the merge gate holds keeps its session here for the human who must review and merge it.
  3. A human invoked the session to explore something or answer a question — it is the user’s to close.
  4. Rare: an ambiguity both too dangerous and too irreversible to guess at.

On top of those, one rule bounds the whole queue: exactly one session per human-initiated goal stays unarchived. Usually that is the router, while it is still orchestrating the sessions below it; if the router archived itself and handed the work to a child, it is that child. One request should leave one session in the queue, not a trail.

Anything else — including “the user will want to read this” — goes in the final message, or in Slack #updates if it is a read-only FYI and the session has a Slack server, and the session archives. Anything the agent noticed but could not fix goes in a GitHub issue, which is the other half of why a session can archive at all: an issue is a work item, and a parked session is not. The prompt also names four things that look like reasons to park and are not: waiting on a machine (CI, an outage, a rate limit, a peer session: none of those is a human, and an unmerged PR is the one carve-out), a blocker another session is already fixing, a reason that went stale while the agent worked (the PR merged; the question is moot), and finishing with nothing to show (a sweep that found nothing and a gate that aborted both ran to completion).

The second of those is the one agents miss, because it looks like a handoff rather than a wait: red CI on main from a failure unrelated to the agent’s own diff, an upstream fix in flight. The prompt tells the agent to look for the session already working the blocker, set all three wake_me_up_when_session_changes_state events on it — session_archived, session_needs_input and session_failed, since a clean finish self-archives without passing through needs_input — plus a wake_me_up_later deadline as a backstop, and then resume its own work once the blocker clears. Escalating is right only when nobody is on the blocker, or after about three hours.

Whether agents comply is, again, a matter of the model obeying English.