Skip to content

[bug] Switching agents reuses the tab component — stale data on screen, and a save can write one agent's values onto another #240

Closed

Description

@serge-ivo

What happens

InstanceDetail renders the active surface without a React key (pages/InstanceDetail.tsx:882):

const active = SURFACES.find((s) => s.id === tab);
const body = active.render({ instanceId: id, … });

Only ONE surface passes a key — CodingTab (lib/surfaces.tsx:151, key={instanceId}). Every
other tab therefore receives a changed instanceId prop on the same mounted component when
you switch agents in the header nav. React keeps the component, so all of its useState survives:
loaded lists, and — the part that matters — populated form fields.

Each tab's useEffect([instanceId]) refetches, but nothing guards the window in between, and
nothing cancels the in-flight request for the previous instance. No tab except BehaviourTab
has a stale-response guard:

Tab api calls stale guard
SettingsTab 25 none
KnowledgeTab 10 none
DataTab 3 none
BoardTab / TmuxTab 2 none
ActivityTab / IndexingTab / RepoTab / LoopRunsSection / TeamworkSection 1 none
BehaviourTab 1 live flag

Two consequences

1. The wrong agent's data on screen. Switch from A to B and B's tab shows A's documents,
settings, board cards or records until each fetch returns. A slow response for A that resolves
after B's can also overwrite B's data with A's — no request ordering is enforced anywhere.

2. A save can write agent A's values onto agent B. This is the serious one. A form is
populated from A's response and held in component state. The prop flips to B. The submit handler
builds its URL from the current instanceId. Pressing Save sends A's field values to B's
endpoint. Nothing in the flow notices, because from the component's point of view a valid form
was submitted to a valid instance.

Highest exposure is SettingsTab (instance name, agent settings, runner pin, permissions) and
KnowledgeTab (rules, memory).

Same user, so not a tenant boundary — but it silently corrupts configuration, and the user's own
mental model is that they were editing the agent that was on screen.

Suggested shape

The codebase already does the right thing in one place, which is good evidence the pattern is
understood: key={instanceId} on CodingTab remounts it cleanly per instance.

  • Apply the key at the render site in InstanceDetail, so every surface gets it by construction
    rather than each one remembering. That fixes both consequences at once — a remount discards the
    stale form and the stale list.
  • Independently, add stale-response guards (a live flag or AbortController) where a fetch can
    outlive its instance, so an in-flight response can't land on a remounted successor.

Verification

  • Switch instances with the network throttled: the new tab never renders the previous agent's
    values, even briefly.
  • Populate a Settings form for A, switch to B before saving, save: the request must not carry A's
    values (after the fix, the form is empty/reloaded, so it cannot).
  • A response for A that resolves after the switch to B leaves B's state untouched.

Activity

  1. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Fixed in 1a1c905.

    Verified state: partly true. The mechanism the ticket describes is real and every claim about the code checked out — the render site in InstanceDetail passes no key, CodingTab (lib/surfaces.tsx:151) is the only surface that carries one, and BehaviourTab is the only tab with a stale-response guard (let live appears in exactly one of the ten). What is NOT currently true is the trigger: there is no instance→instance navigation in the console today. Every path into /instances/:id/* (instances list, a notification, Terminals, Browse-after-subscribe) comes from a DIFFERENT route, which remounts the page. So the leak is latent, not reachable — it becomes reachable the moment anything navigates directly between two instance URLs (a header agent switcher, a 'next agent' link).

    Fixed anyway, and at the routed component rather than the render site: export default InstanceDetail now renders <InstancePage key={id} />. Chosen over per-tab effect cleanup, and over keying the surface body, because (a) a remount discards the stale form AND the stale list in one stroke, where a guard only prevents the late write, (b) the leak was never only in the tabs — the loop banner, the draft chat input, the voice session and the message list are page-level state that the [id] effect did not reset, and (c) a key at the render site still has to be remembered by whoever adds the next surface; at the route it is inherited. The inner component is deliberately not exported so it cannot be mounted unkeyed. The page's own /my/instances fetch also got a live guard.

    Per-tab AbortControllers were not added: with the remount, a late response for the previous instance lands on an unmounted tree, which is a no-op.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions