Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8df1720
fix(workspace): the mic renders on speech-to-text capability, not key…
dolho Sep 11, 2026
9778c72
fix(workspace): the /stt provider error says why, instead of one opaq…
dolho Sep 11, 2026
a1ca15c
Merge origin/dev into fix/2695-stt-capability-probe
dolho Sep 11, 2026
9c8e78f
Merge branch 'fix/2695-stt-capability-probe' into fix/2696-stt-provid…
dolho Sep 11, 2026
06b86be
test(e2e): measure the composer shell from one settled render (worksp…
dolho Sep 11, 2026
385168b
Merge branch 'fix/2695-stt-capability-probe' into fix/2696-stt-provid…
dolho Sep 11, 2026
9696db6
Merge origin/dev into fix/2695-stt-capability-probe
dolho Sep 14, 2026
1fcc6ed
fix(stt): a quota word in the provider's prose is not a quota verdict…
dolho Sep 14, 2026
bc41548
fix(workspace): a running room turn can be stopped, and a stop is not…
dolho Sep 15, 2026
29fa525
fix(workspace): attachments travel with a 1:1 escalated into a room (…
dolho Sep 15, 2026
21dfec6
fix(workspace): review findings — re-entry guard, stale carry notice,…
dolho Sep 15, 2026
9d5f7ef
docs(learnings): a v-else chain can be stolen, and a guard can pin th…
dolho Sep 15, 2026
8125904
fix(workspace): read the terminal that STANDS, not the one returned (…
dolho Sep 15, 2026
c67d83d
docs(learnings): a CAS loser returns its own verdict, not the winner'…
dolho Sep 15, 2026
3027067
fix(workspace): the escalation carries files from BOTH upload surface…
dolho Sep 15, 2026
f85ca2a
fix(workspace): entering a chat is not sending — the mount boundary a…
dolho Sep 15, 2026
286a9e5
fix(db): agent_sync_state.git_dir_bytes is BIGINT on PostgreSQL (#2800)
dolho Sep 15, 2026
7582772
fix(workspace): the refused-cancel line uses InlineError, not a hand-…
Sep 15, 2026
077ba3e
fix(db): #2800 rebuild refuses to drop a column it does not name
dolho Sep 15, 2026
69f31ef
docs(learnings): #2800 — Integer is int4 on PostgreSQL; growth column…
dolho Sep 15, 2026
ac61d8b
fix(workspace): the carry notice uses the button primitive and alerts…
Sep 15, 2026
ed01729
fix(workspace): an agent woken in a room is told about the client's f…
dolho Sep 15, 2026
3731944
fix(workspace): a file put into a room reaches every agent in it, and…
dolho Sep 15, 2026
beae049
fix(stt): Redis is the one capability-cache authority; _local only wh…
dolho Sep 15, 2026
bf0a7fd
Merge remote-tracking branch 'origin/dev' into fix/2695-stt-capabilit…
dolho Sep 15, 2026
2ec6762
docs(learnings): an inline capability is one every other surface lack…
dolho Sep 15, 2026
be0ba13
test(portal): re-anchor the title-ordering guard to the call portal_c…
dolho Sep 15, 2026
b10743e
fix(workspace): the rail receipt names the file the SERVER wrote (#2794)
dolho Sep 15, 2026
757d29d
chore: untrack six debug scripts a `git add -A` swept into the previo…
dolho Sep 15, 2026
a115fb2
test(stt): register the second worker's module through monkeypatch, n…
dolho Sep 15, 2026
4433e3f
fix(session): one platform credential, one 401 verdict, one handler (…
dolho Sep 15, 2026
ceb2e93
docs(http): boundedHttp explained itself with the mechanism this PR d…
dolho Sep 15, 2026
279475f
train: #2805
dolho Sep 15, 2026
26364ea
train: #2699
dolho Sep 15, 2026
5a1a458
train: #2702
dolho Sep 15, 2026
ee5eedc
train: #2798
dolho Sep 15, 2026
6ce3638
train: #2799
dolho Sep 15, 2026
89a18ec
train: #2811
dolho Sep 15, 2026
82b052b
train: backend.md — #2696 folds the stt_capability bullet into tts_se…
dolho Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions docs/memory/architecture/backend.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/memory/architecture/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ CREATE TABLE agent_sync_state (
behind_main INTEGER DEFAULT 0,
ahead_working INTEGER DEFAULT 0, -- #389 P6: working-branch divergence
behind_working INTEGER DEFAULT 0,
git_dir_bytes INTEGER, -- #1596: agent .git on-disk size (bloat curve)
git_dir_bytes BIGINT, -- #1596: agent .git on-disk size (bloat curve); BIGINT since #2800 (int4 on PG overflowed at 2 GiB)
pack_count INTEGER, -- #1595: packs from `git count-objects -v`
loose_objects INTEGER, -- #1595: loose objects (gc-health signal)
maintenance_failures INTEGER DEFAULT 0, -- #1595: consecutive failed maintenance attempts
Expand Down
20 changes: 20 additions & 0 deletions docs/memory/architecture/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ session that *expires* on a browser which later gained a platform login, and the
token's server-side validity post-sign-out (no self-service revoke; ent#281's primitive
is per-email).

**One platform credential, one 401 verdict, one handler (#2791).** The paragraph above
describes *which session a tab is in*; this is the layer under it. The platform JWT used
to live in two places that could disagree — the in-memory
`axios.defaults.headers.common['Authorization']` copy and `localStorage['token']` re-read
per request — with **no** `storage` listener anywhere under `src/frontend/src` and three
separate 401 handlers. Because the Workspace opens in its own tab (ent#456) and polls
every 20s, a stale Workspace tab could log a freshly re-established platform session out
within seconds: its poll went out on the OLD token, 401'd, and the handler called
`authStore.logout()`, deleting the NEW session's token. `utils/platformSession.js` is now
the only reader (`readStoredToken`), the only verdict (`sessionLostVerdict` →
`ignore | stale | logout`, where **stale** means *the credential that failed has already
been replaced, so adopt the current session rather than destroy it*) and the only handler
registry; `main.js` installs a global axios **request** interceptor so every bare-`axios`
caller derives the header per request, and a `storage` listener so a login or logout in
one tab reaches every other. The `axios.defaults` copy is written nowhere (only cleared on
logout, for tabs still running a pre-fix build); the logout revoke carries its token
**explicitly**, because #2258's clear-before-revoke ordering means storage is already
empty by then. Full model and the verdict table:
[workspace-session-signout.md](../feature-flows/workspace-session-signout.md).

**Membership is a DB fact; container state is a projection onto the card (#2196).** The
roster is built from `agent_ownership` / `agent_sharing` and is **never** filtered by
whether a container exists. A live ownership row with no container is a routine state
Expand Down
2 changes: 1 addition & 1 deletion docs/memory/feature-flows/git-sync-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ ahead_main INTEGER DEFAULT 0
behind_main INTEGER DEFAULT 0
ahead_working INTEGER DEFAULT 0
behind_working INTEGER DEFAULT 0
git_dir_bytes INTEGER -- #1596: .git on-disk size
git_dir_bytes BIGINT -- #1596: .git on-disk size (BIGINT since #2800: int4 on PG overflowed at 2 GiB)
pack_count INTEGER -- #1595: packs (count-objects -v)
loose_objects INTEGER -- #1595: loose objects
maintenance_failures INTEGER DEFAULT 0 -- #1595: failed maintenance streak
Expand Down
134 changes: 133 additions & 1 deletion docs/memory/feature-flows/workspace-agents-at-the-centre.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ pending SET rather than a scalar.
|---|---|
| `PortalConversation.vue` | the agent's inbox |
| `PortalRoom.vue` | **every participating agent's** inbox; the chip names the recipients (operator decision 13) |
| `PortalRailFiles.vue` | the target agent's inbox |
| `PortalRailFiles.vue` | the chosen target — in a room, **every participating agent** by default (#2794) |
| `Portal.vue::onEscalateToRoom` (#2794) | the **new** participants' inboxes — see below |

- the whole conversation is the target, with an affordance naming what will
happen; `isFileDrag` keeps a dragged link or text selection from lighting it
Expand All @@ -265,6 +266,137 @@ pending SET rather than a scalar.
ships against the existing upload path, and when they land the gesture does not
change — only the caller's `upload`.

### The file follows an escalation (#2794)

A 1:1 uploads each file to the CURRENT agent as it is attached, so @mentioning
a second agent used to move the conversation to a room and leave the file
behind — the person had watched a chip confirm the upload and believed both
agents had it. Only the original one ever did, and the room showed no trace of
a file at all.

The rule is the issue's own: *whatever a user could do inside a room,
escalating into one must produce the same result.* So the escalation owes the
room's fan-out to exactly the participants that do not already hold the file.

- the composable **keeps the `File` handle** on each entry (`markRaw`, so a
proxy can never reach `FormData.append`) — the same bytes reach a second
destination without asking the person to pick the file again;
- it exposes **`settled()`**, and `send()` awaits it before escalating: an
upload still in flight is waited for, never silently left behind. Two
overlapping drops now **chain** rather than run beside each other — that is
the same "sequential, or the per-email limiter trips" rule one level up, and
without it `settled()` could resolve while an earlier batch was still going;
- `send()` does **not** clear the chips. On success the component unmounts as
the room opens; on failure the shell hands the text back and the chips are
still standing beside it — the recovery path with no extra plumbing. It does
guard re-entry while it waits, because the composer is emptied *before* the
await and a second Enter in that window would emit a second escalation for
`Portal.vue`'s `escalating` flag to drop on the floor;
- the fan-out runs **before** the message is posted. The message is what wakes
the mentioned agent, and a turn that starts before the file is in its inbox
cannot see the thing it was asked about. The order is the feature;
- the origin agent is excluded **by name, not by position**: the shell builds
`agents` as `[origin, ...mentioned]`, and a plan trusting that order would
double-send the day it changes;
- the room then **says what happened** — what arrived and for whom, a file that
missed a participant named per file *and* per agent, and a file that never
finished uploading in the 1:1 named too. The notice is retired by the next
message in the room, because by then it describes history.

Decidable rules live in `components/portal/portalAttachments.js`; the SFCs are
dispatchers over it (`vitest.config.js` pins `environment: 'node'` with no
mount harness, so a rule inside an SFC is one no test can reach).

**Two adjacent defects fixed with it.** The room composer had shipped as
`<form v-else>` chained to the "this conversation has ended" line (ent#358) —
the right rule — but `v-else` binds to the immediately preceding *element*, and
the batch notice, the chips (ent#524) and the budget banner (#2620) were each
inserted in between, so the chain ended on `attachments.length`: **attaching a
file to a room replaced the composer**, and a **closed room rendered a live
one**. `roomComposerChain.spec.js` had by then pinned the broken state as the
contract. The composer now states its own condition (`v-if="!isClosed"`), the
spec pins the outcome instead, and the room clears its chips after a successful
send the way the 1:1 always has.

### A file reaches every agent in the room, and every agent is told (#2794)

Testing the escalation above against a live instance turned up the rest of the
path, and it was worse than the original report. In a room holding
`analyst-demo` and `sidekick`, a client sent a screenshot and asked *"@sidekick
what is displayed on the pasted image?"*. sidekick answered *"I don't see any
image attached to your message."* — truthfully. Three independent gaps, each
invisible on its own, and each of which alone is enough to produce that reply.

**1. The rail aimed at one agent.** `PortalRailFiles`' `Send to` select defaulted
to `participants[0]` while `PortalRoom`'s own drop zone fanned out to all of
them. Two surfaces in the same chat, two meanings for *send a file here*, and the
one with the visible control was the wrong one — so the file reached the agent
that was not being asked about it. A room now defaults to **everyone in it**, with
the individual agents still selectable underneath.

The rules are in `portalFiles.js` (`uploadTargets`, `defaultUploadTarget`,
`resolveRecipients`, `uploadTargetLabel`, `uploadReceipt`), not in the SFC, for
the reason this document keeps giving: `environment: 'node'`, no mount harness,
so a rule in a `.vue` file is a rule no test can reach. Two of them encode a
direction rather than a value:

- `resolveRecipients` fails **toward the fan-out** — a target that has left the
room resolves to everyone, because a file sent to one agent too many is
recoverable from the rail's own delete and a file sent to nobody is the silent
loss this issue is about;
- a file counts as sent only when it reached **every** recipient. A partial
delivery is a failure line naming the agents it missed — counting it as a
success would rebuild the reported bug inside its own fix, since *"Sent
shot.png to analyst-demo and sidekick"* while sidekick got nothing is exactly
the reassurance that made the gap invisible the first time.

**2. Pasting did nothing.** There was no paste handler on either composer, so the
most common way anyone attaches a screenshot was inert *and silent*. The reported
session shows what that costs: the client's file was called
`Pasted image (3).png`, i.e. they had already been driven out to a file manager.
`usePortalFileDrop` now exposes `onPaste`, bound on both composers, feeding the
same `addFiles` batch as a drop — a second path in, never a second
implementation. It suppresses the default **only** when the clipboard carries no
`text/plain`, so pasting out of a rich editor still types the text it came with.

**3. No agent was ever told — the core of it.** A room turn was built from
`_build_turn_prompt`, which is a header plus the transcript, and from nothing
else. The sentence that makes a file visible to an agent, and the vision blocks
that make *"what is in this picture"* answerable at all, were written **inline in
`portal_chat`** — so the 1:1 conversation was the only surface in the product
that had them. Delivery had never been the problem; the *telling* did not exist.

That composition now lives in one place,
`client_portal/service.py::collect_inbox_context`, returning
`(manifest_prefix, images)`; `portal_chat` and
`shared_sessions/service.py::_wake_agent` both call it. The room prepends the
prefix to the turn prompt and passes `images=` to `execute_task`. Three
decisions, none obvious from the diff:

- **the manifest is a PREFIX.** An agent that meets *"what is in the image?"*
before it has been told an image exists is the agent that answers "I don't see
any image attached";
- **whose inbox** — the posting principal's, because a portal inbox is keyed by
the client's email and in a Workspace room that principal is the person who put
the file there. *Residual:* a room with two humans surfaces only the email of
whoever's message triggered this wake. Reading every human's inbox costs one
`docker exec` per human per wake, and the shape rooms actually have is one
person and N agents;
- **the image-intent test reads the whole delta, agent lines included.**
*"@sidekick can you look at the screenshot the client sent?"* is an ordinary
room move, and scoping the test to human text would make exactly that relay
arrive image-less — this bug, one hop along.

Fail-safe throughout: no client email, an unreadable inbox or a raising collector
each yield `("", [])` and the turn runs unchanged. `images` is `None` rather than
`[]` when there is nothing, so a room without files is a byte-for-byte no-op.

The one-composer property is the one worth guarding, because the failure being
fixed *is* a surface that quietly composes nothing:
`test_2794_room_file_awareness.py` counts the manifest sentence across the whole
backend and fails if it appears anywhere but `client_portal/service.py` — so a
third surface inventing its own is caught, not just a second one.

---

## An answered ask says whether work started (ent#468)
Expand Down
82 changes: 80 additions & 2 deletions docs/memory/feature-flows/workspace-session-signout.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,80 @@ form simply reappeared, indistinguishable from "you were never signed in".
**Degradation:** if `sessionStorage` is unavailable (private mode), the marker reads
as absent — pre-#2261 behaviour, rather than a workspace nobody can enter.

## One credential, one verdict, one handler (#2791)

Everything above is about *which session a tab is in*. #2791 is the layer under
it: **where the credential lives, and who is allowed to end it.**

One browser used to hold the platform JWT in two places that could disagree —
the in-memory `axios.defaults.headers.common['Authorization']` written once at
login by `auth.js::setupAxiosAuth`, and `localStorage['token']` re-read per
request by `api.js` — with no cross-tab listener anywhere under
`src/frontend/src`, and three separate 401 handlers. The Workspace made it bite
hardest because it opens in its own tab (ent#456) and polls every 20s.

**The reported symptom.** Log out and log back in on the main app with a
Workspace tab open from the previous session. That tab still holds the OLD JWT;
its next poll 401s; the handler calls `authStore.logout()`, which removes
`localStorage['token']` — *the token the re-login had just written*. The main
tab's next request finds nothing and hard-redirects to `/login`. A stale tab
killed a fresh session, and the handler never asked whether the credential that
failed was still the current one.

### The three things that are now singular

**One source.** `utils/platformSession.js::readStoredToken()` is the only reader.
The `axios.defaults` copy is gone (`setupAxiosAuth` is a documented no-op), and
`main.js` installs a global axios **request** interceptor that rebuilds the
header from storage on every request — so all ~368 bare-`axios` call sites get
the current credential without being rewritten, and one added tomorrow cannot
forget to opt in. An **explicit** header on the config still wins, and exactly
one caller needs that: the logout revoke, which must carry a token storage has
already dropped (the #2258 ordering above is unchanged, so the token is captured
*before* the clear and passed *after* it — otherwise #187 silently stopped
revoking anything).

**One verdict.** `sessionLostVerdict()` is a pure function returning
`ignore | stale | logout`, and it replaced a predicate that had been hand-copied
into `api.js`, `main.js` and `portalHttp` and drifted three ways:

| situation | verdict |
|---|---|
| already on `/login`, `/setup`, `/m` | `ignore` |
| the failed token is **not** the stored one | `stale` — adopt the current session, never destroy it |
| no stored token, on the Workspace | `ignore` (an ordinary external client) |
| no stored token, anywhere else | `logout` |
| on the Workspace **and** a portal token is live | `ignore` — **AC #5**: a client whose browser holds a dead operator JWT is no longer thrown onto the operator login by `initializeAuth`'s `fetchUserProfile` |
| otherwise | `logout` |

The `stale` arm is the fix for the reported symptom. The Workspace veto is scoped
by path *as well as* by portal token deliberately: off the Workspace the surface
is an operator one, so an expired operator JWT still bounces there even with a
stray portal token — this change does not widen that.

**One handler.** `setPlatformUnauthorizedHandler` / `notifyPlatformUnauthorized`
in `utils/platformSession.js`. `main.js` registers the reaction (it is the only
module that already has both the router and the store);
`api.js`, the global interceptor and `portalHttp` all report to it.
`clientPortal.js` keeps `isPlatformSession` as its local gate — not redundant,
because it is the only thing that knows this tab's client session was
*suppressed* (#2261's `platformFallbackSuppressed`), which no amount of reading
localStorage reconstructs.

### Cross-tab sync

`main.js` listens for `storage` on the platform token key. A sibling tab logging
in → `adoptStoredSession()` (converge, re-fetch the profile, reset
`profileVerified` so role-gated UI stays closed until *this* token's profile
lands). A sibling logging out → `applySessionEndedElsewhere()`, which drops the
in-memory mirror only: it fires no second server revoke for an already-revoked
token, and writes nothing to storage, because N background tabs reacting to one
event would otherwise each clear it again.

Neither branch navigates. A background tab pushing `/login` is the noise this
issue reports; the visible tab converges through the router guard and its next
request, both of which read the state these set.

## Stated residuals (not hidden)

- ~~**Client-session expiry with a later platform login** still falls back to the
Expand All @@ -182,8 +256,12 @@ as absent — pre-#2261 behaviour, rather than a workspace nobody can enter.
## Files

- `src/frontend/src/stores/clientPortal.js` — `signOutEverywhere()`, `PLATFORM_LOGIN_ROUTE`
- `src/frontend/src/stores/auth.js` — `logout()` local-clear-before-revoke ordering
- `src/frontend/src/utils/platformSession.js` — #2791: the one reader, the one verdict, the one handler registry
- `src/frontend/src/stores/auth.js` — `logout()` local-clear-before-revoke ordering; `adoptStoredSession` / `applySessionEndedElsewhere`
- `src/frontend/src/main.js` — global request interceptor, the registered reaction, the `storage` listener
- `src/frontend/src/api.js` — reports to the shared handler (no private predicate, no hard reload)
- `src/frontend/src/views/Portal.vue` — `onSignOut`, `signingOut` frame
- `src/frontend/src/components/portal/PortalSidebar.vue` — footer button + caption
- `src/frontend/src/components/portal/portalUtils.js` — `signOutLabelFor`
- `src/frontend/tests/unit/workspaceSession.spec.js`, `workspaceSignOut.spec.js`
- `src/frontend/tests/unit/workspaceSession.spec.js`, `workspaceSignOut.spec.js`,
`platformSessionVerdict.spec.js`, `platformSessionSync.spec.js`
Loading