Skip to content

DO NOT MERGE — merge train: 2805,2699,2702,2798,2799,2811 - #2813

Closed
dolho wants to merge 39 commits into
devfrom
train/20260915-1457
Closed

dolho wants to merge 39 commits into
devfrom
train/20260915-1457

Conversation

@dolho

@dolho dolho commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2805, #2699, #2702, #2798, #2799, #2811. Never merged; members merge individually once green.

Built with git merge --no-ff on each member's head SHA, in dependency order — #2805 first (isolated), then #2699#2702 (stacked), then the two room PRs, then #2811.

Three conflicts, all docs-only, no ejections.

Why this train rather than #2808

#2808 already covers #2798 and #2799, but its green tick is stale for #2799: it was built at 10:13 from ac61d8b6c, before six later commits on that branch (the round-two room-files work, its review fixes and the untrack). #2798's head is covered by #2808; #2799's is not. This train carries the current head of all six.

Local verification on the train tree

frontend    135 files, 3039 tests    pass
backend     197 passed, 1 skipped    (all six members' own test files)
alembic     63 revisions, 1 head (0062)  PASS

Local backend runs on Python 3.12 against the repo's pinned 3.13, so the matrix here is the authority — that is what this train exists for.

dolho and others added 30 commits September 11, 2026 11:53
… presence (#2695)

`stt_available` was `bool(tts_service.is_available())` — a non-empty check on
the ElevenLabs key. ElevenLabs permissions are per endpoint, so a key granted
Text-to-Speech but not Speech-to-Text rendered a fully working-looking mic that
failed on every press with `401 missing_permissions`, while spoken replies
played normally on the same instance. Nothing in the UI or the admin panel could
say so: `key_configured` was honestly true.

New `services/stt_capability_service.py`: one provider probe per key — a
one-byte non-audio POST to `/v1/speech-to-text`, which authorises before it
validates, so 401/403 is `refused` (with the provider's status word), any other
definitive status is `capable`, and a transport error / 5xx is `unknown`. The
verdict is cached in Redis under a digest of the key (6h decided / 2min
unknown, per-process fallback when Redis is down), so a key change is a miss by
construction and the key resolver stays uncached across workers. Reads are
bounded (`WAIT_BUDGET_SECONDS`: a slow provider answers `unknown` now and the
probe fills the cache in the background) and fail soft: only a definitive
refusal hides the mic.

`client_portal.service._stt_ready` is now THE gate — the roster card, the
agent page and `transcribe_portal_audio` all resolve it, so the control a
client sees and the endpoint it calls still cannot disagree (#2212's rule). A
real `/stt` 401 stores `refused`, so the reported symptom heals the cache even
if the probe never ran.

`GET`/`PUT /api/settings/elevenlabs` carry `stt_capability`, `stt_detail` and
`stt_checked_at` beside `key_configured`; re-saving a key invalidates its row.
Settings → Voice renders "can transcribe" / "cannot transcribe — <reason>" /
"transcription not verified" next to the presence badge, via the pure
`utils/sttCapability.js` rule.

Tests execute every consumer of the verdict (classifier partition, cache
keying, bounded wait, roster threading, endpoint refusal, live-refusal
feedback, settings state) — none read source text.

Fixes #2695

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…ue 422 (#2696)

`transcribe_portal_audio` mapped every non-200 from ElevenLabs onto
`422 "Could not transcribe the audio"`. A missing endpoint permission, a
rejected key, exhausted credits, a provider rate limit and a rejected audio
container all read identically, while the actionable status word sat in a
backend WARNING one line above — a live instance cost an operator with
container access a full round-trip to answer a question the system already
knew.

`stt_capability_service.classify_stt_failure()` (pure) maps a provider answer
onto a named category with its own client status and sentence:

  permission / auth / quota   401·402·403 by status word  → 503, operator-actionable
  rate_limit                  429                          → 429, the existing retry wording
  audio                       400·413·415·422              → 422, "the recording could not be read"
  provider                    5xx                          → 502, the existing transport wording
  unknown                     anything else                → 502, still says who failed

No arm returns the old string; a test sweeps every status 300-599 to pin that,
so an unrecognised provider answer cannot regress to it. The client sentence
never carries the provider body. `record_live_failure` keeps the status word +
category for THIS key (`stt:last_failure:<digest>`, 24h; per-process fallback)
and still feeds #2695's capability cache on a 401/403, so the mic hides on the
next load. `GET /api/settings/elevenlabs` (admin-only) carries it as
`stt_last_failure`, and Settings -> Voice renders "Last voice-input failure:
<why> (HTTP <status> <word>) — <time>" under the capability badge.

Fail-soft is unchanged: every branch raises ClientPortalError, never a 500,
and the client can always type instead.

Fixes #2696

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
Resolve docs/memory/learnings.md: append-only log, both sides kept (dev's
2026-09-10 entries first, this branch's 2026-09-11 entry after).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…ace-model-choice)

The spec's own docstring says every box is taken from ONE render, but the
icon-button loop re-measured each button after `picker`/`send` were captured,
and skipped Send by coordinate (`box.x >= send.x`). The picker is a native
<select> sized by its widest option label, which re-measures ~150 ms after
paint when the web font lands (211 -> 199 px at 768 on a cold profile); a
Send read after that sits left of the earlier `send.x`, is mistaken for an
icon button, and fails 'an icon button sits right of the picker'. The race is
on dev too — instrumented: dev's picker drifts identically — this branch just
loses it more often (its unmocked requests shift the timing).

Wait for `document.fonts.ready` and for the picker to hold its box across two
consecutive reads before measuring anything, and skip Send by element
identity. 3/3 green locally at 375/768/1280 on this branch (was 2 of 5 failing
every run).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
Three conflicts, resolved on their merits rather than by side:

* `docs/memory/learnings.md` — both sides appended a new entry at the top of a
  ledger. Kept BOTH; this is an append-only file and picking a side would delete
  someone's lesson.

* `client_portal/service.py` — `_row_to_card` gained `stt_ready` here and
  `can_manage_canvases` on dev, in the same signature and the same roster call.
  Kept both parameters and both kwargs; dev's `may_manage_canvases` comment is
  carried verbatim. Both call sites (the roster and the #2160 single-agent
  lookup) now pass both.

* `workspace-model-choice.spec.js` — took dev's version WHOLESALE, which makes
  this branch's `06b86be9` a no-op. Both commits fix the same flake: this branch
  waited for `document.fonts.ready` and the picker's width to settle, then
  skipped Send by element identity; dev's #2705 instead takes every box in ONE
  `page.evaluate` and emulates reduced motion. Dev's is strictly stronger — when
  all boxes come from one frame, a font re-measure cannot invalidate a relative
  assertion, so the coordinate Send-skip it keeps is sound again and no wait is
  needed at all. Two fixes for one race is worse than either; this one loses.

The two `--check` whitespace warnings (`hardeningGuide.js`, `test_voice_auth.py`)
are pre-existing on origin/dev and arrive with the merge — verified byte-for-byte
against `origin/dev` — so they are deliberately left alone rather than swept into
an unrelated branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…#2696)

Review finding, reproduced by driving the classifier: a 401 whose body carries
no status token was classified `quota` whenever its SENTENCE happened to contain
one of `_QUOTA_WORDS`.

    401 {"detail":"Invalid API key for your plan"}                 -> quota
    401 {"detail":{"message":"…not valid for this subscription"}}  -> quota

Both are ordinary auth failures. The client was told "the account is out of
credits or not on a plan that allows it" and the admin panel said the same, so
an operator whose key simply needed replacing was sent to a billing page. That
is worse than the opaque 422 this issue replaces — it is confidently wrong in a
direction someone acts on.

The cause is that `provider_status_word` collapsed two different things:
`det["status"]`/`det["code"]`, which are machine tokens the provider documents,
and `det["message"]`/a bare string detail, which is prose written for a human.
The comment above `_QUOTA_WORDS` already claimed matching happened on "the
status token"; the code matched whatever came back.

`provider_status_parts(body) -> (token, prose)` keeps them apart, and
`classify_stt_failure` matches ONLY the token. A 401/403 carrying prose alone is
now `auth` — the honest reading of "the key was rejected and the provider did not
say why". `provider_status_word` stays as the operator-facing display value
(token else prose), so nothing is lost from the panel: the prose still shows,
it just no longer votes.

Verified in the backend image: 38 passing in this suite (5 new table rows + 3
new tests), 66 with #2695's alongside it, and the portal/settings sweep has the
identical non-passing set on this branch and on its base — no new failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… a failure (#2795)

Two independent gaps stacked up, so once a room fanned a message out there was
no way to interrupt any agent short of waiting for the turn timeout.

**1. The room's tiles never offered Stop.** `PortalWorkCard` has always
rendered a Stop button; `PortalRoom.vue` simply never handed it `:can-stop` /
`@stop`. Wired to the Work tab's own store action — `stopItem` re-checks the
server's verdict, calls the same portal terminate route, treats a 404 as the
lost race rather than a refusal, and refetches so CANCELLED comes back from the
server instead of being written optimistically. Two surfaces, one cancel path.

**2. The server said those rows were unstoppable.** `can_stop` gated on
`kind in ("turn", "delegated")`, and a room wake projects as `room` — so the
Work tab listed the run and hid the only control that would have ended it.

That widening is not cosmetic: the terminate route's own gates are
`_require_roster(agent)` and `execution_belongs_to_caller` (agent match +
`source_user_email` match), and `_wake_agent` satisfies both by construction —
every wake runs through `execute_task(..., source_user_email=<the poster>)` on
an agent that is a room participant, which on the Workspace can only be an
agent already on the poster's roster. The route accepted these rows all along.
`test_the_projection_and_the_terminate_route_agree` now evaluates both
predicates against one row so the claim cannot rot. The kind list became a
named ALLOWLIST (`STOPPABLE_KINDS`) rather than gaining a third literal: an
unrecognised trigger projects as `other` and must stay unstoppable. `loop` is
still excluded — a loop is stopped from the Loops tab, where stopping the LOOP
is what the person means.

Nothing else about the gate moves: "only the person who started the run may
stop it" is untouched, and stopping one participant's execution leaves the
others alone (the fan-out is sequential, so the next agent is woken after the
cancel returns).

**3. A cancel read as a fault.** `_wake_agent` treated CANCELLED exactly like
FAILED: it posted "<agent> could not respond (no response)." — the surface
blaming the agent for something the reader themselves asked for — and dropped
the cached resume handle. That drop exists for a DEAD handle; a cancel is no
evidence of one, and dropping it makes the next turn pay for a cold context
rebuild. CANCELLED now posts "<agent>'s turn was stopped." and keeps the
handle. The read cursor is still not advanced, so the delta the stopped turn
never answered is re-delivered on the next wake.

**Escape** gets a rule of its own rather than being scoped out: a room fans out
to several agents, so `soleStoppableItem` stops the turn only when there is
exactly one to stop, and is a no-op otherwise — guessing by position destroys
work somebody is still waiting for. In practice the fan-out is sequential, so a
room normally has one live row and Escape behaves as it does in a 1:1. It goes
through `shouldCancelOnEscape` with the typeahead and add-agent popups as
overlays, so ent#155's "anything nearer the keystroke wins" rule is unchanged.

Also guards the live-work `v-if`/`v-else-if` chain with an AST test. Not
hypothetical: the first draft of this change inserted the stop-error line
between two of its arms and silently repointed the "…is thinking…" fallback at
`stopError`. The SFC compiled and every other test passed — the #2794 defect,
committed inside its own sibling fix. `roomComposerChain.spec.js` pins the same
hazard one region down.

Tests: `tests/unit/test_2795_room_stop.py` (17) and
`src/frontend/tests/unit/roomStopWork.spec.js` (17, incl. a negative-tested
chain guard). Full frontend suite 2922 green; the room/work backend suites 152
green.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…2794)

Attaching a file in a 1:1, then @mentioning a second agent, correctly moved the
conversation to a room and left the file behind. `PortalConversation` uploads a
dropped file straight into the CURRENT agent's inbox as it is attached, and the
`escalate-to-room` event carried only `{ agents, message }` — so the person had
watched a chip confirm the upload and believed both agents had it, while only
the original one ever did and the room showed no trace of a file at all.

The rule the issue states is the one this follows: whatever a user could do
inside a room, escalating into one must produce the same result. A room-native
drop is one upload per participant, so an escalation owes exactly that to the
participants that have not already received the file — no more (the origin
agent must not get two copies) and no less.

- `usePortalFileDrop` keeps the `File` handle on each entry, so the same bytes
  can reach a second destination without asking the person to pick the file
  again, and exposes `settled()` so a caller can wait for an in-flight batch.
  Overlapping drops now CHAIN rather than race: two batches firing together is
  the request burst the sequencing already existed to avoid, and `settled()`
  could otherwise resolve while an earlier batch was still going.
- `send()` awaits `settled()` before escalating and emits the entries with the
  message. Waiting is the honest branch of the AC and the last moment it is
  possible, since the composer is about to unmount. It deliberately does NOT
  clear the chips: on success the component unmounts as the room opens, and on
  failure the shell already hands the text back and the chips are still
  standing beside it — the recovery AC with no new plumbing.
- `onEscalateToRoom` fans each carried file out to the participants that do not
  already have it, BEFORE posting the message — 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. Per-agent failures are collected
  rather than aborting the carry.
- The room then SAYS what arrived, for whom, and what did not: a file that
  missed a participant is named per file and per agent ("attach it again here
  to retry"), and a file that never finished uploading in the 1:1 is named too.
  Never silently dropped.

Decidable rules live in the new pure `components/portal/portalAttachments.js`
(`vitest.config.js` pins `environment: 'node'` with no mount harness); the SFCs
are dispatchers over it. 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 — and the notice reads its
recipients off the plan rather than re-deriving them from `agents`.

## Two adjacent defects, found on the way

Escalating lands attachments in a room, and attaching in a room was broken.

**The room composer rendered on the wrong condition.** It shipped as
`<form v-else>` chained to the "this conversation has ended" line (ent#358) —
render the composer unless the room is closed. `v-else` binds to the
immediately preceding ELEMENT, and three changes since have each inserted a
conditional in between (the batch notice and the attachment chips in ent#524,
the budget banner in #2620), so the chain ended on `attachments.length`. Two
live defects in one expression: attaching a file to a room REPLACED the
composer, and a closed room rendered a live composer directly under the line
saying it had ended. The composer now carries `v-if="!isClosed"` — a `v-else`
is a promise about whatever happens to sit above it, and this neighbourhood has
broken that promise three times.

`roomComposerChain.spec.js` had pinned the broken state as the contract, so it
is rewritten to pin the OUTCOME: the composer names its own condition, no
composer form is chained at all, the chips render beside the composer rather
than instead of it, and a closed room still says so.

**The room never cleared its chips.** It accumulated every chip it had ever
drawn, describing files delivered several messages ago as though they were
still pending. It now clears after a successful send, the 1:1's rule.

Tests: `src/frontend/tests/unit/roomEscalationAttachments.spec.js` (33) plus
the rewritten chain spec. Full frontend suite 2943 green, raw-colour and
loading-gate ratchets included.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… flow doc (#2794)

/review on the branch surfaced two real findings and one stale doc.

**Re-entry during the settle wait.** The escalation now AWAITS the in-flight
uploads, and `input.value` is cleared BEFORE that await — so the composer is
empty and live for seconds rather than one microtask. A second Enter in that
window cleared the newly typed text and emitted a second escalation, which
`Portal.vue`'s own `escalating` flag then dropped on the floor: message gone,
no error, and no composer left to recover it from. `escalatingNow` guards it,
held separately from `sending` (which means "a turn is running" and is read by
the header, the Stop control and the reattach poller), and released in a
`finally` on BOTH paths — a flag left set would outlive a FAILED escalation and
leave the composer the shell had just restored permanently dead.

**The carry notice outlived its message.** It describes the message that
created the room, and sat under the composer for every later message too. The
room's own send retires it. It cannot fire early: the escalation's first post
is made by the shell, not by the room.

**Doc.** `workspace-agents-at-the-centre.md` owns the ent#524 upload gesture —
its destination table and its "uploads run sequentially" contract both moved.
Adds the escalation destination, the chaining/`settled()` rule, the
before-the-post ordering, and the two adjacent composer-chain defects.

Full frontend suite 2945 green.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…e defect (#2794)

The #2794 class, worth the ledger because it recurred inside its own sibling
fix during the same session: the room composer's `v-else` was correct when
written, three later inserts stole it, and the guard added afterwards pinned
the broken adjacency as the contract for three months.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…2795)

/review finding on this branch. `_wake_agent` tells a user cancel from a
failure by reading `execute_task`'s returned status — exact on a current agent
image, which relabels its own 504/502/500 to a `cancelled` 200 when its process
registry says the turn was terminated (#679 F3). An OLDER image re-raises:
`execute_task` writes FAILED, that write loses the CAS to the CANCELLED the
terminate route already wrote, and returns FAILED anyway. The room would then
post "<agent> could not respond (no response)." for a stop the reader had just
asked for — the exact AC #4 violation this PR exists to fix — and drop a resume
handle that was never bad.

The 1:1 is immune for a reason worth copying carefully: it never trusted the
return value either, it remembers the cancel client-side
(`cancelledExecutionIds`). A room has no such memory, so it asks the row.

Three properties: the re-read is scoped to the branch where it can change the
answer (the first draft fired on every terminal — a test now pins the
successful-reply path at zero reads); it is fail-OPEN, so an unreadable row
leaves the returned status in force; and it only runs on a path that has
already lost an LLM turn.

Tests: 24 in `tests/unit/test_2795_room_stop.py` (was 17), covering the
old-image cancel, a genuine failure, both no-read paths, and both fail-open
paths.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
#2795)

Found reviewing this branch: `_wake_agent` read `execute_task`'s returned
status to tell a cancel from a failure, which is exact only while the agent
image relabels its own cancel terminals. On an older image the FAILED write
loses the CAS to the terminate route's CANCELLED and returns FAILED anyway.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
#2794)

Operator testing found the hole: attach a file through the rail's **Files**
panel, @mention a second agent, and nothing was carried — and because the
composer held no attachments, not even a notice saying so. Verified on the live
instance: the file reached the 1:1's agent and no other.

There are two upload surfaces and only one of them is the composer.
`PortalRailFiles.vue::uploadBatch` sends straight to its own "Send to" target
and keeps no pending state at all, so `attachments` was empty at send time and
the carry had nothing to work with. The two are indistinguishable to someone
who just wants to attach a file, and the rail is the more discoverable of them.

`clientPortal.uploadDocument` is the ONE funnel all three surfaces already share
(#2582 says so and relies on it), so the record goes there: a carry log of
uploads that have not yet gone out with a message. `mergeCarrySources` unions it
with the composer's own entries, deduped on `name + size` — not on the `File`
reference, which would double-carry every composer upload, since a composer
attachment passes through the same funnel and therefore appears in both views.
The composer entry wins a tie: it holds the live per-file outcome the chip is
rendering, so a chip that FAILED stays failed and is reported as not carried
rather than being masked by a same-named log entry.

The boundary is drawn exactly where the composer clears its chips — on mount
(files from a previous visit are not pending), after a sent turn, and after an
escalation consumes them (so a second escalation in the same conversation cannot
carry them twice). That is the same rule the chips already follow, applied to
the surface that has no chips.

The log retains `File` objects, so it is bounded three ways and the tightest
wins: 15 minutes, 20 entries, 64 MiB — evicting oldest. A single file over the
byte cap is kept anyway; evicting it would silently drop the one file the person
cares about, which is the failure this whole issue is about.

Tests: 50 in `roomEscalationAttachments.spec.js` (was 35) — the merge rules, the
three prune bounds, and the boundary sites. Full frontend suite 2960 green.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…te the carry (#2794)

Operator reproduced it twice on the live instance: attach a file to Analyst
through the rail's Files panel, open Analyst's chat, @mention a second agent —
no carry, and no notice either. Found by instrumenting the live Pinia store, and
proven both ways: with the boundary the log entry survives but `uploadsCarriedAt`
is stamped the moment the chat mounts and the carry finds nothing; without it the
entry is still there at escalation and the file reaches both inboxes.

The rail is a SIBLING of the stage (ent#474) and survives every navigation, so
"attach from wherever you are, then open the chat you want to escalate from" is
the ordinary gesture — and `onMounted`'s `markUploadsCarried` consumed exactly
that upload. A thread switch, ⌘J and an agent switch all remount this component,
so one boundary broke several gestures, and it broke them SILENTLY: an empty
carry set produces no notice, which is the same silence the issue exists to fix.

The rule it was reaching for — "files from a previous visit must not ride along"
— is already covered twice: `CARRY_MAX_AGE_MS` bounds staleness, and the log is
plain Pinia state, so a page load starts it empty regardless. Mounting a
component was never evidence that anything had been SENT. The two things that
genuinely consume a pending upload are a message going out and an escalation
taking it, and both already mark it themselves.

The replacing test asserts the ABSENCE at the mount site and pins the consume
points as a whole-file count, so a third one cannot be added quietly.

Full frontend suite 2961 green; verified live on the operator's exact flow.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
`git_dir_bytes` was declared INTEGER for the PostgreSQL backend (tables.py
Integer + Alembic 0019 `INTEGER`), i.e. int4 with a 2 GiB ceiling. The column
exists to observe workspace-repo bloat (#1596), so the values it is there to
record are exactly the ones that overflowed: any agent whose `.git` passed
2,147,483,647 bytes made every SyncHealthService upsert raise
`psycopg2.errors.NumericValueOutOfRange: integer out of range`, and that
agent's sync health went dark at the moment it mattered. SQLite never showed
it (its INTEGER is 64-bit), which is how it shipped on 2026-07-14.

Dual-track (Invariant #9):
- schema.py: `git_dir_bytes BIGINT` — single source of truth for both
  backends (init_schema_postgres translates the same string), so fresh PG
  builds get int8 via 0001_baseline.
- tables.py: `BigInteger`.
- Alembic 0062: `ALTER COLUMN git_dir_bytes TYPE BIGINT` (proven on a real
  postgres:16 upgraded from 0061 with the column forced back to int4:
  information_schema reports `bigint` afterwards and a 44 GiB insert lands).
- SQLite `agent_sync_state_git_dir_bytes_bigint`: a declared-type rebuild
  via the #1160 rename-swap, NOT a bare no-op. schema-parity compares a
  fresh init_schema DB against an upgraded one by declared column type, so a
  no-op would leave upgraded files reading INTEGER against a fresh BIGINT
  and turn that guard red forever. One row per agent, all columns copied
  verbatim, the one index re-created, idempotent.

CI regression seam: `TestGitDirBytesRoundTrip` in
test_1596_git_sync_observability.py is now `requires_postgres`, so the
schema-parity PostgreSQL tier (#2434) runs its [postgres] leg — the leg that
had been red for two months while the tier selected only marked tests. A new
information_schema assertion names the column type rather than a stack; two
SQLite tests pin the rebuild (rows preserved, index back, no-op pre-#1596).

Audit of sibling byte-count columns: `agent_shared_files.size_bytes` stays
Integer — bounded by MAX_FILE_SIZE_BYTES (50 MB) at the only writer, so it
cannot reach int4's ceiling by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rolled p (#2795)

A failed verb surfaces an `InlineError` next to its control and persists until
dismissed (design-system contract, principle 18). The refused-cancel line was a
hand-rolled `<p role="status">` with no dismiss; the sibling surface for the
same verb already does it right (`PortalWork.vue:43`, same `stopError` ref).

`role="alert"` comes with the primitive, which is the correct semantic for a
problem the person must notice.

The AST guard locates the element by its static `data-testid`, which the
component node still carries, so `roomStopWork.spec.js`'s "the stop-error line
sits OUTSIDE the chain" is unchanged and still bites.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rename-swap copies exactly the columns its INSERT...SELECT lists and DROPs
the old table. Compare the live agent_sync_state column set against that list
first and raise — before touching anything — on an unknown column, so a
future/unforeseen column is surfaced as a boot failure (`first_pending`,
#1160) rather than silently destroyed. No known path produces one today; this
is a belt on a migration whose failure mode is data loss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s are BigInteger, PG tests need the marker

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on a problem (#2794)

Three mechanical corrections to the notice added by this PR:

- The dismiss control was a hand-rolled underline link. Buttons are
  `BaseButton` (design-system contract, Primitives first) — ghost/sm, the
  variant every other dismiss-shaped control in the portal already uses.
- `role="status"` is polite, so the problem arm — files that did NOT travel —
  was announced as a passing remark. It now reads `alert` when
  `carryNotice.problem` and stays `status` otherwise.
- `vi` was imported and never used in the spec.

Markup, testids and the source guards are otherwise untouched.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iles (#2794)

A client opened a room with `analyst-demo` and `sidekick`, sent a screenshot,
and asked "@sidekick what is displayed on the pasted image?". sidekick replied
"I don't see any image attached to your message." — truthfully.

Delivery was never the problem. The bytes were in an inbox, the rail listed
them, the transcript carried the question. What did not exist was the TELLING:
a room turn was `_build_turn_prompt`, i.e. a header plus the transcript, and
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.

Proven by isolating it: on the pre-fix code, with the file placed in sidekick's
OWN inbox by hand, it still answered "I don't see any image attached".

The composition moves to `client_portal.service.collect_inbox_context`, which
both `portal_chat` and `shared_sessions.service._wake_agent` now call. The room
prepends the manifest to its turn prompt and passes `images=` to
`execute_task`.

Three decisions the diff does not show:

- 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 put the file there.
  Residual, stated in the docstring: a room with two humans surfaces only one
  of them;
- the image-intent test reads the WHOLE delta, agent lines included. "@sidekick
  look at the screenshot the client sent" is an ordinary room move, and scoping
  it to human text would make 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 no-op.

`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` — the
failure being fixed IS a surface that quietly composes nothing, so a third one
is caught, not just a second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… pasting works (#2794)

Two more gaps on the same path as the parent commit, each individually enough
to produce the reported "I don't see any image attached".

**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 one chat, two meanings for "send a file here", and the
one with the visible control was the wrong one. So the client's screenshot
reached `analyst-demo` and the question went to `sidekick`. A room now defaults
to EVERYONE in it, with the individual agents still selectable underneath.

The rules live in `portalFiles.js`, not the SFC (`environment: 'node'`, no mount
harness — a rule in a .vue file is a rule no test can reach). Two encode a
direction rather than a value:

- `resolveRecipients` fails TOWARD the fan-out: a target that has left the room
  resolves to everyone. A file sent to one agent too many is recoverable from
  the rail's own delete; 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 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.

**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 the cost: the client's file was called "Pasted image (3).png" —
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. 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…en it cannot be asked (#2695)

Review finding on the AC the issue underlines ("must not reintroduce
cross-worker staleness"): `read_cached` fell through to the per-process
`_local` on a Redis MISS, not only on a Redis error, while `store` wrote
`_local` unconditionally and `invalidate` popped it in the calling worker
only. Sequence: admin re-saves the key on worker A → A pops its local,
deletes the Redis row, re-probes; if that probe lands `unknown` (2-minute
row) or times out, the row expires and worker B serves its stale `refused`
from `_local` for the rest of the 6 h — mic withheld on B, offered on A,
for the same customer.

`read_cached` now treats Redis as authoritative whenever it ANSWERS: a hit
is returned, a miss returns None AND evicts this worker's local copy (a
corrupt row is a miss too, never a fall-through); `_local` is read only
when there is no client or the read raised. `store` still writes both, so
the outage fallback stays warm.

Tests: the Redis-present branch had zero executing coverage (the autouse
fixture stubs `_redis` to None). A dict-backed `_FakeRedis` shared by two
module instances (= two uvicorn workers, each with its own `_local`) now
pins: TTLs reach the wire and rows decode; an invalidate on A is honoured
by B and B's local copy is evicted; the review's 6 h sequence (unknown row
expires) is a miss; the local copy is used only while the read raises; a
corrupt row is a miss. 4 of the 5 are red against the pre-fix service.

Riders from the same review:
- `PROBE_TIMEOUT_SECONDS` comment said it sits BELOW `WAIT_BUDGET`; it is
  8.0 vs 4.0 and deliberately above — the reader stops at 4 s, the probe
  runs on to fill the cache. Comment now says why.
- "the Workspace mic is hidden" (service log, docstring, Settings hint,
  backend.md) is browser-dependent: `resolveMicMode` falls back to Web
  Speech when `serverStt` is false, so a refused key disables server-side
  dictation and hides the mic only where the browser has no engine.
- `stt_capability_service.py` gets its own catalog entry in backend.md
  instead of riding the `tts_service.py` line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2794)

The room could not see the client's files because the sentence that tells an
agent about them was ~25 lines inline in `portal_chat`. Three independent causes
produced one symptom, and the symptom named the agent ("I don't see any image
attached") rather than the platform — which is why it read as a model failure
and got worked around instead of filed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…hat makes (#2794)

`test_the_spawn_sits_between_the_persist_and_the_turn` indexed on
`images, image_names, doc_files = await _collect_inbox_for_turn` as "the first
thing the turn path does after the spawn". Moving the manifest composition out
to `collect_inbox_context` left that string in the file — inside the new
function, ~1900 lines BELOW the spawn — so `persist < spawn < turn` stayed green
while comparing the positions of two lines in different functions. Green for the
wrong reason is the failure mode this ledger keeps recording, so the anchor is
now the call `portal_chat` itself makes, plus a uniqueness assertion so a later
refactor cannot let `index()` drift to a second occurrence.

Mutation-checked: restoring the pre-#2794 inline shape fails it
(`ValueError: substring not found`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
Review finding on my own round-two commit. The rail's fan-out rewrite dropped
the server's response:

  - const res = await feeds.upload(agent, file); sent.push(res?.filename || file.name)
  + await feeds.upload(agent, file);             sent.push(file.name)

`upload_client_file` sanitizes through `_safe_filename` and returns the name it
actually wrote, so the receipt could name a file the inbox does not contain —
the same honesty class this PR exists to fix, reintroduced by its own fix (the
§4.14 "a fix that breeds the next bug" shape).

The failure line deliberately keeps `file.name`: for something that never
arrived, the name the person picked is the only one they can recognise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…us commit (#2794)

`b10743e0d` committed 384 lines of unrelated Playwright capture scripts
(`.capture-*.mjs`, `.probe.mjs`) that had no business in this PR. They were
untracked files in the worktree — restored there by an accidental `git stash
pop` during this session — and `git add -A` took them along with the one-line
fix it was meant to carry.

Checked before removing, because this is a public repo: they contain no
credentials (they read `process.env.ADMIN_PASSWORD`), no internal URLs (only
`localhost:8001`/`:8002`), and no PII. So this is scope and hygiene, not an
incident — CLAUDE.md "keep the working directory clean".

`--cached` only: the files stay on disk as untracked, which is exactly the state
they were in before, and they remain in the stash they came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…ot a bare sys.modules write (#2695)

Both red checks on this PR were the same two lines.

`_load_worker` builds a second instance of the module to stand in for a second
uvicorn worker. The synthetic name has to be in `sys.modules` while
`exec_module` runs — `@dataclass` resolves the class's module BY NAME during the
module body — and that was done with a bare assignment plus a `finally` pop:

    sys.modules[name] = mod
    try:
        spec.loader.exec_module(mod)
    finally:
        sys.modules.pop(name, None)

`tests/lint_sys_modules.py` exists to stop exactly that, because one leaked
entry is a module every later test in the session imports instead of the real
one. It failed twice over: the `lint (sys.modules pollution check)` job
directly, and `regression diff`, whose sole new failure was the pytest mirror of
the same rule (`test_lint_sys_modules::test_committed_baseline_matches_current_repo_state`).

`monkeypatch.setitem(sys.modules, name, mod)` is the fix the lint names, and it
is available here because the only caller is a fixture. It is also stronger than
the hand-rolled pair: pytest removes the entry at teardown even if `exec_module`
raises before the `finally` is reached.

The entry now lives until teardown instead of being popped immediately. Harmless
and deliberate — the name is unique per fixture instance and nothing after
`exec_module` resolves it.

Not a baseline regeneration: the violations are gone, so the committed baseline
(0 for this file) is satisfied as written.

Verified: `python tests/lint_sys_modules.py` → "no new violations"; the two
affected test files pass under all three CI seeds (12345 / 67890 / 99999), 58
tests each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
dolho and others added 9 commits September 15, 2026 14:15
…2791)

Log out and log back in on the main app with a Workspace tab open from the
previous session, and the NEW session dies within seconds. That tab holds the
old JWT, its 20s poll 401s, and the handler calls `authStore.logout()` — which
removes `localStorage['token']`, i.e. the token the re-login had just written.
The handler never asked whether the credential that failed was still the current
one.

Underneath it, one browser held the platform JWT in two places that could
disagree (the in-memory `axios.defaults` copy vs localStorage re-read per
request), with no `storage` listener anywhere under `src/frontend/src`, and
three separate 401 implementations that had each drifted.

`utils/platformSession.js` makes all three singular.

**One source.** `readStoredToken()` is the only reader. The `axios.defaults`
copy is no longer written (`setupAxiosAuth` is a documented no-op); `main.js`
installs a global axios REQUEST interceptor that rebuilds the header per
request, so ~368 bare-`axios` call sites get the current credential without
being rewritten and a new one cannot forget to opt in. This is the AC's second
half ("or is provably never read in preference to the store") and it is the
stronger of the two.

An explicit header still wins, and exactly one caller needs that: the logout
revoke. #2258 clears local state BEFORE the revoke, so with the defaults copy
gone the revoke would have gone out unauthenticated and #187 would have silently
stopped revoking anything. The token is captured before the clear and passed
after it.

**One verdict.** `sessionLostVerdict()` → `ignore | stale | logout`, pure so a
node-env spec can reach it. `stale` — the failed token is not the stored one —
is the fix for the report: adopt the current session instead of destroying it.
The Workspace veto closes AC #5: a client whose browser holds a DEAD operator
JWT is no longer thrown onto the operator login by `initializeAuth`'s
`fetchUserProfile`. It stays scoped by path as well as by portal token, so an
expired operator JWT still bounces off an operator surface.

**One handler.** `setPlatformUnauthorizedHandler` / `notifyPlatformUnauthorized`.
`main.js` registers the reaction; `api.js`, the global interceptor and
`portalHttp` report to it. `api.js` no longer hard-reloads, no longer leaves
`auth0_user` behind, and carries no private predicate.

**Cross-tab sync.** A `storage` listener adopts a sibling's login and drops the
mirror on a sibling's logout — without a second server revoke and without
writing to storage, since N background tabs reacting to one event would each
clear it again. Neither branch navigates: a background tab pushing /login is the
noise this issue reports.

`workspaceSession.spec.js`'s predicate block asserted its own hand-copied
`shouldBounce` helper — which is why it stayed green while the three real
predicates drifted, and would have stayed green through this change too. It now
asserts the real function.

Verified: 131 files / 2937 tests pass. The three load-bearing guards were
mutation-checked — removing the `stale` arm reds 2, letting the interceptor
overwrite explicit headers reds 1, restoring `api.js`'s own logout reds 2.

Related to #2791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…eletes (#2791)

Review finding on my own diff. The docblock's "why not `axios.create()`" argued
from `stores/auth.js` mutating `axios.defaults.headers.common.Authorization` at
login and deleting it at logout — the exact copy #2791 removes.

The conclusion survives the mechanism (the global is still the only thing
carrying a live credential, now because the request interceptor resolves it per
request and `create()` gives an instance its own chain the global never
reaches), which is precisely why the comment would have gone on reading as true.
A comment that describes a mechanism the code no longer has is the class this
repo's learnings ledger already records; the old reason is kept in parentheses
because it explains why the answer did not change.

Related to #2791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
# Conflicts:
#	docs/memory/architecture/backend.md
# Conflicts:
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/learnings.md
…rvice; keep-both duplicated it

The keep-both rule is right for append-only docs (learnings.md) and wrong here:
#2696 REWRITES the tts_service bullet to inline the capability service and
deletes the separate one. Verified against both branch tips (1 stt bullet on
2695's, 0 on 2696's). Resolved to 2696's tip — for a stacked pair that IS the
intended combined state.
@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 15, 2026
@dolho

dolho commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Closing: superseded. vybe's train #2808 landed #2803 and #2798 into dev minutes after this opened, so this train is stale and CONFLICTING — which is also why it never ran a single check (pull_request workflows run against refs/pull/N/merge, and GitHub cannot create that ref for a conflicting PR).

Nothing was merged from here. Recorded for whoever rebuilds a train over the remainder (#2805, #2699, #2702, #2811, and #2799 if it has not landed):

@dolho dolho closed this Sep 15, 2026
@dolho
dolho deleted the train/20260915-1457 branch September 15, 2026 12:10
@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant