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
Conversation
|
Live verification, second pass (local stack on this branch,
|
/review ReportBranch:
Critical Findings[C1] Concurrency: the heartbeat pruned live slots past the 20-entry cap (Confidence: 9/10) — FIXED in Informational Findings[I1] [I2] One 250 ms tick per live card (Confidence: 7/10) [I3] Scope widening, named: [I4] Delegation summary shape is the agent's (Confidence: 6/10) [I5] Reduced-motion not exercised in a browser — source guard only; noted in the verification table. Clean Categories
Coverage question (#2829)Executed on all three layers: the agent-side slot + builder with real Summary
|
/review — re-review after C1 (head
|
|
merge-train 2026-09-16: held for the last slot, not merged yet. The head ( A data point for the slowdown you're chasing: it reproduces on plain
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 Merged to |
…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
033f759 to
62095c8
Compare
… 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
/review — re-review after the CI fixBranch: What changed since the last reviewThe Root cause. The fix — four independent layers (
|
|
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: Background run, heartbeat-fed — Work tab Now card for a manually-triggered schedule: Also confirmed the reviewer checkbox: a delegated child (analyst → sidekick via |
…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>
|
merge-train 2026-09-17: two mechanical commits pushed to this branch (validated on the train, no change to the feature's intent):
Not changed, noted for a follow-up: no test drives |
…ity-line # Conflicts: # docs/memory/learnings.md


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", "Runningpytest 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-injectederrorever 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.jscomposes the line from two facts —tool(the agent's display name:Read,Bash,mcp:trinity,Task:explore,nullbetween tools) andsummary(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.activityFromStreamEventparses the real frame shape and summarises the input with a port of the agent server'sget_input_summary, held to parity bytests/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).session_activity.by_execution, threaded through both live parse sites and the Codex parser; the legacy singleactive_toolslot is untouched) and the 5 s beat carriesexecutions: [{execution_id, tool, summary, since}]for the process registry's running set — a finished run leaves by construction.HeartbeatPayloadbounds 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_activitykeys it; the Work read folds it onto live, non-stale rows of rostered agents asWorkItem.activitythrough the samesanitize_text+ bound as titles and masks an off-roster delegation target; a line older than 30 s is dropped. A Redis-only siblingGET …/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.PortalWorkCardreservesh-4 overflow-hiddenfor the card's whole live life; a keyed<Transition>slides the new line up (transition: noneunderprefers-reduced-motion);createActivityLineQueueholds 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.submitUserTextalready pins beforedeliver; the card mounts after, so awatch(sending)re-pins once onnextTick, guarded byfollowing— 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.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)
sleep 12→ Reading.../developer/CLAUDE.md→ Runninggrep -r "workspace" …→ Runningsleep 8→ Writing a reply → terminal card; transcript distance-from-bottom 0 after the send.until false; do sleep 25; …; reserved row measured 16 px with and without a line; light and dark screenshots./work/activityat 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_activitysanitises/bounds/masks; age ceiling; fold only onto live rostered rows (stale and off-roster get none); no beat = no line; the/activityread'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.test_ent525+ heartbeat suites 102 passed; frontend 137 files / 3118 passed;check:tokensOK; raw-colour ratchet unchanged;vite buildgreen.Test plan
cd tests && pytest unit/test_ent620_*.py unit/test_ent525_portal_work.py unit/test_agent_heartbeat.py unit/test_heartbeat_service.pycd src/frontend && npm run test:unitchat_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.mdrow,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 noexecutionsand its cards show nothing (honest), until recreated onto the rebuilt image. The own-turn stream line needs no rebuild.Cross-tracker:
Fixescross-references the private issue but does not auto-close it — closed manually at release.🤖 Generated with Claude Code
https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf