Conversation
… 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
…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
…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.
Contributor
Author
|
Closing: superseded. vybe's train #2808 landed #2803 and #2798 into 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):
|
|
Resolve by merging |
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.
Integration surface for #2805, #2699, #2702, #2798, #2799, #2811. Never merged; members merge individually once green.
Built with
git merge --no-ffon 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.
docs/memory/learnings.md×2 (fix(workspace): a running room turn can be stopped, and a stop is not a failure (#2795) #2798, fix(workspace): attachments travel with a 1:1 escalated into a room (#2794) #2799) — append-only by contract, both entries kept in landing order. Four distinct entries on the train, no duplicate headings.docs/memory/architecture/backend.md(fix(workspace): the /stt provider error says why, instead of one opaque 422 (#2696) #2702) — keep-both was wrong here. bug(workspace): the /stt 422 collapses every provider failure into "Could not transcribe the audio" while the cause sits in a backend log #2696 does not append; it rewrites thetts_service.pybullet to inline the capability service and deletes the separatestt_capability_service.pyone. Mechanical keep-both produced twotts_service.pybullets and resurrected the deleted one. Resolved to bug(workspace): the /stt 422 collapses every provider failure into "Could not transcribe the audio" while the cause sits in a backend log #2696's tip instead: for a stacked pair, the upper branch's tip IS the intended combined state. Verified against both branch tips (1 stt bullet on 2695's, 0 on 2696's).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
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.