Skip to content

feat: visualize dynamic workflows in the Agents tab and Lineage card - #12598

Open
Bil0000 wants to merge 4 commits into
pingdotgg:t3code/codex-turn-mappingfrom
Bil0000:workflow-visualization-v2
Open

Bil0000 wants to merge 4 commits into
pingdotgg:t3code/codex-turn-mappingfrom
Bil0000:workflow-visualization-v2

Conversation

@Bil0000

@Bil0000 Bil0000 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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:

  • Server. A local_workflow task reports its phases, members and usage through workflow_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.
  • Client. projectedSubagentsToRuntime nulled 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-review coordinator 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.

Lineage card with a workflow row unfolded into its phases and agents

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.

Agents tab showing a completed nine-agent, three-phase workflow

Clicking a member row navigates to that member's own thread. It is the ordinary subagent chat view — breadcrumb server / list:refresh, a Subagent of · Review session auth badge, and a Lineage back-link to its parent. No new surface, no bespoke dialog.

A workflow member opened as a normal subagent thread

All three are real runs captured on a dev server, not mockups.

What changed

feat(server) — capture the telemetry, project the members

Decodes workflow_progress onto 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 same app_thread.created / node.updated / subagent.updated / message.updated sequence any subagent emits, which is why the third screenshot needs no new code to render.

Three properties of the frames drove the design:

  • An empty workflow_progress is 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.
  • A workflow is normally launched in the background, so most of its run happens after the launching turn has settled. A deliberately narrow between-turns path refreshes the coordinator entity (and only that) so a live run keeps reporting instead of freezing at turn end.
  • The run's filesystem handles arrive only on the Workflow tool's launch acknowledgement, which is an ordinary tool call and therefore lands in 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 it

Carries 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 tools label that never handled the singular.

Notes

  • Member threads carry relationshipToParent === "subagent", which the existing sidebar filters already exclude — a fan-out of fifty members does not flood the thread list.
  • Everything added to OrchestrationV2Subagent is optional, so an older client decodes a newer projection unchanged.
  • OrchestrationGetWorkflowScriptError is renamed OrchestrationWorkflowFileError now that two readers share it; the wire method and its shape are unchanged.
  • No new dependency, no schema migration, no change to any non-Claude adapter. Other providers report no workflow telemetry and are unaffected.

Verification

  • vp run --filter {@t3tools/contracts,@t3tools/client-runtime,@t3tools/web,t3} typecheck — clean.
  • Tests across decoder merge semantics (usage-only frames, phase union, run-handle harvest), transcript parsing (split assistant messages, meta lines, truncated tails), path containment and symlink escape, the member projection, and the affected web components.
  • Exercised against live runs on a dev server, which is where the screenshots come from.

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.

…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.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Sep 19, 2026
<ChevronDownIcon aria-hidden className="size-3.5" />
) : (
<ChevronRightIcon aria-hidden className="size-3.5" />
)}

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment on lines +37 to +39
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]",

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

@macroscopeapp

This comment has been minimized.

Comment on lines 2971 to 2980
reason: Schema.Literals([
"invalid-path",
"root-unavailable",
"not-found",
"outside-root",
"not-js",
"wrong-extension",
"not-regular-file",
"changed-during-read",
"read-failed",
]),

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

@macroscopeapp

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;

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.

🟡 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Comment thread apps/server/src/orchestration/workflowAgentAnswers.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts Outdated
// 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);

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.

🟠 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

@macroscopeapp

This comment has been minimized.

@macroscopeapp

macroscopeapp Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 3 blocking correctness issues found at or above your repo's Minimum Blocking Severity

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".
Comment on lines +53 to +57
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");

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.

🟡 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

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}`;

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.

🟠 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/orchestration/workflowAgentAnswers.ts
});
});

/**

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.

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

@macroscopeapp

This comment has been minimized.

@macroscopeapp

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.
@macroscopeapp

This comment has been minimized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant