Skip to content

feat(workspace): the Work card says what the agent is doing — one line that slides up, for every live run; the chat scrolls to the card on your own send (abilityai/trinity-enterprise#620) - #2844

Merged
vybe merged 9 commits into
devfrom
feature/620-work-activity-line
Sep 17, 2026

Conversation

@dolho

@dolho dolho commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#620

Summary

While an agent works, the Work card — in the chat and in the rail's Work tab — now says what it is doing right now in one fixed-height line: "Reading .../routers/agents.py", "Running pytest tests/unit …", "Searching for "sync_health"", "Fetching docs.example.com", "Using github", "Delegating to sidekick: …", "Thinking". For the chat's own turn and for delegated, scheduled and room runs. A new line slides up over the old; the card never changes height. When your own message starts work, the transcript scrolls to the card.

Why it was invisible. The card's "current step" slot was fed by the ent#286 stream through a handler that matched evt.type === 'tool_use' / 'thinking' — shapes the raw stream-json frames never carry (type:'assistant', message.content[].type). Only the backend-injected error ever labelled. And no run other than the chat's own had any live path at all.

One vocabulary, two feeds

  • src/frontend/src/utils/workActivity.js composes the line from two facts — tool (the agent's display name: Read, Bash, mcp:trinity, Task:explore, null between tools) and summary (the agent's bounded input summary, never raw input). The operator Chat tab (execution-status.js) and Agent Detail (useSessionActivity) compose from it too — three vocabularies became one.
  • Own turn — the stream. activityFromStreamEvent parses the real frame shape and summarises the input with a port of the agent server's get_input_summary, held to parity by tests/fixtures/tool_input_summary.json (asserted by pytest and vitest, the bug: src/scheduler/utils.py declares byte-parity vendoring with no test enforcing it (and the claim is already false) #1713 shape).
  • Every other run — the heartbeat. The agent server now keys its active tool per execution (session_activity.by_execution, threaded through both live parse sites and the Codex parser; the legacy single active_tool slot is untouched) and the 5 s beat carries executions: [{execution_id, tool, summary, since}] for the process registry's running set — a finished run leaves by construction. HeartbeatPayload bounds it at the model (≤20 entries, summary ≤120, tool ≤64, id shape, extra="forbid"); a refused beat costs a card line, never the feat: Agent heartbeat push for fast failure detection (RELIABILITY-004) #307 liveness verdict. 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 same sanitize_text + bound as titles and masks an off-roster delegation target; a line older than 30 s is dropped. A Redis-only sibling GET …/work/activity?agents= (same gates: platform-only 404, roster set-membership, agent cap, per-viewer limit) is polled every 2.5 s only while a card is live; the full read stays at 12 s.
  • The row. PortalWorkCard reserves h-4 overflow-hidden for the card's whole live life; a keyed <Transition> slides the new line up (transition: none under prefers-reduced-motion); createActivityLineQueue holds each line ≥700 ms, collapses a burst to its latest member, never re-keys an identical line, keeps the last line on a quiet run, and is cleared at terminal. The Work tab's Now rows and the room's cards receive the line too.
  • Scroll. submitUserText already pins before deliver; the card mounts after, so a watch(sending) re-pins once on nextTick, guarded by following — the person's own send only; bug(workspace): new messages yank the transcript to the bottom while you are reading — agent chat and rooms #2624's no-yank rule holds for arrivals.
  • Honest silence. No beat (old image), a stale row, an off-roster agent, an aged line → the row is empty; the existing three-state steps rule is untouched. Nothing the payload excludes today (execution_log, tool_calls, response) becomes visible — pinned.

ent#418 stays incubating: only the fields this line needs are carried, in the heartbeat, as the issue permits. PR #2713's pipeline-stage push (not on dev) is untouched.

Verified live (local stack on this branch, base image rebuilt, agents recreated)

  • Own turn, stream-fed: Thinking → Running sleep 12 → Reading .../developer/CLAUDE.md → Running grep -r "workspace" … → Running sleep 8 → Writing a reply → terminal card; transcript distance-from-bottom 0 after the send.
  • Scheduled run, heartbeat-fed, in the Work tab's Now card: Thinking → Running until false; do sleep 25; …; reserved row measured 16 px with and without a line; light and dark screenshots.
  • Two concurrent runs on one agent (a manual trigger + the cron firing) each carried their own line — the per-execution slot, live.
  • /work/activity at rest: {"items": {}}; a finished run disappears from the beat on the next cycle.

Tests

  • tests/unit/test_ent620_agent_activity.py — per-execution slot (two concurrent runs don't share), completion → "between tools", pruning, bounds, fail-open builder.
  • tests/unit/test_ent620_work_activity.py — model accepts a pre-fix(agent-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618) #620 beat, refuses six shapes; the read keys + stamps; clean_activity sanitises/bounds/masks; age ceiling; fold only onto live rostered rows (stale and off-roster get none); no beat = no line; the /activity read's gates; the projection still excludes the log.
  • tests/unit/test_ent620_summary_parity.py + src/frontend/tests/unit/workActivity.spec.js (48) — vocabulary, the real frame shape (and that the old shape matches nothing), summariser parity, queue rules, resolver, placement guards.
  • Mutations, each red: stream parser on the old shape (2), queue without the minimum (3), identical line re-keyed (1), stale line kept (1), summariser drift (1); fold onto stale rows (1), no roster mask (1), age ceiling ignored (2).
  • Suites: backend test_ent525 + heartbeat suites 102 passed; frontend 137 files / 3118 passed; check:tokens OK; raw-colour ratchet unchanged; vite build green.

Test plan

  • cd tests && pytest unit/test_ent620_*.py unit/test_ent525_portal_work.py unit/test_agent_heartbeat.py unit/test_heartbeat_service.py
  • cd src/frontend && npm run test:unit
  • Browser pass above (own turn, scheduled run, two concurrent runs, both themes)
  • Reviewer: a delegated child (analyst → sidekick via chat_with_agent) shows "Delegating to sidekick" on the parent and the child's own line in the Work tab — confirmed 2026-09-17 on the local stack (see rehearsal comment)

Docs: requirements/core-agent.md §5.33 (WORKSPACE_WORK_ACTIVITY_LINE), feature-flows/workspace-work.md (new section), feature-flows.md row, architecture/workspace.md + architecture/reliability.md (heartbeat payload).

Base-image note: the heartbeat half ships in docker/base-image/agent_server/; an agent on the old image reports no executions and its cards show nothing (honest), until recreated onto the rebuilt image. The own-turn stream line needs no rebuild.

Cross-tracker: Fixes cross-references the private issue but does not auto-close it — closed manually at release.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf

@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 16, 2026
@dolho

dolho commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Live verification, second pass (local stack on this branch, analyst-demo + sidekick on the rebuilt image):

Check Result
Own send: lines change in the fixed row, card pinned into view Thinking → Running sleep 12 → Reading …/CLAUDE.md → Running grep … → Running sleep 8 → Writing a reply; distance-from-bottom 0
Scroll up mid-turn while lines keep changing scrollTop unchanged (769 → 769) across the whole turn — #2624 holds
Work tab Now card on a scheduled run; line gone at terminal Thinking → Running until false; do sleep 25; …; row measured 16 px; card left with the run
Two concurrent runs on one agent, each its own line ✅ in the read (/work/activity returned two ids: one Bash, one thinking); the UI moment was not captured on screen
Delegation via chat_with_agent ✅ parent card: "Delegating to sidekick" (stream feed). ⚠️ the child's card does not appear in a 1:1's Work tab: the agent-to-agent /chat path stamps triggered_by=agent with no source_channel/chat id, so ent#525's "a child is found by the chat" join has nothing to join on. With sidekick in scope the read returns the child with its line (vy53oXQt:sidekick:thinking), i.e. this PR's half works; the missing stamp is a pre-existing ent#457/#525 gap, not something this line can invent. Noting rather than widening scope.
Dark theme ✅ screenshot
Reduced motion source-verified only (@media (prefers-reduced-motion: reduce) rule in the card's scoped style); not exercised in a browser

@dolho

dolho commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

/review Report

Branch: feature/620-work-activity-linedev (merge-base daf65406a, head 7e7a71e7b)
Files Changed: 32 (+1933/−79)
Scope: CLEAN — one named widening (below)
Plan Completion: 8 ACs → 7 DONE, 1 PARTIAL

AC Status Evidence
1 one line naming tool + object, summarised and bounded DONE workActivity.js::activityLine; summaries from get_input_summary / its port (parity fixture); HeartbeatExecutionActivity.summary ≤ 120, clean_activity re-bounds
2 fixed-height row, slide-up, truncate, no re-animate DONE PortalWorkCard.vue h-4 overflow-hidden + keyed <Transition>; queue re-keys on TEXT only; live: 16 px measured with and without a line
3 same line on chat card and Work tab, own turn + delegated/scheduled; honest silence PARTIAL Own turn (stream) ✅, scheduled run in the tab ✅, delegated child's line ✅ when the child is in scope. In a 1:1, a chat_with_agent child lands on /chat with triggered_by=agent and no chat stamp, so ent#525's find-by-chat join never lists it — the tab shows no child card, hence no line. Pre-existing ent#457/#525 gap (the stamp, not the line); a room or a scope naming the delegate shows it. Named on the PR; not widened here.
4 no flicker; quiet run keeps its line; never stuck DONE createActivityLineQueue (≥700 ms, burst collapses, last line kept); age ceiling 30 s client + server; beat TTL 15 s; registry intersection
5 scroll on own send only DONE watch(sending)nextTickpinToBottom() iff following; live: scrollTop unchanged while scrolled up through a whole turn
6 disclosure DONE never on /ws; both reads roster set-membership; clean_activity = title sanitiser + bound + off-roster mask; model extra="forbid"; projection pinned to exclude log/tool_calls/response
7 both themes, reduced motion DONE gray ladder only; @media (prefers-reduced-motion: reduce)transition: none (source-verified)
8 terminal unchanged DONE row only while isLive; queue cleared on live → false

Critical Findings

[C1] Concurrency: the heartbeat pruned live slots past the 20-entry cap (Confidence: 9/10) — FIXED in 7e7a71e7b
File: docker/base-image/agent_server/heartbeat.py (_executions_activity)
Evidence: for entry in running[:ACTIVITY_MAX_EXECUTIONS]: … live_ids.append(eid) then for stale in known - set(live_ids): forget_execution(stale).
Issue: the prune set was built from the capped slice, so with >20 concurrent executions on one agent (ceiling is 32) the 21st+ had its slot forgotten on every beat while still running — it read "Thinking" forever, or flickered between its tool and "Thinking" as each tool_use re-registered it. Fix: prune against the registry's whole running set; cap only the wire. Pinned by test_a_run_past_the_cap_keeps_its_slot (mutation back to the slice → red).

Informational Findings

[I1] /work/activity returns every running id the beat carries, not only the ids the tab renders (Confidence: 6/10)
get_work_activity returns the agent's whole running set. I traced the interactive /chat path: the agent is handed task_execution_id (the ledger id), so every id here is a row get_work's now already lists for a rostered viewer — no disclosure beyond the full read. An ids= filter (from store.now) would still be a cheap belt bounding the payload to what is rendered; deferred.

[I2] One 250 ms tick per live card (Confidence: 7/10)
PortalWorkCard owns its queue's promotion timer; a Work tab with N live cards runs N intervals. Bounded by the fleet's concurrency and cleared on unmount/terminal; a shared ticker is a follow-up if it ever shows in a profile.

[I3] Scope widening, named: useSessionActivity.js (Agent Detail) and execution-status.js (operator Chat tab / PublicChat) now compose their labels from the shared module — the issue asks to "make the vocabulary one", so this is the ask, but it changes copy on two surfaces outside the Workspace ("Read..." → "Reading .../x.py..."; unknown tool "Working..." → "Using X...").

[I4] Delegation summary shape is the agent's (Confidence: 6/10)
"Delegating to sidekick" relies on the agent-side generic branch producing agent_name: <x> for mcp__trinity__chat_with_agent (first short string param). If the MCP tool's first param ever changes, the line degrades to "Using trinity" — honest, not wrong. Pinned in the parity fixture.

[I5] Reduced-motion not exercised in a browser — source guard only; noted in the verification table.

Clean Categories

  • SQL: none; Redis: one GET per rostered agent with live rows, plus the existing setex.
  • Auth: /activity route mirrors get_work's gates (platform-only 404, roster set-membership, MAX_AGENTS 422, per-viewer limit) — tested; heartbeat stays agent-own-key (authorize_heartbeat, untouched).
  • Credentials: the summary passes sanitize_text (test plants a key in a command); the raw tool input never rides the beat (extra="forbid", test).
  • Error handling: builder and reads fail open to empty (tested); store's poll swallows and lets the age ceiling expire a stale map.
  • Enum completeness: THEME… n/a; activityLine handles unknown tools by name, never a dead "Using a tool…".
  • Backward compat: a pre-fix(agent-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618) #620 image's beat validates (executions optional, tested); the legacy active_tool slot untouched; test_agent_heartbeat.py's payload-shape pin updated.
  • Docs: requirements §5.33, feature flow, architecture (workspace + reliability heartbeat line), index row.

Coverage question (#2829)

Executed on all three layers: the agent-side slot + builder with real start/complete_tool_execution calls; the backend fold through get_work / get_work_activity with stubbed seams and a real clean_activity; the frontend vocabulary/parser/queue/resolver with real inputs (raw frames, the parity fixture). Nine mutations red (five frontend, three backend, one agent-side). Text-matched only: the card row/transition, the conversation wiring, the store's poll registration — placement guards, not coverage. Live: own turn, scheduled run, two concurrent runs (API), no-yank, both themes.

Summary

  • Critical: 1 — fixed (C1, 7e7a71e7b)
  • Informational: 5 — I1/I2 are follow-up-sized; I3 is a named widening the issue asks for; I4/I5 awareness
  • Scope: clean; AC Feature/vector log retention #3's 1:1 delegated-child gap is pre-existing and named

@dolho

dolho commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

/review — re-review after C1 (head 7e7a71e7b vs daf65406a)

Delta since the reviewed head: 3 files, +17/−6 — heartbeat.py builds the prune set from the registry's whole running list (live_ids = [… for e in running …]) and caps only the emitted slice; test_a_run_past_the_cap_keeps_its_slot pins it; the dead Sync branch is gone from workActivity.js.

C1 — closed. Read the fix end to end: known - set(live_ids) now subtracts every live id, so a 21st+ execution keeps its slot across beats while out stays ≤ ACTIVITY_MAX_EXECUTIONS. An entry with a non-string id is excluded from live_ids and cannot hold a slot in the first place (slots are keyed by the string id the parser passes), so nothing is pruned that should not be. Mutation (prune from the slice again) → test_a_run_past_the_cap_keeps_its_slot red; restored green.

No new findings. The change touches no path the other findings sat on; I1–I5 stand as informational.

Verification on the fixed head: backend test_ent620_* + heartbeat + test_ent525 suites → 201 passed; frontend 137 files / 3118 passed; workActivity.js has no Sync reference left. Local instance: base image rebuilt from 7e7a71e7b, analyst-demo + sidekick recreated onto it (the fix's comment is present in the running container), backend/frontend on the same head.

Verdict: 0 critical, 5 informational (unchanged), scope clean. Nothing further from my side.

@vybe

vybe commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-16: held for the last slot, not merged yet. The head (125df890) still carries the ci: TEMPORARY diagnostic change to .github/workflows/backend-unit-test.yml, and merging would land that on dev. It rides once that commit is dropped.

A data point for the slowdown you're chasing: it reproduces on plain dev, not only on this branch. Today, on the base side, where the checkout is dev itself:

PR seed runtime siblings
#2841 base 99999 stuck for over 30 minutes, cancelled about 10 minutes
#2487 base 12345 stuck for 29 minutes, cancelled about 10 minutes

In both, the head seeds stalled the same way or finished normally. Re-running just the stalled seed passed in about 10 minutes. On your own diagnostic run, base seed 67890 had only reached 46% when the 25-minute timeout fired. Its two workers were last seen in test_ent169_shared_sessions.py and test_2572_credentialless_adoption.py. It depends on the random order, so it looks like an order-dependent slowdown on dev rather than anything in #620.

Merged to dev on this train: #2851, #2841 and #2487. #2841 touches PortalConversation.vue, PortalRoom.vue and three docs this PR also edits. The simulation shows only feature-flows.md and requirements/core-agent.md as textual conflicts, but a dev merge is needed before this can land.

dolho and others added 5 commits September 16, 2026 20:27
…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
@dolho
dolho force-pushed the feature/620-work-activity-line branch from 033f759 to 62095c8 Compare September 16, 2026 17:28
… 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
@dolho

dolho commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

/review — re-review after the CI fix

Branch: feature/620-work-activity-linedev (merge-base 5c4dc4b, head 9febe40)
Scope: CLEAN — the new commits are a test-infra fix for the CI hang, not feature scope creep.
Result: 0 critical, 0 blocking. The feature was reviewed earlier (C1 found + fixed in 71bd302); this pass covers what changed since.

What changed since the last review

The backend-unit-test job was cancelled at the 45-min ceiling on every pytest (head, …) shard. It was not a runner flake — the branch's own test file was killing the runner (and, confirmed from the dev box's journalctl, restarting the desktop session twice: user@1000.service: code=killed, status=9/KILL).

Root cause. test_ent620_agent_activity.py evicted 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 monkeypatch.setattr("agent_server.services.subprocess_lifecycle…") landed on the wrong copy and the real _drain_bounded ran with a MagicMock process (pid=99999): terminate_process_group + the cgroup orphan sweep SIGKILLed the process group that pid resolved to and everything in the host's root cgroup.procs. Third occurrence of the #728 wrong-module-copy class; first where the wrong copy runs process-killing code.

The fix — four independent layers (62095c8)

  1. Production fence (orphan_sweep.py): kill_cgroup_orphans refuses to sweep unless the cgroup.procs it read lists PID 1 — true only inside a container's cgroup namespace (verified live on a running agent: cgroupns=private, PID 1 present), never a host/runner root 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 (cgroup membership, not parent chain, so a test's own setsid-reparented child is still recognised as ours). A refusal both raises and is recorded, so the autouse fixture fails the test even when the production caller swallows Exception.
  3. Copy-proof patching (test_drain_bounded.py): patches through _drain_bounded.__globals__ instead of dotted strings, and stubs _terminate_process_group for every test.
  4. Hard lint rule (lint_sys_modules.py): an unguarded module-level 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) converted to the path-guarded form (test_git_status_dual_ahead_behind.py precedent).

New pinning tests: test_2845_orphan_sweep_host_fence.py, test_2845_signal_guard.py.

Checklist notes

  • 4.14 Incomplete-fix completeness — the guard is applied to all five eviction sites, not just the one that triggered, and the lint enforces no new ones; two regression tests name fix(agent-runtime): kill npx MCP orphans outside claude pgid that hold stdout pipe open (#618) #620. Complete.
  • Auth / SQL / credential / concurrency — N/A: the diff is test infrastructure plus one fail-safe production guard. The only production change (orphan_sweep.py) is strictly more conservative (it can only decline to sweep).
  • Conftest global os.kill wrap — verified non-leaking: monkeypatch.setattr(os, "kill", …) records/restores the guarded fn; the guard's own deliberate refusals are cleared by a module-local teardown fixture before the conftest check runs.
  • Diagnostic commits8d7781a (verbose diagnostic workflow) + ea20f57 (its revert) net to a 0-line change on the workflow; they collapse into the squash.

Verification

  • Full local unit suite: 16503 passed, 0 signal refusals, session intact. (15 local failures are a pre-existing Python-3.12 ipaddress ::ffff: quirk, green on CI's 3.13.)
  • The previously-lethal subprocess/drain/orphan set under xdist × 3 CI seeds: green on the host that had died, no refusals.
  • CI now green: all 6 pytest shards success, lint (sys.modules pollution check) (carrying the new hard rule) success, regression diff success.

Ledger: docs/memory/learnings.md entry added for the class (9febe40).

Summary — Critical: 0. Informational: 0 blocking. Ship it.

@dolho

dolho commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Demo-day rehearsal on this branch (local stack, agents on the rebuilt base image), two more live captures:

Own turn, stream-fed — the card's line at 4 s: Running sleep 12 (then Reading …/CLAUDE.mdRunning grep …Running sleep 8 at 17/19/21 s):

own turn — Running sleep 12

Background run, heartbeat-fed — Work tab Now card for a manually-triggered schedule: Running sleep 10:

Work tab — background run line

Also confirmed the reviewer checkbox: a delegated child (analyst → sidekick via chat_with_agent) shows mcp:trinity · agent_name: sidekick on the parent and Bash · sleep 20 on the child in /work/activity — two executions, two lines.

sim and others added 2 commits September 17, 2026 13:36
…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>
@vybe

vybe commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

merge-train 2026-09-17: two mechanical commits pushed to this branch (validated on the train, no change to the feature's intent):

  • 7274f6c07tests/unit/test_2845_signal_guard.py now skips as a module when the guard is not installed (os.kill is not sg.guarded_kill). 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 really SIGTERMed the session's own process group — pytest, xdist and the calling shell (reproduced: rc −15). CI (Linux) is unaffected; on macOS the file now reports 9 skipped. _SIGNAL_GUARD_ACTIVE in tests/unit/conftest.py was the flag this skip was meant to read.
  • ca532ff1cheartbeat.py::_executions_activity cuts tool to ACTIVITY_TOOL_MAX = 64 (with ), matching HEARTBEAT_ACTIVITY_TOOL_MAX. The agent never bounded it (codex MCP names are server.tool, Task:<type> comes from the model), so one name over 64 chars would 422 the whole beat and read as a lost heartbeat. New test test_heartbeat_bounds_the_tool_name_to_the_backend_ceiling fails with the cut removed; the backend constant is pinned beside the existing HEARTBEAT_ACTIVITY_MAX_EXECUTIONS pin.

Not changed, noted for a follow-up: no test drives process_stream_lineexecution_activity(eid) (removing execution_id=execution_id at stream_parser.py:532 leaves the suite green), and the frontend wiring assertions are source-text (workActivity.spec.js:225-270).

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260917-1237 (#2868)

…ity-line

# Conflicts:
#	docs/memory/learnings.md
@vybe
vybe merged commit 9b92fcc into dev Sep 17, 2026
28 checks passed
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.

2 participants