Skip to content

Show who a conversation is waiting on, with the Bot's avatar - #843

Closed
guidovizoso wants to merge 13 commits into
mainfrom
guido/handoff-progress-row
Closed

guidovizoso wants to merge 13 commits into
mainfrom
guido/handoff-progress-row

Conversation

@guidovizoso

@guidovizoso guidovizoso commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes

When a Bot asks another Bot, the answer is worked out in a scratch thread nobody is shown, and a routine firing into a conversation runs with no browser attached. The busy signal said only "something is running", on the socket and nowhere else, so an open conversation looked idle while it waited and a reload forgot even that.

Now a conversation says what it is waiting on:

  • At the end of the transcript, where the answer will land: "Asking Risk Analyst… 0:13" with the clock running, or "Market Scout is running a routine…". One row per Bot asked, one for a routine. The clock reads minutes, then hours, and does not restart when the answer turns around. The row is hidden while this tab's own turn runs. ProgressRow is a reusable line for later waits.
  • In the sidebar, which stays lit while a hop or a routine is pending between turns.
  • After a reload, because the roster and the channel read carry the list too.

Read from the rows that already say so, never written. A hop is a work_items row until it is finished, and a routine firing is a routine_runs row until it has a status. server/src/channels/working.ts reads those; a hop's relay (relay:<hop key>) is folded into the hop it answers, so a hop retried five times or on its way back is one entry. A given-up hop, the notice saying so, and a relay already being spoken are not waits. A second table of "busy reasons" would be a copy that a crashed pod leaves behind.

When it is announced. Every busy event now carries the whole list (never a change to it). The handoff desk announces a hop the moment it is queued, the runner announces again once the queue has moved (finished with the relay already queued, released for another try, or dropped), and a routine signals busy around its firing and clears it after the run row has its outcome. Announcing is best-effort: a conversation that can't be told does not fail or refuse the hop.

Private to the person whose thread it is. Each member of a shared channel has their own thread in it. The list is read from the reader's own thread and their own routines, and sent only to the thread's owner; other members hear the busy flag alone, as before, and learn nothing about which Bot somebody asked.

Fast enough to read on every page. Migration 0063_work_items_open_thread adds a partial index on unfinished work_items by (kind, payload->>'threadId'), and the relay join cuts the hop key out of the relay key (a primary-key lookup) instead of building 'relay:' || key for every row.

Where it runs

  • New state that outlives a request? No new state. What a conversation waits on is derived from work_items and routine_runs in Postgres on every read and every announcement. The one schema change is an index.
  • What happens on the second replica? The list is the same query whichever process reads it. A hop run on one replica is announced from that replica, and its event reaches a browser connected to any other through the existing pg_notify on channel_activity. A browser that reloads on another replica gets the same list from the roster or channel read.
  • Anything serialised? Nothing is written, so nothing needs a lock. Ordering is the concern instead: two announcements for one channel can be read on two replicas and delivered in either order. Each list carries workAt, the database's clock_timestamp() taken just before the read, and the browser keeps the list with the latest stamp, so a late event cannot put back a hop that has already settled.
  • Anything fanned out to a browser? Yes, through pg_notify(channel_activity, …) as the busy flag already was, addressed by memberIds: the list to the thread's owner, the bare busy flag to the other members. Every process's listener delivers to the sockets it holds. A missed event is corrected by the next one or by the roster refetch on reconnect.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway. Nothing here acts; the hops and routines themselves are unchanged.
  • New refusals and new failures each write a row. There are no new refusals. A failed announcement is swallowed on purpose and is not a failure of the hop or the routine, which keep their existing trail rows.
  • Nothing new is trusted from the client that the server can resolve itself. Whose thread the list is read from comes from the server's channel mapping and the signed-in actor, never from the request.

Changelog

  • "A conversation shows who it is waiting on" under Unreleased, naming the migration.

Merge notes

This and the owner-app-grants PR both add a Drizzle migration numbered 0063 (0063_work_items_open_thread here, 0063_owner_app_grants there). Whichever merges second has to regenerate its migration as 0064; the SQL is unchanged. The index is a plain CREATE INDEX, which holds writes to work_items while it builds. Finished rows are purged by the queue, so the table is usually small, but the build is not free on a deployment with a large backlog.

