Skip to content

DO NOT MERGE — merge train: 2863,2864,2844,2855 - #2868

Closed
vybe wants to merge 19 commits into
devfrom
train/20260917-1237
Closed

vybe wants to merge 19 commits into
devfrom
train/20260917-1237

Conversation

@vybe

@vybe vybe commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2863, #2864, #2844, #2855. Never merged; members merge individually once green.

Train-only resolutions (member-vs-member, not member-vs-dev): docs/memory/learnings.md (keep both), tests/registry.json (rebuilt from stages), src/mcp-server/src/server.ts (import block: #2863's fence comment + #2855's configureAudit import). Combined MCP tree: tsc --noEmit clean, npm test 423/423 locally.

🤖 Generated with Claude Code

webmixgamer and others added 19 commits September 16, 2026 17:50
…2807)

Every access gate on the MCP tool surface RETURNS its denial as JSON (the
envelope agents parse), and the audit wrapper labelled a call by
throw/no-throw. So a refused chat_with_agent / chat_with_<slug> / fan_out /
run_agent_loop call left an mcp_operation row reading `success: true`, and an
operator reading the audit log could not tell a permitted call from a refused
one. Reproduced live before the fix: the refused call's row was
{"tool": "chat_with_agent", "duration_ms": 32, "success": true}.

The deny sites now serialise through one helper, access.ts::accessDenied,
which stamps `context.outcome = {kind: "denied", reason}` on the per-call tool
context (the seam #905 already uses for requestId); withAudit reads it after
execute and writes `success: false`, `denied: true`, `error: <reason>`. A
thrown backend 403 is marked denied too. The caller's JSON is byte-identical.
All 29 deny branches across 10 tool modules go through the helper, and a
decision-based guard fails `npm test` for a `!allowed` branch or an
`error: "Access denied"` envelope that bypasses it. createServer injects the
audit URL and secret (configureAudit), so a test can observe the row.

Tests: audit-denial.test.ts (captured audit POST, byte-identical envelopes,
root-context-only stamp, stamp-then-throw, thrown 403, the guard);
access-wiring.test.ts records /api/internal/audit over the real transport and
runs deny-then-allow on one session; the J10 strict xfail
test_the_operator_can_see_that_a_call_was_refused is flipped.

Fixes #2807

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	docs/memory/learnings.md
…e that slides up, for every live run; the chat scrolls to the card on your own send (trinity-enterprise#620)

The card's "current step" slot was blank: its handler matched
`evt.type === 'tool_use'`, a shape the raw stream-json frames never carry,
so only the backend-injected `error` ever labelled — and no run but the
chat's own turn had a live path at all.

One vocabulary, two feeds:

- `utils/workActivity.js` composes the line from `{tool, summary}` —
  "Reading .../x.py", "Running pytest …", "Searching for …", "Fetching …",
  "Using <server>", "Delegating to <agent>: …", "Thinking". The Chat tab
  (`execution-status.js`) and Agent Detail (`useSessionActivity`) compose
  from it too. The stream path parses the real frame shape and summarises
  the input with a port of the agent's `get_input_summary`, held to parity
  by `tests/fixtures/tool_input_summary.json` (pytest + vitest).
- The agent server keys its active tool PER EXECUTION
  (`session_activity.by_execution`, threaded through both live parse sites
  and the Codex parser) and the 5 s heartbeat carries a bounded
  `executions[]` for the registry's running set — a finished run leaves by
  construction. `HeartbeatPayload` bounds it (≤20, summary ≤120, tool ≤64,
  id shape, extra keys refused). `heartbeat_service.read_execution_activity`
  keys it; the Work read folds it onto live, non-stale rows of rostered
  agents as `WorkItem.activity` through the title sanitiser + roster mask,
  dropping a line older than 30 s; `GET …/work/activity` (Redis only, same
  gates) is polled every 2.5 s while a card is live.
- `PortalWorkCard` reserves one `h-4 overflow-hidden` row for the live
  life; a keyed slide-up `<Transition>` (a swap under reduced motion);
  `createActivityLineQueue` holds ≥700 ms, collapses a burst, never re-keys
  an identical line, keeps the last line on a quiet run, clears at terminal.
  The Work tab's Now rows and the room's cards receive the line too.
- A person's own send re-pins the transcript once the card mounts, guarded
  by `following` (#2624).

Mutations (all red): stream parser on the old shape, queue without the
minimum, identical line re-keyed, stale line kept, summariser drift; fold
onto stale rows, no roster mask, age ceiling ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…running execution, not the capped slice (trinity-enterprise#620 /review C1); drop the dead Sync branch

A 21st concurrent execution is still alive; forgetting its slot on every
beat reset it to "Thinking" for as long as the fleet stayed that busy.
The wire stays bounded at ACTIVITY_MAX_EXECUTIONS; the prune set is the
registry's whole running set. Pinned by
test_a_run_past_the_cap_keeps_its_slot (mutation: prune from the slice →
red).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…o name the shard that hangs on this branch (revert before merge)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…imeout to name the shard that hangs on this branch (revert before merge)"

This reverts commit 125df89.
…v desktop (trinity-enterprise#620)

The #620 heartbeat work added test_ent620_agent_activity.py, which evicts the
whole `agent_server` package from sys.modules at module level, unconditionally.
Collected after test_drain_bounded.py, that re-registers the package under a
fresh module object, so the drain test's dotted-string monkeypatches
(`monkeypatch.setattr("agent_server.services.subprocess_lifecycle...", ...)`)
land on the wrong copy. The REAL `_drain_bounded` then runs with a MagicMock
process (pid=99999): `terminate_process_group` + the cgroup orphan sweep
SIGKILL whatever real process group that pid resolves to and everything in the
host's root cgroup. That took the CI runner down on every pytest shard
("The runner has received a shutdown signal") and a developer's whole desktop
session twice in one afternoon (`user@1000.service: code=killed, status=9/KILL`).
The #728 class, third occurrence.

Four independent layers, so this cannot ship again:

1. Production fence (docker/base-image/.../orphan_sweep.py):
   `kill_cgroup_orphans` refuses to sweep unless the `cgroup.procs` it read
   lists PID 1 — true inside an agent container (cgroupns=private, verified
   live), never true of a host/runner root cgroup or a cgroupns=host agent.
   Fail-safe: leak an orphan rather than kill the host, logged.

2. Test signal guard (tests/signal_guard.py, installed for the whole unit
   suite in tests/unit/conftest.py): wraps os.kill/os.killpg — one module, no
   package copy can bypass it — and refuses any signal to the session's own
   group/ancestors or to a process outside the session's cgroup, recording it
   so the autouse fixture fails the test even when the caller swallows the
   exception (the production drain catches Exception). Cgroup membership, not
   parent chain, so a test's own setsid-reparented child is still recognised
   as ours.

3. test_drain_bounded.py patches through `_drain_bounded.__globals__` (the one
   dict the bound function reads regardless of how many copies exist) instead
   of dotted strings, and stubs `_terminate_process_group` for every test.

4. Lint (tests/lint_sys_modules.py): an unguarded module-level `for ... in
   list(sys.modules): ... pop` registry-scan eviction is now a hard failure,
   never baselined. The five files that legitimately evict `agent_server`
   (heartbeat, auto_sync, git_maintenance, 2742, ent620_agent_activity) are
   converted to the path-guarded form (test_git_status_dual_ahead_behind.py
   precedent).

Verified: the full local unit suite (16503 passed) and the previously-lethal
subprocess/drain/orphan set under xdist × three CI seeds now run on the host
with zero signal refusals and the session intact. New tests
test_2845_orphan_sweep_host_fence.py and test_2845_signal_guard.py pin the
fence and the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… runner and the desktop (trinity-enterprise#620)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…e 0.9.5 cut (work-order F1, ent#500)

The assignments layer is not shipping in 0.9.5, so the MCP tool that reads it must not be
advertised. `server.ts` no longer imports/registers `createAssignmentTools`; `access.ts`
drops the policy row (the ent#628 totality test forbids a row for an unregistered tool);
`tool-visibility.test.ts` pins the ABSENCE of the registration instead of its presence.
`tools/assignments.ts` and `assignments.test.ts` stay — re-enabling is one import + one
line + the row. Docs rows (architecture, user-docs, feature-flow) say "fenced".

MCP suite: 409/409; `tsc --noEmit` clean.

Related to Abilityai/trinity-enterprise#500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….0 too (#2419)

The provider's anthropic-ratelimit-unified-{5h,7d}-utilization header is a
fraction of the cap. _parse_utilization multiplied by 100 only up to 1.0 and
passed anything larger through as an "already-percent" value, so an overage
reading of 1.2 came out as 1.2%: the ent#434 weekly alert filed an exhausted
subscription as has_headroom and the #2409 ranker rated it the best
destination. The parser now always scales, matching the provider's own
client; non-finite or negative results are None (the guard sits on the scaled
value, since a finite 1e307 overflows only after the multiply), and -0.0 is
normalised. A window reading past 100% logs the raw header strings at INFO,
the capture path for the first real overage sample.

Residuals: cached Redis snapshots self-heal within one refresh interval;
pre-fix history rows cannot be repaired (1.2 from "0.012" and from "1.2" are
identical); the first post-deploy sweep may raise critical items for every
overage subscription; a negative reading now classifies as unassessable
rather than has_headroom.

Fixes #2419

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…llow-up, #2865

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`fallback_to_api_key` fails OPEN on a raising settings read, so the test
kept running past its mocked `db` into two call-time reads it never
stubbed: the agent container and the platform API key. Whatever the
pytest process held decided the outcome — on CI under one randomly
order a key leaked in from an earlier test, the fallback ran for real,
and the `is None` assertion failed on a PR that touched only the MCP
server (regression-diff job on #2863).

Reproduced locally with ANTHROPIC_API_KEY set: identical assertion
message. Stub both reads so the assertion states its actual premise —
no key ⇒ nothing to fall back onto ⇒ None, and no exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lled (abilityai/trinity-enterprise#620) — mechanical

Where /proc cgroup is unreadable (macOS) install() returns False and the real
os.kill/os.killpg stay in place, so test_our_own_process_group_is_refused
SIGTERMed the session's own process group (pytest, xdist, the shell).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ling (abilityai/trinity-enterprise#620) — mechanical

HeartbeatExecutionActivity.tool has max_length=64 but the agent never bounded
it (codex MCP names are server.tool, Task:<type> comes from the model); one
over-long name 422'd every beat and read as a lost heartbeat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…129 (F1) — mechanical

/review I1: the ent#500 describe/it names said the tool is advertised to
operator scopes; those cases exercise the operatorOnly gate, and the block
now pins that the tool is NOT registered. /review I2: the booted server
registers 129 tools (124 operator) with inline login on; the user doc said
130/125. The architecture doc already reads 129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/learnings.md
#	src/mcp-server/src/server.ts
#	tests/registry.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants