An attention inbox: refusals and stalls, surfaced instead of waited on - #255
Merged
davidmckayv merged 2 commits intoAug 26, 2026
Merged
Conversation
A boundary refusal or a stalled run was recorded and then waited for somebody to happen to look — at the right channel, or at the audit page only an administrator has. The trail knew; nobody was told. The inbox is a view over the trail, not a second record of it. Refusals (computer.action_refused, mcp.call_rejected) and stalls (agent.stream_stalled) are already written transactionally by the gateway and the stall guard, so deriving the inbox from those rows means it cannot miss one: there is no dual write to drift, and nothing new runs on the action path. The only state it owns is the resolution — who marked a row handled, and when — in a table beside the append-only trail rather than in it, ids by value with no foreign keys for the trail's own documented reason. GET /api/attention composes recent rows minus resolutions, then scopes per item by the same canUseBot the roster and the computer use; an administrator sees everything the way they see every Bot. Not under /api/admin: the audit page is the administrator looking back, the inbox is the working person being told now. POST /api/attention/:eventId/resolve marks one handled for everyone, with attribution. First writer wins by unique index rather than check-then-write, and the second presser is read back who got there first. Only a row of the three attention kinds resolves; anything else answers the same 404, so the endpoint cannot be used to probe what the trail holds. Which Bot a row is about is not where it looks: a tool rejection's target is the TOOL — targetType "mcp_tool", targetId the ref — and its Bot travels only in the payload. Reading targetId unconditionally called a refusal's Bot "google-drive/search_files", which canUseBot correctly denies, which hid every tool rejection from exactly the person it was for. botOf reads targetId only for computer and agent rows, and a row that cannot name its Bot is dropped rather than shown to everybody. In the app: an Attention page listing what is open with Resolve on each row, and a sidebar entry with a count badge drawn only when nonzero.
anygivenfriday
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 26, 2026 05:31
# Conflicts: # CHANGELOG.md # server/drizzle.config.ts # server/drizzle/meta/0016_snapshot.json # server/drizzle/meta/_journal.json # server/src/app.ts # server/src/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
A boundary refusal or a stalled run is recorded and then waits for a person to happen to look — at
the right channel, or at the audit page only an administrator has. The trail knows; nobody is told.
Attention, in the sidebar for everybody, lists the refusals (
computer.action_refused,mcp.call_rejected) and stalls (agent.stream_stalled) nobody has handled yet, scoped per item tothe Bots this person may use via the same
canUseBotthe roster and the computer use — anadministrator sees everything the way they see every Bot. A count badge draws only when nonzero.
Marking an item handled clears it for everyone and records who did.
It is a view over the trail, not a second record of it. Those rows are already written
transactionally by the gateway and the stall guard, so the inbox cannot miss one: no dual write to
drift, nothing new on the action path, and the append-only trail untouched. The only state it owns
is the resolution — one table (
attention_resolutions), ids by value with no foreign keys, for thetrail's own documented reason (
core.ts,actorUserId).Deliberately not under
/api/admin: the audit page is the administrator looking back; the inbox isthe working person being told now.
One subtlety worth review attention: which Bot a row is about is not where it looks. A tool
rejection's target is the tool —
targetType: "mcp_tool",targetIdthe ref — and its Bottravels only in the payload. Reading
targetIdunconditionally called a refusal's Botgoogle-drive/search_files, whichcanUseBotcorrectly denies, which hid every tool rejection fromexactly the person it was for.
botOfreadstargetIdonly forcomputerandagentrows, and arow that cannot name its Bot is dropped rather than shown to everybody.
Where it runs
attention_resolutions(
audit_event_idunique,resolved_by,resolved_at), migration0016. Nothing held inprocess; the view is computed per request.
same resolutions from Postgres. A resolution written on one replica is subtracted by the
other's next read.
onConflictDoNothing, notcheck-then-write: the second writer's insert conflicts and it reads back who won —
alreadyResolved: truewith the standing attribution.same shape as the existing grant polls. A push upgrade over the channel socket is a natural
follow-up and deliberately not in this PR.
Boundary and audit
the gateway and stall guard already write. Resolution attribution (who, when) is recorded in
its own table.
the server's own trail; a made-up id and a row of another kind answer the same 404, so the
endpoint cannot probe what the trail holds.
Changelog
Unreleased.Proof
Unit (
attention-view.test.ts, 7 cases): the gateway's own refusal reason used whole; the toolrejection's Bot read from the payload, never the ref; a Bot-less row dropped rather than shown to
everybody; resolved rows subtracted; foreign event types ignored. Integration
(
attention-store.integration.test.ts): the resolve race — first writer wins, second told who —and
resolvedAmonganswering exactly the resolved subset.Suites: server 1088 pass / 0 fail; app 158 pass,
tscclean, production build clean.Live, against a deployment whose trail held real
mcp.call_rejectedrows:In the browser: sidebar shows "Attention · 8"; pressing Resolve on a row drops the list to 7 and
the badge follows through invalidation.