Skip to content

fix: usage tracker and agent names follow the agent, not its sidebar slot#53

Open
AliHamzaAzam wants to merge 7 commits into
mainfrom
fix/agent-identity-desync
Open

fix: usage tracker and agent names follow the agent, not its sidebar slot#53
AliHamzaAzam wants to merge 7 commits into
mainfrom
fix/agent-identity-desync

Conversation

@AliHamzaAzam

Copy link
Copy Markdown
Owner

Problem

Switching between agents showed agent A's usage limits while agent B was selected, and user-assigned agent names appeared to jump between agents whenever sidebar positions shifted.

Two independent root causes:

  1. The TUI's in-lane agent cursor was positional. session_idx indexes lane.agent_sessions, which the daemon re-sorts newest-activity-first on every poll; the index was only re-derived by identity on lane change, so sitting on a lane let the cursor silently drift onto whichever agent occupied that slot. The usage corner (focused_account_key) and key/capture routing (selected_window) then targeted the wrong agent.
  2. The daemon re-paired transcripts to tmux windows positionally every tick (newest-activity transcripts zipped against slot-ordered windows). When two agents swapped activity rank, each transcript's tmux_window re-bound to the other window, moving names, panes, and usage accounts between rows. Labels themselves were always correctly keyed by transcript id; the pairing moved the rows out from under them.

Fix

Daemon: sticky transcript-window pairing. Pairing is anchored by a @repomon_session tmux window option: stamped once at first contact, honored on every subsequent tick, read back at zero cost through the existing list-windows probe. Bindings live in tmux, so they survive daemon restarts and die with their window (slot-name recycling handled for free). First-contact heuristics run once, ordered by window id (never reused) rather than recyclable slot names.

Lifecycle hardening (from an adversarial multi-agent review of the initial design):

  • Stamps are written only for transcripts that are actively writing — a stale transcript standing in during the spawn race (the .jsonl appears a beat after the window) is displayed but never bound, so a guess can't wedge.
  • Supersession: /clear and fork-on-resume rotate the session id inside a window; when fresh unclaimed transcripts outnumber free windows, the warmest stale binding (the one that just went quiet, i.e. the rotated predecessor) is released and re-stamped to the live transcript. Idle fleets are never shuffled.
  • Truncation protects both bound transcripts (a live window proves its agent) and actively-writing ones (the fresh backstop).
  • Stamps target window ids, are skipped on ticks that ran on the last-good window snapshot, and agent.adopt stamps its resumed session id itself — ground truth instead of a guess.

TUI: identity-anchored session cursor. The cursor is pinned to a durable SessionRef (transcript id first — UUIDs are never reused; window name only for placeholders) and re-resolved every sync tick. Deliberate moves (Tab, notification jumps, spawn focus, expanded rows) funnel through the pin; an unanchored lane pins on arrival; a missing agent is tolerated for a few refreshes (overlay flap) before adopting the occupant. Expanded-mode rows compare identity, not raw index, before wiping scrollback, and the spawn-focus give-up clock counts data refreshes rather than event-loop iterations.

Verification

  • 30 new unit tests across daemon pairing/selection and TUI cursor behavior; full workspace suite and clippy green.
  • Live e2e in an isolated instance (own socket/DB/config/tmux server): pairing held through activity flips, a daemon restart (bindings read back from tmux), slot-name recycling, and a /clear rotation (the rotated window re-stamped to the continuation; the idle agent untouched).

No wire changes: AgentSession fields and lane.list ordering are untouched, so the iOS companion and older TUIs are unaffected.

🤖 Generated with Claude Code

AliHamzaAzam and others added 5 commits July 10, 2026 13:02
The overlay re-paired transcripts to tmux windows every tick by
activity rank, so two agents swapping turns swapped their windows:
names, panes, and usage accounts jumped between sidebar rows. Pair by
a sticky binding stamped on the window (@repomon_session) instead; the
old oldest-with-oldest heuristic now runs only on first contact,
ordered by window id so a recycled slot name cannot steal a survivor's
window. Bindings live in tmux, so they survive daemon restarts and die
with their window.

Bound-but-quiet transcripts are also protected from live-count
truncation: their window proves the agent alive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The in-lane agent cursor was a raw index into agent_sessions, which
the daemon re-sorts newest-active-first on every poll; the index was
only re-derived on lane change, so sitting on a lane let the cursor
drift onto whichever agent occupied that slot. The usage corner then
showed the wrong account's limits and keys routed to the wrong pane.

Pin the cursor to a durable SessionRef and re-resolve the index every
sync tick. Deliberate moves (Tab, notification jumps, spawn focus,
expanded rows) all funnel through the pin; an unanchored lane pins on
arrival; a missing agent is tolerated for a few refreshes (overlay
flap) before the pick adopts the occupant.
Slot names like lane-7-2 are recycled when an agent exits and another
spawns, so a remembered Window ref (session memory, cursor anchor)
could resolve to a brand-new different agent. Transcript UUIDs are
never reused; the window remains the fallback for just-spawned
placeholders that have no .jsonl yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A multi-agent review of the sticky-pairing change confirmed lifecycle
holes, all fixed here with tests:

- Only stamp @repomon_session for transcripts that are actively
  writing: a quiet transcript standing in during the spawn race (the
  .jsonl appears a beat after the window) is displayed but not bound,
  so the guess can no longer wedge for hours.
- Supersession: /clear and fork-on-resume rotate the session id inside
  a window; when fresh unclaimed transcripts outnumber free windows,
  the coldest stale bindings are released and re-stamped to the live
  transcripts. Idle fleets are never shuffled.
- select_kept_summaries keeps the fresh backstop: an actively-writing
  transcript can no longer be truncated in favor of a stale bound one.
- Stamp by window id, not name (a slot recycled mid-tick cannot
  inherit the old binding), skip stamping on ticks that ran on the
  last-good window snapshot, and treat set-option's "no such window"
  as the benign absence it is.
- agent.adopt stamps the resumed session id on its window itself, at
  the one moment the mapping is ground truth.
- TUI: expanded-mode rows compare agent identity (not raw index)
  before wiping scrollback, and the spawn-focus give-up clock counts
  data refreshes rather than event-loop iterations.
Coldest-first picked the wrong window when several bound agents were
quiet: the transcript that stopped writing most recently is the one
that just rotated (/clear) into the newcomer, while a long-cold one is
simply an idle agent whose window must not be given away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 10, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Live incident: first contact after a daemon restart had to pair three
windows against several simultaneously-plausible transcripts, guessed
two of them wrong by activity rank, and the sticky stamps froze the
mistake - the user's agent names pointed at the wrong panes and a live
session ranked too old to even be listed.

Activity rank cannot see which conversation is physically inside which
pane, so it no longer decides anything durable. Pass 2 now only
nominates candidates; the stamp task captures the lane's unclaimed
panes and stamps each transcript onto the single pane that visibly
contains its last message (whitespace/markdown-insensitive
fingerprint). No match, or an ambiguous one, just defers to a later
tick - the pairing stamps itself the moment the agent's turn is on
screen. The rank-based zip remains as the display-only hint.

Verified end-to-end with a deliberately crossed activity order: the
old code stamps the swap; the gate stamps the truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AliHamzaAzam

Copy link
Copy Markdown
Owner Author

Follow-up commit 4269d7e after a live recurrence: first contact after a daemon restart guessed two of three windows wrong by activity rank (several transcripts fresh at once), and the sticky stamps froze the mistake. Stamps are now gated on pane evidence — a transcript only binds to the window whose pane visibly contains its last message (normalized fingerprint, unique match required). The activity-rank zip remains display-only. Verified e2e with a deliberately crossed activity order: the old code stamps the swap, the gate stamps the truth. The wrong live bindings on the affected lane were repaired in place.

🤖 Generated with Claude Code

The TODAY section was capped at 12 commits and the page's scroll
position was derived purely from the selected lane, so commits past
the fold were unreachable. The wheel and PgUp/PgDn now scroll the
whole Fleet page freely (cap removed); any deliberate selection move
(arrows, click, jump) snaps the view back to following the selection.
@AliHamzaAzam

Copy link
Copy Markdown
Owner Author

Also rode along on this branch: 86d6eab adds free page scrolling to the Fleet view (wheel / PgUp/PgDn) and removes the 12-commit TODAY cap — commits past the fold were unreachable. Arrow keys, clicks, and jumps snap the view back to following the selection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants