feat(workspace): a stage advance reaches the Work card on the write, not the next poll (ent#533) - #2713
Draft
trinity-ability wants to merge 12 commits into
Draft
feat(workspace): a stage advance reaches the Work card on the write, not the next poll (ent#533)#2713trinity-ability wants to merge 12 commits into
trinity-ability wants to merge 12 commits into
Conversation
…e stage advances (ent#533) Rule #1: the requirements land before the code. requirements/core-agent.md — new §5.23 with the three ACs and the mechanism; §5.22's live-push sentence names the new trigger, and its "out of scope" bullet stops claiming a backend broadcast is out of scope now that ent#533 delivers exactly that (the poll stays, so §5.22's behaviour is unchanged). requirements/scheduling.md §34.1 — one bullet: the notice is still a READ surface. No DAG logic, no transition logic, no pipeline state in the DB (Rule #8); Trinity only learns that a file changed. architecture/workspace.md — the trigger, the coalescing budget, and why the 10 s cache needs a Redis generation rather than an in-memory drop: `_cache` is per-process and prod runs two workers, so an in-memory invalidation leaves the other worker stale while dev's single worker passes green. architecture/agent-runtime.md — the "two loops" sentence was already wrong (main.py arms seven schedulers), so it is rewritten without an exhaustive count rather than bumped 2→3, which would have shipped a NEW false claim under cover of a minimal edit. architecture/api-endpoints.md, backend.md — the route and the router entry. The module count there was likewise already stale (72 stated, 73 present); it is corrected to the post-change truth rather than perpetuated. feature-flows/workspace-work.md + the index — why the watcher is agent-side, why the agent's own key and not the internal secret, why the generation fails open, why the poll stays, and why the client debounce needs both an earlier-deadline rule and a floor. Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…its trigger (ent#533) Failing first, per TDD. Thirty-two cases over the seam that does not exist yet: the heartbeat-shaped door (no key / a user key / a system key / another agent's key are the SAME 403 with the SAME detail, so the route is not an existence oracle), the thin agent-keyed trigger asserted by its EXACT key set plus a leak test over the state file's own fields, ids grammar-checked to 422 before anything is published while `stage` normalises instead of rejecting, per-agent coalescing that answers 200/published:false rather than 429, and the Redis generation proven the only way it can fail in production — two independently-primed caches over one Redis, where the worker that never saw the notice must still re-read inside the TTL. Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ishes a thin trigger (ent#533)
GREEN for the 32 cases committed red.
`routers/agent_pipeline_state.py` — `POST /{name}/pipeline-state/changed`,
registered immediately after `agents_router` (Invariant #4) and declaring
`# mcp: none` on line 1 (Invariant #13): an agent talking about itself is not
an operator capability. Auth is the #307 heartbeat's, verbatim — the agent's
OWN agent-scoped key, `track_usage=False` so a notice never amplifies a key's
usage counter, then `authorize_heartbeat`. Every rejection is one 403 with one
detail, so the route cannot be used to learn which agents exist. NOT
`verify_internal_secret` (an agent is never given one) and not an admin gate.
Every import in the handler is function-local: `database`'s module import runs
`init_database()`, and a new router must not be what drags it — or
`client_portal` — onto main.py's import graph.
`models.PipelineStateChangedPayload` — ids grammar-checked through
`pipeline_state.valid_id` (the ONE copy of the `pipelines.ts` rule, imported
inside the validator so models.py keeps no module-scope edge into
client_portal); `stage` normalised rather than rejected, because a free-form
stage id must cost the field and never the notice.
`client_portal/work/pipeline_state.py` — `notify_changed` coalesces 20 notices
/ 10 s per agent and builds its event dict inside the same function that
broadcasts it (ent#467's guard resolves the name using only that scope), with
`agent_name` top-level so the payload is agent-keyed and `event_bus.py` needs
no change at all. `mark_changed` drops the local entry AND bumps a Redis
generation the 10 s cache now compares beside its TTL: `_cache` is
per-process, prod runs two workers, and an in-memory-only invalidation would
leave the other worker stale while passing green on dev's single worker. The
generation is read BEFORE the read, so a notice landing mid-read invalidates
the entry it is about to store instead of being stamped onto it. Redis down ⇒
None on both sides ⇒ exactly ent#525's TTL-only cache.
Refs Abilityai/trinity-enterprise#533
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Failing first. The loop runs once a second in every agent container forever,
so the cases are about what it must survive rather than what it computes:
a bounded one-level scan that drops non-JSON, invalid ids and non-files and
answers {} for a missing directory (and never raises, even if scandir does);
a `(mtime_ns, size)` signature so the canonical writer's non-atomic `cp` onto
the read surface fires AGAIN as the file grows — which is what makes a
truncated read self-healing rather than sticky; a deletion that is explicitly
NOT a stage advance, so a cleanup pass cannot storm the backend; a
`read_stage` that returns a bounded string or None and nothing else, because
the value lands on a SCOPE_ALL channel; a silent baseline, so a container
restart does not replay every instance on disk; a swallowed transport error;
the per-tick cap; and the same two-env-var gate the #307 heartbeat uses, plus
the arming line in the agent server's main.py — a loop nobody starts is a
silent no-op.
Refs Abilityai/trinity-enterprise#533
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#533) GREEN for the 29 cases committed red. `agent_server/pipeline_state_watch.py` mirrors `heartbeat.py` — sleeps first, one shared AsyncClient, swallows everything, and armed only when TRINITY_BACKEND_URL and TRINITY_MCP_API_KEY are both present, so an old image behaves exactly as before. No Dockerfile, no startup.sh, no new pip dependency: stdlib plus the httpx the heartbeat already brings (asserted, not assumed). Three choices are load-bearing. The signature is `(mtime_ns, size)`, not mtime alone, because the canonical writer copies onto the read surface with a plain `cp` — a growing file fires again, which is what makes a truncated read self-healing. A deletion is NOT a change, so a cleanup pass cannot storm the backend for a card update that would not happen anyway. And the baseline is taken silently after the first sleep, so a container restart does not replay every instance on disk as a fresh advance. The per-tick cap advances past capped-away keys rather than retrying them: the next real write moves them again and the poll covers the gap, whereas retrying would let one noisy pipeline monopolise the budget forever. The id pre-filter is convenience, not a boundary — the backend re-validates with the same grammar and a mismatch fails safe (422 → debug → the poll). Also fixes this file's own dependency guard, which parsed import lines textually and read the relative `from .config import ...` as a third-party root named "" — the assertion was right, its parser was not; it now walks the AST and skips relative imports. Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ne rules and the poll (ent#533) Eight new cases plus the existing websocket source guard, whose exact count of Work-store consumers goes 2 → 3. Two of them are the reason the store is touched at all. `earlier deadline wins` proves a later 2 s `agent_activity` push cannot postpone a pending 500 ms stage refetch — without it the store defeats this feature's own latency goal — and it is asserted in BOTH directions, since a rule that only shortens is a rule that also has to not lengthen. The burst case proves the 1500 ms floor: earlier-deadline-wins alone turns the debounce into a `delay`-length throttle, and the Work read is limited 120/60 s per viewer with a 429 that renders as LoadFailed / InlineError — visible error text is the one outcome a latency change must not produce. Its counterpart proves the floor does NOT bind on the common path, so AC-1 stays proven. AC-2 is a source guard pointed at `components/portal/portalWork.js`, where `WORK_POLL_MS` actually lives — the same guard aimed at the store would pass vacuously, since the store only imports it. Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…th deadline rules (ent#533) GREEN for the eight cases committed red. `utils/websocket.js` gains one appended `data.type` branch — the same thin trigger contract as its neighbours: nothing is read off the payload, the store just looks again through the access-controlled read. `stores/portalWork.js` debounces the new push at 500 ms under two rules that each exist for a traced failure. **Earlier-deadline-wins**: a plain re-arm lets a 2 s `agent_activity` push landing 400 ms after a stage advance drag the refetch out to 2.4 s, so without it the store defeats the feature it is being changed for; `Portal.vue`'s own `scheduleRefresh(1500)` gets the same benefit. **The 1500 ms floor**: because deadlines now only shorten, a stream of pushes becomes a `delay`-length throttle rather than a coalescing debounce, and the Work read is limited 120/60 s per viewer with a 429 that `PortalWork.vue` renders as LoadFailed / InlineError — a latency fix that can put error text on the card is not a fix. The floor never binds on an idle card with one advance, which is the path AC-1 is about. No .vue file, no copy, no token, no new state: the only perceivable difference is that the same sentence updates ~1–1.6 s after the write instead of ≤12 s. `WORK_POLL_MS` and `_ensurePolling` are untouched (AC-2). Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
From /update-tests' coverage audit over the new seam. The notice must validate its key with `track_usage=False` — the heartbeat's rule, for the heartbeat's reason: a notice is not a *use*, and counting it would inflate the key's usage_count and write to SQLite on every pipeline tick. It was in the docstring and in the code, and asserted nowhere. The generation is sampled BEFORE the read, not after. Sampling after stamps a notice that arrived mid-read onto the very entry it invalidates: the read returns pre-notice data carrying the post-notice generation, and the card sits stale for the full TTL with nothing left to signal it. That is the one ordering the whole design rests on and the most plausible thing for a later reader to "tidy up", so it now has a test that fails when the order is flipped — verified by flipping it. Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t one (ent#533) From /sync-feature-flows' drift pass. Nothing in the heartbeat flow became FALSE — it never claimed exclusivity — but ent#533 gave `heartbeat_service.authorize_heartbeat` a second caller, and that is exactly the kind of shared dependency a doc-only cross-reference exists to catch: someone tightening the predicate for the heartbeat would silently change the pipeline-state notice route too. Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…een tests red (ent#533)
Caught by running the FULL unit suite rather than the touched files: the
branch reported `6 failed, 15115 passed` while every targeted run was green,
and `test_drain_bounded.py` — untouched by this lane — was the casualty.
The shim was copied from `test_agent_heartbeat.py`, where it is load-bearing
only by accident of the alphabet. It is redundant to begin with:
`tests/unit/conftest.py::_preload_real_agent_server` already registers
`docker/base-image/agent_server` as a namespace package before collection, and
`test_drain_bounded.py` documents that in its own header ("conftest.py
preloads the real agent_server package; just import").
It is also not inert. Its unconditional eviction loop pops every
`agent_server.*` entry at COLLECTION time, and `test_drain_bounded.py`
imports `subprocess_lifecycle` at module scope, then patches it BY NAME at run
time — so after the eviction `patch()` re-imports a second copy and the
patches land on a module the test's own reference no longer points at. That
file sorts before this one, which is why only the full-suite run could see it:
`test_agent_heartbeat.py` sorts BEFORE drain and so evicts harmlessly, while
this file sorted after.
Proof both ways at 3edd0dc: `pytest unit/test_drain_bounded.py
unit/test_agent_heartbeat.py` reproduces the same six failures on the
untouched base (the latent defect is pre-existing), and the base's full suite
is green (nothing steps on it there). Fixing the shared shim pattern is a
separate change; this lane just stops stepping on it.
Refs Abilityai/trinity-enterprise#533
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t#533) `authorize_heartbeat` already has the right shape — `scope == "agent"` and nothing else — but the route's tests only exercised the three rejections somebody happened to think of (`user`, `system`, another agent's key), which is the same coverage `test_heartbeat_service.py` has carried since #307. `architecture/api-endpoints.md` states a wider contract for this route ("a user/system/connector/other-agent key is 403"), and connector was asserted nowhere at either level. That gap is the #2323 shape exactly. `mcp_api_keys.scope` is free text with no CHECK constraint, so a gate whose tests name only the enemies it knows reads as safe right up until the next scope is invented — `ops` was that sixth scope for the admin gate, and the only reason it cost nothing here is that this predicate was already written as an allowlist. Four cases pin that: `connector`, `ops`, an explicit `None` scope and a principal with no `scope` key at all. Each carries THIS agent's name, so the scope check is what is on trial and not a name mismatch that would have rejected them regardless. No source change — the behaviour was already correct. Mutation-verified so the additions are not decorative: rewriting the predicate as the denylist `scope not in ("user", "system")` turns exactly these four red while the three pre-existing cases stay green, i.e. the old set could not have caught that rewrite and the new set can. tests/unit/test_ent533_pipeline_state_broadcast.py: 34 -> 38 passed Refs Abilityai/trinity-enterprise#533 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…5.33 (ent#533) `/validate-pr` caught it: `core-agent.md` carries TWO numbering runs — §5.1–5.22 inside the `## 5. Agent Chat & Terminal` block, and a trailing run §5.23–§5.32 appended after the Cornelius block. This lane appended its section at the end of the first run and picked 5.23, which the trailing run has held since ent#523/524. Two `### 5.23` headings is not a cosmetic problem: it is the single source of truth, and the two `§5.23` cross-references this lane added (§5.22's "delivered by ent#533" pointer and scheduling.md §34.1's "See core-agent.md §5.23") both resolved ambiguously — a reader following either one could land on the Workspace agents-at-the-centre section instead. 5.33 is the next globally free number. The section stays where it is, beside the §5.22 it amends: both runs remain individually ascending, and 5.23–5.32 being taken is what explains the jump. The three pre-existing §5.23 references (core-agent.md's own 2026-09-05 ruling, workspace.md's Requirements line, scheduling.md:1469) point at the ent#523/524 section and are untouched. Not fixed here, and pre-existing on dev: `### 5.14` is likewise duplicated (ent#155 stop-control vs Workspace deliverables). Out of this lane's scope. Refs Abilityai/trinity-enterprise#533 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 Work card's pipeline steps (ent#525) refreshed only on the 12 s poll, so a stage advance could lag a full poll behind a file the agent had already written.
The file lives in the agent's own container, so the only thing that knows when it changed is the agent — the backend can only ask. That decided the fork:
agent_server/pipeline_state_watch.pyscans~/.trinity/pipeline-state/once a second withos.scandir, keyed on(mtime_ns, size)per file. Steady state is one directory scan per second and no network at all — the canonicalpipeline-tickwriter runs every 15 minutes, so a POST is an event, not a stream. The baseline snapshot is taken silently at startup, so a restart never replays every instance on disk.POST /api/agents/{agent_name}/pipeline-state/changed,Authorization: Bearer <the agent's own MCP key>, validated withtrack_usage=Falseand thenauthorize_heartbeat— feat: Agent heartbeat push for fast failure detection (RELIABILITY-004) #307's predicate, now guarding two routes. It is an allowlist: true only whenscope == "agent"and the bound name equals the path. A user, system, connector,opsor null-scoped key, and any other agent's key, all get the same 403 with the same detail — a differential would make the route an oracle for which agents exist (Invariant security: implement safe tar extraction with symlink/hardlink validation #8)./api/internal/*was the wrong door twice over: its blanket router is gated onX-Internal-Secret, which is deliberately never injected into an agent container, and its one agent-key predicate (_pull_authorized) additionally requires a pull-pilot agent — a route placed there would have been silently dead for most of the fleet./api/agents/{name}/…is also Invariant Security: Agent Credential Leakage in Execution Logs [FIXED] #15's shape for a fact about one named agent, and where both existing always-on agent self-reports already live.pipeline_state_changedevent on/wscarrying identifiers only —pipeline_id,instance_id, an opaquestagebounded to 80 printable chars, and a server-stampedchanged_at. Neverhealth,blockers,escalations, per-stage metrics, or any file body.agent_nameis top-level, which is what makes ent#467 scope it to clients whose roster contains the agent; the dict literal is built in the same function that broadcasts it, as that guard's AST discovery requires.services/event_bus.pyis untouched — an agent-keyed payload needs no bus change.client_portal/work/pipeline_state.py; the store refetches through the existing access-controlled route.WORK_POLL_MS = 12000and_ensurePollingare untouched, so a dropped, coalesced or failed notice is invisible and an old base image behaves exactly as ent#525 shipped.uvicorn --workers 2, so the other worker would keep serving its own ≤10 s-old entry, and dev's single--reloadworker would have passed green. The generation is read before the read, not after, so a notice landing mid-read invalidates the entry it is about to store rather than being stamped onto it as already-seen. Redis down ⇒ generationNoneon both sides ⇒ exactly today's TTL-only behaviour.docker/base-image/Dockerfilealready doesCOPY ./agent_server, so the new module ships by construction; no new dependency (stdlib +httpx, already present), andUSER developeris untouched (Invariant Client/Viewer User Role (AUTH-002) #17).Internal constants, none of them user-visible: watcher tick 1 s,
NOTIFY_LIMIT = 20 / 10 sper agent server-side,_GEN_TTL_SECONDS = 120, and client-sidePIPELINE_PUSH_DEBOUNCE_MS = 500under aPUSH_MIN_GAP_MS = 1500floor. The floor is load-bearing rather than decorative: it caps a viewer at 40 push refreshes/min against the Work read's existing 120/60 s per-viewer budget, so a burst can never reach that limiter — whose 429 would be visible. The store's debounce also gains earlier-deadline-wins, so a later 2 sagent_activitypush cannot postpone a pending 500 ms stage refetch; without it the store would have quietly defeated the feature.Fixes abilityai/trinity-enterprise#533
User-visible change: none
No
.vuefile is touched.PortalWorkCard.vue/PortalWork.vuerenderitem.stepsexactly as today; ent#525's three steps states and their three sentences are unchanged; no new copy, control, default, or empty/error/loading state. The only perceivable difference is when the same card updates — ~1–1.6 s after the agent writes instead of ≤12 s.One open question for the maintainer
Should a pipeline-state file that is present but unreadable say "doesn't report steps", or "steps could not be read right now"?
When a state file exists but cannot be parsed (truncated mid-
cp, bad JSON, failed download),pipeline_state._readreturnsnone, which the card renders as the positive claim "<Agent> doesn't report steps." — whiledocs/memory/architecture/workspace.mddefines the third state asunknown= "stopped, unreachable, unreadable, or two runs on one agent". The code and the written contract disagree about this one case.What the branch does today: (a) — leave it.
_readis byte-identical todev(verified: 2521 bytes, 58 lines, both sides), so this lane neither causes the disagreement nor changes it. It is also not made more likely: the push-driven refetch lands ~1.5 s after the watcher first sees a new(mtime, size), i.e. after a KB-sized copy has completed — better phasing than today's arbitrary 12 s poll.Both sentences already exist, so nothing new would be invented — but which one a person reads is a user-visible fork with two plausible answers, which is why the plan declined to decide it. Options:
unknown— ~4 lines in_readplus one test, and the code conforms to the contractworkspace.mdinsteadVerification
Gate run (WAVE-4), detached
/verify-local --skip-agent— prod images, isolated sibling stack:npm run test:unit: 2,660 passedVerify mode note.
/verify-localFULL refuses its agent stage in global mode on a host with a live dev stack (that stack ownstrinity-agent-network, andagent-trinity-systemon it is live infrastructure). This lane touchesdocker/base-image/**, so the agent half was proved manually instead — inside the freshly built base imagef2f79d8f2ba6, in a throwaway container on the default bridge, with an in-container stub receiver.Base-image proof (plan §10), verbatim. Write a state file, then rewrite it byte-identically, then advance the stage:
Two identical lines, then exactly one more. The byte-identical rewrite firing again is the contract, not a defect: the signature is
(mtime_ns, size)and the watcher's job is "any change" — a writer that rewrites the same bytes has still written, and a growing file must fire again so a slow write self-heals. Coalescing is not the watcher's job; it lives at the backend's 20/10 s limiter and the client's 500 ms / 1500 ms floor.The negative halves, which are what make the positive lines mean anything:
STUB /api/agents/probe/heartbeatlines still landing every 5 s (so the stub was demonstrably alive and listening throughout)id -u→ 1000 (Invariant Client/Viewer User Role (AUTH-002) #17 unchanged)IMPORT OK /app/agent_server/pipeline_state_watch.pytrinity_mcp_probeis a throwaway fixture string, not a credential.Post-rebase neighbourhood (rebased onto
682fce300):Per file, so any later delta is attributable:
test_ent533_pipeline_state_broadcast38 ·test_ent533_agent_pipeline_state_watch29 ·test_918_report_broadcast1 ·test_ent467_ws_agent_scope37 ·test_ent525_portal_work59 ·test_agent_heartbeat12 ·test_1483_ws_setters_wired1 ·test_1483_route_order1 skipped ·test_1310_auth_wiring12 ·test_293_admin_gate_rejects_agent_keys25 ·test_models_centralized4 ·test_2338_journey_catalog50.Structural passes:
/reviewand/cso --diffboth clean, with the full auth failure matrix pinned (every non-agentscope, and a different agent's key, asserted to the same 403). A Gemini second voice ran at plan stage; its two substantive findings are in the branch — the earlier-deadline-wins rule and the open question above.Deviations from the issue text
pipeline_id+instance_id; attribution stays where it already lives, in the read./api/agents/…, not/api/internal/…. Verified, not assumed — see the Summary: internal is secret-gated and agents hold no secret, and its one agent-key path additionally requires a pull-pilot agent.backend.md's router count was already stale — it read 72 against 73 present. Corrected to the post-change truth (74) rather than perpetuated.agent-runtime.md's "runs two loops" was likewise already wrong (seven schedulers are armed), so it was rewritten without an exhaustive count instead of being bumped 2→3, which would have shipped a new false claim under cover of a minimal edit.docs/memory/requirements/core-agent.mdsection number./validate-prcaught that this lane's new section had taken5.23, which the file's trailing numbering run has held since ent#523/524 — two### 5.23headings, with both of this lane's own§5.23cross-references resolving ambiguously. Renumbered to 5.33 (the next globally free number) in its own commit. A pre-existing duplicate### 5.14ondevis left alone as out of scope.Collisions
src/backend/main.pysetter block and adds a siblingdata.typebranch toutils/websocket.js'sdefault:arm. Both overlaps are append-only: whichever merges first wins, and the loser simply re-adds its own lines. Neither lane editsservices/event_bus.py.src/backend/models.py,architecture/backend.md,architecture/agent-runtime.md,architecture/api-endpoints.md,architecture/workspace.md,requirements/core-agent.md,requirements/scheduling.md,feature-flows.md.682fce300produced exactly one conflict — thefeature-flows.mdindex row — resolved by keeping both sides' rows in date order.Follow-ups — listed for the maintainer, deliberately not filed
client_portal/work/pipeline_state.py::_cacheis keyed onagent_namealone, but the value it stores depends on the caller's roster (holder names are masked per viewer). Within the 10 s TTL, viewer B can therefore be served an entry computed for viewer A's roster — harmless in the more-masked direction, a disclosure in the other (the ent#467 class). This lane changes only when that cache misses, never its key, so it neither causes nor worsens it. The fix is to cache the unmasked value and mask at serve time — not to hash the roster into the key, which would multiply the entries the cache exists to bound.routers/auth.py::get_redis_client()has nosocket_connect_timeoutand never memoises failure, so every caller pays a full connect attempt while Redis is down. Platform-wide, not specific to this lane.tests/unit/test_agent_heartbeat.pyinstalled a collection-timesys.modulesshim that broketest_drain_bounded.pywhenever it was collected first — latent and alphabet-dependent. Removed on this branch (it was turning six green tests red); the same pattern may exist in other agent-server test files.valid_idusesre.matchwith a$anchor, so a trailing newline passes.re.fullmatchis the correct spelling. Not exploitable here — the backend re-validates — but it is the wrong primitive.CLAUDE.md:242still says "~500 endpoints across 72 routers";backend.mdis now 74..claude/agents/test-runner.mdwants catalog rows for the two new test files (private submodule, so not touchable from this PR).trinity-agent-base:latestnow points at the freshly built image. The previous image keeps its0.8.5tag and was not deleted.🤖 Generated with Claude Code