Skip to content

DO NOT MERGE — merge train: 3276,3273,3241,3275,3255,3145,3269 - #3279

Closed
trinity-ability wants to merge 65 commits into
devfrom
train/20261006-1646
Closed

trinity-ability wants to merge 65 commits into
devfrom
train/20261006-1646

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

Integration surface for #3276, #3273, #3241, #3275, #3255, #3145, #3269. Never merged. Members merge individually, in this order, once this train is green.

Assembled off dev fa50b3888; each member merged --no-ff at its head SHA. Sibling conflicts resolved here (routine classes only): tests/registry.json rebuilt from stages; the SQLite MIGRATIONS tail kept both entries. The Alembic chain is 0089 → 0090_platform_alert_subjects (#3255) → 0091_chat_session_claude_id (#3145) → 0092_portal_messages_attachments (#3269), one head, re-parented on the member branches.

🤖 Generated with Claude Code

obasilakis and others added 30 commits October 5, 2026 11:17
On a pull-pilot agent, POST /api/agents/{name}/chat (MCP chat_with_agent,
the connector tool and the trinity CLI) is admitted onto the durable
queue and claimed by a worker. Every interactive trigger is now
pull-owned on a pilot.

Memory on a pilot is one Claude conversation per chat_sessions row,
i.e. per (agent, user), resumed by id through run_resumable_turn
(ResumeLock, persist_session, cold retry on resume-not-found). The id is
cached on chat_sessions.cached_claude_session_id (SQLite migration +
Alembic 0084) and joins the session reaper's keep set.

The post-turn work /chat did inline runs in the caller after the
terminal: assistant message, collaboration close, response shape,
idempotency complete, timeout receipt. GET /chat/history serves the
caller's session from the database on a pilot; DELETE /chat/history
also clears the cached ids. execute_task carries chain_depth so a
cold-retry row keeps its depth.

Non-pilot agents are unchanged.

Fixes #3127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DELETE /chat/history forgot the cached Claude ids but left the sessions
active, so GET /chat/history still showed the old conversation. The
sessions that carry a cached id are now closed with it; sessions /chat
never used stay open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 0085 (#3127)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 0088 (#3127)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- capacity_manager: take dev's earlier pull_exclusive placement (#2514);
  keep the #3127 comment that /chat is pull-owned on pilots.
- Rechain the chat_sessions Alembic revision as 0090_chat_session_claude_id
  off dev's head 0089_supersede_queue_flood_backlog; SQLite entry ordered
  after supersede_queue_flood_backlog.
- run_pulled_chat_turn passes request_text=request.message to the
  resumable turn. /chat runs no admission-seam skill gate, so the
  executor backstop must gate on the caller's own words (ent#751).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rinity-enterprise#784)

Replaces `landingThread` with one pure rule, `agentLanding`, that every door
which has to RESOLVE a landing calls. ent#523 landed you in the chat you were
most recently active in; most visits to an agent start new work, so resuming
cost two actions every time. The default is now a new, empty chat.

Nothing is minted by the landing: the helper is pure and returns a null
session, and the row is born on the first send (`newThread`, ent#451), so
repeated visits accumulate no empty chats. An unused Main is already kept out
of the chat list by `sidebarThreadsOf` — the AC 4 guard, still green.

`agentLanding` takes `lastOpenSessionId` as the seam ent#621's agent-switch
keys will pass, honoured only when it still names a live, unarchived chat of
this agent in the principal's own thread list — so a stale or forged id falls
back to the default rather than landing somewhere it should not (#3140 class).
No Map is built here; ent#621 adds the state and the handler.

`resolveAgentLanding` keeps its signature and delegates, so the `?agent=` deep
link and the sidebar row cannot drift. `forceNew` stays accepted for existing
`?new=1` links and is now redundant rather than wrong.

Shell wiring (`landOnAgent`, the focus gate) follows in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lityai/trinity-enterprise#784)

Wires the shell to the ent#784 rule and gates the composer focus.

`landOnAgent` is now synchronous. The awaited `ensureMainListed` is gone from
the landing path — the rule needs nothing from the network — and with it the
overtake race that awaited round trip required. The pinned Main is still
minted on the first visit; `watch(activeAgentName)` owns that promise (ent#523)
and no longer blocks the landing.

The landed chat KEEPS `/workspace/a/:name` rather than escaping to bare
`/workspace`, so a reload, a bookmark or a copied link still names the agent;
the first send replaces it with the thread's own URL as today. An idempotency
guard makes the second watcher fire (the thread list arriving) a no-op, so it
cannot remount an unsent chat and throw away what was being typed.

`landOnAgent` now asks `guardLeaveCall` BEFORE touching `activeAgentName`,
which feeds `convKey`. Back/forward and a typed `/workspace/a/:name` reach the
landing without passing a click door, so they could end a live voice call
without a word (the ent#551 class; the click doors were already guarded).

Focus is gated on WHY the fresh composer mounted, not on the pointer alone.
A gesture (New chat, ⌘J, the agent picker, switch-agent) passes `always` and
focuses on any pointer, keeping #2579 AC 2 on Android. A landing passes
`fine-pointer` and focuses only where that cannot summon an on-screen
keyboard — the "unprompted" the issue names. `shouldFocusOnRestore` is renamed
`shouldAutoFocusComposer`: two reasons now share the one rule.

Tests: the focus gate is proven by MOUNTING PortalConversation under a stubbed
`matchMedia` (#2918), asserting `document.activeElement`, with focus shown to
be elsewhere first. Reverting either the rule or the gate turns 10 cases red on
behaviour — session ids and activeElement, not source text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-enterprise#784)

Tiered docs for a feature change: the owning area file, the requirement, and
the feature flow.

- `architecture/workspace.md` — `agentLanding` is the one landing rule and what
  it now answers; the synchronous `landOnAgent`, the kept URL, the guard order,
  and the `focusOnMount` mode.
- `requirements/core-agent.md` — the ent#523 "One page" rule amended rather
  than rewritten: what reversed, and that landing mints no row.
- `feature-flows/workspace-agents-at-the-centre.md` — the rule table, the "a
  tab is not a landing" note (the strip is now the only way back), the two
  doors that RESOLVE vs the gesture doors that ASSERT, and a `## Changed by
  ent#784` section rather than edited history. Names the follow-up: backend
  adoption of an empty Main on `new_thread=True` is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ted chat (Abilityai/trinity-enterprise#784)

ent#784's landing stays on `/workspace/a/:name`, so the watcher that resolves
that route can now re-fire with the route unchanged — once per thread-list
refresh — for as long as the person stays there. `landOnAgent`'s idempotency
guard keys on `startingNewChat`, which the first send clears, so a refresh
settling in the window between `onSessionAdopted` and the asynchronous
`router.replace` nulled `pendingSession` and bumped `convGen`, remounting an
empty composer over the thread whose first reply was streaming (and handing the
`/c/:id` watcher a #3140 "chat isn't available" for the chat just created).

Guarded in the watcher instead: a list-only re-fire is a no-op, keyed on "the
route did not change on this fire" AND on having already landed this name. Both
clauses are load-bearing — the name alone swallows the cold deep link, whose
only fire IS the list arriving with the param already in place. Deliberately
NOT keyed on `pendingSession`: back/forward from `/workspace/c/:id` arrives
with a session set and must still land fresh (T4).

New behavioural pin `portalAgentLandingRemount.mount.spec.js` (shallow-mounts
Portal.vue, asserts the conversation's props and instance identity, not source
text): the adopted-session case went red before this change with
`sessionId: null`; the three regression cases — composing, same-agent
back/forward, cold deep link — were green before and stay green.

Also fixes `portalUnavailableTargets.mount.spec.js`, which still asserted
ent#523's landing (`/workspace/a/scout` → `/workspace/c/t1`) and was failing on
this branch; it now proves the page opened by the conversation on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…source-text pins (Abilityai/trinity-enterprise#784)

`landOnAgent` was pinned only by regexes over its own function body
(`workspaceNewChat.spec.js:181-211`), so nothing executed it: an inverted
guard, or the right lines in the wrong order, reads byte-identically to a
regex. The shell is mount-testable (`portalUnavailableTargets.mount.spec.js`),
so these assert STATE instead.

Decision #13 (ent#551 class) — back/forward reaches the landing without passing
a click door, and `activeAgentName` feeds `convKey`, so a live call must be
asked about BEFORE anything is written: mid-call, the dialog opens and the chat,
its props and its instance identity are untouched; confirming then performs the
landing it deferred; cancelling leaves call and chat exactly as they were.

T4 — one navigation to an agent page mounts exactly ONE conversation, however
often the thread list moves underneath it.

Mutation-verified, both directions: with `guardLeaveCall` removed from
`landOnAgent`, the two mid-call cases go red; with the T4 guards removed, the
composing and one-mint cases go red. The source-text pins are kept as
supplements (they still pin "no await / no ensureMainListed / no router.push"
inside the function, which state cannot see).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…inity-enterprise#784)

F1 of the operator's 2026-10-05 reopen. `agentLanding` always answered a new
chat, so a draft typed into an existing chat was not where opening the agent
landed — and the draft mark on the agent's row pointed at words that clicking
it could not get back to. `e2e/workspace-drafts.spec.js:92` and `:116` describe
the wanted behaviour and were red at c8af3d7.

The rule is now the ruling's precedence: a link naming a chat (never reaches
here), then the ent#621 `lastOpenSessionId` seam unchanged as the first arm,
then the agent's chat holding an unsent draft — newest `updatedAt`, with the
unsaved `new:<agent>` chat weighed on the same clock — then a new chat. A
`new:` winner is `sessionId: null`, which is where those words already live; a
thread winner opens that thread and its remount restores the draft.

The candidate set is the set that lights the sidebar's mark: `isDraftedThread`
is shared by `agentsWithDrafts` and the new `draftedLandingFor` rather than
copied, so "the mark means click here to continue" cannot drift — a room and an
archived thread are excluded on both sides. The drafts map is passed IN by both
doors (`landOnAgent`, `resolveAgentLanding`), never read inside the rule, so
one rule serves every door and stays pure.

`composerFocusMode` moves above the branch in `landOnAgent`: a landing on a
drafted chat is the same kind of arrival, and `openThread` does not touch that
ref, so a previous gesture's `always` would otherwise have made the next
landing focus on a touch device (T3(b)). The restore's own caret already shares
`shouldAutoFocusComposer`. The T4 remount guard is untouched: the watcher still
returns on a list-only re-fire, and the drafted-thread arm leaves the agent URL
through `openThread`, so the guard is never reached with a stale answer.

Tests: `portalDraftLanding.spec.js` (each arm, newest-wins both ways, the tie,
room/archived exclusion, and the property that every MARKED row is a landing
candidate) and `portalDraftLanding.mount.spec.js`, which replays both e2e flows
at the shell's seam — red at c8af3d7 on the e2e's own symptom, 2 of 3 failing
with `sessionId: null` where the draft was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-enterprise#784)

F2 of the operator's 2026-10-05 reopen: "opening an agent must not create a new
chat every time; if an empty chat with that agent already exists it is reused,
and at most one empty chat per agent exists at any time."

Arm 4 of the precedence, under the drafts arm and above a new chat.
`agentEmptyChat` is the read side: an unarchived, non-room thread of this agent
with no message sent — ent#523's own "unused Main" test, widened to any row
that fits it. Both fields are required because the two reads disagree: the
cross-agent batch omits `message_count` (so an absent count must not read as
used) while the per-agent read carries it (so a count of 2 must not read as
empty just because `last_message_at` is missing). Several empty rows — legacy
data — resolve Main first, then newest `created_at`, then id, so list order
never decides and two doors reading one list cannot reuse two different rows.

`ensureMainListed` is the write side, and the only place the "at most one" half
can be held: it is a GET that INSERTS (`list_sessions` → `ensure_main_session`),
so for an agent with an empty chat but no Main — legacy, since nothing mints a
non-Main empty row today — visiting it would add a SECOND empty chat and then
land on one of the two. It now returns early for that case. #2579's "the pinned
tab has to be there" still holds for every agent whose chats are all used,
which is the case it was about; that is pinned as its own mount case rather
than left to the comment. No backend change: nothing here asks the server for
anything it does not already do.

Three existing specs carried fixtures with neither message field, which under
the old rule meant "a thread exists" and under the ruling means "an empty chat
to reuse" — the assertion they were written for. Each is updated to a USED row
and the empty case is asserted separately, so none of them went green by
accident: `portalAgentsAtCentre` (the landing arms), `workspaceAgentLanding`
(the `?agent=` door, both id shapes) and `workspaceNewChat`, where `?new=1` is
load-bearing again — it is the one way past the whole precedence, so the two
answers now have to differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and the caret (Abilityai/trinity-enterprise#784 review)

ent#784 made /workspace/a/:name the URL a landed new chat RESTS on. Three
things had only been true because nobody stayed there; each was reproduced
in a browser against the rebased branch.

- Clicking the row of the agent whose new chat is already on stage cleared
  `startingNewChat` in `openAgentPage` and then pushed the URL it was
  already on, so the landing never re-ran. The composer still read "New
  chat" and sent its first message without `new_thread`, which the server
  resolves to the agent's Main chat. The click is now a no-op that hands
  the caret back.
- The rail rules still read the agent URL as "a page, not a conversation",
  so a landed new chat had no rail, and the first send (which moves the URL
  to /workspace/c/:id) slid one in beside a reply mid-stream. The URL is
  rail-free only until the landing has put the named agent on stage, and
  its column is reserved mid-load like any 1:1 route.
- A landing that reuses a chat (the agent's empty one) goes through
  `openThread`, and a thread mount focuses nothing, so the caret stayed on
  the sidebar row. The shell hands it over under the landing's own
  fine-pointer rule.

Tests: portalAgentLandingStage.mount.spec.js (10 cases, mounted shell).
Four were red before the fix, for these reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rinity-enterprise#621)

Checkpoint A of ent#621: the declaration every Workspace key will be
dispatched from, with nothing wired to it yet (the shell dispatcher is B).

`portalKeymap.js` declares all nine shell chords — ⌘J, ⌘., ⌘/, ⌥↑/↓,
⌥⇧↑/↓, ⌥. — plus the reserved ⌘K (ent#577) and the six component-owned
protocol keys (Esc, the call's M, the preview's arrows, tab roving, a
dialog's Tab cycle, the typeahead's bare arrows). The protocol entries
are declared precisely because `keymapCollisions` is only worth anything
if it can see the whole surface: a shell chord that shadows Esc is the
2026-09-07 class, and a map listing only its own keys cannot tell you.

Matching is platform-free — `primary` is meta XOR ctrl everywhere (the
shipped ⌘J semantics) and a chord matches the printed `key` OR the
physical `code`. The two arms disagree about Shift on purpose: without
the `key` arm DE's `Shift+7` → `/` is dead, without the `code` arm `⌥.`
is dead on a US Mac (it types `≥`), and with Shift accepted on the
`code` arm macOS `⌘?` Help search is swallowed.

- `isNewChatHotkey` / `newChatHotkeyLabel` delegate to the map, keeping
  their signatures and their truth table, so "what ⌘J means" has one
  answer. The physical `KeyJ` arm only widens the set.
- `resolveComposerKey` claims BARE arrows only. It used to claim an
  arrow whatever the modifiers were and the caller `preventDefault`s a
  `move-*`, so ⌥↓ / ⌥⇧↓ were silently dead whenever an @-popup happened
  to be open — a handler claiming a chord it never declared.
- The reserved ⌘K resolves to nothing: the shell must not
  `preventDefault` a key it cannot answer, so the browser's Ctrl+K keeps
  working until Spotlight ships. It is hidden from the key list too — a
  dead row in a help dialog is worse than an undocumented key.
- `keymapSuppressed` is a matrix: anything modal stops every shell key
  (⌘J included — today it remounts under an open file preview), while a
  call stops only the keys that would move you. ⌘J keeps its own "leave
  the call?" ask; ⌘/ is allowed, because opening a dialog leaves nothing.
- `hasModalOpen` is a DOM probe, not a registry: it sees overlays that do
  not exist yet, and its exemption is per action (the rail keys do not
  see the rail's own sheet; ⌘/ can close its own list).
- The stale `agentLanding` header comment is corrected — the
  switch-agent keys ARE a caller, passing arm 2's `lastOpenSessionId`;
  the gesture doors (New chat, the picker, ⌘J) still mean "fresh".

`portalKeymap.js` reuses `isMacLike` / `nextActiveIndex` and
`portalUtils.js` delegates back, which closes a module cycle. It is
benign — neither module touches an import of the other at evaluation
time — and both init orders are exercised: `workspaceKeymap.spec.js`
imports the map first, `portalChatTabsAndTitles.spec.js` the utils.

Tests: 190 pass across the four specs. Mutation battery, restored from a
scratch copy: dropping the bare-arrow guard reddens the composer row;
a chat chord without its Shift reddens four keymap cases including the
collision check; Shift on the physical arm reddens the Decision 29 case;
a reserved key that resolves reddens the ⌘K case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ityai/trinity-enterprise#621)

Checkpoint B of the Workspace key map. The map and its pure rules landed in
58350ed; this wires them to the shell.

* `Portal.vue`'s `onGlobalKeydown` becomes the ONE dispatcher over
  `resolveWorkspaceKey` + `keymapSuppressed`, with the full ladder: an
  unclaimed chord returns without `preventDefault` (⌘K keeps reaching the
  browser until ent#577), ⌘J keeps its place at the top and its own
  "leave the call?" ask, a nearer owner's `defaultPrevented` wins, a focused
  `<select>` keeps its native Alt+↓, and anything modal / the mobile drawer /
  a live call suppresses the rest.
* `lastOpen`: a plain session-scoped Map with ONE writer. `pendingSession` is a
  load instruction, not the chat on stage — a null under an unchanged agent is
  ignored, a null under a new agent is a real unsent new chat and is recorded
  as such, so `agentLanding`'s own arms return to the draft. (T1 / Decision 35)
* `landOnAgent(name, { lastOpenSessionId, focus })`, with the idempotency guard
  HOISTED above the focus-mode write and above `agentLanding` (Decision 34):
  the key path's own push re-fires the route watcher, and a second landing over
  a thread list that has meanwhile grown a minted Main would `openThread` it,
  replacing the chat the key just landed on.
* `stepAgent(±1)` walks `orderedRoster`, lifted to the shell (T6) and handed to
  both sidebar instances, which stop sorting — the keys must walk the order the
  eye reads, and there were two of those computeds before. The fresh arm pushes
  `/workspace/a/:name` AFTER the state write, so the watcher re-fire is absorbed.
* `PortalConversation.cycleChat(delta)` + `defineExpose` (an identifier, not an
  inline body): the strip's inputs live there, and it emits `open-thread` /
  `new-chat` exactly as a tab click does.
* `PortalSidebar` gains `activeAgentName` → `aria-current`, an active-row tint
  on the semantic `action-primary` token, auto-expand when a walk lands beyond
  the fold, `visibleAgentRows(..., { keep })` and the same keep in search
  (Decisions 27/37). The sidebar had no notion of a current AGENT at all, by
  key or by click.
* An `aria-live` announcer for every move; `data-ws-rail-sheet` on the same
  element that carries the sheet's `aria-modal`, so the probe can exclude it.

Red list re-pinned in the same commit, never loosened (Decision 42):
`portalVoiceMode.spec.js` exact `defineExpose` string, `workspaceNewChat.spec.js`
`landOnAgent` signature + guard-first shape, `portalAgentsAtCentre.spec.js`
roster-order pin moved to the shell plus a new `keep` case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…walk (Abilityai/trinity-enterprise#621)

Checkpoint B's behavioural half. Both files MOUNT (#2918): a regex over
`Portal.vue` passes an inverted suppression condition and a reversed `delta`
byte-identically.

* `workspaceKeymap.mount.spec.js` — the real `Portal.vue` (shallow) with ONE
  custom stub: the conversation, because the auto-stub exposes nothing and
  `conversationRef.value?.cycleChat?.()` is optional-chained twice, so deleted
  wiring would hide behind its own `?.`. Real `KeyboardEvent`s on the real
  `window`. 18 cases: the walk and both wraps, the caret returning to the field,
  a roster of one, the fresh arm (route + `focusOnMount`), the announcer, the
  last-open memory (return, unsent-new-chat, a stale id falling through), the
  chat keys reaching `cycleChat(±1)`, and the whole suppression ladder — modal,
  the rail sheet's per-action exemption, the drawer, a live call,
  `defaultPrevented`, a focused `<select>`, reserved ⌘K, and `e.repeat`.
  Wrappers are unmounted between cases: one left standing answers the next
  test's key press first and `preventDefault`s it, after which the shell
  correctly yields and the case reads as broken wiring.
* `portalChatCycle.mount.spec.js` — 8 cases on the real conversation: the walk
  agrees with `agentChatTabs`' own order, both directions wrap, other agents'
  chats are not in it, the provisional tab is walked from and onto (`new-chat`,
  as a click answers), under two tabs is silent, and `cycleChat` is exposed.
* `portalAgentsAtCentre.spec.js` gains the `keep` behaviour (function) and the
  sidebar's call site (structure) — pinned inside the `visibleAgentRows(` call,
  because the file's other `keep:` is `searchAgents`' and a loose pin stayed
  green with the collapse's argument deleted.

Mutation battery, each reverted from a scratch copy and restored byte-identical:
`defaultPrevented: false` → "yields to a nearer owner" red; `-delta` in
`cycleChat` → 5 cases in portalChatCycle red; `keep` dropped from the collapse →
"orders the roster BEFORE the collapse" red; `focus: 'always'` dropped → "a key
press IS a gesture" red; `lastOpenSessionId: null` → "returns to the chat that
was open there" red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bilityai/trinity-enterprise#621)

Checkpoint C. The three remaining shell keys land on the ONE dispatcher B
built — no second listener — and every control the keys drive now says which
key drives it, derived from the same map the dispatcher resolves against.

- `⌘.` toggles the rail in whichever form the viewport shows (column at and
  above `sm`, bottom sheet below it), reusing `railState.tab` and the
  persisted width: the key stores nothing of its own, so it cannot disagree
  with the expand/collapse button's memory (T5).
- `⌥.` opens a closed rail on its remembered tab first, then walks `railTabs`
  — the tabs this session may SEE — never the registry, so a door-failed tab
  stays as unreachable by key as it is by click (Decision 25).
- Both rail keys are suppressed, NOT dispatched to a no-op, where there is no
  rail: the difference is `preventDefault`, and a `⌘.` that silently ate
  Safari's Stop on every rail-less page would be a worse bug than a missing
  feature. `keymapSuppressed` reads the map's own `needsRail` flag for this.
- `⌘/` opens `PortalKeyList` on `BaseModal` (#1923's Esc, focus trap and
  focus-return; always mounted and `v-model`-driven, because a `v-if` toggle
  unmounts past the close branch and focus never comes back). Every row is
  rendered from `keyListRows(WORKSPACE_KEYMAP, …)`; ⌘K is reserved for ent#577
  and has no row, because a help dialog promising a key nothing binds is worse
  than an undocumented key. The list is the one action allowed to see through
  its own dialog, so the chord that opened it closes it.
- Discoverability: agent rows, the rail's expand/collapse and tab buttons, and
  the chat tabs each carry the chord in BOTH channels — the tooltip people
  read and `aria-keyshortcuts` in ARIA's spelling — plus a sidebar-footer
  button so a mouse reaches what `⌘/` reaches. All of it comes through
  `keyHint` / `keyShortcutsFor` over the map: a typed glyph would be a second
  declaration of the same chord, and the copy that goes stale is always the
  one nobody tests.

`BaseModal` now passes its attributes to the DIALOG element rather than
dropping them on `Teleport`: `data-ws-key-list` has to sit on the same element
as `aria-modal`, because that is the element the dispatcher's `:not(...)`
exclusion matches. Without it the marker is a comment, not a selector.

The sidebar footer's two icon buttons share one class string (`PortalRail`'s
`ICON_BTN` shape) — two identical inline copies is two places for a hover
state to drift, and the raw-colour ratchet counts every copy.

Tests (all mounts, no source-text pins): the rail and key-list cases in
`workspaceKeymap.mount.spec.js` prove a real `keydown` on the real `window`
reaches the real dialog in the document — `teleport`, `BaseModal` and
`PortalKeyList` are deliberately unstubbed, since the `[aria-modal]` probe can
only see an overlay that actually reached `<body>`. `portalKeyList.mount.spec.js`
asserts rows against the map (never against repeated strings) and drives Esc,
the close button and focus-return with focus placed by hand first.
`portalKeyHints.mount.spec.js` asserts each control's two attributes equal
`keyHint`/`keyShortcutsFor` output, so a hand-typed hint fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ai/trinity-enterprise#621)

T6 lifted `orderedRoster` out of `PortalSidebar` and into `Portal.vue` — one
order, computed once, handed to both sidebar instances so the switch-agent keys
walk exactly the order the eye reads. Three specs pinned the identifiers at
their old address and went red on the lift rather than on a broken rule:

- `portalRosterRow.spec.js` asserted `visibleAgentRows(orderedRoster.value` and
  the `orderedRoster` computed in the sidebar's source. Both are gone from that
  file, and neither proved more than "the code was written". Converted to a
  MOUNT of `PortalSidebar`: the bound is now a count of rendered rows, and "the
  sidebar does not re-sort what it was handed" is a deliberately unsorted roster
  coming back out in the same order. A re-sort that slipped back in fails this
  even though it would satisfy any spelling of the old regex.
- `portalSidebarRecency.spec.js` pinned the `null` primary (ent#500 does not
  exist) on the sidebar's call site. Re-pinned onto the shell's — the one call
  site there now is. It stays a call-site pin: "the primary argument is null" is
  an argument-position fact no rendered order can show, because with ent#500
  absent a named primary and null agree on screen.
- `portalSidebarSearch.spec.js` pinned the same `orderedRoster.value` spelling;
  re-pinned onto `props.roster`, plus the negative that keeps the lift honest —
  no second `orderRosterAgents(` inside the sidebar.

No assertion was dropped or weakened; each was shown red by a one-line mutation
(a re-sort in the sidebar, a reintroduced second sort, a guessed primary in the
shell), with both source files restored byte-identical after each.

Sweep green: 130 files / 2368 tests, including the raw-colour, loading-gate and
source-text ratchets (the source-text baseline is unchanged — the mount
conversion left `portalRosterRow` at its one existing read).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rinity-enterprise#621)

Checkpoint D. The docs described a Workspace with one inline hotkey; it now has
a declared map, a single dispatcher, and one landing rule the keys call.

- `requirements/core-agent.md` §5.21a — the new sub-section beside §5.21: the
  nine working chords, the reserved one, one declaration / one dispatcher, the
  inverted editable-target rule (every key works from the message field), the
  suppression rule, the non-US `key`-or-`code` statement, the roster-order and
  last-open rules, and discoverability. OSS-core recorded explicitly rather
  than inferred from the merge.
- `feature-flows/workspace-chat-tabs-and-titles.md` — "New chat in the header"
  keeps the ent#451 facts and gains "The key map": the table, the matcher's
  platform-free rule, the dispatcher's ladder in order (the order IS the
  design), the two rules it leans on, and the key list that is built FROM the
  map so a key cannot ship undocumented. Title and front matter follow; the
  Tests section names the ent#621 specs.
- `feature-flows/workspace-agents-at-the-centre.md` — the stale half of "two
  doors RESOLVE, the rest ASSERT": the switch-agent keys resolve now, and they
  pass `lastOpenSessionId`. Walking the roster is navigation, not a request for
  a blank page. The pure-helper table says `orderRosterAgents` is called once,
  in the shell.
- `architecture/frontend.md` — the Workspace shell paragraph: one map, one
  listener, the ladder, and the two consequences for anyone adding a key.
- `Portal.vue` — one stale dispatcher comment ("the rail keys land in the next
  checkpoint" — they landed in C). The `agentLanding` header comment the plan
  also named was already corrected in Checkpoint A.

Public wording only: the shipped behaviour, no enterprise design detail and no
link to any private design document. The enterprise-docs guard's own pattern
runs clean over `docs/` and `CLAUDE.md`.

Sweep still green: 130 files / 2368 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…21 review H1)

The last-open writer tested only "did the agent change", so a null
`pendingSession` under an unchanged agent was always read as a load
instruction. But ⌘J and the strip's New chat tab (both `newChatWithAgent`)
null it on the agent already on stage — the commonest way to be sitting on
an unsent chat — so ⌥↓ then ⌥↑ came back to the chat ⌘J had just left
instead of the new one, against AC-8.

The watcher now also observes `startingNewChat` and passes
`keyChanged || (fresh && !sid)`: a deliberate fresh chat records `null` for
the current agent, while every load-instruction clear (`openAgentPage`,
`openRoom`, the unreachable arm, `openThread`) leaves the flag false and is
still ignored, so the chat the person was reading survives those.

Two mount cases: ⌘J on the agent on stage then ⌥↓/⌥↑ returns the new chat,
and a load-instruction clear under an unchanged agent still returns the old
one. Reverting only the watcher turns the first red and leaves the second
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ding (ent#621 review I1-I3)

I1 — the ladder sentence read as though nothing claims the event before the
suppression rung, but ⌘J during an active voice call calls preventDefault
first and routes to the leave-call guard. That is the pre-existing
ent#534/551 ask, kept deliberately, so the exception is now stated in
architecture/frontend.md and in AC-3 rather than the code being moved.

I2 — the docs named a `platform` argument `keymapCollisions(map)` does not
take. Matching is platform-free, so one call is the whole proof; the three
mentions say that instead of implying a per-OS check.

I3 — "one window listener" becomes "one chord dispatcher" in frontend.md
(and AC-3), naming PortalRail.vue's pre-existing sheet-only Esc listener as
the `close-top` protocol entry it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…w listener (ent#621 review I3)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…browser (ent#621 review)

A key repeat resolved to nothing and the dispatcher returned before
`preventDefault`, so the auto-repeat of a chord the shell had just claimed
went to the browser. Reproduced against the rebased branch: a held ⌥. types
`≥` into the message field on a Mac, and a held Ctrl+J opens Downloads on
Chrome/Firefox for Windows and Linux (⌘J's repeats were swallowed before
the map existed).

The dispatcher now remembers the action of the press it claimed (`heldKey`)
and swallows repeats of that chord, matched through the new pure
`workspaceChord`. A repeat still never dispatches, the repeat of a press the
shell did not claim stays the browser's, and any new press ends the hold.

Also re-pins the "no rail" case after the ent#784 review fix: a landed
agent URL is a chat with a rail, so ⌘. works there; the rail-less page in
the test is now the unreachable-agent refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Skill gate runs once: the pulled turn passes gate_checked=True, and the
  pilot admission branch audits a self-approved gate like the push branch.
- Agent-to-agent /chat (trigger agent) on a pilot: run_resumable_turn opts
  into the claim phase (caller_waiting=True), the claim orders session:-keyed
  rows with interactive turns, and the collaboration activity rides the
  queue payload so the pull sink closes it.
- Resume lock waits one turn's lock TTL; lock-busy closes the activity and
  emits the terminal event only on a CAS win, and answers 429 capacity.
- Full backlog returns FAILED/CAPACITY and maps to 429 capacity.
- Docs: chat_with_agent description, execution.md, three feature flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 of #3246 (one pending row per platform-alert subject). Adds the
stdlib-only leaf services/platform_alerts.py, which both migration tracks
and the service graph will import:

- Kind registry for every in-repo platform emitter, with its id prefix and
  lifetime class: 14 days by default (OPERATOR_PLATFORM_ALERT_LIFETIME_DAYS),
  a fixed 30-day net for edge-triggered kinds with a clear hook, or none for
  the four a person must act on. EXTERNAL_PREFIXES keeps role-drift- out;
  register() takes kinds from outside this module.
- Snooze window after a person ends an alert: 7 days
  (OPERATOR_PLATFORM_ALERT_SNOOZE_DAYS), per the operator's T5 ruling.
- Key belt and subject_for(): "<kind>:<key>", id-shaped keys kept,
  anything else sha256[:16]; event kinds have no subject.
- derive_legacy_subject(): reads every pre-#3246 id shape back to its
  subject, or to "known kind, no subject" when the id does not say which
  condition (git-bloat-, rows missing their context key). Unknown, gate
  and external prefixes are never derived.
- plan_sweep(): pure planner for the upgrade sweep. Keeps the newest
  pending row per (agent, subject) (created_at, then id; NULL oldest; a row
  that already holds a subject keeps it), ends the rest, stamps survivors,
  gives subject-less known kinds a lifetime stamp, stamps person-ended rows
  inside the snooze window, and plans nothing on its own output.

Nothing calls the leaf yet; the migration (C2) and the seam (C4) do.
Tests: tests/unit/test_3246_platform_alerts_leaf.py (78), including a
bare-interpreter load by path. Mutation-checked: survivor min instead of
max, greedy sid regex, no snooze window, and reversed prefix order each go
red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…per-subject index (#3246)

C2 of #3246, both schema tracks in one migration, ordered columns → sweep →
index: the partial unique index can only be created after the sweep has
collapsed the duplicates an installed backlog holds.

- `operator_queue.subject TEXT` + `last_seen_at TEXT` (nullable, no backfill
  beyond the sweep) in schema.py, tables.py and both migration tracks.
- `run_platform_alert_sweep(run)` in db/migrations.py — one function both
  tracks call over their own connection (sqlite3 cursor / SQLAlchemy
  `text()`), so the survivor rule cannot drift. It hands the fetched rows to
  the leaf's pure `plan_sweep` (function-local import; neither track pulls
  the service graph into `init_database()`), stamps the newest pending row
  per derived subject, ends the rest as ONE batch in the ent#611 vocabulary
  (`cancelled` / `platform` / `superseded` / NULL email / `batch_id`) with a
  compare-and-set on `status='pending'`, stamps a lifetime only on known
  kinds whose subject cannot be derived (never merged on a guess), and
  stamps `subject` on person-ended rows inside the 7-day snooze window.
  Agent-raised rows, `gate-` rows and external prefixes never derive.
- `uq_operator_queue_pending_subject` (partial: pending + subject) and
  `idx_operator_queue_agent_subject`, created after the sweep.
- Alembic `0090_platform_alert_subjects` chained after the live head
  `0089_supersede_queue_flood_backlog`.

Test (red first at collection — the migration did not exist):
tests/unit/test_3246_platform_alert_sweep.py drives the registered entry
against the pre-upgrade `schema.py` DDL holding duplicates: survivor and
stamps, one batch, untouched rows, snooze stamp, a second run changes
nothing, the index exists only after the run and bites on a second pending
row, DDL parity with schema.py, both tracks registered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…m ending (#3246)

C3 of #3246 — the DB seam the platform-alert service (C4) will stand on.

db/operator_queue.py:
- `create_platform_item(agent, item, *, subject, max_pending_for_type)`:
  find → touch → count → insert in ONE `_lock_agent_for_create` transaction.
  A reading of a subject with a pending row updates it in place by a
  compare-and-set on `status='pending'` (title / question / priority /
  context with `seen_count`+1 / `last_seen_at` / `expires_at`); a lost CAS
  falls through to a fresh row, so a row a person ended is never
  overwritten. The find runs BEFORE the #1677 per-type count, so an update
  is never refused at budget. An `IntegrityError` from the partial unique
  index (a lock that failed open) re-finds and touches the winner. Returns
  `{"outcome": created|updated|refused_at_budget, "row", "changed"}`;
  `changed` is false on a bare repeat reading so nothing broadcasts.
- `find_pending_by_subject`, `find_person_ended_by_subject` (the seam's
  snooze read, newest person ending at or after `since`).
- `end_items_by_platform(ids, *, reason, batch_id)` — the `bulk_cancel_items`
  shape with `disposed_by='platform'`, NULL email, re-selected by batch id
  so only CAS-won rows come back.
- `mark_expired`'s per-id CAS gains `expires_at < now`: a row refreshed
  between the candidate select and the CAS keeps its new deadline.
- `_insert_values` takes keyword-only `subject` / `last_seen_at`;
  `_row_to_item` / `_SELECT_COLS` carry both.

services/ask_service.py: `clear_platform(ids, *, reason, batch_id=None)`
mirroring `expire()` — no Actor, `PLATFORM_ENDING_REASONS =
(condition_cleared, superseded)` enforced, one `platform_cleared` audit row,
one thin `operator_queue_cancelled` trigger per agent, observers get only
the rows this call won. database.py facade for the four accessors.
`test_ent329_operator_resume.py` G2 list gains the new CAS accessor.

#3130 / #3220: `_own_pending_conds`, the flood guard and
`create_bounded_alert_outcome` are untouched; their suites stay green.

Test (red first — the accessors did not exist):
tests/unit/test_3246_platform_alerts_db.py on the unit island's real
SQLite: two readings → one row, bare repeat → unchanged, person-ended row
never overwritten, budget refuses only with nothing to update, the index
refuses a second pending row, `mark_expired` keeps a refreshed row and still
expires an overdue one, the platform ending skips a person-ended row and
hands observers only CAS-won rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
obasilakis and others added 20 commits October 6, 2026 16:11
…fail-open, document late release (Abilityai/trinity-enterprise#665)

- routers/voip.py: an IntentInProgressError keeps its own message (names the
  key, says retry) instead of the per-execution duplicate text.
- intent_guard: a keyed send that went out unguarded because the store was
  down still reports sent: true.
- db/idempotency.py + effect-idempotency.md: a stalled sender's late
  release() can delete the reclaimer's row, alongside the late complete().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alerts-one-row

# Conflicts:
#	docs/memory/feature-flows/operating-room.md
#	src/frontend/src/components/operator/QueueCard.vue
#	src/frontend/src/components/operator/QueueItemDetail.vue
#	tests/registry.json
Conflicts in the landing rule's doc, portalUtils.js, Portal.vue and
workspaceNewChat.spec.js, all between ent#784 (already carried here) and
ent#621's options bag on landOnAgent; resolved to the ent#621 side, which
is a superset of dev's hunk in every case. Portal.vue keeps the key map
as the only "⌘J" definition, so dev's isNewChatHotkey import stays dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…son confirms (ent#621 merge-train)

The call branch `preventDefault`ed the event and then re-entered the ladder
with that same event, so on the resumed pass the suppression rung read the
shell's own mark as "a nearer owner claimed it" and bailed — ⌘J was dead
exactly when the person had said "end the call and leave". The continuation
now re-enters as the `resumed` pass after a `nextTick` (the confirm dialog is
aria-modal and leaves the DOM on that flush), and `keymapSuppressed` waives
only the `defaultPrevented` rung for it; modal, drawer and call still apply.

Also from the finding: the physical-`code` arm answers only when the printed
key is not a single printable ASCII character, so a Latin non-QWERTY layout
keeps paste and undo (Dvorak's ⌘V arrives on `Period`, ⌘Z on `Slash`). The
non-ASCII cases the arm exists for (`≥`, Cyrillic) are unchanged.

Tests: pure cases for the resumed rung and the Dvorak shape in
workspaceKeymap.spec.js; the shell mounted for real in
workspaceKeymap.mount.spec.js — ⌘J during a call asks, confirm fires the
new chat, cancel fires nothing; the two voice-mode source pins re-pinned to
the new continuation.

`isNewChatHotkey` stays: workspaceKeymap.spec.js and
portalChatTabsAndTitles.spec.js (here and on the stacked ent#621 follow-up
branch) import it as the ⌘J equivalence pin, and the chat-tabs flow documents
it as the delegate kept on purpose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…; DELETE carries the deny list (Abilityai/trinity-enterprise#792)

The backend file routes normalised a user path with posixpath.normpath, which
keeps exactly two leading slashes, while the agent server's Path.resolve()
reads them as one. A `//`-prefixed path matched none of the path-anchored deny
patterns and neither capability fence.

- _normalize_user_path collapses any run of leading slashes to one.
- PUT, mkdir and DELETE send the agent the normalised path that was checked,
  not the raw input, so the check and the action read one string.
- DELETE /files applies the deny list too (_is_user_deletable_path), after the
  access check and the skills.manage fence, before the container lookup. A
  directory that holds a path-anchored protected path is refused as well
  (.ssh, .claude, the home dir); anchors are derived from the pattern tuple.
  Refusal: 403 "Cannot delete protected path: {path}".

Tests run against the shipped module: test_files_protected_paths.py no longer
tests an inline copy. Added // rows, a delete table, the pinned anchor set,
three Hypothesis properties, route-logic checks and a real-router check on all
three routes (mkdir takes its path in the JSON body); // rows on the ent#596
fence; live-tier DELETE and // rows in test_files_guardrail_bypass.py.

Mutations, each restored byte-identical: reverting the normaliser line turns
30 tests red; dropping the DELETE check 11; dropping the holding-directory
clause 22; forwarding the raw path 4; cutting anchors at the first glob 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read, not only anyio's (#3246 merge-train)

`spawn_on_loop` handled a running loop or an anyio worker thread and re-raised
from anything else — "not a shape any production caller has". #3246 made it one:
the headroom sweep (`subscription_recovery_service`) and the skills reconcile
(`skills_sync_service`, `routers/skills.py`) end platform alerts through
`asyncio.to_thread`, the loop's DEFAULT executor, which anyio does not own. The
anyio portal raised, `platform_alerts._spawn` / `ask_service._ended` swallowed
it, and every such ending committed with no `platform_cleared` audit row and no
`operator_queue_cancelled` trigger, behind a WARNING traceback. Reproduced: loop
thread OK, `anyio.to_thread` OK, `asyncio.to_thread` → RuntimeError.

Fixed in the helper rather than at the call sites: a captured host loop with a
`call_soon_threadsafe` fallback. `main.py::lifespan` records the loop before any
boot phase, and every on-loop spawn refreshes it, so the next thread-origin
caller — a `threading.Thread` a service starts itself included — inherits the
fix instead of each site having to remember `anyio.to_thread.run_sync`. The
task is still created ON the loop thread (`_schedule_on_loop`'s contract) and
the caller never waits for the work. A closed or non-running captured loop is
not a target (the private-loop footgun below), and with nothing to hop to the
helper still raises loudly — never the silent no-op ent#430 removed.

Pinned by `test_3246_platform_alerts_thread_origin.py`: `clear` / `observe`
driven through `asyncio.to_thread` land the audit row and the trigger with no
"could not schedule" warning; the spawn from the default executor and from a
plain thread; the lifespan capture is first in the boot sequence; the loud
failure with no loop; a closed captured loop is ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…efore closing (#3246 merge-train)

`test_1816_system_agent_adoption` ran `ensure_deployed` on a `new_event_loop()`
and closed it with the #3246 follow-ups (audit, broadcast) still registered in
`operator_resume_service._inflight` — pending tasks bound to a dead loop, or
finished ones whose `_inflight.discard` callback was queued when `stop()`
landed and never ran. The next test to gather `_inflight` on ITS loop failed
deterministically with "The future belongs to a different loop"
(`test_ent329_operator_resume::test_spawn_keeps_a_strong_reference`); CI
shuffles, so it recurred.

`_run` now cancels the leftover tasks (cancelled, not awaited: a follow-up may
wait on a transport this island never provides), gathers them, and runs one
more tick so the done callbacks drain, before closing. Verified in both file
orders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing slashes too (Abilityai/trinity-enterprise#792)

PR review found the agent-side copy of the policy with the same shape: the
PreToolUse file-guardrail.py and read-only-guard.py normalised file_path with
os.path.normpath, which keeps exactly two leading slashes, then matched absolute
patterns such as /home/developer/.ssh/*. In the shipped image a //-prefixed
.ssh or .claude/settings.json path exited 0 where the one-slash form exited 2.
files.py names file-guardrail.py as KEEP IN SYNC.

- Both _normalise functions collapse any run of leading slashes to one.
- tests/unit/test_ent792_file_guardrail_hook.py runs the shipped hook's main()
  against the shipped baseline; test_read_only_guard.py gains // rows.
  Reverting the hook fix turns 7 and 2 tests red; the fixed hooks, mounted into
  trinity-agent-base, deny the // forms in the real image.

Review follow-ups in the same commit:
- a string prefix of an anchor ("/et", "/home/dev") is not a directory above
  it: allowed rows plus a segment-based oracle, so `a.startswith(head)` no
  longer survives (5 red);
- docs: the agent echoes the normalised path (path/deleted fields and its
  404/409 messages); the flow's backend list names .env.*; only the skills.manage
  fence exists on dev; the security report records the hook finding the first
  pass missed and corrects its .claude/skills claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…3127) — mechanical, per the merge-train note on the PR

#3255, #3145 and #3269 each added an 0090 revision off
0089_supersede_queue_flood_backlog, which is the #2068 two-heads
fork once two of them land. The tables are disjoint (operator_queue
vs chat_sessions), so this is a re-parent: 0090_chat_session_claude_id
becomes 0091_chat_session_claude_id with down_revision
0090_platform_alert_subjects. The SQLite entry is keyed by name and
needs nothing; only the two doc references to the id change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) — mechanical, per the merge-train note on the PR

tests/registry.json was the only conflict; rebuilt from the git stages
(dev's list deduped + this branch's one new entry), never spliced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…) — mechanical, per the merge-train note on the PR

`build` was red on roomEscalationAttachments.spec.js "does NOT clear
them". The slice ran from `async function send()` to `submitUserText`,
so it now read #3265's ordinary-send `clearAttachments()`, which runs
only after the escalation branch has returned. Behaviour was right; the
pin matched by accident. The slice now ends at `const reply =
replyTo.value`, the first line of the ordinary-send tail. Moving a
`clearAttachments()` into the escalation branch still turns it red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#3265) — mechanical, per the merge-train note on the PR

#3255, #3145 and this PR each added an 0090 revision off
0089_supersede_queue_flood_backlog (the #2068 two-heads fork). The
tables are disjoint (operator_queue, chat_sessions,
enterprise_portal_messages), so this is a re-parent:
0090_portal_messages_attachments becomes 0092_portal_messages_attachments
with down_revision 0091_chat_session_claude_id. The SQLite entry is
keyed by name and needs nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rget (#3246)

Merge-train validation found the `not loop.is_running()` rung of
`_live_host_loop` unpinned: deleting it left all 104 tests green. Such a
loop accepts `call_soon_threadsafe` and parks the task forever. The new
case captures one, expects the loud RuntimeError, and asserts nothing
was queued on it. Deleting the rung now turns it red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/registry.json
# Conflicts:
#	tests/registry.json
# Conflicts:
#	src/backend/db/migrations.py
# Conflicts:
#	src/backend/db/migrations.py
#	tests/registry.json
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.

5 participants