Skip to content

An attention inbox: refusals and stalls, surfaced instead of waited on - #255

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
anygivenfriday:feat/attention-inbox
Aug 26, 2026
Merged

An attention inbox: refusals and stalls, surfaced instead of waited on#255
davidmckayv merged 2 commits into
CopilotKit:mainfrom
anygivenfriday:feat/attention-inbox

Conversation

@anygivenfriday

Copy link
Copy Markdown
Contributor

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 to
the Bots this person may use via the same canUseBot the roster and the computer use — an
administrator 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 the
trail's own documented reason (core.ts, actorUserId).

Deliberately not under /api/admin: the audit page is the administrator looking back; the inbox is
the 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 tooltargetType: "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.

Where it runs

  • New state that outlives a request? One table, attention_resolutions
    (audit_event_id unique, resolved_by, resolved_at), migration 0016. Nothing held in
    process; the view is computed per request.
  • What happens on the second replica? The same answer: both compose the same trail and the
    same resolutions from Postgres. A resolution written on one replica is subtracted by the
    other's next read.
  • Anything serialised? Resolving. By unique index + onConflictDoNothing, not
    check-then-write: the second writer's insert conflicts and it reads back who won —
    alreadyResolved: true with the standing attribution.
  • Anything fanned out to a browser? No. The app polls at 15s with refetch-on-focus, the
    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.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway — nothing here acts.
  • New refusals and new failures each write a row — none are created; the inbox reads the rows
    the gateway and stall guard already write. Resolution attribution (who, when) is recorded in
    its own table.
  • Nothing new is trusted from the client — the only client input is an event id, answered from
    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

  • One entry under Unreleased.

Proof

Unit (attention-view.test.ts, 7 cases): the gateway's own refusal reason used whole; the tool
rejection'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 resolvedAmong answering exactly the resolved subset.

Suites: server 1088 pass / 0 fail; app 158 pass, tsc clean, production build clean.

Live, against a deployment whose trail held real mcp.call_rejected rows:

GET /api/attention                 → 8 items, each naming its Bot and the decision's own sentence
POST .../resolve                   → {resolvedBy: "dev-local-user", alreadyResolved: false}
POST .../resolve (again)           → {resolvedBy: "dev-local-user", alreadyResolved: true}
GET /api/attention                 → 7 items; the resolved id absent
POST .../<random uuid>/resolve     → 404

In the browser: sidebar shows "Attention · 8"; pressing Resolve on a row drops the list to 7 and
the badge follows through invalidation.

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.
# 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
@davidmckayv
davidmckayv merged commit e8aa344 into CopilotKit:main Aug 26, 2026
guidovizoso added a commit that referenced this pull request Aug 27, 2026
* Take on a cron parser, the one piece of time arithmetic not worth owning

* Give a deployment somewhere to keep a standing instruction

* Read a cron in somebody's timezone, and say it back in words

* Keep and guard a person's standing instructions

* Say every accepted schedule in words, and pin the recompute rules

* Promise only the prose the renderer can keep

* Move a routine's clock exactly once, however many sweeps race

* Count only the runs the fatigue rule can act on

* Put Routines in the catalogue, the first entry that never leaves the building

* Say what a built-in connector needs, which is nothing

* Let a Bot keep, change and drop routines by calling tools

* Tell the truth about a write that landed and a routine that sleeps

* Run a routine's turn with nobody's browser open

* Pin the order the fatigue rule reads in, and the cut it makes

* Open one door for the worker, and compare the secret behind it

* Leave a trace when the worker is turned away

* Run a routine's turn into the thread the person will read

* Stop once, clean only what was taken, and name the real reason

* Offer every due routine to the shared queue, once

* Say which order the clock and the queue really move in

* Consume a claimed firing honestly, and reap what is done with

* Loop the sweep on a laptop, the way a cluster schedules it

* Guard the worker start against every look-alike process

* Schedule the routines sweep the way the culler is scheduled

* Refuse at install what would crash-loop at midnight

* Serve a person their own standing instructions

* Show every standing instruction, and let a person stop one

* Say when the next run is, and type what the last one was

* Say what a routine is, who it runs as, and what it will not do

* Tell the truth about the secret and the single container

* Name the pods that fail when the key is missing

* Bound every dispatch, and close the runs that never ran

* Renumber the routines migration behind the attention inbox

