Skip to content

Unify session-state precedence, preserve agent exit vs tmux termination, and standardize status colors #2950

Description

@whoisasx

Summary

AO currently has the right raw ingredients for session state, but it combines and presents them with the wrong precedence:

session.activity      Raw agent runtime activity
session.isTerminated  AO/tmux session lifecycle fact
session.prs           Durable SCM/PR facts
session.status        Display status derived from the facts above

This causes a live agent to remain visually stuck in In review, Needs you, or Ready to merge after it resumes work. It also collapses an agent process exiting into the AO/tmux session being terminated, even though the tmux session can remain alive and inspectable.

The same underlying ambiguity reaches the UI:

  • Kanban placement is driven by derived session.status.
  • The topbar activity pill is correctly driven by raw session.activity.
  • The sidebar dot is currently driven by Kanban/attention status instead of raw agent activity.
  • Working and Needs you use adjacent orange/yellow colors that are difficult to distinguish.
  • Merged, Terminated, Exited, and Unknown do not yet have a sufficiently clear and consistent semantic treatment.

This issue defines one canonical state-priority model, preserves the distinction between agent exit and tmux termination, aligns all affected frontend surfaces, and establishes a semantic color system for both dark and light themes.

User-facing problem

Active work can remain in the wrong Kanban column

Today, once a session owns an open PR, PR/SCM status is evaluated before activity.active. A session that resumes work can therefore remain:

  • In review because the PR is open, draft, or awaiting review
  • Needs you because CI failed or changes were requested
  • Ready to merge because the PR is approved/mergeable

The board no longer answers the most immediate question: "Which agents are working right now?"

Expected behavior:

For every non-terminal session, active agent work wins. SCM determines board placement only when the agent is not actively working.

Agent exit and tmux termination are conflated

These are different facts:

  • activity = exited means the agent process/runtime inside the tmux session exited.
  • isTerminated = true means AO considers the managed session terminal.

A terminated tmux/AO session necessarily has no running agent, so termination may normalize activity to exited.

The reverse is not true. An agent can exit while tmux remains alive and leaves an interactive shell behind. That state must remain inspectable and visible as Exited in Needs you.

Status visuals do not communicate a stable semantic language

The current dark-theme tokens use:

Working    #F59F4C  orange
Needs you  #E8C14A  yellow/amber

These hues are too similar in compact dots, badges, and column accents. Merged and Terminated also collapse into passive Done styling, while Exited and Unknown are both muted gray in the activity presentation.

Current implementation and root cause

Backend display priority

backend/internal/service/session/status.go::deriveStatus() currently evaluates:

1. isTerminated -> merged or terminated
2. waiting_input / blocked -> needs_input
3. open PR aggregation
4. any merged PR
5. activity.active -> working
6. missing hook signal -> no_signal
7. idle

Because active comes after open PR and merged-PR checks, it cannot produce working while an open PR exists.

Lifecycle collapse

backend/internal/lifecycle/manager.go::ApplyActivitySignal() currently does this for ActivityExited:

if s.State == domain.ActivityExited {
    next.IsTerminated = true
}

This destroys the valid combination:

activity = exited
isTerminated = false
tmux still alive

The storage model already persists activity_state and is_terminated independently, so no schema change is needed to represent this correctly.

Existing safety behavior

backend/internal/sessionguard/guard.go already refuses both user/API delivery and AO-generated nudges when activity = exited, even if isTerminated = false.

That check must remain. After the agent exits, the pane may contain an interactive shell; automatically pasting an agent prompt into it could execute the prompt as shell commands.

Important lifecycle caveat

In the current code, MarkTerminated updates durable state without necessarily tearing down the external runtime/worktree. Merge and tracker completion can call this flag-only path.

Physical runtime/worktree cleanup is already tracked separately in:

This issue must not duplicate that cleanup work. It should establish the state contract that an agent-exit hook alone never marks the AO session terminated. Any claim that isTerminated literally proves tmux destruction depends on the teardown issues above being resolved.

Canonical concepts

Agent activity

Raw, persisted activity reported by the agent hook pipeline:

active
idle
waiting_input
blocked
exited

Frontend-only unknown remains a defensive fallback for missing, invalid, or version-skewed data. It is not a backend business state.

Session status

A display status derived by the backend at read time from:

  • activity_state
  • is_terminated
  • PR lifecycle, CI, review, comments, and mergeability facts
  • hook-signal capability and first-signal timing

Derived status must not be stored.

Attention zone

The frontend grouping derived from backend session.status:

Working
Needs you
In review
Ready to merge
Done

Idle remains a nested stack at the bottom of Working, not a separate top-level column.

Required priority and board placement

Priority AO/tmux session Agent activity SCM condition Derived status Kanban placement
1 Terminated Exited Any attributed PR merged merged Done
2 Terminated Exited No attributed PR merged terminated Done
3 Alive Active Any non-terminal SCM condition working Working
4 Alive Exited Any SCM condition exited Needs you
5 Alive Waiting input Any SCM condition needs_input Needs you
6 Alive Blocked Any SCM condition needs_input Needs you
7 Alive Idle CI failing or changes requested Existing SCM status Needs you
8 Alive Idle PR open, draft, or review pending Existing SCM status In review
9 Alive Idle Approved or mergeable Existing SCM status Ready to merge
10 Alive Idle No open PR idle Idle stack inside Working

Additional defensive behavior:

  • no_signal remains in Needs you.
  • Frontend unknown remains visible in Needs you and should produce telemetry/logging.
  • A merged PR observed before terminal bookkeeping completes may temporarily derive merged as a defensive fallback, but activity.active still wins while the live agent is working.

Proposed backend derivation

if rec.IsTerminated {
    if anyMerged(prs) {
        return domain.StatusMerged
    }
    return domain.StatusTerminated
}

switch rec.Activity.State {
case domain.ActivityActive:
    return domain.StatusWorking
case domain.ActivityExited:
    return domain.StatusExited
case domain.ActivityWaitingInput, domain.ActivityBlocked:
    return domain.StatusNeedsInput
}

open := openPRs(prs)
if len(open) > 0 {
    return aggregatePRStatus(open)
}

if anyMerged(prs) {
    return domain.StatusMerged
}

if signalCapable && rec.FirstSignalAt.IsZero() &&
    now.Sub(rec.Activity.LastActivityAt) > noSignalGrace {
    return domain.StatusNoSignal
}

return domain.StatusIdle

Transition examples

Agent resumes from In review

Before: activity=idle, PR=review_pending
Status: review_pending
Board: In review

Hook: activity -> active

After: status=working
Board: Working

Agent resumes from Needs you

Before: activity=waiting_input or blocked
Status: needs_input
Board: Needs you

Hook: activity -> active

After: status=working
Board: Working

The same applies when the session was in Needs you because CI failed or changes were requested: active work moves it to Working; once the agent returns to idle, SCM facts determine its column again.

Agent becomes idle after opening a PR

activity=idle
PR=open/draft/review_pending

Status: PR-derived status
Board: In review

Agent becomes idle without a PR

activity=idle
no open PR

Status: idle
Board: nested Idle stack inside Working

Agent exits but tmux remains

activity=exited
isTerminated=false

Status: exited
Board: Needs you
Terminal: remains inspectable
Automated pane delivery: suppressed

AO/tmux session becomes terminal

isTerminated=true

Any PR merged -> merged
Otherwise     -> terminated

Source of truth by frontend surface

Surface Source Meaning
Kanban column Backend-derived session.status Current workflow/attention placement
Session card badge Backend-derived session.status Exact session/SCM display state
Topbar activity pill Raw session.activity What the agent runtime is doing now
Inspector activity row Raw session.activity Agent runtime activity timeline
Browser working indicator Raw session.activity Whether the agent is actively working
Sidebar session dot Raw session.activity Compact agent runtime activity

All mappings should remain centralized in frontend/src/renderer/lib/session-presentation.ts. Components must not add independent status/activity switch statements.

Semantic color system

