Skip to content

Tell the channel that was running that it has stopped, not the one just opened - #315

Merged
davidmckayv merged 3 commits into
CopilotKit:mainfrom
zopeVaibhav:fix/stale-channel-busy-dots
Sep 1, 2026
Merged

Tell the channel that was running that it has stopped, not the one just opened#315
davidmckayv merged 3 commits into
CopilotKit:mainfrom
zopeVaibhav:fix/stale-channel-busy-dots

Conversation

@zopeVaibhav

Copy link
Copy Markdown
Contributor

What this changes

A channel stops showing a working indicator once its turn has ended, including when the person has
moved to another channel in the meantime.

busy was reported from an effect keyed on [busy, channel.id], where busy came from component
state. ChannelChat remounts per channel, so opening another one left the running turn's
busy: true with nothing to revoke it: the new instance posts busy: false for the channel just
opened, and the old instance's decrement lands on an unmounted component, where setState is a
no-op and the effect never re-runs. Nothing else sends it. onRunBusy fires only inside threadLock
(copilot.ts:1177, copilot.ts:1218) and its one consumer is the handoff delivery at
index.ts:883, which a person's run does not pass through.

The signal now comes from the turn rather than from the screen. say holds the count in a ref, posts
busy: true on the first turn in and busy: false on the last turn out, and does it through a plain
function rather than a mutation handle, because the async continuation survives unmount even though
the state update does not. Opening a channel also stops posting a redundant busy: false twice
before anything has run in it.

Fixes #314.

Where it runs

  • New state that outlives a request? None. The counter is a ref inside one component,
    replacing state that was already there. busy remains what it was: never queried, never
    stored, announced only.
  • What happens on the second replica? Unchanged. The POST may land on any replica and fans
    out over channel_activity exactly as before, and nothing new is read or written on the way.
  • Anything serialised? Nothing new. No two processes contend over anything here.
  • Anything fanned out to a browser? Only the existing busy event over the existing Postgres
    NOTIFY. This changes which moments produce one, not how one travels.
  • New listener, port, or schedule? None. Strictly fewer requests than before: two fewer per
    channel opened, one more only where one was previously missing.

Boundary and audit

  • Every acting call still goes through the gateway. No acting path is touched.
  • New refusals and new failures each write a row. Neither is introduced.
  • Nothing new is trusted from the client. The same channel id and boolean as before, against the
    same membership check.

Changelog

  • A line in CHANGELOG.md under Unreleased.

Proof

Run against the local stack, with fetch wrapped in the page to record every /busy and /activity
call. Same steps both times: send a message, switch channel immediately, wait for the reply.

Before:

db0bab97/busy :: {"busy":false}     <- on opening the channel
db0bab97/busy :: {"busy":false}
db0bab97/busy :: {"busy":true}      <- turn starts
db0bab97/activity :: {"agentId":null,...}
47f93ad9/busy :: {"busy":false}     <- the channel moved to, clearing itself
47f93ad9/busy :: {"busy":false}

No false for db0bab97. Dots still showing 45 seconds after the reply completed.

After:

db0bab97/busy :: {"busy":true}
db0bab97/activity :: {"agentId":null,...}
db0bab97/activity :: {"agentId":"general-assistant",...}
db0bab97/busy :: {"busy":false}     <- sent from the turn, after the screen was gone

Dots cleared, preview correct, and the two mount-time posts are gone.

No unit test. The defect is which component emits the signal, and there is no component-test harness
here: every file in app/tests tests a module directly, and standing one up for this would be a
larger change than the fix. bun run typecheck, bun run lint and bunx biome format . are clean,
and 206 app tests plus server/tests/channel-events.integration.test.ts pass.

@zopeVaibhav
zopeVaibhav force-pushed the fix/stale-channel-busy-dots branch from 74c8c49 to b612beb Compare September 1, 2026 19:02

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Real bug, and the fix is in the right place: the report belongs to the turn, not to the screen that started it. Verified the removed setChannelBusyMutationOptions had no other caller. CI green.

@davidmckayv
davidmckayv merged commit 5275f3c into CopilotKit:main Sep 1, 2026
13 checks passed
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.

A channel goes on showing a working indicator after its turn has ended

2 participants