Skip to content

refactor(ui): the non-chart skeleton sweep, and the sidebar ordered by most recent collaboration (#1921, abilityai/trinity-enterprise#491) - #2573

Merged
vybe merged 33 commits into
devfrom
dolho/issue-1921-polish
Sep 7, 2026
Merged

vybe merged 33 commits into
devfrom
dolho/issue-1921-polish

Conversation

@dolho

@dolho dolho commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Journey Impact

Journey Impact: none: both items re-treat or re-order existing surfaces — no new promise, no new lane. #1921 swaps a loading visual; ent#491 re-sorts an existing list. Covered by unit tests on the roster sort, the collapse slice, and the loading-treatment allowlist.

Summary

The two polish items, stacked on the tandem branch (#2568 → this) because ent#491 touches portalUtils.js and PortalSidebar.vue, which #2558 rewrites.


#1921 — one non-chart loading treatment

Bespoke spinners, animate-spin rings and bare "Loading…" text on non-chart
surfaces become skeleton placeholders keyed on "no data yet". The scanline beam
stays only where a chart loads (principle 12 as amended by #2540).

The primitive was the first fix. SkeletonLoader.vue — the component this
sweep exists to spread — used a bare animate-pulse with no
motion-reduce:animate-none. It failed the issue's own AC 5, and every surface
converted to it would have inherited the violation.

HOLDOVERS is now empty. Both non-chart ScanlineReveal consumers (a skills
list and a JSON <pre>) are converted, so the beam is chart-only in fact, not
only by rule. The allowlist stays an explicit empty constant so a new non-chart
importer still fails loudly rather than quietly joining a list that no longer
exists.

Swept (the issue's named inventory, minus what #2540/#2544 already took):
Settings (page + three table bodies + activity), GitPanel (status + PAT),
SchedulesPanel (list + webhook), TasksPanel (list + log modal),
ExecutionsPanel, InfoPanel, TemplateSelector, SystemViewsSidebar,
Operations (queue first load), Login, Dashboard, /m ×4,
LibrarySkillsSection, FinishSetupCard.

Each placeholder mirrors the surface it becomes — list rows for a list, table-cell
bars for a table row, form fields for a form — because a centred ring in a
differently-sized box is the layout shift principle 4 forbids.

Two real bugs fell out of it

TemplateSelector and GitPanel both gated on a bare v-if="loading", so a
background poll with content already on screen swapped the loaded grid / status
back to a placeholder. Both now key on a firstLoad verdict. The #1927 ratchet
falls 72 → 70.

One spinner I deleted and then put back — worth reading

The Dashboard's history spinner looks like a background-refresh indicator, and
the first pass removed it as one, citing "scheduled background refresh is
invisible"
.

It is not one. fetchHistoricalCommunications has exactly three callers —
mount, the Refresh button, and a time-range change — and no interval anywhere. All
three are first-load or explicit user actions, which is precisely when in-flight
feedback is sanctioned (AC 6). The deletion rested on "it fires on every poll"
without checking that a poll existed.

#2536's e2e caught it, because that spec measures the view-switcher's bounding
box with and without this element — the deletion removed its instrument and the
test failed on its first assertion. Restored, with the reasoning recorded in the
requirements entry: the rule only applies once you have shown there is a background
refresh.

The baseline is regenerated to 70 rather than renaming the flag to slip past the
scanner, which would defeat the ratchet instead of satisfying it.

Sanctioned spinners are untouched (AC 6): the 16px indicator inside a pressed
control, and the refresh icon that pairs with a disabled refresh button.

/m is plain CSS, not Tailwind, so it gets the recipe spelled out locally — pulse
in the chrome fill, a prefers-reduced-motion static fallback, an sr-only line —
rather than a Tailwind class that would not apply there.


ent#491 — sidebar ordered by most recent collaboration

The agent you last worked with on top; agents with no history follow
alphabetically, so a freshly shared agent is still findable.

Rooms count — and needed a real timestamp first. enterprise_rooms has no
last_message_at column, and list_rooms returned only created_at. A room's
"recency" was therefore its creation time: a busy month-old room ranked below
one opened this morning and never used. last_message_for_rooms is a batched
MAX(created_at) GROUP BY room_id riding the same round trip as the existing
message-count query — derived, not denormalised, because a column would need a
writer on every append for a value one GROUP BY already returns. An empty room is
absent from the result and keeps created_at, which is the honest answer for a
room nobody has spoken in.

Then orderRosterAgents counts rooms, crediting every participating agent —
the unreadByAgent fan-out rule, for the same reason: there is no single agent a
multi-agent conversation is "with". Before this it skipped is_room rows outright,
so an agent you only ever meet in a room read as never-used.

Sends re-sort; replies do not — and those two clauses pull against each other.
last_message_at moves identically for a send and a reply, so a purely derived
order would reshuffle the sidebar under the reader's cursor every time a brief
landed for a different agent. The order therefore reads a session-held snapshot
seeded from thread recency and advanced only by the user's own sends. The seed
fills only missing keys, so a refresh triggered by an incoming reply can never
walk back a bump; a reload re-derives from the server and is correct again.

The primary-companion tier stays a seam. ent#500 does not exist — no
agent_assignments table, no column, nothing server-side that can name a primary —
so the sidebar passes null. A guessed primary is a confident wrong answer where
an empty seam is merely incomplete. orderRosterAgents keeps its already-tested
primaryName parameter.

The #2424 rule still holds (an agent with an open ask is never collapsed out),
ordering is applied before the collapse so the surviving rows are the ones
actually used, and search results stay ordered by relevance.


Documentation

requirements/core-agent.md §5.27 (ent#491) and §5.28 (#1921).

Testing

  • src/frontend/tests/unit/portalSidebarRecency.spec.js — 16 tests: the room
    fan-out, the max-across-sources rule, the empty-room fallback, no-history
    ordering, the snapshot's precedence and its non-numeric guard, and that an
    absent snapshot behaves exactly as before for every existing caller.
  • tests/unit/test_ent491_room_recency.py — 5 tests, including an AST assertion
    that the batched reader issues one execute() and that list_rooms makes
    no db call inside its per-room loop (an N+1 on the sidebar's bootstrap).
  • Frontend 97 files / 2174 tests, vite build clean, both ratchets pass
    (loading-gate baseline regenerated 72 → 70; raw-color scan clean).
  • Backend 67 tests across the rooms suites.

Not in this cut

  • agentRowMeta still ignores rooms for the row's preview text and
    timestamp
    . Deliberate and pinned by an existing test: ordering by room activity
    is what the issue asks for; putting a room's message in an agent row's preview
    is a different decision about what that row means, and it belongs with whoever
    owns the row's copy.
  • The remaining 69 bare loading gates are outside this issue's named inventory.

Fixes #1921
Fixes abilityai/trinity-enterprise#491

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY

dolho and others added 30 commits September 7, 2026 10:36
Every (user, agent) pair gets one Main chat — the place the agent reaches you
when no conversation named itself. Reset retires it and starts the agent cold.

`enterprise_portal_sessions` gains `is_main` and `archived_at` on both
migration tracks (SQLite `portal_session_main_chat` + Alembic 0053), plus the
partial unique index `idx_portal_sessions_main`. That index is the invariant,
not an optimisation: `ensure_main_session` is reachable from two request paths
in every uvicorn worker, so a check-then-insert races two Mains into existence
for one pair. Its `WHERE is_main = 1` predicate is load-bearing — an archived
row keeps its (agent, client) pair forever, so an unconditional unique index
would refuse the second Reset. No backfill; Main is minted lazily, and only
from `list_sessions` and `_resolve_session_id`, never from the cross-agent
batch (#2198) that would then write a row per agent the user never opened.

The landing rule is one edit: `_resolve_session_id(agent, email, None)` now
resolves to Main instead of the most recent thread, which is the whole of
AC 2 because every homeless turn already funnels through it — asks via
`ensure_thread_for_ask` (ent#364/#429), a scheduled brief (ent#498), a headless
API turn. An explicit session id still wins.

Reset needs no second reset primitive: a fresh row carries no
`cached_claude_session_id`, and the turn engine resumes only on a cached id, so
"starts cold" is a property of the new row rather than an action against the
old one. `routers/sessions.py::reset_session_memory` stays untouched — clearing
a cache and retiring a thread are different verbs. Per-user memory is not
touched. Refused with a named 409 while a turn is in flight, since retiring the
thread mid-turn lands the reply somewhere only a search would find.

Resetting an untouched Main is a no-op that says so (`archived_session_id:
null`): archiving anyway mints an empty thread per click and files it under a
name nobody chose. An untitled archive is dated, because these accumulate in
one list.

Related to Abilityai/trinity-enterprise#523

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
Clicking an agent opened a report about it, with the conversation one click
further behind "Start a chat". The operator ruled the inverse: agents are the
central entity, so clicking one opens the chat you were last in.

`PortalAgentPage.vue` is dismantled rather than deleted — every section has a
named new home, so "no capability is lost" is checkable rather than asserted:

  stats strip + Activity chart  -> PortalAgentBand.vue, always visible
  chats / what it can do / reports -> PortalAgentDetails.vue
  Canvas, Files                 -> already rail tabs since ent#475
  Recent work / Activity list   -> already the rail's Work tab since ent#525
  asks                          -> the conversation already mounted the
                                   surviving copy; the page's was #2449's
                                   second one

`/workspace/a/:agentName` keeps its URL and resolves to a chat. `landingThread`
is the rule — most recently active, Main as the floor — and the pure
`resolveAgentLanding` that answers the same question for the `?agent=` deep
link now defers to it, so a deep link and a sidebar click cannot land a
first-time visitor in different places. It used to take the first row of a
sorted list, which agreed with "most recent" by accident; an unused Main sorts
last on recency, which is where that accident would have surfaced.

Agent details opens into the RAIL'S PLACE (ruled 2026-09-05) as a sibling of
the rail, not a rail tab: the rail is participant-scoped with a fixed five-tab
set, while this is about one agent and is dismissed rather than switched away
from. The rail's state is a setup ref, so closing details returns it on the tab
it was showing.

Main is named by its role in both the tab strip and the header, and is not
renameable — it is the same thread for the life of the pair, and a title
derived from whatever was said in it first would make the two disagree about
which chat you are in. Archived chats leave the tab strip (Reset would
otherwise grow it by one permanent entry per use) and stay in the sidebar and
in Agent details. An unused Main is filtered from the SIDEBAR only, via a
projection rather than a filter on `threads`, because the tab strip must show
Main from the first visit.

The scanline stays on the chart and nowhere else (#2540): the band's stat
figures load with a skeleton beside a beam that wraps only the chart.

Five existing specs pinned behaviour that MOVED; they are re-pointed at the new
surfaces rather than deleted, and the two guards that pinned the retired
two-column Overview assert the replacement rule and name the old one, per the
#2169 convention in that file. Two more (`portalRail`, `portalLoadingTreatment`,
`workspaceRoomsGate` F23) pinned `v-if`/`v-else-if` spellings on a chain whose
membership this change alters; they now assert the rule — keyed on the verdict,
branches are its v-else — not the ordinal.

npm run test:unit: 1988 passed (89 files). vite build clean. Raw-color ratchet
measured against origin/dev: no touched file grew `raw_nongray` or
`hardcoded_colors` (both 0 everywhere); only gray chrome, which the scanner
itself calls partially sanctioned.

Related to Abilityai/trinity-enterprise#523

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

**ent#523 tail.** The sidebar orders agents by most recent collaboration then
name, applied BEFORE the collapse so the rows that survive the limit are the
ones the person uses — ordering after it would sort a slice chosen by the old
order. This is deliberately NOT ent#491 (still incubating): `orderRosterAgents`
ships the order this AC states and leaves `primaryName` as the seam. Each row
gains the preview line, which is the newest chat's TITLE rather than a message
body: the sidebar list is viewer-scoped metadata (#2198) and carries no message
content, so a body preview would reinstate the N+1 that batch call removed.

The composer says when an agent cannot take a message, reading the same pure
rule as the sidebar chip and the details header. A label, never a disabled
input — disabling relocates the dead state rather than removing it, and a
client whose agents are all stopped gets an entirely inert Workspace.

**ent#524.** One drop/batch implementation (`usePortalFileDrop`), three
consumers — the conversation, the room, and the rail's Files tab — because the
issue forbids a second and the reason is visible in the defect: the gesture
already existed on the Files panel, and the `files?.[0]` bug existed there too,
because each surface had written its own.

  - the whole conversation is the drop target, on by default, with an
    affordance that names what will happen; `isFileDrag` keeps a dragged link
    or selection from lighting it, and the overlay is pointer-events-none so it
    cannot swallow the drop it announces
  - a room's drop fans out to every participating agent's inbox and the chip
    names the recipients (operator decision 13, 2026-09-06)
  - both `<input type="file">` gain `multiple`; `onPickFile` / `onPick` /
    `onDrop` stop reading `[0]`
  - one chip per file with its own outcome; a refused file names itself and the
    limit; a 429 batch reports which files landed and when to retry. Uploads
    run sequentially, not `Promise.all` — firing twenty at once is the surest
    way to trip the per-email limiter (ent#287) on a gesture that would have
    succeeded spread over a second

The #1927 ratchet caught `v-if="f.uploading"` in the room, and it was right to:
a scanner cannot tell that from the bare fetch-in-flight gate it exists to stop.
The fix is the one the ratchet asks for rather than a rename — a chip has three
outcomes and no fourth, so both surfaces render from one derived
`attachmentState`, and PortalConversation's baseline entry drops 1 -> 0.

npm run test:unit: 1988 passed (89 files). vite build clean. Loading-gate
baseline regenerated and diffed: one file shrank, none grew.

Related to Abilityai/trinity-enterprise#523
Related to Abilityai/trinity-enterprise#524

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

Tests

- `tests/unit/test_ent523_main_chat.py` (22) — both migration tracks and the
  index PREDICATE (the difference between "Reset works twice" and "Reset works
  once"); `ensure_main_session` idempotency and the lost-race path, run against
  a throwaway sqlite carrying the real partial index rather than a mock of it;
  the landing rule through `_resolve_session_id` AND `ensure_thread_for_ask`;
  Reset's archive/mint/system-line, its cold-by-construction property (asserting
  the ARCHIVE keeps its cached id, which is what stops a future edit wiring in a
  second reset primitive), the in-flight 409, the untouched-Main no-op,
  repeatability, and the uniform 404. The index DDL is read out of `schema.py`
  rather than retyped, so the test cannot keep passing against an index the
  product no longer creates.
- `src/frontend/tests/unit/portalAgentsAtCentre.spec.js` (41) — every pure rule
  (tab pin, archived exclusion, landing, roster order, preview, composer
  notice), the ent#524 rules (file-vs-text, rejection copy, retry-after,
  attachment state), and the source guards no unit test can reach. Mutation-
  checked: removing the Main pin fails 2 of them.

Docs

- requirements `core-agent.md` §5.23. Also fixes a PRE-EXISTING collision: §5.21
  was used twice (ent#451/473 and ent#525); the second becomes §5.22, with its
  one cross-reference.
- new `feature-flows/workspace-agents-at-the-centre.md` + both index entries.
- `architecture/workspace.md` — Main, Reset, the one page, the file drop.
- `workspace-agent-page.md` marked SUPERSEDED with a table of where each section
  went, rather than deleted: the page's reason for existing is still why its
  content had to go somewhere rather than away.
- `workspace-chat-tabs-and-titles.md` — its "what this leaves to #523" section
  closed out, including that the sidebar criterion needed no routing once the
  agent page IS the conversation.

Related to Abilityai/trinity-enterprise#523
Related to Abilityai/trinity-enterprise#524

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
…ver test follows the new rule

Two loose ends from the live pass.

The Agent-details chat list rendered in sidebar order, so Main sat wherever
recency put it while the tab strip above pinned it first — the same list at two
lengths, disagreeing about where Main is. It now uses the strip's comparator.
Archived chats stay included here, unlike the strip: this panel is where the
full history lives, and a retired Main is still a chat you can open.

`test_ent451_new_chat.py::test_no_session_still_resumes_the_latest` asserted the
rule ent#523 reverses. Its original reasoning — a deep link, a refresh and an
API caller that never held a session id all arrive this way, so resume — held
only while there was nowhere designated; "most recent" was a guess, and Main is
the answer that replaces it. Renamed and rewritten to state that, rather than
deleted: a deleted guard leaves no record that a rule was retired on purpose.
What has not changed is asserted too — the branch still resolves to an existing
thread rather than opening one.

Related to Abilityai/trinity-enterprise#523

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
… and a late landing

Three findings from the pre-landing review of this branch, plus a stale
docstring.

**C1 — the system line was replayed as the agent's own words.**
`_format_history_context` splits speakers binarily ("Client" for a user row,
"You" for everything else), so Reset's `role='system'` line came back to the
model as something the AGENT said: "You: Main was reset. The previous
conversation is saved as …". Reachable on the first turn after every Reset,
because that turn is cold by construction and therefore always carries the
history prefix. System rows are now skipped — putting words in the agent's
mouth is worse than omitting chrome it did not write. Found by checking the
consumers of the new `role` value outside the diff, which is the one category
where reading only the diff is insufficient.

**C2 — an archived chat is a tab, and I had hidden it.**
The operator ruled it explicitly on 2026-09-06: "one system line in Main names
the archived chat, **which becomes the newest tab**". This branch filtered
archived chats out of the strip, reasoning that Reset would grow it by one
permanent entry per use — solving a problem `OverflowTabs` already solves, by
contradicting a ruling. Restored, with the landing rule left as it was: you can
GO to an archive, you are never PUT in one. Two rules, two questions.

**I1 — a late landing could move you off a chat you had chosen.**
The landing watcher fires on the route param AND on the thread list arriving,
so on a cold deep link two `landOnAgent` calls can be in flight: the first
misses and goes to the network, the second finds the list and navigates. The
first one's late resolution then navigated too. Guarded on the route, which is
the authority — the same staleness check `usePortalAgentPage` already makes.

**I2 — `ensure_thread_for_ask`'s docstring** still described the pre-#523
"reuses the client's latest thread" resolution.

Also re-anchored `test_ent79_portal_exposure::test_portal_chat_feeds_prior_history_as_context`,
which reached its seeded history through "no session_id resumes the latest
thread". That resolution is what this issue changes, so the test now names its
session: its subject is that prior turns are fed back as context, not which
thread gets chosen — which `test_ent523_main_chat.py` owns.

npm run test:unit: 2029 passed. Backend: 156 passed across ent#79 / ent#451 /
ent#473 / ent#523.

Related to Abilityai/trinity-enterprise#523

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
A new value in a free-text discriminator column lands in the `else` of every
binary split that reads it, and the readers that never mention the new value
are the dangerous ones — a grep for it finds nothing and looks reassuring. The
failure mode is content, not a crash: `role='system'` came back to the model as
words the agent never said.

And: a self-imposed refinement that narrows something a ruling states
positively is a deviation, not a refinement. The tell is a code comment arguing
against a quoted requirement — and the fix here was to notice that an existing
primitive (`OverflowTabs`' counted overflow) already solved the problem the
deviation was defending against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
`/validate-pr` §2.4: a new API endpoint belongs in the endpoint catalogue, and
`POST .../sessions/main/reset` was only described in the workspace architecture
section and the feature flow. Added beside the other client-portal routes, with
the two facts a reader of that table needs to not re-derive: cold is the new
row's property (so there is no second reset primitive to go looking for), and
the two named 409s.

Related to Abilityai/trinity-enterprise#523

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
ent#468 asked for a decision, render or drop. **Render**, and the reason is that
dropping loses a fact the person is entitled to: on an `operator_resume_enabled`
agent their answer sets real work in motion and spends the owner's budget.
ent#364's AC — "the answer reaches the agent and it resumes" — was true in the
backend and invisible in the product; `resume_requested` and the `answered`
status were on the wire and read by nothing, which is also why ent#430's review
spent a blocker correcting a field with no consumer.

`portalUtils.js::answerConfirmation` consumes BOTH fields, which is the AC's
"either both or neither": `status === 'answered'` is the gate — anything else
means the answer did not land the way this copy would claim — and
`resume_requested` is the wording. The tense follows `_resume_requested`'s own
contract, a report of INTENT rather than a promise of success: "is picking this
up", never "has done it". A failure after that point is an operator-side FAILED
row plus an `operator_resume_dispatch` audit entry. `null` and `false` both say
only "Sent." — reading an absent value as "started" would re-introduce exactly
the over-claim ent#430 removed.

The confirmation cannot live on the ask row, because answering removes it —
which is the AC's third bullet and, structurally, the whole difficulty.
`PortalAsks.visible` gated on `items.length > 0`, so the surface unmounted at
the same instant the confirmation was created; the message would have rendered
for zero frames. It now stays mounted while a confirmation is up, clears itself
after six seconds, and clears its timers on unmount — this surface unmounts on
every chat switch, and a timer that outlived it would write to a dead ref.

Folded into this branch because it lands on the same surface: `PortalAsks`
renders above the composer of the conversation ent#523 rebuilt.

npm run test:unit: 2036 passed (90 files), 7 new. vite build clean.

Fixes Abilityai/trinity-enterprise#468

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
# Conflicts:
#	docs/memory/feature-flows.md
…3 (ent#523)

Compared the shipped surface against the design the operator approved on
2026-09-06 and closed five gaps. One of them was a real rendering bug that only
appears once the agent has data.

**The band overflowed onto the tab strip.** `StackedBarChart` renders bars PLUS
a day-label row PLUS a legend — about `height + 38px` — and the band clipped it
into a fixed 52px box, so with any real activity the legend drew on top of the
chat tabs. The row now sizes to the chart. Invisible on an agent with no
executions, which is exactly what the first screenshots had.

**The chart was full-width, so one execution rendered as a slab.** Flexed
across the band a 7-day window gives ~150px columns; A3 draws a compact block
beside the figures. Bounded to 26rem with a spacer holding the window selector
right.

**The legend now sits beside the bars**, as A3 draws it —
`StackedBarChart legend="side"`, an additive prop defaulting to today's
below-the-bars layout so no existing caller changes. Same markup as the
existing block, so the two cannot disagree about what they say.

**Agent details moved from a text link in the band to an info control in the
header**, where A3 puts it, beside the other per-conversation actions. The band
is numbers; this is the door to everything else about the agent.

**Main carries the bookmark A3 draws on it** — `OverflowTabs` gains an optional
`pinned`, rendered in all THREE sites including the hidden mirror row: a glyph
the visible row draws and the measuring row does not is a tab measured narrower
than it renders, which makes the strip overflow one tab too late. The remeasure
key includes it.

**The agent row carries the timestamp A3 shows** (`now` · `12m` · `2d` ·
`Aug 21`). Deliberately a second format rather than reusing `relativeTime`: this
column is a few characters wide beside a name and a preview, so it drops the
"ago" its position already implies. Two jobs, two formats.

Also names the chart ("Activity · last 7 days") instead of leaving a bare plot
beside a row of numbers.

npm run test:unit: 2055 passed (92 files), 7 new pinning the A3 rules — incl.
that the pin is drawn in the mirror row, since that one is invisible until a
strip overflows wrongly. vite build clean. Raw-color vs origin/dev: no file grew
`raw_nongray` or `hardcoded_colors`; gray chrome only.

Related to Abilityai/trinity-enterprise#523

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
… row (ent#523 re-review)

Found re-reviewing my own board-A3 commit. `agentPreview` and `agentRowTime`
each scan the whole thread list, and the template called them per row and TWICE
each — a `v-if` and an interpolation — so a 10-agent roster over 200 threads did
~8k iterations per render, on a surface that re-renders on every store tick.

`agentRowMeta` computes both for every agent in one pass and the sidebar
memoizes it, so the cost is O(threads + agents) rather than O(rows × threads ×
4). Same rules and same output — pinned by a test that asserts the map agrees
with the two per-agent helpers it replaced, which stay exported and tested so
the "which chat is newest" rule has one definition. The tight time format is
split into a shared `compactAge` so the two callers cannot drift on what "2d"
means.

Also prunes a fired timer's handle from `PortalAsks`' list instead of letting it
accumulate for the life of the mount.

npm run test:unit: 2059 passed (92 files), 4 new.

Related to Abilityai/trinity-enterprise#523

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
# Conflicts:
#	docs/memory/learnings.md
#	src/backend/db/migrations.py
# Conflicts:
#	docs/memory/learnings.md
#	src/frontend/src/components/StackedBarChart.vue
…he dev merge

The revision was rebased from 0053 to 0054 when #2561 landed 0053_user_ui_preferences
on the same parent — two revisions sharing a down_revision is two heads, and
`alembic upgrade head` then applies ZERO revisions. The three docs still named 0053.

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

Gate G1 makes a `new:` journey declaration load-bearing: the PR that declares
one owes a skeleton in the journey tier. This is the record half — the catalog
row, the regenerated JOURNEYS.md, and the count the density assertion checks.

The fixed J01..J10 list becomes a dense J01..JNN run with the count named as
DECLARED_JOURNEY_COUNT, so growing the catalog is a deliberate one-line act in
the same commit rather than a test nobody can add a promise past.

J11 is genuinely new rather than an extension of J05: J05 ends at the executions
list — the operator surface — and every other journey is something the user
reaches for. This is the first one that reaches for them.

Related to Abilityai/trinity-enterprise#498

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

Full transcript visibility is the deliberate choice for Workspace rooms — watching
the team work is the differentiator over a summary — and that choice is only safe
while the agents know they are being watched. Without a signal, agent-to-agent
messages in a user-facing room discuss internals, other customers, costs and
platform mechanics in front of the customer.

What existed was not enough. ent#362 labels each transcript LINE whose sender is
human with '(human)', so an agent woken into a room where the person is reading
silently sees agents talking to agents and nothing else. The room header then
said 'Other agents and people are in this room' unconditionally — false in an
agent-only room, and scene-setting rather than a disclosure in a user-facing one.

The signal is derived from MEMBERSHIP and nothing a participant writes reaches it
(AC 2). `room_is_user_facing` is pure and is the one place the question is
answered; `NON_HUMAN_PARTICIPANT_KINDS` is written as the complement of 'human'
because a kind added later — ent#171's external A2A sender is already anticipated
in count_budget_messages — is likelier to be a person than a machine, and an
allow-list of human kinds would classify it as fleet-internal.

The block names no participant: it is composed into a prompt handed to every
woken agent, so an address would be disclosed sideways to agents that person
never addressed, and it buys nothing since the behaviour change is the same
whoever is reading.

Derived per wake rather than threaded from post_message (which does hold the
list): _wake_agent calls post_message back with the reply and that wakes the next
agent, so a threaded value would have to survive a round trip through a public
function and could go stale when a reply recruits a human.

An unreadable roster assumes a person IS reading — the inverse of the usual
capability default, because the mistakes are not symmetrical: needless caution
costs a more careful answer, a missed signal is the disclosure.

Also fixes a test that has never run: test_ent362's '(human)' assertion called
_render_transcript, a name that has never existed (the renderer is _format_delta),
behind a hasattr guard that skipped silently — leaving the exact labelling this
feature builds on unprotected. Now calls the real function and asserts the label
discriminates.

Related to Abilityai/trinity-enterprise#363

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

ent#366 records the rating and, on down+comment, hands the words to the agent's
own capture-feedback skill. Nothing reached the person who runs the instance.

The rated agent is deliberately not in this loop: a readable score is a loop an
agent may optimise for, and a stranger's verbatim words handed to the thing being
criticised is a prompt-injection path into it. The operator's copy goes straight
to the queue and the agent-facing redaction (comment_withheld) is untouched — the
operator sees the comment, the agent still does not.

ROUTED through create_bounded_alert, never a direct create. The volume is driven
by a client clicking, which is the agent-influenceable side of the #1677
classification; a direct create_operator_queue_item would fail the CI emitter
guard. It gets its own registered type rather than the generic 'alert', because
the budget counts pending rows OF THAT TYPE including ones other emitters wrote —
reusing 'alert' would let five unrelated alerts silence every problem report. The
id prefix is reserved so an agent cannot pre-create the id of a complaint about
itself and swallow it through ON CONFLICT, and the id is hashed rather than
interpolated because an email is not confined to the sink validator's alphabet.

The operator's copy fires on EVERY thumbs-down, not only one carrying a comment:
'this was not useful' is the report and the words are the elaboration, so gating
on them would mean the quietest complaints — a bare thumb, which is what most
people leave — reach nobody.

PREREQUISITE, and a live bug found while building it: operator_queue.type is free
TEXT and the platform has emitted non-protocol types since #1410, but QueueCard
and QueueItemDetail each carried their own approval-question-alert v-if chain and
rendered NO control for anything else — so skill_not_found items have never been
closeable from the queue, and for a BUDGETED type five of them jam the pending cap
forever. Both cards now consume utils/operatorQueue.js::queueResponseKind, the
module that already declares itself the one home of the controls-kind switch, and
its unknown-type default moves from 'question' to 'acknowledge': an unrecognised
item is informational, a freeform box invites a reply that goes nowhere, and under
ent#329 answering can spend a turn. An approval without options still gets a box —
there the operator has a real decision to express.

Stated residual: create_item has no UPDATE path, so an edited comment does not
reach an item already raised. Same residual ent#434's alert carries; the fix is at
the sink, not per-emitter — a comment-dependent id would trade one bounded item
per person for one per keystroke-set.

Related to Abilityai/trinity-enterprise#499

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
…onversation (Abilityai/trinity-enterprise#498)

A role companion's daily brief is a scheduled playbook call whose output has to
reach one primary human where they already work. Until now every scheduled run
terminated in an execution row — the operator's surface — and a person who is not
the operator never saw it.

Almost all of this already existed. channel_completion_report has resolved,
persisted and effect-guarded a portal-bound completion since ent#457, and
report_completion is trigger-agnostic: `schedule` is deliberately NOT in
INLINE_CHANNEL_TRIGGERS, because a scheduled run has no surface that already
answered. The only missing fact was that a scheduled execution row never carried
source_channel='portal'. So this is a STAMP and nothing else — no new delivery
path, no second applier, no terminal writer changed.

The scheduler carries the ADDRESS only, for two independent reasons: it is a
separate process that cannot import the portal package to resolve a session, and
it always sends execution_id, so execute_task's channel-persisting branch can
never run for a cron fire and channel columns passed as kwargs would be silently
inert (the #2426 class). execute_task_internal resolves Main and stamps the
pre-created row before dispatch.

stamp_execution_channel_context is the first UPDATE of those columns — every
other writer sets them at INSERT, which is why no updater existed. Guarded on
source_channel IS NULL and returns whether it landed, so it only ever ADDS a
destination: a row that already has one belongs to an inbound channel turn whose
adapter is waiting on that reply.

Access is checked against where the message will LAND —
agent_on_roster(..., include_owned=True), the Workspace's own roster — not
email_has_agent_access, which admits any admin; an admin who neither owns the
agent nor is shared it cannot open that thread, so a brief delivered there would
be invisible. An unreachable address, a blocked client, an unreadable roster
(fail closed) or an already-stamped row all REFUSE with a named reason, fail the
pre-created row and release the idempotency claim: a visible failure, never a
silent no-op. Running the turn anyway would spend the tokens and put the answer
where nobody can read it.

C9: the portal delivery leg now waits, bounded, on the ent#286 in-flight marker
and then writes regardless. PortalConversation detects a reply by an
assistant-row count delta and renders the last assistant row, so a report landing
mid-turn can be read as that turn's answer; deferring removes that for the common
case while a wait that could REFUSE would trade a cosmetic misread for a lost
brief. Applies to every portal report, not only scheduled ones.

At-most-once per fire is inherited, not rebuilt: report_completion's effect_guard
is keyed on the execution id and a fire is one execution.

Schema on both tracks (Invariant #3): SQLite schedule_workspace_delivery, Alembic
0055 off 0054_portal_session_main_chat. Nullable, no backfill, no index.
_fail_execution_row is allowlisted in the #1804 parity guard as an admission-path
terminal (the refusal precedes execute_task, so no dispatch activity exists), and
the stamp is registered in _EXPECTED_UPDATE_SITES as a non-status writer.

Journey J11 is declared with a strict=True xfail skeleton (#2565).

Related to Abilityai/trinity-enterprise#498

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

The backend unit suite's regression diff caught this: _fail_execution_row
persists an error string via update_execution_status without passing it through
runtime_secret_scrub, which test_ent279_scrub_parity fails on. Real, and my local
suite never ran to catch it.

Scrubbed rather than allowlisted, though the allowlist would have been defensible
— today's only caller passes a platform-composed refusal built from the
schedule's target address and the agent name, before execute_task, which is the
same shape the guard already allowlists for _admission_gate. The signature takes
an arbitrary error: str, and an allowlist entry is pinned to a FUNCTION NAME, so
it would silently extend the exemption to a future caller that does pass agent
output. The seam fails open with a [] fast path.

Related to Abilityai/trinity-enterprise#498

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
…brief's rateability

Two acceptance criteria I had marked done on reasoning rather than evidence.

ent#498 AC 6 says the not-live-pushed caveat is 'stated in the user docs'. It was
stated in requirements, the flow doc and the PR — none of which a person setting
up a schedule reads. docs/user-docs/automation/scheduling.md now carries the
field, where the message lands, who may be named, that an unreachable address
fails the run rather than delivering nowhere, and that it appears on the next
Workspace load rather than mid-conversation.

AC 7 (the delivered message is rateable like any agent message) was claimed 'by
construction' — and construction is exactly what a later edit changes.
_rating_target_is_visible demands an agent match, a client match and
role == 'assistant'; the delivery leg satisfies all three from the SESSION row.
Pinned from both ends: the predicate accepts the delivered shape and rejects a
system-role one (so the test discriminates), and a source assertion that the
delivery leg still writes an assistant row addressed from the session.

Related to Abilityai/trinity-enterprise#498

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

**The complaint travelled back to the agent it was about.** ent#499's docstring
promised "the operator sees the comment; the agent still does not" — and two
pre-existing return paths falsified it, both keyed on agent_name alone:
operator_queue_service._write_responses_to_agent copies a RESPONDED item's
`question` and `context` verbatim into the agent's own ~/.trinity/operator-queue.json,
and routers/operator_queue's respond hook spawns the ent#329 resume dispatch whose
prompt embeds item["question"]. So an operator clicking "Got it" handed the rated
agent the client's address and verbatim words within one 5s sync cycle, and on an
agent with operator_resume_enabled spent one of its turns doing it — the ent#366
disclosure this feature exists to avoid, plus an untrusted-text-into-prompt path.

Both sinks exist to close a loop the AGENT opened: it parked a question, a human
answered, the answer goes back. A platform alarm opened no such loop, and for a
problem report the agent is the SUBJECT. One shared predicate, is_platform_minted,
keyed on the reserved id prefixes that already mark "an agent may not mint this",
gates both so they cannot drift. It discriminates: an agent-authored item still
round-trips, or every parked question would go dead.

Also narrowed the alert's `context` to identifiers — no comment text, no address.
The operator reads who is unhappy from `question`; `context` is the field most
likely to be forwarded or logged.

**ent#363 fired in every room, including an operator's own.** The ticket says
"a room containing a workspace user"; I generalised it to "any non-agent kind",
which swept in the platform `user`. Since create_room always seats its creator and
the only removal path is kind="agent", a human can never leave — so every room was
client-facing, the quiet branch was unreachable, and an ops room's agents were told
to keep infrastructure, costs and queue plumbing out of it, which is the subject
those rooms exist for. FLEET_INTERNAL_PARTICIPANT_KINDS now names agent, system and
user; an unrecognised kind still counts as a reader, which was the right half.

I had shipped a test asserting the generalisation, so nothing could catch it — the
same guard-rail-pointing-the-wrong-way shape recorded for ent#523. That test is
inverted and joined by one that drives the participant shape create_room really
produces, since every existing test fed synthetic lists.

Related to Abilityai/trinity-enterprise#499
Related to Abilityai/trinity-enterprise#363

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
…nsumers, and generalising a requirement's noun

Related to Abilityai/trinity-enterprise#499
Related to Abilityai/trinity-enterprise#363

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
**A shared user could deliver into a colleague's Workspace.** Schedule creation is
assert_agent_access — owner OR shared OR admin — and the delivery target may be any
roster member, so a merely-shared user could schedule a recurring message with a
prompt of their choosing into someone else's Main chat, rendered as an ordinary turn
from the agent. ent#457's "no third party for allow_proactive to protect" does not
carry over: ent#498's author need not be its recipient. Rule: address yourself
freely, address anyone else only as the owner. Enforced on create AND update
(create-only is a formality — create without the field, PUT it after), fail-closed
on an unreadable ownership read, and clearing the target is never privileged.

**A cancellation inside the in-flight wait lost the report.** CancelledError is a
BaseException, so `except Exception` did not catch it: a restart landing in the
≤120s window unwound the effect guard with the terminal already applied and nothing
to re-apply it. The docstring's "never dropped, only deferred" was true of the
timeout path only. Now caught explicitly and the report is written immediately — a
report beside an in-flight turn is a cosmetic misread, one that never lands is the
failure the contract exists to prevent.

**A resolved complaint suppressed every later one about the same target.**
create_item's ON CONFLICT ignores the existing row's status, so once acknowledged
that person could never raise another report about that target — silently, forever,
which is worse than a duplicate. The id is now quantised to the UTC day (ent#434's
bucketing, same reason): one item per person per target per day, and tomorrow gets
through.

**Two synchronous DB paths on the event loop.** resolve_and_stamp makes 4–6
synchronous SQLAlchemy calls including two writes, and the in-flight marker read is
a synchronous Redis GET called up to 60 times per report. Both now go through
asyncio.to_thread. The delivery case fires at ~03:30 UTC, inside the window where
db_backup_service holds SQLite's lock.

Related to Abilityai/trinity-enterprise#498
Related to Abilityai/trinity-enterprise#499

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
Found re-reviewing my own residual fix. can_user_share_agent resolves via
get_user_by_username, so str(current_user.id) finds no user and returns False —
refusing the OWNER as well, which turns the gate from a narrowing into a
functional break. Every other call site in the codebase passes
current_user.username.

Every test I wrote for the gate stubs that function, so all six passed against the
broken argument. Added one that inspects what is passed rather than what comes
back — the only shape that could have caught this.

Related to Abilityai/trinity-enterprise#498

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

Re-reviewing my own residual fixes found the cancel fix was itself unreliable:
after catching CancelledError it fell through to `await asyncio.to_thread(_write)`.
A task is cancelled because the loop is going away, so planning to reach another
await point is planning on the thing that just stopped being available. `_write`
is now hoisted above the wait and called SYNCHRONOUSLY in the handler, then the
cancellation is re-raised — swallowing it would leave the task running against a
closing loop and rob the caller's shutdown path of its exception.

The two structural tests are now AST reads rather than string slices. Both earlier
revisions cut the source on a neighbouring token — `def _write()` (which this
commit moves) and `except Exception` (which appears inside the cancel handler's
own comment explaining why it does not catch) — and failed for reasons unrelated
to what they assert. Third instance of that trap in this branch; a structure
question deserves a structural read.

Related to Abilityai/trinity-enterprise#498

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
…canline is now chart-only in fact (#1921)

The sweep as re-pointed by #2540: bespoke spinners and bare 'Loading…' text on
NON-CHART surfaces become skeleton placeholders keyed on 'no data yet'. The
scanline beam stays only where a chart loads.

The primitive itself was the first fix. SkeletonLoader.vue used a bare
`animate-pulse` with no `motion-reduce:animate-none`, so it failed AC 5 — and
every surface converted to it would have inherited the violation.

HOLDOVERS is now empty. Both entries were non-chart ScanlineReveal consumers
predating the ruling: a skills LIST and a JSON <pre>. The allowlist stays as an
explicit empty constant so a new non-chart importer still fails loudly rather
than quietly joining a list that no longer exists — the scanline is chart-only in
fact, not just by rule.

Four data surfaces converted, each keeping the loaded state's own footprint
rather than a centred spinner in a differently-sized box (principle 4):
SystemViewsSidebar (bare text -> row-shaped), InfoPanel (content-shaped),
ExecutionsPanel (list rows) and TemplateSelector (card grid).

TemplateSelector also loses a bare gate: `v-if="loading"` became a `firstLoad`
verdict, so re-opening the picker with templates already fetched no longer swaps
the grid back to a placeholder. The #1927 ratchet drops 72 -> 71 and the baseline
is regenerated; the ratchet itself caught the stale ceiling, which is what it is
for.

Left alone deliberately: ExecutionsPanel's refresh-icon spin and its 'Load more'
button label are sanctioned in-flight indicators inside a pressed control (AC 6).

Related to #1921

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

Rooms count. There is no single agent a multi-agent conversation is 'with', so
working in a room with three agents is recent collaboration with all three — the
fan-out unreadByAgent already applies. orderRosterAgents skipped is_room rows
outright, so an agent you only ever meet in a room read as never-used and sat at
the bottom under the alphabetical tiebreak.

That needed a backend fix first: enterprise_rooms has no last_message_at column
and list_rooms returned only created_at, so a room's recency was its CREATION
time — a busy month-old room ranked below one opened this morning and never used.
last_message_for_rooms is a batched MAX(created_at) GROUP BY room_id, the sibling
of count_messages_for_rooms, and rides the same round trip rather than adding a
read per room. Derived rather than denormalised: a column would need a writer on
every append for a value one GROUP BY already returns.

Sends re-sort, replies do not — the two AC clauses pull against each other, since
last_message_at moves identically for both. A purely derived order would reshuffle
the sidebar under the reader's cursor every time a brief landed for a different
agent. So the order reads a session-held snapshot seeded from thread recency and
advanced only by the user's own sends; the seed fills only MISSING keys, so a
refresh caused by an incoming reply cannot walk back a bump. A reload re-derives
from the server and is correct again.

The primary-companion tier stays a seam. ent#500 does not exist — no
agent_assignments table, no column, nothing server-side that can name a primary —
so the sidebar passes null. A guessed primary would be a confident wrong answer
where an empty seam is merely incomplete.

Related to Abilityai/trinity-enterprise#491

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
The rest of the named inventory: Settings (page + three table bodies + the
activity panel), GitPanel (status + PAT), SchedulesPanel (list + webhook panel),
TasksPanel (list + log modal), Login, Operations (queue first load) and /m's four
loading states.

Each placeholder mirrors the surface it becomes — list rows for a list, table-cell
bars for a table row, form fields for a form — because a centred ring in a
differently-sized box is itself the layout shift principle 4 forbids.

Two more bare gates became verdicts: GitPanel's git status and (in the previous
commit) TemplateSelector's grid both blanked loaded content back to a placeholder
on a background poll. The #1927 ratchet falls 72 -> 69.

The Dashboard's history spinner is REMOVED rather than restyled. It fired on every
background poll, beside a Refresh button that already signals in-flight by
disabling itself — 'first load animates; scheduled background refresh is
invisible', so the honest treatment is deletion.

/m is plain CSS, not Tailwind, so it gets the recipe spelled out locally — pulse
in the chrome fill, a prefers-reduced-motion static fallback, an sr-only line —
rather than a Tailwind class that would not apply there.

Sanctioned in-flight spinners inside pressed controls are untouched (AC 6).

Related to #1921

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

The e2e caught a wrong deletion. #2536's dashboard-mode-switcher spec measures the
view-switcher's bounding box WITH AND WITHOUT this element, so removing it removed
the test's instrument and the spec failed on its first assertion.

The deletion rested on 'it fires on every background poll'. It does not:
fetchHistoricalCommunications has exactly three callers — mount, the Refresh
button, and a time-range change — and there is no interval anywhere. All three are
first-load or explicit user actions, which is precisely when in-flight feedback is
sanctioned (AC 6). I asserted the poll existed without checking.

The rule 'scheduled background refresh is invisible' only applies once you have
shown there IS a background refresh. Recorded in the requirements entry, because
the mistake is more reusable than the fix.

Net for the sweep is therefore 72 -> 70 bare loading gates, not 69. The baseline is
regenerated to match rather than renaming the flag to slip past the scanner, which
would defeat the ratchet instead of satisfying it.

Related to #1921

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLerYYUUEEmf43UKF2VVRY
…sion off 0055_portal_session_main_chat as 0056

dev renamed the Main-chat revision 0054 → 0055 (#2558 landed after #2561's
0053 and #2566's 0054), so this branch's 0055_schedule_workspace_delivery
chained off a parent that no longer exists and the merge left a stale
0054_portal_session_main_chat.py behind — two heads, zero revisions applied.
Renumbered to 0056 off 0055_portal_session_main_chat, stale file dropped,
docs and the ent#498 revision-pin test updated. check_alembic_heads: 1 head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaBkiyfRkYkmk4iMJkHRdL
@vybe
vybe changed the base branch from dolho/issue-498-tandem to dev September 7, 2026 15:48

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: requirements §5.27/§5.28, loading-gate ratchet 72→70 regenerated (not renamed past), raw-colour scan clean, 16 + 5 named tests, backend batched reader guarded against N+1. Merged dev in (post-tandem) — 27 files net. Merging.

@vybe
vybe merged commit 72f364c into dev Sep 7, 2026
29 checks passed
vybe added a commit that referenced this pull request Sep 15, 2026
…hanges (#2802) (#2804)

Found by running every tier by hand on the 2026-09-14 pre-release pass
(tests/run-full.sh itself aborts before the api tier — #2801). None of
these is a product defect; each was verified against the commit that
changed the behaviour.

Backend guards / Postgres:
- test_1920 scanned the mounted enterprise submodule; OSS guards scan the
  OSS tree only (#1677 convention — the private repo owns its twin).
- test_alembic_postgres asserted alembic_version == 0001_baseline AFTER
  upgrade_to_head(), true only while head was the baseline (v0.8.0). The
  runner stamps the baseline and then upgrades; assert head.

Root live-backend tier:
- test_cb_probe_execution_close: the credential_sanitizer stub lacked
  REDACTION_PLACEHOLDER (exported by the real module since 2026-02), so
  every test failed at import; three more surfaced as
  "module 'routers' has no attribute 'internal'" from the same cause.
- test_agent_permissions: `type` was retired with the taxonomy (#2104).
- test_settings / test_setup: accept the #2715 onboarding wording.
- test_activities: the activity layer's documented sources
  (user/schedule/agent/system) are valid alongside the dashboard buckets.
- test_agent_git: 409 on sync for a read-only public-template agent is the
  ent#162 push blackhole — a fixture limit (skip), and for the owner-gate
  test evidence the gate passed (accepted).
- test_platform_default_model / test_subscription_auto_switch asserted an
  instance default that stored state overrides; skip honestly on an
  enabled instance, and clear the stored row before reading the default.

Frontend e2e (outside the @smoke tier CI runs):
- schedules-toggle-scroll looked for 'Loading schedules...' but the panel
  renders a Unicode ellipsis since #2573; match on a regex.
- ent438-agent-canvas assumed a seeded weather-watch agent; probe-and-skip
  like the other fixture-bound specs (#2199 semantics kept).
- Four specs picked agents[0]; prefer the long-lived harness agent (#2080)
  so an overlapping pytest run cannot delete their fixture mid-test.

Verified: 25 passed / 2 honest skips across the touched backend tests
against a live dev stack; test_alembic_postgres 6 passed on a fresh
disposable Postgres; the five e2e specs 23 passed / 3 skipped.

Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants