Skip to content

Concepts

How june actually works, so you can predict what it’ll do. If something here surprises you, jun config show and jun logs -f are the fastest way to see what’s actually happening on your install.

Session per issue

Each Linear issue maps to exactly one agent session. The first webhook that targets the issue (issue create, state move into an active state, or a comment) starts the session; every subsequent comment resumes the same session. The agent’s memory of prior turns lives in the runner’s own session store — june persists only the session id alongside the issue row in SQLite, plus the working directory the session was created against (so the same issue resumes in the right tree even if you change JUNE_AGENT_ROOT later).

Each runner has its own session column (claude_session_id, codex_session_id, opencode_session_id, etc.). Switching the routing label between runs starts a fresh session in the new runner’s column — the old session id stays so it can be reused if you flip back.

Antigravity (gemini label) and Hermes are stateless: they don’t expose a resumable session id from their headless invocation, so every Linear comment to those runners spawns a fresh process. You can still steer them mid-run; you just can’t expect them to remember prior turns.

State machine

june treats the Linear workflow state as the source of truth for “should I engage with this issue right now?”. The mapping comes from JUNE_WORKING_STATES, JUNE_REVIEW_STATES, JUNE_BLOCKED_STATES, JUNE_WAITING_STATES, and JUNE_WAITING_FROM_STATES (defaults below; see configuration.md for the full list).

Linear state type / nameRouting webhook (issue create or state change)Comment webhook
BacklogIgnored entirely — no DB upsert, no run.Ignored entirely — even a fresh comment.
unstarted / started (Todo, In Progress, …)Starts or resumes the session; moves to Working / In Progress.Starts or resumes the session.
Ready for Review, DoneIgnored (already terminal).Resumes the session.
Blocked, CanceledIgnored (parked).Resumes the session.
WaitingIgnored.Resumes the session.

After a run exits, june moves the issue to:

  • Ready for Review (or whatever JUNE_REVIEW_STATES[0] resolves to) on success;
  • Blocked (JUNE_BLOCKED_STATES[0]) on failure (non-zero exit, is_error, empty stdout, or a BLOCKED: prefix from the runner).

Backlog is the only state that is fully inert. This is deliberate — drafts, ideas, and unfiled items don’t accidentally get a Claude session every time someone comments on them. To engage, move the issue out of Backlog (to Todo) first.

If you change Linear’s state names, set the corresponding env var: JUNE_WORKING_STATES="Working,In Progress,Doing", etc. june matches case-insensitively.

Steer

A new comment arriving while a run is already active steers that run:

  1. The comment is queued in the queued_messages table.
  2. SIGTERM is sent to the running process group; SIGKILL follows after 5 s.
  3. As soon as the run exits, a fresh run is started that resumes the same session with the queued comment as the next user input.

The steered run posts no final comment and does not transition the issue’s state — the issue stays in In Progress until the steer-flush run completes and posts the next deliverable.

Steers can chain. If you keep commenting, runs keep restarting. To prevent infinite ping-pong, consecutive auto-flushes are capped at JUNE_COMMENT_MAX_AUTO_FLUSHES (default 3). When that cap is hit, queued comments stay in the DB and the next routing webhook drains them.

Debounce

A comment arriving with no active run is debounced. June queues the comment and sets a timer for JUNE_COMMENT_DEBOUNCE_MS (default 30 s). Comments that land inside that window are accumulated. When the timer fires (or a separate webhook trips a flush), june starts a single run that sees all of them.

Set JUNE_COMMENT_DEBOUNCE_MS=0 to disable: every comment becomes its own run immediately.

The debounce window is per-issue. Two unrelated issues can have independent debounce timers running concurrently.

Progress comments vs. final stdout

There are two kinds of Linear comments that come out of a run:

  • Final stdout reply — the agent’s stdout at process exit. The orchestrator post-processes it (executes any linear-action blocks, uploads inline image attachments, applies the verification gate) and posts it as a top-level comment, then moves the issue to Ready for Review or Blocked. This is the deliverable. Always exactly one per run.
  • Progress comments — short, separate Linear comments the agent posts via curl to commentCreate while the run is still in flight. Like a TUI’s streaming status: “Edited X.” / “Tests pass, moving on.” / “Hit blocker Y.” Zero per run is fine for short tasks; ~3–10 is the high end for long ones.

When JUNE_PROGRESS_COMMENTS_ENABLED=1 (default), june includes a Progress comments block in the first-run prompt: tone rules (blunt, factual, no narrative voice, no AI tells), cadence guidelines (post when something material happens, not per tool call), the curl recipe with $LINEAR_API_KEY already in the agent’s env, and a “don’t pre-post the answer as a progress comment and then write a meta-announcement” warning so the orchestrator’s final summary doesn’t read as a duplicate.

By default progress comments are top-level on the issue. Set JUNE_THREAD_REPLIES=1 to instead thread them under the triggering comment’s root.

The orchestrator always posts a final summary at run exit — even if the agent posted progress comments during the run, the agent’s stdout reply is the wrap-up. If stdout is empty, the run is treated as failed and the issue is moved to Blocked.

linear-action directives

The agent can ask the orchestrator to mutate Linear on its behalf by emitting a fenced JSON block in its stdout:

```linear-action
{"action":"issueCreate","title":"Wire metrics dashboard","description":"...","teamKey":"ENG"}
```

When the run exits, june:

  1. Extracts every linear-action block from the stdout.
  2. Executes them in order.
  3. Strips the blocks from the comment body before posting it.
  4. Appends a single execution-summary footer like created ENG-123: Wire metrics dashboard (https://linear.app/...).

Currently supported: issueCreate (title required; optional description, teamKey defaulting to the current issue’s team, teamId, labelIds, priority, stateId). Unknown actions and malformed JSON produce a footer line failed: <reason> but do not block posting the comment.

This is the orchestrator-mediated path. The agent also has $LINEAR_API_KEY available in its environment, so it can call the Linear GraphQL API directly via curl/fetch if it needs anything linear-action doesn’t cover.

Verification gate

After the runner returns, june checks whether the result is usable:

  • Non-zero exit (unless the runner already produced a final result and was killed by a post-result grace timer) → blocked.
  • result.is_error === true → blocked.
  • Stdout begins with BLOCKED: → blocked, with the first line as the reason.
  • Stdout is empty → blocked, reason final answer is empty.
  • Otherwise → success.

A blocked verdict produces a Blocked.\n\n<reason> comment and a state move to Blocked. The next-run resume path drops the prior session id when the previous run was blocked, so the fresh run starts from scratch rather than inheriting a corrupted session.

Timeout triage

JUNE_RUN_TIMEOUT_MS (default 0 = no timeout) is the single knob that bounds every runner’s per-run hard timeout. Per-runner overrides exist (JUNE_CLAUDE_TIMEOUT_MS, JUNE_CODEX_TIMEOUT_MS, etc.) if you need to tune one specifically. The hard kill is off by default because long tasks were being SIGTERMed mid-work while the agent was still making progress, and the progress comment system already streams visibility into Linear.

Set JUNE_RUN_TIMEOUT_MS to a positive millisecond value (e.g. 7200000 for 2 h) to re-enable a backstop. When the orchestrator SIGTERMs a Claude run at that deadline, a triage Claude is spawned with Read,Glob,Grep,Bash only and a 5-minute budget (JUNE_CLAUDE_TRIAGE_TIMEOUT_MS). Triage inspects the working tree against the issue and the dying run’s stdout tail, then emits one of:

  • VERDICT: RESUMABLE — the issue moves to Ready for Review with a triage summary that names concrete files; a follow-up comment will resume the session.
  • VERDICT: BLOCKED — the issue moves to Blocked with the triage summary.

Set JUNE_CLAUDE_TRIAGE_ENABLED=0 to skip triage and fall back to the simple “exit 143 → Blocked” path. Only Claude runs are triaged — other runners are simply blocked on timeout.

Health watchdog

Set JUNE_CLAUDE_HEALTH_CHECK_INTERVAL_MS (default 15 min) to a positive value to enable per-run watchdog ticks. Each tick:

  1. If the session JSONL log under ~/.claude/projects/... hasn’t been written to in JUNE_CLAUDE_WATCHDOG_HARD_IDLE_MINUTES (default 25), the run is killed and a fresh start is queued — no watchdog Claude needed, the silence speaks for itself.
  2. If the session log shows activity but the main run has already posted a substantive Linear comment ≥ 5 min ago, the watchdog finalizes the run — kills the process, marks it succeeded, and moves the issue to Ready for Review. This catches the “Claude printed its answer but the CLI didn’t exit” case.
  3. Otherwise, a read-only watchdog Claude is spawned with Read,Glob,Grep,Bash and a 2-minute budget. It reads the session log tail, compares to the issue intent, and emits VERDICT: ALIVE (do nothing), NUDGE (queue a steering comment for the main run), or TAKEOVER (kill, reset session id, restart with a fresh prompt).

Every verdict is posted as a Health check at N min … comment so you can see what the watchdog decided. Disable with JUNE_CLAUDE_HEALTH_CHECK_INTERVAL_MS=0.

Waiting sweep

A periodic sweeper moves stale Ready for Review issues to Waiting when the human has gone quiet. Each tick (JUNE_WAITING_SWEEP_INTERVAL_MS, default 5 min):

  1. List issues currently in JUNE_WAITING_FROM_STATES (default Ready for Review) for JUNE_TEAM_KEY.
  2. For each, fetch the most recent comment.
  3. If the latest comment is from the bot and older than JUNE_WAITING_AFTER_MS (default 1 h), move the issue to JUNE_WAITING_STATES[0] (default Waiting).
  4. Skip issues with no comments, with a recent comment, with the most recent comment from a human, or with an active run.

This keeps your Ready for Review column from accumulating issues that the bot is done with but the human hasn’t looked at yet. Disable with JUNE_WAITING_SWEEP_ENABLED=0. If your team doesn’t have a Waiting state, the sweep no-ops with a warning.

Inline image attachments

Markdown image references in the agent’s reply that point to a local absolute path under the agent root are uploaded to Linear via the fileUpload GraphQL mutation before the comment is created; the markdown is rewritten to point at the returned assetUrl. Supported extensions: .png, .jpg/.jpeg, .gif, .webp, .svg. Remote URLs and relative paths are left untouched. Files outside the agent root are skipped for safety.

![my-screenshot](/absolute/path/inside/agent/root/screenshot.png)

In the other direction, Linear image attachments on the incoming comment are downloaded to <appDir>/data/inbound-images/ before the prompt is built, so the agent’s local-only tools (Read, Bash etc.) can see them at a real path. This means images you paste into a Linear comment show up to the agent without requiring it to authenticate against Linear’s CDN.

Dedupe and ordering

Linear can deliver the same webhook twice (retries on a slow response, etc.). june dedupes by event key:

  • comment:<commentId> — same comment never processed twice.
  • issue:<issueId>:<action>:<timestamp> — same routing event never processed twice.

Dedupe lives in the linear_events table; the first delivery to commit the row wins, subsequent deliveries early-out with duplicate: true.

A concurrency note: two webhooks can race between “is there an active run?” and “insert the new run row”. june guards against that with an in-memory pendingIssueStarts set, so only one of the racing webhooks actually spawns; the other either queues a comment (for steering) or is dropped as already_running.

Orphan reconciliation

If the orchestrator process dies while a run was marked running in SQLite, that row is now an orphan. On next boot:

  • If the child process is still alive (detached + survived the parent), june watches its pid; once it exits, the row is marked orphaned and the issue moves to Blocked with a Run N was orphaned… comment.
  • If the child is gone, the row is immediately marked orphaned and reconciled the same way.

You can also force reconciliation by hand: curl -X POST -H "Authorization: Bearer $JUNE_ADMIN_TOKEN" http://127.0.0.1:8787/admin/reconcile. jun status reports whether the local process is alive; jun doctor doesn’t reconcile orphans but does verify the server can answer a signed fake webhook in-process.

Bot loop protection

The orchestrator’s own bot comments are filtered out of the prompt context, and incoming comment webhooks whose author email matches the bot’s viewer email are ignored. This prevents june from replying to its own deliverable and looping forever. The viewer email is resolved at boot via the viewer query and added to JUNE_BOT_USER_EMAILS; you can add additional emails (other Linear bots in your workspace) to that env var to suppress them too.