main took idx 20 while this branch was built, so 0020_routines becomes 0021_routines: regenerated with drizzle-kit (SQL byte-identical to the original), snapshot chained off main's 0020, journal appended. The last_run_at column comment now says what the sweep actually writes there — the stamp advanced past, fired or drained — rather than implying a run history the routine_runs table owns.

* Format what main merged unformatted

biome 2.5.10 (the version bun.lock has pinned throughout) rejects eight files as merged by #241 and #255 — main's CI is red on both. Formatting them here keeps this branch's whole-tree gate honest; if main fixes itself first, this commit rebases away to nothing.

* Give the endpoint test the attention slot main added

main inserted attentionStore into createApp's positional signature before pageFrames; the endpoint test's hand-built tuple put the runner one slot early, so the route never mounted and all seven cases 404ed.

* Name the channel by id when its name cannot name it

* Seed a history the model will accept, however a turn once ended

* Tell a firing turn that it is one

* Take the attention inbox out of this branch, keeping its applied migration

Reverts e8aa344 (#255) code on guido/routines only: the API routes, store,
view, schema, app wiring, sidebar entry and /attention page go; the 0020
migration, journal entry and snapshot stay, because the local database has
already applied them and an unused table is cheaper than a broken chain.
The routine endpoint test loses the positional attentionStore slot it had
gained for main's signature.

* Clamp a routine's instruction to three lines on the list

* Judge a schedule by its whole cycle, and close the runs nobody will finish

Four lifecycle fixes from review. The 15-minute floor now walks the
expression's cycle instead of sampling the next two occurrences, so a
cron like '45,55 8 * * *' is refused whenever it is created rather than
accepted at 08:50 and wedged after its first firing; a refusal that
still surfaces at sweep time switches the routine off with its reason
on a run row instead of silently burning a due slot every pass forever.
The enabled cap counts and writes under a per-owner advisory lock, so
two racing creates at nineteen admit exactly one. A stale clock is
drained current in one pass by computing the next occurrence from now
while the CAS still compares the old stamp. And failOpenRuns — whose
only caller was unreachable at the shipped five-minute cadence, and
which would have closed a genuinely in-flight run as failed — is
replaced by an age-scoped reaper that closes abandoned rows as skipped,
which also mops up runs stranded open by a server dying mid-turn.

* Drop the attention table on purpose, and index routines by owner

The attention revert kept migration 0020 while removing the schema
declaration, which left drizzle's latest snapshot claiming a table the
schema files no longer knew: the next unrelated db:generate silently
emitted DROP TABLE attention_resolutions CASCADE inside whatever
migration somebody happened to be writing. Migration 0022 makes that
drop explicit and reviewable — the feature never shipped in a release —
and restores the invariant that the latest snapshot matches the schema.
Migration 0023 gives routines the (owner_user_id, enabled) index that
listFor, countEnabled, the owner-scoped writes and the users cascade
were all sequential-scanning without.

* Say '2 minutes ago' from one module, not two copies

* Ask a surviving server whether the worker can reach it before keeping it

start.sh leaves an answering server alone, and that philosophy kept a
server started before WORKER_SHARED_SECRET existed: the worker then got
401 for every handoff and routines never fired, with nothing at start
time saying why. Now the script probes /internal/routines/run with this
run's secret — 400 means the secret was accepted and only the empty
body refused, 401 or 404 means the server cannot take handoffs and is
restarted into this run's environment.

* Let the store's own types and caps speak for the routine tools
@anygivenfriday

Copy link
Copy Markdown
Contributor Author

Re-landing the attention inbox?

Thanks for merging this — and for the cleanup in #278 that I made necessary. Two things there were mine to own: eight files landed unformatted against the pinned biome (I formatted with defaults, not the repo's version — my later PRs will run bun x biome check against the lockfile version before pushing), and the positional attentionStore argument was exactly the kind of slot-shift fragility that then bit your endpoint test on the routines branch. Fair cost, cleanly handled on your side.

Reading the revert commit, it looks procedural — keeping the migration chain honest while routines landed — rather than a verdict on the feature itself. If a re-land is welcome, I'd bring it back as a fresh PR with: the migration regenerated at the current head of the chain (after 0023), the whole tree formatted with the pinned biome, and the createApp wiring done so a hand-built argument tuple can't mis-slot it — happy to take direction on whether you'd rather see that as an options object or just the test updated.

If the revert was a design objection instead, I'd rather hear it before writing code. Either answer is useful.

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