How it was checked

  • New tests:
    • server/tests/channel-work.integration.test.ts, against Postgres: a hop from the moment it is queued, nothing once it is finished, one entry however many tries, two Bots asked as two entries oldest first, not a given-up hop or its notice (but its last attempt while still running), the answer on its way back as the same wait with the same clock, not once the answer is being given, not a scratch thread, a routine until its run has an outcome, only the reader's own thread and routines in a shared channel; and the announcements, including that another member hears nothing about whom it asked.
    • server/tests/agent-handoff.test.ts, agent-handoff-runner.test.ts, routine-runner.test.ts, channel-routes.test.ts: when each piece announces, that a failed announcement never fails a hop or a firing, and that the roster and channel read carry the list.
    • app/tests/channel-work-row.test.tsx and channel-event-patch.test.ts: the row's wording and clock, one row per wait, gone when the list empties, back after a reload, and an older list arriving late not undoing a newer one.
  • In a running app on a combined branch of this and five sibling PRs (local stack, 2026-10-26): asking another Bot showed "Asking Risk Analyst… 0:13", ticking. The hop's delivery itself was blocked by a local allowed-hosts setting, so the row going away when the answer arrived was not seen end to end here; the tests above cover it.
  • The full server suite on the combined branch: 5,821 pass. The only failures were the assertion-shape guard, since fixed here (moved allow-list entries re-keyed, and the new tests assert a missing key directly), and one Mastra test that needs a package not installed locally. The app suite: 1,675 pass.

A refused address fails at once

A hop to a Bot whose address this deployment will not dial (here localhost:4201) was retried once a minute for five tries, so the person watched "Asking Risk Analyst…" for about five minutes before hearing it could not be reached. That refusal cannot change between tries, so it now ends the hop on the first try, like a refusal by the deployment's controls. The AG-UI client keeps only the message of the error a fetch throws, so isEndpointRefusal (in endpoint.ts) recognises it by type or by the sentence endpoint.ts itself writes. Checked in the running app: the person is told in about 12 seconds, down from about 5 minutes.

The busy signal said only that something was running, on the socket and
nowhere else. Read what a conversation waits on from the rows that
already say so: an unfinished hop it asked, folded with its relay, and
an open routine run. The channel read and the roster carry the list, and
every busy event carries the whole list at the moment it is sent.
…moves

The delivery's busy signal fires inside the run, while the row still
says the hop is in flight, so it cannot be what says the hop is over.
The runner now settles after finish or release, with the relay already
queued, and the desk announces a hop the moment it is offered.
Signalled around the firing and cleared after the run row has its
outcome, since the open run row is what the conversation reads.
…el's read

Busy events now carry the whole work list. It replaces the row's list in
the roster and the channel read the conversation screen uses, and both
are refetched with the roster after a reconnect or a resync. The sidebar
stays lit while a hop or a routine is pending between turns.
…e answer will land

"Asking Ana… 0:12" for a Bot its Bot asked, "Ana is running a routine…"
for a routine, at the end of the transcript, from the server's list.
Hidden while this tab's own turn runs. ProgressRow is the reusable line
for later waits.
…every hop

The work list is read on every roster page and every busy event. It filtered
work_items on payload->>'threadId' with no index, and joined each relay to its
hop by building 'relay:' || key for every row, so both sides scanned a day of
finished hops. A partial index on unfinished rows by thread, and a join that
cuts the hop key out of the relay key, turn it into two index lookups.
Every member of a shared channel has a thread of their own in it. The work list
was broadcast to all members on every busy event, read from whichever thread the
signal came from, so another member learned which Bot somebody asked and lost
their own list to it; the roster also listed every member's routine runs.
Hops are now read from the reader's thread and routines from the reader's own;
the list goes only to the thread's owner, and the other members hear the busy
flag alone, as before.

Each list is stamped with the database clock just before it is read, so a
browser can tell an older list that arrived late from a newer one.
Busy events are read on whichever replica sent them and can arrive out of
order. The roster and the open conversation now keep the list with the latest
stamp, so a late event no longer puts back a hop that has already settled.
The branch moved four allow-listed offences down their files, and its own tests
added four new ones: an absent busy flag and work list asserted through a ?.
chain, and an expected undefined key in a toEqual. The moved entries are
re-keyed; the new ones assert the missing key directly instead.
@guidovizoso guidovizoso changed the title Show who a conversation is waiting on, with a running clock Show who a conversation is waiting on, with the Bot's avatar Oct 26, 2026
@guidovizoso

Copy link
Copy Markdown
Collaborator Author

Landed in #847.

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.

1 participant