Skip to content

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
devfrom
vybe/issue-ent533
Draft

feat(workspace): a stage advance reaches the Work card on the write, not the next poll (ent#533)#2713
trinity-ability wants to merge 12 commits into
devfrom
vybe/issue-ent533

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

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:

  • The agent server watches its own directory. A new agent_server/pipeline_state_watch.py scans ~/.trinity/pipeline-state/ once a second with os.scandir, keyed on (mtime_ns, size) per file. Steady state is one directory scan per second and no network at all — the canonical pipeline-tick writer 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.
  • It authenticates with its own key, not a shared secret. POST /api/agents/{agent_name}/pipeline-state/changed, Authorization: Bearer <the agent's own MCP key>, validated with track_usage=False and then authorize_heartbeatfeat: Agent heartbeat push for fast failure detection (RELIABILITY-004) #307's predicate, now guarding two routes. It is an allowlist: true only when scope == "agent" and the bound name equals the path. A user, system, connector, ops or 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 on X-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.
  • The backend publishes a thin trigger and nothing else. A pipeline_state_changed event on /ws carrying identifiers only — pipeline_id, instance_id, an opaque stage bounded to 80 printable chars, and a server-stamped changed_at. Never health, blockers, escalations, per-stage metrics, or any file body. agent_name is 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.py is untouched — an agent-keyed payload needs no bus change.
  • CLAUDE.md Rule security: implement safe tar extraction with symlink/hardlink validation #8 is respected. Trinity learns exactly one thing — a file changed. No DAG state, no stage comparison in the backend, no transition logic, no pipeline state in the database. The read stays client_portal/work/pipeline_state.py; the store refetches through the existing access-controlled route.
  • The 12 s poll stays (AC2). WORK_POLL_MS = 12000 and _ensurePolling are untouched, so a dropped, coalesced or failed notice is invisible and an old base image behaves exactly as ent#525 shipped.
  • A 120 s Redis generation, because the cache is per-process. A notice bumps a per-agent generation that the 10 s cache compares alongside its TTL. Dropping the local entry alone fixes only the worker that received the notice — production runs uvicorn --workers 2, so the other worker would keep serving its own ≤10 s-old entry, and dev's single --reload worker 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 ⇒ generation None on both sides ⇒ exactly today's TTL-only behaviour.
  • No Dockerfile change. docker/base-image/Dockerfile already does COPY ./agent_server, so the new module ships by construction; no new dependency (stdlib + httpx, already present), and USER developer is untouched (Invariant Client/Viewer User Role (AUTH-002) #17).

Internal constants, none of them user-visible: watcher tick 1 s, NOTIFY_LIMIT = 20 / 10 s per agent server-side, _GEN_TTL_SECONDS = 120, and client-side PIPELINE_PUSH_DEBOUNCE_MS = 500 under a PUSH_MIN_GAP_MS = 1500 floor. 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 s agent_activity push 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 .vue file is touched. PortalWorkCard.vue / PortalWork.vue render item.steps exactly 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._read returns none, which the card renders as the positive claim "<Agent> doesn't report steps." — while docs/memory/architecture/workspace.md defines the third state as unknown = "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. _read is byte-identical to dev (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:

  • (a) leave it (what this branch does; the written contract stays wrong)
  • (b) map "present but unreadable" to unknown — ~4 lines in _read plus one test, and the code conforms to the contract
  • (c) leave the code and correct workspace.md instead

Verification

Gate run (WAVE-4), detached /verify-local --skip-agent — prod images, isolated sibling stack:

  • unit: 15,159 passed / 31 skipped
  • build + import-smoke: OK
  • boot + health: OK
  • integration: 70 passed / 13 skipped / 2 registry-deselected
  • npm run test:unit: 2,660 passed

Verify mode note. /verify-local FULL refuses its agent stage in global mode on a host with a live dev stack (that stack owns trinity-agent-network, and agent-trinity-system on it is live infrastructure). This lane touches docker/base-image/**, so the agent half was proved manually instead — inside the freshly built base image f2f79d8f2ba6, 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:

STUB POST /api/agents/probe/pipeline-state/changed Bearer trinity_mcp_probe {"pipeline_id":"digest","instance_id":"i1","stage":"synthesis"}
STUB POST /api/agents/probe/pipeline-state/changed Bearer trinity_mcp_probe {"pipeline_id":"digest","instance_id":"i1","stage":"synthesis"}
STUB POST /api/agents/probe/pipeline-state/changed Bearer trinity_mcp_probe {"pipeline_id":"digest","instance_id":"i1","stage":"publish"}

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:

  • 8 idle ticks → 0 POSTs, with the heartbeat's own STUB /api/agents/probe/heartbeat lines still landing every 5 s (so the stub was demonstrably alive and listening throughout)
  • deleting the file → no POST (a deletion is not a stage advance; the read and the poll handle it)
  • id -u1000 (Invariant Client/Viewer User Role (AUTH-002) #17 unchanged)
  • import smoke inside the image → IMPORT OK /app/agent_server/pipeline_state_watch.py

trinity_mcp_probe is a throwaway fixture string, not a credential.

Post-rebase neighbourhood (rebased onto 682fce300):

cd tests && python3 -m pytest unit/test_ent533_pipeline_state_broadcast.py \
  unit/test_ent533_agent_pipeline_state_watch.py unit/test_918_report_broadcast.py \
  unit/test_ent467_ws_agent_scope.py unit/test_ent525_portal_work.py unit/test_agent_heartbeat.py \
  unit/test_1483_ws_setters_wired.py unit/test_1483_route_order.py unit/test_1310_auth_wiring.py \
  unit/test_293_admin_gate_rejects_agent_keys.py unit/test_models_centralized.py \
  unit/test_2338_journey_catalog.py -m "not slow" -q -p no:randomly
  → 268 passed, 1 skipped

Per file, so any later delta is attributable: test_ent533_pipeline_state_broadcast 38 · test_ent533_agent_pipeline_state_watch 29 · test_918_report_broadcast 1 · test_ent467_ws_agent_scope 37 · test_ent525_portal_work 59 · test_agent_heartbeat 12 · test_1483_ws_setters_wired 1 · test_1483_route_order 1 skipped · test_1310_auth_wiring 12 · test_293_admin_gate_rejects_agent_keys 25 · test_models_centralized 4 · test_2338_journey_catalog 50.

python3 lint_sys_modules.py          → OK: 140 violations in 49 files; baseline allows 206 — no new violations
python3 lint_root_test_placement.py  → OK: no self-contained tests in tests/ root; no unmarked async under tests/unit/
cd src/frontend && npx vitest run tests/unit/portalWork.spec.js \
  tests/unit/rawColorRatchet.spec.js tests/unit/loadingGateRatchet.spec.js
  → 3 files, 53 passed

Structural passes: /review and /cso --diff both clean, with the full auth failure matrix pinned (every non-agent scope, 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

  1. The poll is kept. The source debt entry said "drop the poll"; AC2 supersedes it — the poll is the fallback that makes a dropped notice invisible.
  2. No execution id on the trigger. The agent does not know which ledger row it is serving at write time. AC3's "execution/instance ids" is met with pipeline_id + instance_id; attribution stays where it already lives, in the read.
  3. Route under /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.
  4. Latency is ~1–1.6 s, not sub-second. One polling tick on the agent side is the honest floor without an inotify dependency. Stated that way in the docs rather than rounded to AC1's "~1 s".
  5. 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.
  6. docs/memory/requirements/core-agent.md section number. /validate-pr caught that this lane's new section had taken 5.23, which the file's trailing numbering run has held since ent#523/524 — two ### 5.23 headings, with both of this lane's own §5.23 cross-references resolving ambiguously. Renumbered to 5.33 (the next globally free number) in its own commit. A pre-existing duplicate ### 5.14 on dev is left alone as out of scope.

Collisions

  • Lane A, ent#532 (feat(workspace): the rail's Canvas/Files dot lights on the write, not the next refetch (ent#532) #2709) — still open and draft as of this writing, so there is nothing to resolve yet. It appends to the same src/backend/main.py setter block and adds a sibling data.type branch to utils/websocket.js's default: arm. Both overlaps are append-only: whichever merges first wins, and the loser simply re-adds its own lines. Neither lane edits services/event_bus.py.
  • Doc-line overlaps with other open PRs, all append-or-adjacent and none structural: 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.
  • This rebase onto 682fce300 produced exactly one conflict — the feature-flows.md index row — resolved by keeping both sides' rows in date order.

Follow-ups — listed for the maintainer, deliberately not filed

  1. SECURITY, pre-existing (from ent#525), the highest-value item here. client_portal/work/pipeline_state.py::_cache is keyed on agent_name alone, 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.
  2. routers/auth.py::get_redis_client() has no socket_connect_timeout and never memoises failure, so every caller pays a full connect attempt while Redis is down. Platform-wide, not specific to this lane.
  3. tests/unit/test_agent_heartbeat.py installed a collection-time sys.modules shim that broke test_drain_bounded.py whenever 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.
  4. valid_id uses re.match with a $ anchor, so a trailing newline passes. re.fullmatch is the correct spelling. Not exploitable here — the backend re-validates — but it is the wrong primitive.
  5. The multi-tab margin against the Work read's per-viewer limiter narrows from about 4 tabs to 3 under a sustained ≤1.5 s writer. Still a comfortable margin; worth knowing before anyone lowers that budget.
  6. CLAUDE.md:242 still says "~500 endpoints across 72 routers"; backend.md is now 74.
  7. .claude/agents/test-runner.md wants catalog rows for the two new test files (private submodule, so not touchable from this PR).
  8. Operator note: on the verify host, trinity-agent-base:latest now points at the freshly built image. The previous image keeps its 0.8.5 tag and was not deleted.
  9. If a third file-backed read ever wants push, the honest generalisation is one "agent file changed" notice surface rather than a third bespoke route. Two is not yet a pattern.

🤖 Generated with Claude Code

trinity-ability and others added 12 commits September 11, 2026 15:49
…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>
@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant