[WIP] feat: native subagent & workflow observability - #5219
Conversation
…imeline) Surface Claude Code subagents/workflows and Codex collab agents in the UI using only native provider emissions. Zero migrations, zero new tables: widened task.* activity payloads ride the existing event-sourced activity path, and a client-side fold in client-runtime derives v2-shaped subagent state (field names match #4779 so the orchestration-v2 merge is mechanical). Server: - contracts: TaskAgentLinkage on all task payloads, new task.updated event, typed RuntimeTaskUsage, tool attribution (agentId/parentToolUseId) - ClaudeAdapter: carry subagent_type/workflow_name/tool_use_id/outputFile, handle task_updated (was dropped), attribute subagent tool events via parent_tool_use_id, defensive workflow_progress parse, Workflow run handles - CodexSessionRuntime/Adapter: register multi-agent-v2 children from thread/started + subAgentActivity, intercept child notifications, and synthesize task.* lifecycle (idle=resumable, cumulative usage) [WIP: routing is probe-gated per spec] - ingestion: task.updated + agent-owned tool.progress persisted; wire-slim regression test proving agent fields survive to the client Web: - Agents right-panel surface (workflow phase groups, direct spawns, static status dots, DOM-write elapsed timers, expandable activity ring) - composer live strip + inline workflow run card (8-row urgency cap) - quiet timeline: one lifecycle row per agent (collapse by taskId), agent- attributed tool rows re-homed to the panel, timelineBypass rows suppressed Mobile: same quiet-timeline fold; task.completed kept as terminal signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| const activeCount = phaseMembers.filter((member) => | ||
| // Idle members count as active for phase-liveness: a resumable Codex | ||
| // member has not finished the phase. | ||
| isActiveSubagentStatus(member.status), | ||
| ).length; |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:666
A workflow phase containing only idle members gets state: "pending" instead of "running", misreporting an unfinished, resumable phase as not-yet-started. activeCount is computed via isActiveSubagentStatus, which returns false for idle, so activeCount is 0 even though the inline comment states idle members should keep the phase live. This causes the phase-liveness branch to fall through to "pending" when no members are active by that definition.
const activeCount = phaseMembers.filter((member) =>
- // Idle members count as active for phase-liveness: a resumable Codex
- // member has not finished the phase.
- isActiveSubagentStatus(member.status),
+ // Idle members count as active for phase-liveness: a resumable Codex
+ // member has not finished the phase.
+ isActiveSubagentStatus(member.status) || member.status === "idle",
).length;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around lines 666-670:
A workflow phase containing only `idle` members gets `state: "pending"` instead of `"running"`, misreporting an unfinished, resumable phase as not-yet-started. `activeCount` is computed via `isActiveSubagentStatus`, which returns `false` for `idle`, so `activeCount` is 0 even though the inline comment states idle members should keep the phase live. This causes the phase-liveness branch to fall through to `"pending"` when no members are active by that definition.
| if (attempt !== undefined) { | ||
| // A new attempt on a workflow slot is a reactivation of the same identity: | ||
| // bump the run count and clear the previous attempt's terminal detail. | ||
| if (agent.attempt !== null && attempt > agent.attempt) { | ||
| agent.activationCount += 1; | ||
| agent.result = null; | ||
| agent.error = null; | ||
| agent.completedAt = null; | ||
| } | ||
| agent.attempt = attempt; | ||
| } |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:319
When a workflow slot retries from a failed attempt to a new running attempt, activationCount is incremented twice for the same event — once in fillMetadata (because attempt increases) and again in applyStatus (because the previous status was terminal and the new status is running). A single transition from failed attempt 1 to running attempt 2 reports 3 activations instead of 2, inflating the retry/run counts shown to users.
The reactivation logic in fillMetadata and applyStatus both fire for the same event. Consider having only one of them bump activationCount for a given transition — for example, skip the increment in fillMetadata when the same event will also trigger a reactivation in applyStatus, or remove the duplicate increment from fillMetadata entirely if applyStatus already handles reactivation.
const attempt = asCount(payload.attempt);
- if (attempt !== undefined) {
- // A new attempt on a workflow slot is a reactivation of the same identity:
- // bump the run count and clear the previous attempt's terminal detail.
- if (agent.attempt !== null && attempt > agent.attempt) {
- agent.activationCount += 1;
- agent.result = null;
- agent.error = null;
- agent.completedAt = null;
- }
- agent.attempt = attempt;
- }
+ if (attempt !== undefined) agent.attempt = attempt;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around lines 319-329:
When a workflow slot retries from a failed attempt to a new running attempt, `activationCount` is incremented twice for the same event — once in `fillMetadata` (because `attempt` increases) and again in `applyStatus` (because the previous status was terminal and the new status is `running`). A single transition from failed attempt 1 to running attempt 2 reports 3 activations instead of 2, inflating the retry/run counts shown to users.
The reactivation logic in `fillMetadata` and `applyStatus` both fire for the same event. Consider having only one of them bump `activationCount` for a given transition — for example, skip the increment in `fillMetadata` when the same event will also trigger a reactivation in `applyStatus`, or remove the duplicate increment from `fillMetadata` entirely if `applyStatus` already handles reactivation.
| if (summary) { | ||
| if (status === "failed") { | ||
| agent.error = agent.error ?? bounded(summary); | ||
| } else { | ||
| agent.result = bounded(summary); | ||
| } | ||
| } | ||
| agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); | ||
| agent.updatedAt = at; | ||
| break; |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:519
A duplicate task.completed event overwrites the first terminal result or error and advances updatedAt, even though applyStatus treats the status itself as idempotent. This means a late duplicate completion replaces the settled agent's original outcome and makes it appear freshly updated, contradicting the intended first-write-wins semantics for terminal events.
applyStatus returns early for duplicate terminal statuses but task.completed continues afterward and unconditionally runs agent.result = bounded(summary) (or agent.error = ...) and agent.updatedAt = at. Guard those assignments behind a check that the agent wasn't already terminal.
+ if (summary) {
+ if (status === "failed") {
+ agent.error = agent.error ?? bounded(summary);
+ } else {
+ agent.result = agent.result ?? bounded(summary);
+ }
+ }
+ agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage));
+ agent.updatedAt = at;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around lines 519-528:
A duplicate `task.completed` event overwrites the first terminal `result` or `error` and advances `updatedAt`, even though `applyStatus` treats the status itself as idempotent. This means a late duplicate completion replaces the settled agent's original outcome and makes it appear freshly updated, contradicting the intended first-write-wins semantics for terminal events.
`applyStatus` returns early for duplicate terminal statuses but `task.completed` continues afterward and unconditionally runs `agent.result = bounded(summary)` (or `agent.error = ...`) and `agent.updatedAt = at`. Guard those assignments behind a check that the agent wasn't already terminal.
| const unphasedMembers = workflowMembers | ||
| .filter((member) => member.phaseIndex === null) | ||
| .toSorted((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:692
Workflow members whose phaseIndex is non-null but absent from workflow.phases disappear from the UI entirely — they match no phase and are excluded from unphasedMembers because it filters on phaseIndex === null. This loses the agent from both the phase list and workflowCardMembers. Consider treating members whose phase index is not in knownPhases as unphased as well.
- const unphasedMembers = workflowMembers
- .filter((member) => member.phaseIndex === null)
- .toSorted((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
+ const phaseIndexes = new Set(knownPhases.map((phase) => phase.index));
+ const unphasedMembers = workflowMembers
+ .filter((member) => member.phaseIndex === null || !phaseIndexes.has(member.phaseIndex))
+ .toSorted((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around lines 692-694:
Workflow members whose `phaseIndex` is non-null but absent from `workflow.phases` disappear from the UI entirely — they match no phase and are excluded from `unphasedMembers` because it filters on `phaseIndex === null`. This loses the agent from both the phase list and `workflowCardMembers`. Consider treating members whose phase index is not in `knownPhases` as unphased as well.
| return; | ||
| } | ||
|
|
||
| if (yield* interceptCollabChildNotification(notification)) { |
There was a problem hiding this comment.
🟡 Medium Layers/CodexSessionRuntime.ts:1079
Children tracked only via collabReceiverTurns (from a prior collabAgentToolCall) never emit the synthetic collabAgent/* lifecycle events this change introduces, so their task state stays missing or stuck. handleRawNotification returns early when shouldSuppressChildConversationNotification is true — which covers thread/started, turn/started, turn/completed, status changes, token usage, and thread/closed — before interceptCollabChildNotification runs at line 1079. Those children are never registered through thread/started, and even subAgentActivity-registered children never emit collabAgent/turnStarted, collabAgent/turnCompleted, collabAgent/statusChanged, collabAgent/tokenUsage, or collabAgent/closed because the suppression check intercepts those methods first. Consider moving interceptCollabChildNotification ahead of the childParentTurnId suppression check, or making the suppression skip notifications addressed to registered child threads.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/Layers/CodexSessionRuntime.ts around line 1079:
Children tracked only via `collabReceiverTurns` (from a prior `collabAgentToolCall`) never emit the synthetic `collabAgent/*` lifecycle events this change introduces, so their task state stays missing or stuck. `handleRawNotification` returns early when `shouldSuppressChildConversationNotification` is true — which covers `thread/started`, `turn/started`, `turn/completed`, status changes, token usage, and `thread/closed` — before `interceptCollabChildNotification` runs at line 1079. Those children are never registered through `thread/started`, and even `subAgentActivity`-registered children never emit `collabAgent/turnStarted`, `collabAgent/turnCompleted`, `collabAgent/statusChanged`, `collabAgent/tokenUsage`, or `collabAgent/closed` because the suppression check intercepts those methods first. Consider moving `interceptCollabChildNotification` ahead of the `childParentTurnId` suppression check, or making the suppression skip notifications addressed to registered child threads.
ApprovabilityVerdict: Needs human review 11 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |
…w card Per live-test feedback the roster rendered three times at once (panel, card, strip). New rule: the Agents panel is the only roster. The chat gets one anchored CTA row per spawn batch (workflow run, or a turn's direct spawns): 'Kicked off N subagents · <workflow> — <phase> · N active · Σ tok — Open Agents'. Live status derives from the shared panel model at render time; the row freezes to past tense on settle. Strip and card components deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test round 2 fixes: - Shells/monitors/plan tasks no longer masquerade as subagents: taskType rides on every task payload (adapter linkage + ingestion allowlist) and the fold excludes non-agent task types from the roster. 'Run 12s stall' background shells stay in the ordinary work log. - The spawn CTA no longer says completed while a workflow is mid-flight: for workflow batches the coordinator's own terminal state is authoritative (dynamic spawns can make the known-member list momentarily all-settled). - Agents panel rows are flat status lines: the per-agent unfold (recent tool-call feed) is gone. The only expansion is run-granularity: settled workflow runs collapse to one summary line under 'Earlier', click to list members. Live workflows and direct spawns sort first in bordered sections with settled/total counts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Medium
t3code/apps/web/src/session-logic.ts
Line 845 in 16cdc33
In collapseDerivedWorkLogEntries, direct-spawn tasks whose turnId is absent all share the same direct:no-turn group key, so agents from different turns (or legacy rows) collapse into a single CTA row with a combined agentTaskIds list — distinct spawn events are silently merged. The fallback in agentSpawnGroupKey should be scoped to a per-batch or per-activity identifier rather than a global "no-turn" string.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/session-logic.ts around line 845:
In `collapseDerivedWorkLogEntries`, direct-spawn tasks whose `turnId` is absent all share the same `direct:no-turn` group key, so agents from different turns (or legacy rows) collapse into a single CTA row with a combined `agentTaskIds` list — distinct spawn events are silently merged. The fallback in `agentSpawnGroupKey` should be scoped to a per-batch or per-activity identifier rather than a global `"no-turn"` string.
| const status = asRuntimeStatus(payload.status); | ||
| if (status) applyStatus(agent, status, at); | ||
| const error = asString(payload.error); | ||
| if (error) agent.error = bounded(error); | ||
| const endedAt = asString(payload.endedAt); | ||
| if (endedAt && isTerminalSubagentStatus(agent.status) && agent.completedAt === null) { | ||
| agent.completedAt = endedAt; | ||
| } |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:523
In task.updated, the endedAt timestamp from the payload is never applied to agent.completedAt. When applyStatus transitions the agent to a terminal state, it already sets agent.completedAt to the activity's at time. The subsequent if (endedAt && isTerminalSubagentStatus(agent.status) && agent.completedAt === null) check then always fails because completedAt is no longer null, so the provider's authoritative endedAt is silently dropped. Terminal timestamps reflect ingestion time instead of the provider's end time, producing incorrect durations and ordering. Consider applying endedAt before calling applyStatus, or using it to overwrite completedAt when the agent is terminal.
const status = asRuntimeStatus(payload.status);
+ const endedAt = asString(payload.endedAt);
+ if (status) applyStatus(agent, status, at);
- if (status) applyStatus(agent, status, at);
const error = asString(payload.error);
if (error) agent.error = bounded(error);
- const endedAt = asString(payload.endedAt);
if (endedAt && isTerminalSubagentStatus(agent.status)) {
agent.completedAt = endedAt;
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around lines 523-530:
In `task.updated`, the `endedAt` timestamp from the payload is never applied to `agent.completedAt`. When `applyStatus` transitions the agent to a terminal state, it already sets `agent.completedAt` to the activity's `at` time. The subsequent `if (endedAt && isTerminalSubagentStatus(agent.status) && agent.completedAt === null)` check then always fails because `completedAt` is no longer `null`, so the provider's authoritative `endedAt` is silently dropped. Terminal timestamps reflect ingestion time instead of the provider's end time, producing incorrect durations and ordering. Consider applying `endedAt` before calling `applyStatus`, or using it to overwrite `completedAt` when the agent is terminal.
| const taskId = asString(payload.taskId); | ||
| if (!taskId) break; | ||
| if (isBackgroundTaskActivity(payload)) break; | ||
| const agent = getOrCreate(agents, taskId, payload, at); |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:521
When task.updated is the first event available for an agent (its start row has aged out of retention), getOrCreate creates it with activationCount: 0, and the task.updated branch never initializes the count to 1 — unlike the task.progress and task.completed branches. The agent appears in the roster with zero activations even while running or terminal, and a subsequent task.started event would incorrectly be treated as the first activation, setting activationCount back to 1. Consider initializing activationCount to 1 in the task.updated branch when the agent is newly created.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around line 521:
When `task.updated` is the first event available for an agent (its start row has aged out of retention), `getOrCreate` creates it with `activationCount: 0`, and the `task.updated` branch never initializes the count to 1 — unlike the `task.progress` and `task.completed` branches. The agent appears in the roster with zero activations even while running or terminal, and a subsequent `task.started` event would incorrectly be treated as the first activation, setting `activationCount` back to 1. Consider initializing `activationCount` to 1 in the `task.updated` branch when the agent is newly created.
| // guarantee). | ||
| const spawnRowIndex = new Map<string, number>(); | ||
| for (const entry of entries) { | ||
| const isTaskRow = |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:859
collapseDerivedWorkLogEntries converts any task.progress/task.completed row that has a taskId into an agent-spawn CTA, unless isBackgroundTask is true. When taskType is absent (e.g. older persisted rows), isBackgroundTaskActivity returns false, so ordinary shell/monitor/plan task rows get misclassified as subagent spawns and grouped with real agent rows in the timeline instead of remaining normal work-log entries. Consider gating the agent-spawn CTA path on a positive signal (e.g. isWorkflowCoordinator or a known subagent taskType) rather than treating "not background" as "is a subagent."
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/session-logic.ts around line 859:
`collapseDerivedWorkLogEntries` converts any `task.progress`/`task.completed` row that has a `taskId` into an agent-spawn CTA, unless `isBackgroundTask` is true. When `taskType` is absent (e.g. older persisted rows), `isBackgroundTaskActivity` returns false, so ordinary shell/monitor/plan task rows get misclassified as subagent spawns and grouped with real agent rows in the timeline instead of remaining normal work-log entries. Consider gating the agent-spawn CTA path on a positive signal (e.g. `isWorkflowCoordinator` or a known subagent `taskType`) rather than treating "not background" as "is a subagent."
Rerun workflows looked invisible: the launching turn settles in seconds
('Worked for 8.9s') and turn-folding collapsed all its work entries —
including the spawn CTA — while the fleet runs on in the background. CTA
rows are now exempt from turn folds and pinned outside the '+N tool calls'
overflow toggle, so a live run is always visible at its spawn point. Each
rerun gets its own CTA row (grouping keys on the coordinator id, which is
unique per run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| (entry) => entry.agentSpawn !== undefined, | ||
| ); | ||
| const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); | ||
| const visibleEntries = [ |
There was a problem hiding this comment.
🟡 Medium chat/MessagesTimeline.logic.ts:508
The visibleGroupedEntries are split into pinnedSpawnEntries and overflowCandidates and then concatenated with spawn entries first, so a group like [normalWork, agentSpawn] renders as [agentSpawn, normalWork]. This reorders the timeline whenever an agent-spawn CTA is interleaved with ordinary work rows. Consider filtering hidden entries from the original order instead of repartitioning and concatenating.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/MessagesTimeline.logic.ts around line 508:
The `visibleGroupedEntries` are split into `pinnedSpawnEntries` and `overflowCandidates` and then concatenated with spawn entries first, so a group like `[normalWork, agentSpawn]` renders as `[agentSpawn, normalWork]`. This reorders the timeline whenever an agent-spawn CTA is interleaved with ordinary work rows. Consider filtering hidden entries from the original order instead of repartitioning and concatenating.
Live-test finding: statuses drifted (waiting/stalled agents alarming or reading wrong) while fleets ran. Adopts the monitoring-pill rule from the PR-monitoring design: one steady in-flight presentation. - Panel and CTA: pending/running/waiting all render as Working (sky, no amber); detail stays in the activity sub-line; footer shows one working count. Only settled states differentiate (completed/failed/stopped). - Fold: when a workflow coordinator settles, members that never received their own terminal row cascade to the coordinator's outcome (completed, or interrupted on failure) instead of reading as working forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test: the CTA only appeared after the workflow finished. Two causes, reproduced against the exact persisted thread data: - workEntryIndicatesToolNeutralStatus swallowed spawn rows mid-run (they derive from task.progress, tone 'thinking' = neutral) — visible only once a terminal row flipped them to success. CTA rows are now exempt. - collapse-by-group let the newest progress tick's id/createdAt/turnId win, drifting the row to the bottom of the timeline mid-run. The group anchor (spawn point) is now pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test: a thread running a 9-agent workflow and a thread babysitting a PR both showed no sidebar status once their turns settled. The pill only reflected turn/session state. - Server: in-memory per-thread background liveness registry fed by the task lifecycle ingestion already processes (no persistence, no migration); exposed additively on the thread shell as backgroundLiveness: working | monitoring | null. - Vocabulary (per Theo): two states only. Agent/workflow fleets present as plain Working; Monitoring is reserved for watch loops (monitor tasks and turn-outliving background shells — PR babysitting, log tails) when they are the only live work. - Web: pill resolver and SidebarV2 status honor backgroundLiveness with the same recede treatment as Working (inbox-zero); Monitoring gets a steady label, no shimmer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Effect service conventions review: one clear violation (new shared mutable state held in a module global and consumed by two Effect services) plus one file-layout nit. Everything else in the changed Effect code (namespace imports from effect/* subpaths, per-session Map state inside Effect.gen, contract schemas) looks conformant.
Posted via Macroscope — Effect Service Conventions
Live-test: plain Task-tool subagents vanished from the Agents panel and CTA. The SDK reports them as taskType 'local_agent', which the agent-type allowlist didn't anticipate — real agents were silently classified as background work. Classification is now a denylist (shell/local_bash/ monitor/plan are background; anything else, including future agent-flavored type names, is an agent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First live Codex probe (5 collab agents on gpt-5.6-sol) surfaced three gaps, fixed against the persisted wire data: - Children registered only via subAgentActivity (no thread/started with a spawn source), so no task.started ever formed and rows carried bare thread-id titles. subAgentActivity 'started' now synthesizes the task.started with agentPath-leaf naming. - Codex child rows are ALL timelineBypass, so the quiet-timeline filter suppressed every row before the CTA could form — a Codex fleet had no chat presence at all. Bypassed agent lifecycle rows now feed the CTA collapse (still max one row per batch); non-agent bypassed rows stay suppressed. - Idle now counts as not-live in the sidebar registry: an all-idle (resting, resumable) fleet no longer pins the thread at Working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test round 2 on Codex direct spawns: - Idle (resting, resumable) agents wore a sky dot and sat in the live section, reading as stuck in-progress after the work finished. Idle now renders muted and sorts with settled. - Progress rows carried the bare child thread id as title, clobbering the real name (math_one → UUID) from task.started. The runtime now stamps the registered child's identity (nickname/role/agentPath) on every synthetic collabAgent event, the adapter only emits title when it has a real name, and subAgentActivity registration derives a nickname from the agentPath leaf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live probe on 10 parallel collab agents: the wire emits subAgentActivity
{agentPath: '/root', kind: interacted} about the ROOT thread during collab
runs. Registration adopted the root as a child, so every subsequent root
notification — including the final assistant message and turn/completed —
was intercepted into the agent panel instead of the chat. The parent
looked hung ('Working 5m') after all subagents finished, with its report
riding an 'assistant message' row on the root's panel entry.
Registration now refuses the session's root thread (by id and by /root
path), and interception has a belt-and-braces root check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… row Claude background subagents settle between turns, so their completion rows arrive under later synthetic turn ids (or none). Batch keying by each row's own turn splintered ten parallel spawns into a stream of 'Kicked off N subagents' rows (live thread 7ac7ef05: completions spread across 8 different turn ids). Membership is now decided once at the first row seen per taskId — task.started rows (which carry the true spawn turn) seed the batch and collapse into its CTA instead of being skipped. Repro test derives exactly one CTA with all 10 agents from the persisted thread export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nscript
Two live-test findings with parallel Claude subagents, one root cause:
even with forwardSubagentText off, the SDK forwards subagent-owned
messages tagged parent_tool_use_id, and the adapter emitted them as
parent conversation.
- Subagent assistant snapshots became interleaved leak messages ('sleep
ran successfully…') AND spawned a synthetic turn per completion — which
is also why the Working timer kept resetting to 0: each synthetic turn
restarted the elapsed clock. Subagent-owned assistant messages now only
advance the resume cursor.
- Subagent-owned text/thinking stream blocks wrote into the parent
transcript; they are now dropped (tool_use blocks still flow, with
agentId attribution, for the quiet-timeline re-homing).
Subagent results still reach the UI through the task.* lifecycle
(task_notification summaries) — the panel loses nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test: each parallel Claude subagent's 'sleep 11' shell surfaced as a
parent Work Log group ('Work Log / Sleep for 11 seconds' repeated per
agent, causing top-of-thread layout shift), because the SDK emits
task_started for a subagent's internal Bash with no ownership marker —
only the launching tool_use_id ties it back.
The adapter now resolves that tool_use_id against the in-flight tool map
(which carries agentId from parent_tool_use_id) and stamps agentId onto
the task lifecycle. Agent-owned tasks are: excluded from the parent work
log (session-logic), excluded from the roster fold, and excluded from the
sidebar liveness registry (the owning agent's own liveness covers them).
Note: the remaining 'Agent N done' messages in this thread are the PARENT
narrating completions by design (its own prompt promised to report as
notifications land) — those are genuine parent messages, not leaks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test: phases rendered as flat uppercase labels between member rows —
the run's shape (done → live → pending arc) wasn't visible at a glance —
and the persisted workflow script was unreachable.
- Phase rail: one segment per phase under the workflow header, in order
with chevrons; each segment carries the phase state color and one status
dot per member. The whole run shape reads without scrolling.
- '{} script' toggles an inline read-only viewer fed by a new
orchestration.getWorkflowScript RPC. Server-side containment lifted from
the reviewed #3650 service: realpath of the LEAF re-contained under
~/.claude/projects (symlink-escape test included), .js-only, 256 KiB cap
with truncation marker. Read scope, cached client-side (immutable per
run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Effect service conventions: one new finding on the getWorkflowScript error type (dropped cause). Two findings from the previous run on this PR (module-global liveness registry in threadBackgroundLiveness.ts, mid-file import in ProjectionSnapshotQuery.ts) still appear unaddressed and are not repeated here.
Posted via Macroscope — Effect Service Conventions
| return yield* fail("Resolved script is not a .js file."); | ||
| } | ||
|
|
||
| const stat = yield* Effect.tryPromise({ |
There was a problem hiding this comment.
🔴 Critical orchestration/workflowScriptQuery.ts:59
The containment check runs on resolved (a resolved pathname), but the file is then opened with NodeFS.open(resolved, "r") using that same pathname string. A process that can modify ~/.claude/projects can replace the validated file or an ancestor directory with a symlink between the realpath call and the open call, causing this authenticated RPC to return up to 256 KiB from an arbitrary file readable by the server. The containment check must be tied to the opened descriptor rather than performed on a mutable pathname beforehand. Consider opening the path relative to the root directory and verifying containment via fstat/openat on the descriptor, or otherwise re-resolving after open using the file handle.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration/workflowScriptQuery.ts around line 59:
The containment check runs on `resolved` (a resolved pathname), but the file is then opened with `NodeFS.open(resolved, "r")` using that same pathname string. A process that can modify `~/.claude/projects` can replace the validated file or an ancestor directory with a symlink between the `realpath` call and the `open` call, causing this authenticated RPC to return up to 256 KiB from an arbitrary file readable by the server. The containment check must be tied to the opened descriptor rather than performed on a mutable pathname beforehand. Consider opening the path relative to the root directory and verifying containment via `fstat`/`openat` on the descriptor, or otherwise re-resolving after `open` using the file handle.
There was a problem hiding this comment.
[claude-fable-5] RESPONDING ON BEHALF OF THEO
Real and good catch — fixed in 8d20b68. The read path now opens first and verifies the opened file via the handle: handle.stat() inode+dev must match a fresh lstat of the resolved path, so a swap between resolution and open is detected rather than followed. Short reads honored via bytesRead in the same change.
There was a problem hiding this comment.
The inode comparison is a good improvement, but I believe there's still a gap: containment is checked on the path before opening, but never re-verified for the opened file descriptor.
An attacker with write access to ~/.claude/projects could:
- Let
realpathand containment check pass on a legitimate file inside the root (inode A) - Replace the file with a hardlink to
/etc/passwd(inode B) beforeopen - The inode check at line 76 passes because
lstat(resolved)andhandle.stat()both return inode B - The opened inode was never verified to be contained within the root
To fully close the TOCTOU, the code should re-verify containment using the opened descriptor—either by:
- Using
openatrelative to an opened handle forscriptsRoot() - Re-running
realpathon the descriptor (e.g., via/proc/self/fd/<fd>on Linux) and re-checking containment - Or re-doing
realpath(resolved)afteropenand verifying it's unchanged and still contained
There was a problem hiding this comment.
[claude-fable-5] RESPONDING ON BEHALF OF THEO
Dismissing this one, for two reasons.
-
The proposed mitigations do not detect the hardlink vector either. A hardlink inside
~/.claude/projectspointing at another inode IS a contained path:realpathon it (or on/proc/self/fd/<fd>) resolves to the in-root path, so re-checking containment after open passes identically. Actually closing that vector would requireopenat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)or ownership/st_nlinkheuristics. -
The threat model does not warrant it. This endpoint serves read-only script text to the local paired client, running as the same user who owns
~/.claude/projects. An attacker with write access there is already executing as that user and can read any file directly; the server grants nothing they lack. Also notefs.protected_hardlinksblocks hardlinking files the caller cannot write on default Linux configs. The inode/dev check exists to close the symlink-swap race from the earlier finding (a swapped path between realpath and open), which it does.
Happy to revisit if this endpoint ever serves a trust boundary beyond the owning user.
Stop button now kills the fleet, not just the parent turn — the moment users need it most is a runaway spawn: - Claude: interruptTurn stops every non-terminal tracked task via the SDK's Query.stopTask (best-effort per task, capped concurrency) before interrupting the turn. Live-task set maintained from the task lifecycle. - Codex: children are threads with their own turns; interruptTurn now turn/interrupts each live child turn (tracked from intercepted child turn/started/completed) before the parent turn. Agents panel phases are collapsible per the Claude Code background-tasks pattern: live phases open by default, done phases collapse to header + member dot row; user toggles stick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI: - lint: namespace node:fs/promises import; workflowScriptQuery tests use @effect/vitest it.effect instead of Effect.runPromise - ProjectionSnapshotQuery test expects the new backgroundLiveness field Review findings (verified against source, all real): - Critical: TOCTOU in workflow script read — open first, then verify the opened inode matches a fresh lstat of the resolved path; short reads honored via bytesRead (trailing-NUL fix folded in) - High: stopTask invoked through context.query so SDK 'this' binding survives; previously every stop silently failed via Effect.ignore - Medium: subagent stream filter now drops only text/thinking deltas, so attributed tool items keep their input_json_delta frames - Medium: liveness terminal rows clear BOTH buckets (terminal ticks often omit taskType, stranding monitor entries in the agents bucket) - Medium: nested agents (agentId + agent taskType) stay in the roster; only agent-owned shells are background. monitor_mcp/dream added to the non-agent denylist - Conventions: liveness registry is now ThreadBackgroundLivenessService (Context.Service + Layer, per-instance state, memoized shared Live provided at both consumers); OrchestrationGetWorkflowScriptError carries cause like sibling RPC errors, and readWorkflowScript forwards causes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8d20b68. Configure here.
There was a problem hiding this comment.
Effect service conventions: two findings on the new ThreadBackgroundLiveness service. The rest of the changed Effect code (namespace imports, Effect.fn usage, yield* ThreadBackgroundLivenessService acquisition inside make) looks consistent with the conventions.
Posted via Macroscope — Effect Service Conventions
- workflowScriptQuery: nodeBuiltinImport suppression header (raw fs access is deliberate: containment needs realpath/inode identity, and the file lives outside any workspace root the FileSystem service is scoped to) - ClaudeAdapter: DateTime.makeUnsafe instead of new Date() for the task_updated end_time conversion - stop-everything test: wait on the task.* event stream instead of a wall-clock setTimeout (which also hung under the test clock) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…layer requirement Consolidates the split Services/+Layers/ files into one canonical module (tag + make + layer), counts nested agents toward liveness, and removes the per-consumer Layer.provide self-provides so ThreadBackgroundLivenessService shows up as a requirement in layer types. A single shared instance is provideMerged at OrchestrationInfrastructureLayerLive and at each test composition root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Two Effect service convention issues in the new modules; details inline.
Posted via Macroscope — Effect Service Conventions
…ion fix - ThreadBackgroundLiveness now exports canonical make/layer consumed via namespace import, and references the inferred interface as ThreadBackgroundLivenessService["Service"]. - recordTaskLiveness drops any prior entry for a taskId on every path, so a task reclassified across transitions (untyped -> shell, or newly inert/agent-owned) moves buckets instead of leaving a stale duplicate pinning the thread's status. Regression test added. - workflowScriptQuery constructs OrchestrationGetWorkflowScriptError inline at each failure boundary instead of via a fail() wrapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
One finding on the new orchestration RPC error's shape. The service module hoist (orchestration/ThreadBackgroundLiveness.ts with inline interface, make, layer, Service["Service"], requirement kept on the consuming layers) and the workflowScriptQuery error/cause cleanup from earlier rounds all look right.
Posted via Macroscope — Effect Service Conventions
| export class OrchestrationGetWorkflowScriptError extends Schema.TaggedErrorClass<OrchestrationGetWorkflowScriptError>()( | ||
| "OrchestrationGetWorkflowScriptError", | ||
| { | ||
| message: Schema.String, |
There was a problem hiding this comment.
This new failure carries an unstructured message as its only domain attribute, so the five construction sites in readWorkflowScript (bad path shape, root unavailable, not found, outside root, read failed) encode the failure kind in prose and discard the script path entirely.
Suggest stable structural attributes instead — e.g. a multi-value reason literal ("invalidPath" | "rootUnavailable" | "notFound" | "outsideRoot" | "readFailed") plus scriptPath — with message derived from those in a getter and cause retained for the wrapped platform failure. Each site then constructs the error with its own stage and path rather than a hardcoded sentence.
Posted via Macroscope — Effect Service Conventions

Note
WIP. Extensively live-tested on Claude and Codex (multi-round with real fleets); remaining gaps listed at the bottom.
Problem
When a thread spawns subagents, runs a workflow, or drives Codex collab agents, the UI showed nothing useful: subagent tool calls and narration interleaved anonymously into the parent chat, progress ticks spammed the work log, background shells masqueraded as agents, the sidebar showed no status once the turn settled, and Stop only killed the parent turn while the fleet kept burning tokens.
Spec (final, decisions locked): https://n0hbggyouhn1.postplan.dev
Solution
Surface agents using only native provider emissions on the current orchestrator — no dependency on orchestrator v2 (#2829), zero migrations, zero new tables. Widened
task.*activity payloads ride the existing event-sourced activity path; a client-side fold inclient-runtimederives orchestration-v2-shaped subagent state (field names match #4779, so the v2 merge is a mapper swap).Server
TaskAgentLinkageon all task payloads (role, model, workflow name/phases, run handles, output file, owningagentId), newtask.updatedevent, typed usage, tool attribution (agentId/parentToolUseId)task_updated, resolvesparent_tool_use_idon tool events, keeps subagent narration out of the parent transcript (leak + Working-timer-reset fix), attributes subagent-internal shells to their owning agent, defensiveworkflow_progressparse, Workflow run handlesthread/started/subAgentActivity(root-thread guard from live probe), intercepts child notifications, synthesizes the sametask.*lifecycle (idle = resumable, cumulative usage, real names from agentPath)Query.stopTask) and interrupts every live Codex child turn before the parent turnThreadBackgroundLivenessService(in-memory, no persistence) exposesbackgroundLiveness: working | monitoringon the thread shell — fleets read Working, watch loops read Monitoringorchestration.getWorkflowScriptRPC with TOCTOU-safe containment (open-then-verify inode, realpath under~/.claude/projects, .js-only, size cap)Web
{} scriptopens a read-only script viewtimelineBypassrows fold into the CTA, background shells stay ordinary work-log rowsMobile: same quiet-timeline fold;
task.completedkept as terminal signal.Verification
Remaining gaps
<pre>(no Shiki)forwardSubagentText/agentProgressSummariesoff🤖 Generated with Claude Code (Claude Fable 5)
Note
Add native subagent and workflow observability to the chat interface
+menu entry in the right panel tab bar.task.updatedruntime event type and expands task/tool payloads with agent linkage fields (agentId,parentToolUseId,taskType,workflowName,runHandles) across Claude and Codex adapters.timelineBypassevents are suppressed from the main timeline.ThreadBackgroundLivenessServiceto track per-thread background task state and exposebackgroundLiveness: 'working' | 'monitoring'onOrchestrationThreadShell, surfaced as new sidebar status pills.orchestration.getWorkflowScriptWebSocket RPC (scope-gated to orchestration read) that safely reads.jsscripts under~/.claude/projectswith path containment and TOCTOU-safe checks, capped at 256 KB.interruptTurnnow concurrently stops all live subagent tasks (Claude viaQuery.stopTask, Codex via child-thread interrupts) before interrupting the parent turn.parent_tool_use_idare filtered from the parent transcript; onlytool_useblocks and input deltas pass through.Macroscope summarized 3585e63.
Note
High Risk
Large changes to Claude/Codex runtime mapping, interrupt behavior, and a new file-read RPC on the orchestration path; mistakes could leak agent traffic into chat, strand background work on Stop, or expose scripts outside the intended root.
Overview
Threads that spawn subagents or workflows now surface fleet state without new persistence: providers emit richer
task.*/ tool payloads (linkage fields,task.updated, agent attribution), ingestion records background liveness for shell snapshots, and interrupt stops live child tasks/turns before killing the parent.Web adds an Agents right-panel roster (workflows with phase rails, flat agent rows, optional
{} scriptvia a new read-scopedgetWorkflowScriptRPC with path containment). The chat quiet timeline hides agent-internal noise into one spawn CTA per workflow run or direct-spawn batch (exempt from turn folds). Sidebar pills can show Working / Monitoring after the turn settles.Mobile mirrors the quiet-timeline work-log rules (including per-
taskIdcollapse for subagent rows).Reviewed by Cursor Bugbot for commit 3585e63. Bugbot is set up for automated code reviews on this repo. Configure here.