feat(workspace): the rail's Canvas/Files dot lights on the write, not the next refetch (ent#532) - #2709
Draft
trinity-ability wants to merge 10 commits into
Draft
feat(workspace): the rail's Canvas/Files dot lights on the write, not the next refetch (ent#532)#2709trinity-ability wants to merge 10 commits into
trinity-ability wants to merge 10 commits into
Conversation
…t#532) Requirements before code (Rule #1). §5.20's Canvas and Files dots derive from store data, so they light only when something the client can observe makes the store re-read — a turn ending, a loop event, a terminal agent_activity, the tab being opened, an upload. A canvas rewritten by a scheduled run had no such moment and waited for the next turn or tab open. §5.20 deferred a backend broadcast to the debt inbox; this is that broadcast, written up before it is built. - requirements/core-agent.md: §5.20 AC-4 gains the two triggers and the out-of-scope bullet points at the new §5.33, which states both event shapes, the one-chokepoint-each rule (the idempotent share replay emits nothing), the ids-only wire, the ent#467 payload-derived scope, fire-and-forget delivery that can never fail a write, the §5.20 consumer, and why the shared refresh debounce needs a max-wait cap. - architecture/integrations.md: the thin-trigger-from-a-sync-service paragraph beside the ent#467 contract it publishes under. - architecture/backend.md: canvas_service.py joins the services catalog (it had no entry); the shared-files line names the emit. - feature-flows: workspace-rail.md's "Updated since last view" no longer says no backend event exists, and says why an external client still waits (a portal token opens no /ws at all — a new transport, deferred, not an oversight); agent-canvas.md's "Deferred still" becomes shipped; websocket-event-bus.md gains a 2b block with both shapes and the sync fire-and-forget mechanics. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… (ent#532)
Failing first (5 failed, 6 errored on `no attribute 'set_websocket_manager'`).
What each case is actually for, since a leak guard that passes vacuously is
worse than none:
- the payload is asserted by EQUALITY, not by absence of a known secret, so a
field added to the wire later fails here instead of shipping onto SCOPE_ALL;
the seeded block text, the title, the display name and the `?sig=` url are
then checked against the serialized bytes as a second line.
- `agent_names_in_payload(event)` pins the ent#467 contract from the consumer's
side, and an AST case pins that the call site is a dict LITERAL — a helper
splicing `{"type": event, **payload}` resolves to `<dynamic>` in that guard's
discovery and is classified fleet-level, i.e. it silently fails open.
- one emit per write (patch funnels through write_canvas), none for a write
that raised, none for `create_share`'s idempotent replay — paired with the
non-replay call in the same test so the zero is not vacuous.
- no manager, no running loop, and a raising manager are all silent; the
raising case gathers the pending task, which re-raises if the inner await
wrapper is ever removed.
- the main.py wiring case also asserts our alias names match #1483's discovery
regex — an unmatched alias is silently uncovered, not loudly broken.
Refs Abilityai/trinity-enterprise#532
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… (ent#532)
Green: 11 new + the 257 that were already passing over the eight neighbouring
files (268 total). The emit sits in the service, not the router, because the
router is not the chokepoint: `write_canvas` is the only path that writes
`agent_canvases` (REST PUT, REST PATCH via patch_canvas, MCP, and the Gemini
voice panel all funnel through it), and `_persist_and_register` is the shared
tail of both share entry points (agent extract and channel bytes).
Three properties are load-bearing.
* The dict at the call site is a LITERAL. ent#467's discovery guard resolves a
broadcast payload by AST to decide who may receive it; a `_broadcast(event,
payload)` helper that splices `{"type": event, **payload}` resolves to
`<dynamic>`, carries no agent key, and is classified fleet-level — it fails
open, silently. Verified by running that guard's own discovery: it now finds
38 sites and reads both new payloads as agent-keyed, so neither needed a
FLEET_LEVEL_ALLOWLIST entry and event_bus.py is untouched.
* The emit is AFTER the store and on the success path only, so a cap rejection
or a validation refusal emits nothing — and `create_share`'s idempotent
replay returns its snapshot without reaching the tail, so a replay emits
nothing either. Nothing new was shared.
* A trigger can never fail or delay a write. Both writers are sync and reach
the emit on the event loop, so it is a `create_task` held by a strong-ref set
(a bare handle can be GC'd mid-flight) with the await wrapped inside the
task. No manager, no running loop, and a raising manager all end in "no
trigger" — which degrades to the pre-ent#532 refetch triggers, i.e. latency,
never a wrong dot.
The wire carries ids only (#918): never blocks, title, audience, filename, or
the share url, which embeds a `?sig=` bearer token on a SCOPE_ALL channel.
main.py: two setter imports and two setter calls, both PURE APPENDS to the
existing regions — ent#533 appends its own pair to the same two hunks, and a
reorder there is what turns a trivial rebase into a real conflict.
Also corrects three "36 live broadcast sites" counts to the 38 the guard now
reports (the guard's own comment, integrations.md, websocket-event-bus.md).
The floor assertion is `>= 30`, which is exactly why the number rots silently.
Refs Abilityai/trinity-enterprise#532
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 of the 6 new cases fail on today's store (the sixth is the shared-timer regression guard, which can only bite once the route exists — it is checked against a route-without-cap build before the cap lands, so it is not a test that cannot fail). The one worth reading is "a sustained write stream refreshes on a bound, not only when it stops". `scheduleRefresh` is a pure trailing debounce that clears and re-arms on every call. That was correct for its ent#475 callers, which fire once per execution; canvas writes are not that shape — `patch_canvas` during a streaming run and the voice panel writing per tool call are a sub-2-s stream, and an uncapped trailing timer under a sustained stream fires when the stream ENDS, which for a long run is never. Worse, the timer is SHARED with the loop/activity triggers, so adding a high-frequency writer to it makes a shipped signal slower. That is a regression this lane would introduce, not a pre-existing condition, which is why the cap is required rather than an optimisation. Paired with a case pinning that a SINGLE event still fires at exactly 2 s, for both a new type and an ent#475 one — the cap must bound the pathological case without moving any shipped timing. portalRail.spec.js's source guard goes 2 → 3 and now also names the route condition and the derived `PUSH_MAX_WAIT_MS`, so deleting either is a failing test rather than a silently slower dot. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Green: 89 in the two rail specs, 103 with the raw-colour and loading-gate ratchets (no `.vue` touched, so neither moves). `websocket.js` gets ONE appended block in the `default:` branch — ent#533 appends its own below it, and reordering what is there is how two lanes collide on one hunk. The store's participant gate is unchanged: an event naming an agent the open conversation does not include is still nobody's to act on, and nothing is rendered from the event — the data comes back through the access-controlled client-portal routes, which is the half of #918 that makes an unfiltered channel safe to publish on. `scheduleRefresh` gains a max wait. It was a pure trailing debounce that re-arms on every call, which was correct while every caller fired once per execution. Canvas writes are a different shape, and the timer is shared — so without the cap this lane would have made the shipped Loops/Work refresh slower under a canvas burst. The wait is now bounded from the FIRST event of a burst, so a sustained stream refreshes every ~4s instead of only when it stops, while a single event still fires at exactly 2s: `_debounceSince` is `now`, the wait is the full delay, and no shipped ent#475 timing moves. `PUSH_MAX_WAIT_MS` is derived from `PUSH_DEBOUNCE_MS` rather than written as 4000, so the two cannot drift. The identical un-capped shape in `stores/portalWork.js` is deliberately left alone — it is ent#533's file in this same wave. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te-tests) 15 passed (was 11). /update-tests coverage review found one real gap and one missing path: - the `_broadcast` helper is a per-service COPY — two modules, two guards — and the three "a trigger never fails a write" directions (no manager, no running loop, a raising manager) were asserted only against the canvas copy. A defence proven on one of two sibling call sites is the incomplete-fix class the methodology names explicitly, so all three are now parametrized over both services. - `create_share_from_bytes` (WhatsApp media, voice notes) reaches the same tail, and `list_active_for_agent` does not filter by creator, so those rows ARE Files-tab rows. Now asserted directly rather than inferred from the shared tail. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ync-feature-flows) /sync-feature-flows mapped the changed code files to their flows and found one the plan's §6 list had missed: `file-sharing-outbound.md` owns `agent_shared_files_service.py`, so the new `file_shared` trigger belongs in its service-behaviours table and its side-effects list, not only in the rail's flow and the event-bus flow. Adds the revision-history row those two edits are recorded under. `workspace-rail.md`'s header gains ent#532 in its folded-in line, the same way it carries #2540 — the rail flow now documents behaviour that arrives from the backend, not only from the shell. No new flow file, so `feature-flows.md` (the index) is unchanged. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PEP 8 spacing between a top-level function and the constants block that follows it. No behaviour change. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nt#532) The repo's own test index, appended (purely additive — 12 lines, no existing line touched, so the rebase against ent#533's own append is the array tail and nothing else). Nothing enforces completeness here, which is exactly why it drifts. Refs Abilityai/trinity-enterprise#532 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eview) `test_a_raising_manager_never_fails_the_write` claimed in its own comment that deleting `_send`'s inner try/except would turn it red. It would not. The test drained the loop first and only then snapshotted `asyncio.all_tasks()`, which returns only tasks that have NOT finished — so `pending` was empty and the `gather` that was supposed to re-raise awaited nothing at all. Measured, not reasoned: with the wrapper deleted from BOTH service copies, the whole file stayed green at 15 passed. The three "a trigger never fails a write" directions were parametrized over both copies precisely so a guard proven on one of two siblings could not ship — and the one direction that needed a live task was proving nothing on either. Snapshot the task before letting it run, and assert there is one. The gather now runs the emit and re-raises what it raised, so both parametrizations fail with the wrapper gone and pass with it. No production code changes: the wrapper was always correct, only the proof of it was hollow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Resolve by running |
|
Resolve by merging |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Workspace rail's Canvas and Files dots (ent#475) already know how to light — what they lacked
was anything telling them a write had happened. The signal was derived from refetches the client
could observe: a turn ending, a room going working→idle, a loop event, a terminal
agent_activity, a tab open, an upload. A canvas rewritten by a scheduled run has none of thosebehind it, so the dot waited for the next chat turn or tab open.
Two backend events close that gap, both #918-shaped thin triggers:
canvas_updatedcanvas_service.write_canvas— the only path that writesagent_canvases{type, agent_name, canvas_id}file_sharedagent_shared_files_service._persist_and_register— the shared tail ofcreate_shareandcreate_share_from_bytes{type, agent_name, file_id}Three properties are the whole design:
access-controlled portal routes it already used. The Files payload carries the
file_idanddeliberately not the
url— that url embeds a?sig=download token, a bearer credential, and/wsisSCOPE_ALL. The tests assert this over the serialized bytes, not the dict: asecret seeded into a block, a title, or a filename must not appear on the wire.
agent_nameis the scoping mechanism — aclient receives the event only if it may access that agent, decided by
agent_names_in_payload(). Noevent_bus.pychange, noscope=kwarg, noFLEET_LEVEL_ALLOWLISTentry. The call sites are dict literals on purpose: ent#467'sdiscovery guard resolves the payload by AST, and a helper that splices
{"type": event, **payload}would resolve to<dynamic>and fail it. Live discovery goes 36 → 38 sites; theguard's own comment and
integrations.mdare updated in the same commit so the counts do notrot (the floor assertion is
>= 30, so neither would have failed).the event loop, so the emit is a fire-and-forget task with a strong reference. All three failure
directions end in no trigger, never in a failed write: no manager wired, no running loop (a
future caller from an executor thread), or a manager that raises — caught inside the task, which
is also what keeps it from resurfacing as "Task exception was never retrieved". A lost trigger
costs latency only; the rail still re-reads on every pre-ent#532 trigger. The manager arrives by
setter from
main.py(Invariant Fix: Add missing Docker labels to system agent container #1 — a service does not import the app).One emit per write.
patch_canvasfunnels throughwrite_canvas; thecreate_shareidempotentreplay returns its snapshot without reaching the tail, so a replay correctly emits nothing; a write
that raised emits nothing.
One internal constant, open to veto
portalRailFeeds.js's push debounce is shared with the ent#475 loop/activity triggers, and itis trailing-edge: it re-arms on every call, so a stream of events closer together than the delay
fires only when the stream ends. That was fine while every caller fired once per execution. It is
not fine for canvas writes (
patch_canvasduring a streaming run, the voice panel writing per toolcall), and because the timer is shared, an uncapped burst would also defer the loop and activity
reads riding it — making a shipped signal slower.
So a pending read is never deferred past
PUSH_MAX_WAIT_MS = 2 × PUSH_DEBOUNCE_MSfrom the firstevent of a burst. This is an internal timing constant, not a setting and not a user-facing
default: it is derived from the existing debounce so the two cannot drift, and single-event timing
is unchanged byte for byte (a lone event still fires at exactly 2s, pinned by its own test). Happy
to drop the cap and document the starvation instead, or to pick a different multiple — say the
word and it is a one-line change.
Fixes abilityai/trinity-enterprise#532
User-visible change: none
No
.vuefile is touched, and no component, token, control, copy, empty state, error state ordefault changes. The Canvas and Files dots, their derivation and their clear-on-open behaviour are
exactly ent#475's. The only delta is when an already-shipped dot lights: within the existing 2s
debounce of the write, instead of at the next observable refetch. The issue records its own journey
impact as none.
Verification
Full local verification ran before this branch was pushed (
/verify-local --skip-agent; nodocker/base-imagechange):/health: OKRe-run after the rebase onto
dev(the neighbourhood, not the full suite):Also:
/reviewand/cso --diffboth clean on this branch; a second-voice review ran duringplanning and its objections are reconciled in the plan's audit trail (the debounce cap above is the
one that changed the design).
tests/registry.jsoncarries the new file;test_1483_ws_setters_wired.pyneeded no edit — it AST-discovers any alias matching
^set_.*(?:ws|websocket)_manager$inmain.pyand asserts it is called, which both new setters satisfy by name.
New tests:
tests/unit/test_ent532_rail_thin_triggers.py(ids-only by strict equality plus leakchecks over serialized bytes; one emit per write; nothing for a raised write or an idempotent
replay; channel media emits too; the three never-fail directions parametrized over both
per-service copies of the helper; an AST case pinning the dict literal; the
main.pywiring) andsix cases added to
portalRailFeeds.spec.js(participant gating, burst coalescing, the bound, theunchanged single-event timing, and that a canvas burst cannot defer the loop refresh sharing the
timer).
Rebase and collisions
Rebased onto
devat682fce300. One conflict,tests/registry.json— rebuilt from the gitstages (dev's array plus this lane's single entry appended, deduped and JSON-validated), never by
splicing markers; a repo-wide marker grep is clean.
resolution was needed. When it lands it wraps this exact
db.upsert_agent_canvas(...)call intry/except CanvasLimitExceeded. The emit here is already written to survive that shape — itsits after the store, on the success path only, so a cap-rejected write (409) emits nothing
either way. Whichever merges first, the other rebases with a one-hunk resolution.
with this branch:
src/backend/main.py(the ws-manager setter imports and calls) andsrc/frontend/src/utils/websocket.js(thedefault:route branch). Both edits on this side arepure appends that touch no existing line, so first-merged wins and the loser re-adds its
lines — trivial as long as neither branch reorders or regroups those blocks.
docs/memory/requirements/core-agent.md§5.33 was still the next free section number after therebase; §5.20's two cross-references point at it.
Follow-ups — listed, not filed (maintainer's call)
/ws(it needs theplatform JWT), and their only push is the per-execution stream, which ends at
stream_end—there is no long-lived portal channel to carry a trigger, and building one is a new transport
with its own auth, not this issue's scope. The flow doc's caveat stays, reworded to say why.
This is the one item worth a
.claude/DEBT_INBOX.mdentry; that file lives in a separate(private) repo, so it is left for a human to add rather than written from here.
chat_response_readycould drive ent#557'srefreshThreads(). It is already a/wseventcarrying
agent_name; ent#557 deliberately shipped its unread badge on the existing 20s pollrather than wait for this mechanism. For platform sessions it could now ride the same
websocket.jsroute block this PR adds..claude/agents/test-runner.mdwants a row for the new file, in its- **Name** (unit/test_x.py) - description [UNIT]form. That file is in the private.claudesubmodule — a separate repo — so it is not part of this PR.
scheduleRefresh(delay)truncates an explicit delay above the cap (latent, not reachabletoday: every caller uses the default). If a future caller passes a deliberately longer delay it
would be clamped to the max wait.
logger.debugon a failed broadcast — deliberate (a trigger is best-effort and a noisywarning on every disconnect would be worse), but arguable if these ever need to be observable.
per event type would save a round trip; not worth the branch for a P3 whose reads are already
coalesced.
agent_name— an event naming no agent stays fleet-visible bydesign. Unreachable from these two call sites (both are keyed on a required agent name), noted
only because it is a property of the channel rather than of this change.
portalWork.js:119has the identical un-capped debounce shape. Deliberately left alone —it is ent#533's file in this same wave, and a cross-lane edit to a shared-shape file is how two
branches collide on one hunk.
Two process notes: this PR's diff touches
src/frontend/but no.vuefile, sofrontend-e2ewill not run unless you add the
uilabel when flipping it out of draft — your call whether thatis worth it for a store/util change. And the closing reference is cross-tracker, so the private
issue does not auto-promote on merge; it needs a manual
status-in-dev.🤖 Generated with Claude Code