Skip to content

DO NOT MERGE — merge train: 2660, 2653, 2651, 2652 - #2664

Closed
vybe wants to merge 10 commits into
devfrom
train/20260909-1840
Closed

vybe wants to merge 10 commits into
devfrom
train/20260909-1840

Conversation

@vybe

@vybe vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE. Integration surface only — this branch exists to run the full suite over #2660, #2653, #2651 and #2652 together, which nothing else in the lifecycle does: every PR is tested against dev, never against its siblings. The members merge individually once this is green, and this PR is then closed and its branch deleted.

No closing keywords here on purpose — the members carry those, and the train must promote and close nothing.

# PR Lane Author Subject
1 #2660 A trinity-ability .claude submodule bump — strict fast-forward, docs-only
2 #2653 B dolho Trinity's logo and wordmark in the Workspace top left (ent#556)
3 #2651 B dolho a pull-dispatched terminal triggers SUB-003 (#2643)
4 #2652 B dolho an arriving message no longer yanks the transcript (#2624)

Assembled with --no-ff on each member's head SHA, so a red job can be attributed to a PR rather than a commit. All four merged with zero conflicts.

Why these four, and what this run is actually looking for. #2653 and #2652 both change the portal surface — Portal.vue / PortalSidebar.vue against PortalConversation.vue / PortalRoom.vue / the new PortalJumpToLatest.vue — and only a combined unit + frontend-e2e run exercises them in the same tree. Locally the merged tree gives 115 files / 2572 tests passed, so the two coexist at the unit layer; e2e is the part that needs real Chromium.

Four other candidates were validated and ejected to their authors today (#2663, #2656, #2657, #2532), each with a reason on its own thread.

dolho and others added 10 commits September 9, 2026 14:30
`pull_coordination_service.apply_task_result` is a CAS-won terminal
writer and carried every OTHER terminal hook — the #1578 completion
event, the #1804 activity close — but no SUB-003 hook. So a pull-owned
turn the provider refused:

  * recorded no `subscription_rate_limit_events` row, so the subscription
    was never skip-listed and no usage card or pressure badge counted it;
  * never called `handle_subscription_failure`, so the agent stayed
    pinned to the subscription that had just refused it;
  * landed FAILED, where `redelivery_governor` treats `billing` as a
    correlated code — so the re-delivery that might have recovered it is
    the thing most likely to be paused.

Everything the push path gained in #441/#471/#792, and everything #2638
added on top, was unreachable from a pull-dispatched turn.

Inert today (`PULL_MODE_PILOT_AGENTS` gates the path and nobody is
piloted), which is why it needed a test rather than a soak: piloting a
subscription-backed agent would have silently removed SUB-003 from that
agent with nothing saying so.

Three properties are load-bearing.

**The vocabularies do not line up.** The worker's typed `error_code`
calls the quota class `billing` (`result_callback._STATUS_MAP` maps an
agent 429 to it); this subsystem calls it `rate_limit`. `_switch_failure_kind`
is the map, and it is an ALLOWLIST — a code it has not heard of switches
nothing, because "switch unless the code looks benign" would churn an
agent through every subscription it owns the first time a worker reports
an unfamiliar crash class.

**CAS-won branch only**, beside the two hooks it sits with. A replayed
terminal short-circuits above the write and a late one loses the CAS, so
neither spends a second switch (the #1083 rule). Past that gate,
`handle_subscription_failure` owns the rest: it records the event
unconditionally, then takes the #799 per-agent `agent_switch_lock` and
re-reads under it, so two failures racing on one agent still switch once.

**A SUCCESS terminal is excluded, on the merits.** The gate is
`error_code` rather than the FAILED/CANCELLED split — a worker that
labels a quota refusal `cancelled` still refused for a quota reason — but
a turn the provider SERVED is evidence the subscription works, so a stray
`error_code` riding a success must not move the agent off it. It is also
the one branch where the sink never binds `err_text`, so an ungated hook
would raise NameError and turn a committed, billed terminal into a 500.

Re-delivery, stated explicitly per AC #3: this hook re-delivers nothing.
A switched agent's next re-delivery is expected to succeed IF the lease
reaper re-queues the row (under `MAX_REDELIVERY`) and the #1085 governor
is not paused — and `billing` is exactly the class the governor counts,
so a fleet-wide quota event can legitimately hold re-delivery back even
after a successful switch. The switch is not wasted then; the next
scheduled or claimed turn benefits.

The sink is sync and its caller async, so the call goes through
`subscription_auto_switch.spawn_subscription_failure` — the same wrapper
shape as `spawn_task_terminal_event` and `spawn_close_execution_activity`:
one coroutine (not a wrapper around a pre-built one, which leaves two
objects to close and still warns "never awaited"), a strong reference
held until it finishes, a raising switch logged and swallowed, and
no-running-loop treated as a clean skip.

Still NOT wired on the pull sink, named so it is not mistaken for done:
the #526 AUTH dispatch breaker and the governor's own
`record_terminal_failure`. `apply_result` has both; neither is a SUB-003
concern.

23 tests, mutation-checked: a pass-through failure-kind map, hoisting the
call above `if won:`, and dropping the SUCCESS exclusion each turn the
suite red. Plus a structural AST pin that the one call site stays inside
the CAS gate.

Related to #2643. Related to #1081.

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

Both Workspace chat surfaces scrolled to the bottom on EVERY arrival.
`scrollDown()` was an unconditional `scrollTop = scrollHeight` and every
path called it: the room's 3s poll, an agent reply settling, a cancelled
turn settling, the history load, the send. Scroll up to re-read an
earlier answer and the next message yanked you back down — in a room,
from any participant, every three seconds, so reading anything but the
tail was close to impossible.

The design-system contract already said it (principle 5: updates
preserve scroll, selection, focus, and expansion). What was missing is
the distinction between an ARRIVAL and an INTENT:

  * an arrival — the poll fetching somebody else's message, a reply
    settling, a stream ending — follows only if the reader was already
    following;
  * an intent — sending, opening a thread, clicking jump-to-latest —
    always pins and re-arms, whatever the prior position.

Hence two entry points (`onArrive` / `pinToBottom`) rather than a flag
threaded through one `scrollDown()`.

The rule lives in ONE composable, `useStickToBottom`, because the two
components own the same `flex-1 min-h-0 overflow-y-auto` container and
had two copies of the same bug. `chat/ChatMessages.vue` still carries
the unconditional pattern behind its `autoScroll` prop — a different
surface, out of this issue's scope, and it can adopt the composable
unchanged.

Decisions worth keeping:

* **64px, not exact.** `scrollHeight` EXCLUDES the border under
  `box-sizing: border-box` (the gotcha `PortalRoom.vue` already
  documents), and fractional layout leaves a sub-pixel gap when the
  reader IS at the bottom. It is also roughly "a line off the bottom",
  which is what a reader would call still following.
* **A missing element answers "following".** Before first paint, and on
  a thread too short to overflow, the reader is at the bottom; false
  would open every thread detached and badge its first reply as unread.
* **A thread switch re-arms WITHOUT scrolling.** The outgoing element is
  about to be replaced; the incoming thread's load pins it.
* **The affordance needs detached AND behind.** Scrolled up with nothing
  new below is nothing missed. It says what was missed ("3 new
  messages"), since a bare arrow cannot tell a reader whether to go.

One defect the unit tests structurally could not find, and the e2e did:
**pinning takes two passes, one frame apart.** `nextTick` covers the
component's own patch, but a child patching on a later tick — the
loading skeleton swapping out, markdown rendering a long thread — grows
the transcript after the first measurement and the browser clamps the
assignment to the height it had then. Measured in a real browser: a
40-message thread opened 20px above its newest message, stably, every
time.

Verified: 2537 frontend unit tests (21 new over the composable), and the
e2e run against a real Chromium — the two room cases and the
open-at-the-bottom case pass, and reverting `onArrive` to the
unconditional scroll turns the arrival case red.

Related to #2624

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

The Workspace's brand corner was a hand-drawn outline `<svg>` and the
bare word "Workspace", in two places that had to agree and had no shared
source: the signed-in shell (`PortalSidebar.vue`) and the sign-in screen
(`views/Portal.vue`). The real marks already shipped and were already
used by `NavBar` and `PublicChat`; the Workspace never adopted them.

Both surfaces now render ONE component, `PortalBrand.vue`. "Both
surfaces carry the same mark and wording" is an acceptance criterion,
and two copies of the markup is exactly how that stops being true.

The name is a value in `portalBrand.js`, not a literal in two templates
— and it is deliberately the PRODUCT name, not an instance name. The
platform does resolve a per-instance label
(`services/instance_identity.py`), and a company running Trinity could
plausibly want its own name in that corner; but this surface's audience
is the operator's own organisation (ent#78, ruled 2026-09-06) and naming
the product is the point of the ask. One constant with one reader is
what keeps the white-labelling call cheap later.

Details that are load-bearing rather than tidiness:

* **The light/dark swap follows `NavBar`** rather than inventing a
  second pattern — a dark mark on a dark ground is the one failure this
  must not have. Verified in a real browser at both themes.
* **Not a dead affordance, and not a platform route.** The shell links
  to the Workspace root; the sign-in screen renders the mark INERT,
  because a client session holds no `users` row and the reader is
  already at the root. `:is` picks `router-link` or `div`.
* **The accessible name is on the wrapper, the images are decorative.**
  A screen reader says the product once — and still says it when the
  wordmark truncates away at a narrow sidebar. (This is why the images
  carry `alt=""` rather than `alt="Trinity"`: with the name beside them,
  alt text would double-read.)
* **`truncate min-w-0` on the wordmark, `shrink-0` on the mark.** The
  sidebar resizes to `SIDEBAR_MIN` (200px, ent#492) and this row also
  carries the ask and unread badges; without them the name pushes the
  badges out of the band.
* **The mark keeps the 24px box the icon it replaces used**, so the
  `h-14` band ent#547 is shortening does not grow. Measured at 56px.

Tab titles: the platform renders `Trinity — <label>` (#1418), so a
Workspace tab already reads "Trinity — Workspace". That single format is
kept rather than given a second one for one surface. What is added is
identifiability where the ROUTE knows its subject:
`/workspace/a/:agentName` now titles `Workspace · <agent>` through the
existing `agentTabTitle`, so an operator sees the display name and a
client falls back to the slug. Threads and rooms cannot do this — their
subject is component state, not a route param.

The generic icon stays where it is still a legitimate EMPTY-STATE
illustration (the `Portal.vue` stage zones). It was a brand mark only in
the two blocks this replaces.

Verified: 2534 frontend unit tests (18 new source-guard cases,
mutation-checked — a dark-on-dark swap and a platform-route link each
turn the suite red), plus a real-browser pass over both surfaces at 1280
and 1920, light and dark, asserting exactly one mark visible and the
right one per theme.

Related to Abilityai/trinity-enterprise#556

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

CI reproduced the exact defect the two-pass pin was meant to remove: a
40-message thread opened 20px above its newest message. Frame-chasing is
the wrong primitive, and this is the third attempt at it:

  1. one `nextTick` — short by 20px, found by the e2e locally;
  2. a fixed second pass one frame later — fixed it on a fast local
     machine, and CI still opened the thread 20px short;
  3. a settle loop re-pinning until the height stopped changing — ALSO
     short, because the transcript grew again after the loop had already
     watched it hold still for a frame. Measured: `scrollHeight` stable
     at 4316 from the first observation, `scrollTop` stuck at 3813,
     i.e. the pin had run at 4296 and nothing re-ran.

There is no window that is both short enough to be free and long enough
to be right, so the window is gone. A `ResizeObserver` watches the
container (a resized window, a dragged column) and its content wrapper
(the transcript growing) and re-pins whenever the reader is following —
any cause, any delay, nothing to tune. It also makes a STREAMING reply
follow for free, and it is gated on the same `following` flag as every
other arrival path, so there is one rule and not two. Setting
`scrollTop` changes no box's size, so it cannot feed itself.

The settle loop also had a defect of its own, found while testing it: it
read `scrollHeight` three times per pass, so the stability check
compared a different measurement than the one it had just assigned. That
code is gone rather than fixed.

Verified in a real browser: the gap is 0 from the first frame, and all
three e2e cases pass. 2540 unit tests, with the settle-loop cases
replaced by four that pin the observer contract — it re-pins on late
growth, watches both boxes, leaves a DETACHED reader alone when the
transcript grows, and still pins where there is no ResizeObserver at
all.

One test-only note worth keeping: the observer cases `markRaw` their
stand-in elements. `ref({...})` proxies a plain object, so an identity
assertion fails against the raw one — Vue never proxies a real DOM
element, so the raw form is the faithful stand-in.

Related to #2624

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

Submodule pointer only. Brings in trinity-dev `226f228`: DEVELOPMENT_WORKFLOW.md's
P-09b listed "a member with a CRITICAL is ejected, not repaired" as load-bearing,
and justified it with the blocking argument the skill explicitly rebuts. The skill
replaced that rule in 9dcd4cb — ejection turns on whether a fix is mechanical or
needs intent, not on severity — so the two documents disagreed on a load-bearing
rule, and a reader of the workflow doc would eject a member the skill says rides.

Also keeps P-09b's Phase 2 summary honest about the coverage question, cross-
referenced to #2659.

No product behaviour. Lane A.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… owned it

`portalBrand.js` declared `WORKSPACE_ROOT = '/workspace'` while
`portalUtils.js` — same directory — already exported the identical constant,
consumed by `stores/clientPortal.js:546` for the sign-out target and pinned by
`portalAgentPageUx.spec.js` and `workspaceSession.spec.js`. `PortalSidebar.vue`
imported the new one on the line directly above its existing destructure from
the module that already had it.

That is the defect this PR was written to remove — its own header calls the old
state "two places that had to agree and had no shared source" — fixed for the
name and recreated for the route. The two specs pinned the two copies
independently, so a later divergence (the sidebar mark linking one way, sign-out
redirecting the other) would have shipped green. It was also a latent
`SyntaxError`: adding `WORKSPACE_ROOT` to the adjacent `./portalUtils`
destructure would have created a duplicate binding.

The reasoning that justified the constant moves with it rather than being
dropped — the brand mark is now documented at the definition as a second
consumer, since that is where the next reader will look.

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

Verified: npm run test:unit → 113 files / 2534 tests passed; vite build OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lts14MGxLdPU5tvxL6CfAo
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.

2 participants