Repository navigation
DO NOT MERGE — merge train: 3276,3273,3241,3275,3255,3145,3269 - #3279
Closed
trinity-ability wants to merge 65 commits into
Closed
trinity-ability wants to merge 65 commits into
trinity-ability wants to merge 65 commits into
Conversation
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>
…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 `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>
This was referenced Oct 6, 2026
19 tasks
4 of 5 tasks
This was referenced Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integration surface for #3276, #3273, #3241, #3275, #3255, #3145, #3269. Never merged. Members merge individually, in this order, once this train is green.
Assembled off
devfa50b3888; each member merged--no-ffat its head SHA. Sibling conflicts resolved here (routine classes only):tests/registry.jsonrebuilt from stages; the SQLiteMIGRATIONStail kept both entries. The Alembic chain is0089 → 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