Conversation
…ents A `local_workflow` task reports its phases, members and usage through an undeclared `workflow_progress` array on `task_progress`, and its members are not provider tasks: they exist only inside the coordinator's snapshot and emit nothing on the event stream. Nothing downstream could see a workflow run. Decode that telemetry onto the coordinator's subagent entity, and project each member as an ordinary subagent with its own child thread, so a member opens exactly the way any other subagent does. Three details the frames force: - An empty `workflow_progress` is a usage-only frame; it must not blank the roster, and phases only ever join the plan. - A workflow is normally launched in the background, so most of its run happens after the launching turn settles. A narrow between-turns path keeps the coordinator live instead of freezing it at turn end. - The run's filesystem handles arrive only on the Workflow tool's acknowledgement, which is an ordinary tool call, so they are harvested outside the subagent branch. A settled member's answer is read from its own transcript, contained under `~/.claude/projects` by the reader lifted out of the workflow-script query, because the snapshot only carries a capped excerpt.
A workflow coordinator arrived in Lineage as a bare subagent row: one line, no sign of the dozen agents it ran. The Agents tab could not show them either, because the runtime projection dropped every workflow field on the way from the v2 projection. Carry those fields through, expand the coordinator into one runtime row per member, and give the Lineage row a disclosure that unfolds into its phases and agents. A member row opens that member's thread the same way any subagent row does — same navigation, same chat view, nothing new to learn. Collapsed, the row shows up to four status dots so a run's health reads at a glance without crowding its name out of a 312px card. Also fixes the Agents tab phase rail, whose chips sat low against their labels because a mono line box is taller than its glyphs.
| <ChevronDownIcon aria-hidden className="size-3.5" /> | ||
| ) : ( | ||
| <ChevronRightIcon aria-hidden className="size-3.5" /> | ||
| )} |
There was a problem hiding this comment.
Shared primitives over custom controls: this call site overrides the icon-xs button’s size and padding. Define the required geometry as a Button size/variant and select it here instead.
No self-contained diff is feasible because the fix requires changing the shared primitive.
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
This is the established pattern here rather than a deviation. apps/web/src/components/chat/threadDetailsPanelStyles.ts:1-6 states it directly: "The core control variants intentionally become denser at the sm breakpoint. The panel has its own fixed density, so every size and type override here includes its desktop counterpart." That module is built entirely from call-site geometry overrides, and its own THREAD_DETAILS_PANEL_ICON_ACTION_CLASS (line 42) is size-6 rounded-md border-transparent bg-transparent p-0 sm:size-6 applied to a size="icon-xs" Button — the same shape as ThreadLineageWorkflowRow.tsx:110, at a different number. The two sibling rows in this same panel already use it (ThreadRelationshipsControl.tsx:317-320, ThreadAutomationsPanel.tsx:109-112), and GitActionsControl.tsx:1692-1699 is an exact structural twin of the row button at line 39 (panel constant + cn + height override + sm: counterpart).
Repo-wide there are 40+ such call sites across ~25 files, including components/ui/sidebar.tsx:324 inside the shared-primitives directory. The sm:size-5 / sm:h-6 duplicates are also required, not redundant: cn is twMerge (lib/utils.ts:7-9), which treats size-5 and sm:size-6 as different groups, so a bare size-5 would lose to icon-xs's sm:size-6 at >=640px.
Adding a bespoke compact size to the shared buttonVariants cva (already 15 sizes) to serve two call sites in one panel would make this row's geometry live in button.tsx while its immediate neighbours' lives in threadDetailsPanelStyles.ts, which is the inconsistency worth avoiding.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| className={cn( | ||
| THREAD_DETAILS_PANEL_LINK_ROW_CLASS, | ||
| "h-6 gap-2 pl-7 pr-2.5 text-[12px] font-normal sm:h-6 sm:text-[12px]", |
There was a problem hiding this comment.
Shared primitives over custom controls: this call site overrides Button’s height and padding. Add a dedicated compact row size/variant in components/ui/button and use it here instead.
No self-contained diff is feasible because the fix requires changing the shared primitive.
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
This is the established pattern here rather than a deviation. apps/web/src/components/chat/threadDetailsPanelStyles.ts:1-6 states it directly: "The core control variants intentionally become denser at the sm breakpoint. The panel has its own fixed density, so every size and type override here includes its desktop counterpart." That module is built entirely from call-site geometry overrides, and its own THREAD_DETAILS_PANEL_ICON_ACTION_CLASS (line 42) is size-6 rounded-md border-transparent bg-transparent p-0 sm:size-6 applied to a size="icon-xs" Button — the same shape as ThreadLineageWorkflowRow.tsx:110, at a different number. The two sibling rows in this same panel already use it (ThreadRelationshipsControl.tsx:317-320, ThreadAutomationsPanel.tsx:109-112), and GitActionsControl.tsx:1692-1699 is an exact structural twin of the row button at line 39 (panel constant + cn + height override + sm: counterpart).
Repo-wide there are 40+ such call sites across ~25 files, including components/ui/sidebar.tsx:324 inside the shared-primitives directory. The sm:size-5 / sm:h-6 duplicates are also required, not redundant: cn is twMerge (lib/utils.ts:7-9), which treats size-5 and sm:size-6 as different groups, so a bare size-5 would lose to icon-xs's sm:size-6 at >=640px.
Adding a bespoke compact size to the shared buttonVariants cva (already 15 sizes) to serve two call sites in one panel would make this row's geometry live in button.tsx while its immediate neighbours' lives in threadDetailsPanelStyles.ts, which is the inconsistency worth avoiding.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
This comment has been minimized.
This comment has been minimized.
| reason: Schema.Literals([ | ||
| "invalid-path", | ||
| "root-unavailable", | ||
| "not-found", | ||
| "outside-root", | ||
| "not-js", | ||
| "wrong-extension", | ||
| "not-regular-file", | ||
| "changed-during-read", | ||
| "read-failed", | ||
| ]), |
There was a problem hiding this comment.
reason selects the caller-visible RPC message, so this is a control-flow/user-facing discriminator inside one generic tagged error. Define separate Schema.TaggedError classes for these failure cases (with cause required on variants that always wrap a failure), preserve the existing messages, and expose their union from the RPC schema.
Suggested fix: No diff — this requires coordinated changes to the error declarations, construction sites, and RPC error union.
Posted via Macroscope — Effect Service Conventions
There was a problem hiding this comment.
This design predates the PR. On the merge base, packages/contracts/src/orchestrationV2.ts already declared OrchestrationGetWorkflowScriptError with the identical reason: Schema.Literals([...]) field and the same 8 members, and it was already the sole member of the Schema.Union([...]) at rpc.ts:1479. This PR's diff shows the reason: Schema.Literals([ line as unchanged context — the only changes are the class name and tag, scriptPath -> path, and the literal not-js -> wrong-extension, done so one name covers both contained reads. The rpc.ts diff is two identifier lines with no change to the union's arity or shape.
The reason-literal discriminator is also the convention across this package rather than a one-off: DeviceBootError (device.ts:379), DeviceOperationError (device.ts:397), DeviceActionUnavailableError (device.ts:425), usage.ts:206, and preview.ts:342 all use the same tagged-error + reason: Schema.Literals + message-getter shape.
No consumer discriminates on it either — AgentsPanel.tsx:308-309 renders a constant "Could not load the script." on failure, and ClaudeAdapterV2.ts:3512 erases the error with Effect.orElseSucceed(() => []). Splitting into eight tagged classes would change the shipped wire contract from one tag to eight for mixed-version remote, mobile and relay clients, force a value-to-constructor switch at workflowFileRead.ts:107 where reason: read.failure is assigned dynamically, and diverge this error from its five siblings — for no consumer benefit. If the maintainers want that shape it belongs in its own PR covering all six sites.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
This comment has been minimized.
This comment has been minimized.
| if (message.type !== "system" || message.subtype !== "task_progress") return; | ||
| const taskId = message.task_id; | ||
| const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); | ||
| if (registered === undefined || registered.task.workflow === undefined) return; |
There was a problem hiding this comment.
🟡 Medium Adapters/ClaudeAdapterV2.ts:4637
A late task_progress frame re-projects a terminal workflow coordinator's stale running member state, overwriting the member's completed/failed projection. This path only checks registration and workflow presence before calling projectClaudeWorkflowMembers; reject frames unless registered.task.status is still "running", matching the turn-aware guard.
- if (registered === undefined || registered.task.workflow === undefined) return;
+ if (
+ registered === undefined ||
+ registered.task.workflow === undefined ||
+ registered.task.status !== "running"
+ )
+ return;🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts around line 4637:
A late `task_progress` frame re-projects a terminal workflow coordinator's stale `running` member state, overwriting the member's completed/failed projection. This path only checks registration and workflow presence before calling `projectClaudeWorkflowMembers`; reject frames unless `registered.task.status` is still `"running"`, matching the turn-aware guard.
There was a problem hiding this comment.
Not applying this one. The frame it depends on cannot be produced: the Claude CLI's workflow progress emitter opens with if (Pe?.type !== "local_workflow" || Pe.status !== "running") return; (CLI 2.1.261, the onSdkEmit handler in the local_workflow runner), so no task_progress is emitted for a task once it has left running — the post-notification frame this describes never reaches applyWorkflowProgressWithoutTurn. The in-turn twin also already carries the guard being asked for, at ClaudeAdapterV2.ts:3579-3585, and it returns before projectClaudeWorkflowMembers is reached.
Adding registered.task.status === "running" to the between-turns guard would also break two behaviors the design depends on. projectClaudeWorkflowMembers derives member status only from member.state, never from the coordinator, so a member still reported start in the last snapshot before the notification is settled only by a trailing frame — gating that frame leaves a permanently spinning member row. And the transcript-answer retry at ClaudeAdapterV2.ts:3387 deliberately re-enters a settled member on later frames until answeredFromTranscript is true; gating those frames means a member whose transcript had not flushed yet never gets its answer. Since a background workflow spends most of its life with no active turn, that is exactly where those frames land.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| // The opened inode must be the same one realpath resolved to: a | ||
| // process swapping the path between realpath and open changes the | ||
| // inode, which this comparison catches. | ||
| const pathStat = await NodeFSP.lstat(resolved); |
There was a problem hiding this comment.
🟠 High orchestration/workflowFileRead.ts:88
A request can return a file outside ~/.claude/projects despite the containment check. If an intermediate directory is replaced with a symlink after realpath, both open(resolved) and lstat(resolved) follow that symlink and observe the same outside inode, so the comparison at line 88 does not prevent the escape. Use component-wise no-followed directory handles (or an equivalent kernel-enforced containment check) before reading.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/workflowFileRead.ts around line 88:
A request can return a file outside `~/.claude/projects` despite the containment check. If an intermediate directory is replaced with a symlink after `realpath`, both `open(resolved)` and `lstat(resolved)` follow that symlink and observe the same outside inode, so the comparison at line 88 does not prevent the escape. Use component-wise no-followed directory handles (or an equivalent kernel-enforced containment check) before reading.
There was a problem hiding this comment.
Thanks for this -- the OS mechanism you describe is real and we reproduced it, but we are not taking the fix, and here is why. The containment root is NodePath.join(NodeOS.homedir(), ".claude", "projects") (workflowFileRead.ts:23-25), which on our hosts is mode 0700 inside a 0700 home. Creating the intermediate symlink therefore requires code execution as the server's own uid, which already grants read of every file this function could possibly return -- the escape confers nothing its precondition does not. Neither RPC caller can reach it: getWorkflowScript (ws.ts:1772) is gated on AuthOrchestrationReadScope (RpcAuthorization.ts:25) and grants no filesystem write, and the other caller derives its path from harness-emitted transcriptDir (workflowAgentAnswers.ts:83). On the remedy: Node exposes no openat(2), so component-wise O_NOFOLLOW descent needs a native addon, and the readlink("/proc/self/fd/N") variant is Linux-only -- our desktop app runs as a host server on macOS, where it would silently no-op and leave a guarantee that reads as uniform but is not. You are right about one thing, though, and we are fixing it: the comment at workflowFileRead.ts:71 and :85-87 overstates what the lstat/fstat comparison achieves. It catches a swapped leaf (we measured 137958 vs 137962) but not an intermediate directory swap, where both stats re-walk onto the identical inode. We are rewriting those lines to say exactly that.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
This comment has been minimized.
This comment has been minimized.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This is a substantial cross-cutting feature that adds automatic workflow projection, child threads and messages, transcript file reads, contract changes, and interactive lineage UI. Unresolved threshold-level concerns include filesystem containment and workflow identity handling, and the diff adds file-level static-analysis suppressions, so human review is warranted. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
…pand Three defects found in review of the workflow visualization. A member's `startedAt` was restamped with the current instant on every projection pass, so settling overwrote the start with the completion time and the member reported a zero duration. The first instant is now retained alongside the member's status and reused across transitions. A member transcript larger than the 512 KiB cap was read from its head, where the final answer is not. The truncated prefix still parsed into plausible turns, which marked the answer as transcript-sourced and suppressed the progress excerpt — leaving a mid-run turn on screen as if it were the result. A truncated read now reports no turns so the excerpt is used instead. A workflow whose snapshot declared phases before any member joined them expanded to a blank strip: every phase row suppresses itself when it has no members, but the empty state only checked whether the phase list was empty. Also pluralizes the Agents tab's tool count, which read "1 tools".
| const body = content | ||
| .filter((block) => text(field(block, "type")) === "text") | ||
| .map((block) => text(field(block, "text"))) | ||
| .filter((value): value is string => value !== undefined) | ||
| .join("\n\n"); |
There was a problem hiding this comment.
🟡 Medium orchestration/workflowAgentAnswers.ts:53
Assistant answer text is stripped at both ends, so intentional leading or trailing whitespace is lost from the returned answer. The text() call at map decodes the payload instead of preserving it; read the raw string from field() and reject only truly empty blocks.
- const body = content
- .filter((block) => text(field(block, "type")) === "text")
- .map((block) => text(field(block, "text")))
- .filter((value): value is string => value !== undefined)
- .join("\n\n");
+ const body = content
+ .filter((block) => text(field(block, "type")) === "text")
+ .map((block) => {
+ const value = field(block, "text");
+ return typeof value === "string" ? value : undefined;
+ })
+ .filter((value): value is string => value !== undefined && value.length > 0)
+ .join("\n\n");🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/workflowAgentAnswers.ts around lines 53-57:
Assistant answer text is stripped at both ends, so intentional leading or trailing whitespace is lost from the returned answer. The `text()` call at `map` decodes the payload instead of preserving it; read the raw string from `field()` and reject only truly empty blocks.
There was a problem hiding this comment.
We looked at this closely and are leaving it as is. The trim in text() (unknownField.ts:6-10) is doing real work: it returns undefined for a whitespace-only block, which is precisely what makes the if (body.length === 0) continue; guard at workflowAgentAnswers.ts:60 correct. Reading the raw string and rejecting only genuinely empty blocks would let a ' \n ' turn through, and ClaudeAdapterV2.ts:3522 would mint a durable assistant message that renders to nothing -- a blank bubble the user cannot dismiss. On the visible impact, we scanned every assistant text block in our real transcripts (6852 blocks across 4195 files): 27 carry stray whitespace, all trailing-only, and zero have leading whitespace or are whitespace-only. In the agent-*.jsonl files this parser actually reads, it is 1 block in ~2176. We rendered each real shape through this repo's own remark pipeline -- with and without remarkBreaks -- and the HTML is byte-identical trimmed or raw, including fenced code and trailing hard-break spaces, because the destination is markdown where leading and trailing whitespace is unrepresentable. The one shape that would differ is a leading 4-space indent turning an indented code block into a paragraph, and that has never appeared. Trimming also keeps this path consistent with the excerpt fallback at claudeWorkflowProgress.ts:66, which trims identically.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| // Derived from the coordinator's own ids, not from its task id: | ||
| // a task id is unique only within one provider thread, and the | ||
| // coordinator's node id already carries that scoping. | ||
| const memberKey = `${input.coordinator.task.id}:agent:${member.index}`; |
There was a problem hiding this comment.
🟠 High Adapters/ClaudeAdapterV2.ts:3383
Concurrent provider threads reporting the same SDK taskId produce identical workflow member IDs, so one workflow's member updates overwrite or suppress the other's projected agents. coordinator.task.id is derived from task:${taskId} without provider-thread scope, and that unscope value is used for memberKey and all derived node, thread, and message IDs. Include the native provider-thread/session scope in the coordinator/member identity before deriving these IDs.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts around line 3383:
Concurrent provider threads reporting the same SDK `taskId` produce identical workflow member IDs, so one workflow's member updates overwrite or suppress the other's projected agents. `coordinator.task.id` is derived from `task:${taskId}` without provider-thread scope, and that unscope value is used for `memberKey` and all derived node, thread, and message IDs. Include the native provider-thread/session scope in the coordinator/member identity before deriving these IDs.
There was a problem hiding this comment.
Good catch on the derivation -- you are right that coordinator.task.id carries no provider-thread scope. ClaudeAdapterV2.ts:3600-3606 builds nativeItemId = \task:${input.taskId}`andIdAllocator.ts:420-421joins only('node','provider',driver,'native-item',nativeItemId), so the comment at ClaudeAdapterV2.ts:3380-3382 claiming 'the coordinator's node id already carries that scoping' is simply wrong, and we are rewriting it. We are not changing the ids, for three reasons. First, task:${taskId}is not new here --git log -Spoints at e672aea009, and the coordinator's own node id,:thread-rootid, turn-item id and prompt message id already rest on it, so if two task ids collided the two coordinators would already be one node before any member is projected;memberKeyis a strict suffix of that id and adds no surface. Second,workflowMemberStatescannot be shared across provider threads anyway: Claude setssupportsMultipleProviderThreadsPerSession: false(ClaudeAdapterV2.ts:205) andProviderSessionManager.ts:1553-1561rejects attaching a second app thread outright. Third, the precondition has no trigger -- real harness ids arew+8 base36 and a+16 hex, and across 4195 transcripts here none repeats across sessions. For the record, childThreadId` is scoped as you suspected (ClaudeAdapterV2.ts:3613-3618, members at :3307-3310), so child thread ids would not have collided.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
| }); | ||
| }); | ||
|
|
||
| /** |
There was a problem hiding this comment.
This adds substantial backend behavior without a focused adapter test. Add coverage that observes workflow-member child-thread/node/subagent/message projection, terminal transcript hydration versus excerpt fallback, deduplication across repeated snapshots, and progress received after the launching turn settles.
Suggested fix: No diff — the fix belongs in the adapter test file.
Posted via Macroscope — Effect Service Conventions
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Adds two adapter tests for the workflow-member path, which until now was only covered at the decoder and projection layers. The first drives a live coordinator and asserts each member gets its own child thread, subagent row, execution nodes and prompt message, and that re-sending an unchanged snapshot emits nothing further. The second settles the launching turn before the members finish, then asserts the background path still updates them and that a member answers from its own transcript when one exists and from the snapshot excerpt when it does not. Also corrects two comments that claimed more than the code delivers. The contained read is not fully TOCTOU-safe: its inode comparison catches a leaf swapped between realpath and open, but not an intermediate directory replaced with a symlink, which both stats re-walk onto identically. Closing that needs openat(2), which Node cannot express, and the root is the server user's own 0700 directory, so planting the symlink already requires the uid the read runs as. Separately, a workflow member's key does not carry provider-thread scope — it extends the coordinator's node id, which is derived from the raw task id.
The problem
A dynamic workflow is invisible on v2. Launch one and the Agents tab stays empty, while the Lineage card in the thread-details panel shows a single flat subagent row — no phases, no agents, no usage, no way to see what any of them did.
Two causes, one per layer:
local_workflowtask reports its phases, members and usage throughworkflow_progress, an array the Claude Agent SDK types do not declare. Nothing read it. Worse, a workflow's members are not provider tasks at all: they exist only inside the coordinator's progress snapshot and emit nothing on the event stream, so there was no entity for a client to render or open.projectedSubagentsToRuntimenulled every workflow field on the way from the v2 projection into the runtime model, so even the fields that did exist never reached the UI.What it looks like
The thread-details panel. Under Lineage → Previous agents, the
auth-reviewcoordinator row unfolds in place into its phases (LIST 2/2,RANK 1/1) and the agents inside them. Every coordinator row carries status dots and elapsed time whether it is open or closed, and the list pages the rest behind Show 7 more.The same kind of run in the Agents tab: a phase rail across the top, per-phase progress, and each member with its model, tokens, tool calls and duration. This run is finished —
9/9 settled.Clicking a member row navigates to that member's own thread. It is the ordinary subagent chat view — breadcrumb
server / list:refresh, aSubagent of · Review session authbadge, and a Lineage back-link to its parent. No new surface, no bespoke dialog.All three are real runs captured on a dev server, not mockups.
What changed
feat(server)— capture the telemetry, project the membersDecodes
workflow_progressonto the coordinator's subagent entity, and projects each member as an ordinary subagent with its own child thread. A member is not special-cased downstream: it emits the sameapp_thread.created/node.updated/subagent.updated/message.updatedsequence any subagent emits, which is why the third screenshot needs no new code to render.Three properties of the frames drove the design:
workflow_progressis a usage-only frame, not an empty roster. Merging is additive — a usage frame must not blank the member list, and a phase only ever joins the plan.toolCalls, skipping the subagent branch entirely. They are harvested outside that branch.A settled member's answer is read from its own transcript, because the snapshot carries only a capped excerpt. That read reuses the containment and TOCTOU-safe open already written for the workflow-script query, lifted into
workflowFileRead.ts— realpath re-containment under~/.claude/projects, extension check, and an fstat/lstat inode comparison so a swap between resolve and open cannot slip a different file through.feat(web)— render itCarries the workflow fields through the runtime projection and expands a coordinator into one row per member. The Lineage row gets a disclosure that unfolds into the run's phases and agents; a member row opens its thread through the existing thread navigation.
Also fixes two things visible in the shots above: the Agents tab phase rail, whose chips sat low against their labels because a mono line box is taller than its glyphs, and a
1 toolslabel that never handled the singular.Notes
relationshipToParent === "subagent", which the existing sidebar filters already exclude — a fan-out of fifty members does not flood the thread list.OrchestrationV2Subagentis optional, so an older client decodes a newer projection unchanged.OrchestrationGetWorkflowScriptErroris renamedOrchestrationWorkflowFileErrornow that two readers share it; the wire method and its shape are unchanged.Verification
vp run --filter {@t3tools/contracts,@t3tools/client-runtime,@t3tools/web,t3} typecheck— clean.Risk
Confined to the Claude adapter's workflow path and the surfaces that render it. The decoder is defensive — an unrecognised entry is dropped rather than failing the frame, and an unknown member state reads as running, which self-corrects on the next snapshot rather than freezing a live row.
Made with Claude Opus 5 in Claude Code.