Color must be paired with text labels and motion/state treatment. It must not be the only carrier of meaning.

Kanban zones and terminal cards

Visual role Dark theme Light theme Treatment
Working Teal #36C2B4 Teal #087F75 Live/operational; active indicator may breathe
Needs you Amber #F2B84B Amber #946200 Human attention; static
In review Blue #5B8DEF Blue #255FC4 External review/waiting
Ready to merge Light green #75C98D Green #237A3D Ready but not completed
Merged Forest surface #173624, accent #4BAE72 Surface #D9F2E1, accent #176B37 Completed successfully; use readable accent on dark surface
Idle Gray #8E96A3 Gray #69717D Quiet nested stack
Terminated Graphite #6F7680 Graphite #626975 Neutral terminal outcome

Terminated should not use red. Termination can be intentional and is not inherently a failure. Red is reserved for actionable failure states such as Exited and CI failed.

Agent activity pill and sidebar dot

Activity Dark theme Light theme Motion
active Teal #36C2B4 Teal #087F75 Breathe/pulse
idle Gray #8E96A3 Gray #69717D Static
waiting_input Amber #F2B84B Amber #946200 Static
blocked Amber #F2B84B Amber #946200 Static
exited Coral red #EE6A6A Red #B83232 Static
unknown Violet #A78BFA Violet #6D46C2 Static

Card-level exceptions inside an attention zone

The column color communicates the high-level attention bucket. The card badge may communicate the exact cause:

  • ci_failed and exited: red/coral
  • changes_requested and needs_input: amber
  • no_signal and unknown: violet
  • pr_open, draft, and review_pending: blue
  • approved and mergeable: light green
  • merged: dark forest-green surface with readable green accent
  • terminated: graphite

Introduce role-specific status tokens rather than continuing to overload generic warning/success colors:

--color-status-working
--color-status-needs-you
--color-status-in-review
--color-status-ready
--color-status-merged
--color-status-merged-surface
--color-status-idle
--color-status-exited
--color-status-terminated
--color-status-unknown

Both themes must meet contrast requirements for text, dots, borders, and subtle card surfaces.

Live propagation

No new transport is required. The existing path is sufficient:

Agent hook reports activity
-> lifecycle manager persists activity_state
-> SQLite trigger writes session_updated to change_log
-> daemon SSE stream publishes the CDC event
-> frontend debounces invalidation by 150 ms
-> workspace query refetches sessions
-> backend derives the new status
-> board, topbar, sidebar, and inspector rerender

Normal connected behavior should move a card shortly after the activity hook arrives. The existing 15-second workspace polling interval remains the fallback if SSE is unavailable.

Implementation plan

Backend lifecycle and domain

  • Stop setting IsTerminated = true solely because an ActivityExited hook arrived.
  • Preserve activity=exited, isTerminated=false while tmux/AO runtime remains available.
  • Continue normalizing activity to exited when the AO session is actually marked terminal.
  • Clear in-memory tool-flight/permission state when the agent exits.
  • Add derived domain.StatusExited.
  • Add exited to the session API status enum.
  • Reorder deriveStatus() so active wins over all non-terminal SCM/input display states.
  • Keep waiting_input and blocked mapped to needs_input whenever the current activity remains paused on the user.
  • Keep no_signal behavior unchanged.
  • Audit automated reaction entry checks so an exited agent is not repeatedly targeted for pane nudges.
  • Preserve the just-in-time session guard refusal for exited agents.
  • Prefer a distinct AGENT_EXITED send error over inaccurately reporting an exited-but-live-tmux session as SESSION_TERMINATED.

API contract

  • Regenerate the OpenAPI spec and frontend schema with npm run api.
  • Commit the generated backend OpenAPI and frontend TypeScript artifacts together.

Frontend presentation

  • Add exited to SessionStatus parsing and defensive conversion.
  • Map status=exited to the Needs you attention zone.
  • Add the Exited card badge.
  • Keep board placement driven by backend session.status.
  • Keep the topbar, inspector, and browser activity indicators driven by raw session.activity.
  • Change the sidebar dot to use the same raw activity presentation as the topbar.
  • Keep idle sessions in the nested Idle stack inside Working.
  • Add the role-specific status color tokens for dark and light themes.
  • Apply the semantic palette consistently to columns, card badges, the merged/terminated Done cards, activity pills, and sidebar dots.
  • Ensure only active/working indicators breathe; attention, exited, terminated, and unknown indicators stay static.
  • Log or emit telemetry when the frontend falls back to unknown.

Safety requirements

Required test matrix

Backend status derivation

  • Active + no PR -> working
  • Active + PR open -> working
  • Active + draft PR -> working
  • Active + review pending -> working
  • Active + CI failing -> working
  • Active + changes requested -> working
  • Active + approved/mergeable -> working
  • Waiting input + no PR -> needs_input
  • Blocked + no PR -> needs_input
  • Waiting/blocked -> active transition with a PR -> working
  • Idle + PR open/review pending/draft -> corresponding PR status
  • Idle + CI failing/changes requested -> corresponding actionable PR status
  • Idle + approved/mergeable -> corresponding merge-ready status
  • Idle + no PR -> idle
  • Exited + non-terminated + any PR state -> exited
  • Terminated + merged PR -> merged
  • Terminated + no merged PR -> terminated
  • Existing multi-PR and stacked-PR worst-wins behavior remains correct when activity is idle

Lifecycle and safety

  • Agent-exit hook stores activity=exited without setting isTerminated
  • Explicit/runtime termination sets isTerminated=true and activity exited
  • Exited agents reject user/API delivery
  • Exited agents reject AO-generated nudges
  • Exited agents do not accumulate stale tool-flight state
  • A later valid active signal can represent an agent relaunched in the still-live tmux session

Frontend

  • exited parses as a real session status, not unknown
  • Exited card renders in Needs you with an Exited badge
  • Active agent with any PR status renders in Working
  • Idle agent with PR renders in the correct SCM column
  • Idle agent without PR renders in the nested Idle stack
  • Merged and Terminated cards have distinct Done treatments
  • Topbar activity pill and sidebar dot use the same raw activity mapping
  • Dark and light theme status tokens render with readable contrast
  • Only active/working indicators animate
  • Unknown remains visible, labeled, non-animated, and actionable

Event propagation

  • Activity-state changes emit session_updated
  • SSE invalidation refetches workspace/session data
  • The board moves after the live event without waiting for the 15-second polling fallback

Acceptance criteria

  • A session in In review or Needs you moves to Working as soon as its current agent activity becomes active.
  • When that agent becomes idle again, SCM facts determine whether it returns to Needs you, In review, or Ready to merge.
  • An idle session without an open PR stays in the nested Idle stack.
  • An agent exit does not terminate a still-live tmux/AO session.
  • An exited agent is visible in Needs you and its terminal remains inspectable.
  • A terminal session is shown as Merged when any attributed PR merged; otherwise it is shown as Terminated.
  • Board status remains backend-derived and no derived display status is persisted.
  • Topbar and sidebar activity indicators consume the same raw activity truth.
  • Working and Needs you are visually distinct at a glance.
  • Ready to merge and Merged use related but clearly different green treatments.
  • Terminated is neutral graphite, not failure red.
  • Exited is actionable coral red.
  • Unknown is visible violet and emits diagnostics.
  • Existing runtime/worktree safety constraints remain intact.

Related issues

Non-goals

Metadata

Metadata

Assignees

Labels

P2Significant degradation with a workaround or bounded impact.area/session-lifecycleSessions, lifecycle reducer, status, restore, kill, and reaper.area/terminal-runtimeTerminal protocol, PTY, tmux, ConPTY, and process liveness.comp/daemonGo daemon, process lifecycle, and backend control plane.comp/desktopElectron main process and React renderer.risk/state-integrityCould corrupt, duplicate, lose, or mis-associate session/runtime/PR/storage state.todoStatus: not startedtype/bugExisting behavior is broken or incorrect.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions