Show live status for parallel branches

This commit is contained in:
Release Repro 2026-07-27 17:08:08 -04:00
parent 6efba896f4
commit c812274db8
No known key found for this signature in database
13 changed files with 497 additions and 97 deletions

View file

@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
@ -13,35 +14,98 @@ beforeEach(() => {
});
afterEach(() => teardown());
const parallelStage: Stage = {
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "stage@1",
name: "stage",
handler: "agent",
status: StageState.RUNNING,
duration: "--",
nodeId: "stage",
visit: 1,
graphVisit: 1,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
...overrides,
};
}
const parallelStage = makeStage({
id: "fork@1",
name: "fork",
handler: "parallel",
status: "succeeded",
status: StageState.RUNNING,
duration: "12s",
nodeId: "fork",
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
};
});
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, { event: "parallel.completed", ...partial });
function branchStage(
name: string,
index: number,
status: StageState,
groupId = "fork@1",
visit = 1,
): Stage {
return makeStage({
id: `${name}@${visit}`,
name,
nodeId: name,
visit,
status,
parallelGroupId: groupId,
parallelBranchIndex: index,
});
}
function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer {
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, {
event: "parallel.completed",
stage_id: "fork@1",
...partial,
});
}
function startedEvent(branchCount: number): EventEnvelope {
return event({
event: "parallel.started",
properties: { branch_count: branchCount },
});
}
function completedEvent(
results: Array<{ id: string; status: string }>,
successCount: number,
failureCount: number,
): EventEnvelope {
return event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: successCount,
failure_count: failureCount,
results: results.map((result) => ({ ...result, context_updates: {} })),
},
});
}
function renderParallel(
events: EventEnvelope[],
allStages: Stage[],
stage = parallelStage,
): TestRenderer.ReactTestRenderer {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter>
<ParallelChildren
stage={parallelStage}
stage={stage}
events={events}
runId="run-1"
allStages={[
{ ...parallelStage, id: "branch-a@1", name: "branch-a", nodeId: "branch-a", handler: "agent" },
{ ...parallelStage, id: "branch-b@1", name: "branch-b", nodeId: "branch-b", handler: "agent", status: "failed" },
]}
allStages={allStages}
/>
</MemoryRouter>,
);
@ -49,37 +113,132 @@ function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer
return renderer;
}
describe("ParallelChildren", () => {
test("renders branch status and stage links without checkout metadata", () => {
const renderer = renderParallel([
event({
event: "parallel.started",
properties: { branch_count: 2 },
}),
event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: 1,
failure_count: 1,
results: [
{ id: "branch-a", status: "succeeded", context_updates: {} },
{ id: "branch-b", status: "failed", context_updates: {} },
],
},
}),
]);
function textContent(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => typeof child === "string" ? child : textContent(child))
.join("");
}
const rendered = JSON.stringify(renderer.toJSON());
expect(rendered).toContain("branch-a");
expect(rendered).toContain("Succeeded");
expect(rendered).toContain("branch-b");
expect(rendered).toContain("Failed");
const hrefs = renderer.root.findAllByType("a").map((link) => link.props.href);
expect(hrefs).toEqual([
"/runs/run-1/stages/branch-a@1",
"/runs/run-1/stages/branch-b@1",
function branchRowText(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("li").map(textContent);
}
function hrefs(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("a").map((link) => link.props.href);
}
function statValue(renderer: TestRenderer.ReactTestRenderer, label: string): string {
const stat = renderer.root
.findAllByProps({ className: "flex flex-col gap-0.5" })
.find((item) => textContent(item).startsWith(label));
if (!stat) throw new Error(`stat ${label} not found`);
return textContent(stat.findAllByType("span")[1]);
}
describe("ParallelChildren", () => {
test("renders live branch names, statuses, counts, and stage links", () => {
const renderer = renderParallel(
[startedEvent(2)],
[
branchStage("review_glm", 0, StageState.SUCCEEDED),
branchStage("review_opus", 1, StageState.RUNNING),
],
);
const rows = branchRowText(renderer);
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("Succeeded");
expect(rows[0]).toContain("review_glm");
expect(rows[1]).toContain("Running");
expect(rows[1]).toContain("review_opus");
expect(hrefs(renderer)).toEqual([
"/runs/run-1/stages/review_glm@1",
"/runs/run-1/stages/review_opus@1",
]);
expect(statValue(renderer, "Succeeded")).toBe("1");
expect(statValue(renderer, "Failed")).toBe("0");
});
test("keeps looped fork links scoped to the selected fork visit", () => {
const renderer = renderParallel(
[startedEvent(1)],
[
branchStage("review_glm", 0, StageState.SUCCEEDED, "fork@1", 1),
branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2),
],
);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]);
});
test("keeps duplicate branch targets in index order and only links recorded stages", () => {
const renderer = renderParallel(
[
startedEvent(2),
completedEvent(
[
{ id: "review", status: "failed" },
{ id: "review", status: "failed" },
],
1,
1,
),
],
[branchStage("review", 0, StageState.SUCCEEDED)],
);
const rows = branchRowText(renderer);
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("Succeeded");
expect(rows[1]).toContain("Failed");
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review@1"]);
});
test("renders a completed result without a matching stage as an unlinked row", () => {
const renderer = renderParallel(
[
startedEvent(1),
completedEvent(
[{ id: "legacy_branch", status: "succeeded" }],
1,
0,
),
],
[],
);
expect(branchRowText(renderer)).toEqual(["Succeededlegacy_branch"]);
expect(hrefs(renderer)).toEqual([]);
});
test("counts partial and skipped branches as neither succeeded nor failed", () => {
const allStages = [
branchStage("partial", 0, StageState.PARTIALLY_SUCCEEDED),
branchStage("skipped", 1, StageState.SKIPPED),
];
const running = renderParallel([startedEvent(2)], allStages);
const completed = renderParallel(
[
startedEvent(2),
completedEvent(
[
{ id: "partial", status: "partially_succeeded" },
{ id: "skipped", status: "skipped" },
],
0,
0,
),
],
allStages,
);
expect([
statValue(running, "Succeeded"),
statValue(running, "Failed"),
]).toEqual(["0", "0"]);
expect([
statValue(completed, "Succeeded"),
statValue(completed, "Failed"),
]).toEqual(["0", "0"]);
});
});

View file

@ -10,10 +10,12 @@ import { formatDurationMs } from "../../lib/format";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
/** Branch row view state: completed outcomes plus a synthesized in-flight row. */
/** Branch row view state sourced from a live branch stage or completed result. */
interface BranchRow {
branchIndex: number;
id: string;
status: StageState;
stageHref: string | null;
}
function StatItem({
@ -38,25 +40,23 @@ function StatItem({
}
function ChildRow({
result,
stageHref,
row,
}: {
result: BranchRow;
stageHref: string | null;
row: BranchRow;
}) {
const tone = stageStatusTone(result.status);
const tone = stageStatusTone(row.status);
const inner = (
<>
<span
className={`inline-flex w-24 shrink-0 justify-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${tone}`}
>
{stageStatusLabel(result.status)}
{stageStatusLabel(row.status)}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-sm text-fg-3">
{result.id}
{row.id}
</span>
{stageHref && (
{row.stageHref && (
<ArrowTopRightOnSquareIcon
className="size-3.5 shrink-0 text-fg-muted transition-colors group-hover:text-fg-2"
aria-hidden="true"
@ -67,9 +67,9 @@ function ChildRow({
return (
<li className="flex items-center gap-3 px-4 py-2.5">
{stageHref ? (
{row.stageHref ? (
<Link
to={stageHref}
to={row.stageHref}
className="group flex flex-1 items-center gap-3 rounded -m-1 p-1 transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500"
>
{inner}
@ -94,24 +94,63 @@ export function ParallelChildren({
}) {
const overview = useMemo(() => parseParallelOverview(events), [events]);
// Map node_id -> latest stage_id so we can deep-link branches.
const latestStageByNode = useMemo(() => {
const latest = new Map<string, Stage>();
for (const s of allStages) {
const prev = latest.get(s.nodeId);
if (!prev || s.visit > prev.visit) latest.set(s.nodeId, s);
const stagesByBranchIndex = useMemo(() => {
const byIndex = new Map<number, Stage>();
for (const candidate of allStages) {
if (
candidate.parallelGroupId === stage.id
&& candidate.parallelBranchIndex != null
) {
byIndex.set(candidate.parallelBranchIndex, candidate);
}
}
return new Map(Array.from(latest.entries()).map(([nodeId, s]) => [nodeId, s.id]));
}, [allStages]);
return byIndex;
}, [allStages, stage.id]);
const items: BranchRow[] = overview.results.length > 0
? overview.results
: overview.branchCount && overview.branchCount > 0
? Array.from({ length: overview.branchCount }, (_, i) => ({
id: `branch ${i + 1}`,
status: StageState.RUNNING,
}))
: [];
const branchCount = overview.branchCount ?? stagesByBranchIndex.size;
const slots = Array.from({ length: branchCount }, (_, index) => ({
index,
stage: stagesByBranchIndex.get(index) ?? null,
result: overview.results[index] ?? null,
}));
const rows = slots.map<BranchRow>(({ index, stage: branchStage, result }) => {
if (branchStage) {
return {
branchIndex: index,
id: branchStage.name,
status: branchStage.status,
stageHref: `/runs/${runId}/stages/${branchStage.id}`,
};
}
if (result) {
return {
branchIndex: index,
id: result.id,
status: result.status,
stageHref: null,
};
}
return {
branchIndex: index,
id: `branch ${index + 1}`,
status: StageState.PENDING,
stageHref: null,
};
});
const liveBranchStages = Array.from(stagesByBranchIndex.values());
const liveSuccessCount = liveBranchStages
.filter((branchStage) => branchStage.status === StageState.SUCCEEDED)
.length;
const liveFailureCount = liveBranchStages
.filter((branchStage) => branchStage.status === StageState.FAILED)
.length;
const successCount = overview.isComplete
? overview.successCount ?? 0
: liveSuccessCount;
const failureCount = overview.isComplete
? overview.failureCount ?? 0
: liveFailureCount;
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
@ -121,13 +160,13 @@ export function ParallelChildren({
<StatItem label="Branches" value={overview.branchCount ?? "—"} />
<StatItem
label="Succeeded"
value={overview.successCount ?? (overview.isComplete ? 0 : "—")}
value={successCount}
tone="success"
/>
<StatItem
label="Failed"
value={overview.failureCount ?? (overview.isComplete ? 0 : "—")}
tone={overview.failureCount && overview.failureCount > 0 ? "danger" : "default"}
value={failureCount}
tone={failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
@ -139,21 +178,13 @@ export function ParallelChildren({
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-muted">
Branches
</h3>
{items.length === 0 ? (
{rows.length === 0 ? (
<p className="text-sm text-fg-muted">No branches recorded yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg bg-panel outline-1 -outline-offset-1 outline-line">
{items.map((result, i) => {
const stageId = latestStageByNode.get(result.id);
const href = stageId ? `/runs/${runId}/stages/${stageId}` : null;
return (
<ChildRow
key={`${result.id}-${i}`}
result={result}
stageHref={href}
/>
);
})}
{rows.map((row) => (
<ChildRow key={row.branchIndex} row={row} />
))}
</ul>
)}
</section>

View file

@ -13,6 +13,8 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage {
visit,
graphVisit: null,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
status,
duration: "--",
startedAt: null,
@ -182,6 +184,28 @@ describe("mapRunStagesToSidebarStages", () => {
expect(result[0].resumedFromStageId).toBeNull();
});
test("maps parallel branch identity without parsing it in the client", () => {
const stages: PaginatedRunStageList = {
data: [
{
id: "review_opus@2",
name: "review_opus",
handler: "agent",
status: "running",
node_id: "review_opus",
visit: 2,
parallel_group_id: "review_fork@1",
parallel_branch_index: 3,
},
],
meta: { has_more: false },
};
const result = mapRunStagesToSidebarStages(stages);
expect(result[0].parallelGroupId).toBe("review_fork@1");
expect(result[0].parallelBranchIndex).toBe(3);
});
test("preserves the authoritative handler for renderer dispatch", () => {
const stages: PaginatedRunStageList = {
data: [

View file

@ -25,6 +25,10 @@ export interface Stage {
graphVisit: number | null;
/** StageId of the prior execution superseded by this resumed replay, if any. */
resumedFromStageId: string | null;
/** Exact StageId of the parent parallel execution, if this is a branch. */
parallelGroupId: string | null;
/** Zero-based outgoing-edge index within the parent parallel execution. */
parallelBranchIndex: number | null;
startedAt: string | null;
providerUsed: StageModelUsage | null;
}
@ -96,6 +100,8 @@ export function mapRunStagesToSidebarStages(
visit: stage.visit,
graphVisit: stage.graph_visit ?? null,
resumedFromStageId: stage.resumed_from_stage_id ?? null,
parallelGroupId: stage.parallel_group_id ?? null,
parallelBranchIndex: stage.parallel_branch_index ?? null,
status: stage.status,
duration: stage.wall_time_ms != null
? formatDurationMs(stage.wall_time_ms)

View file

@ -10652,6 +10652,11 @@ components:
items:
$ref: "#/components/schemas/ParallelBranchResult"
description: Ordered per-branch results produced by a parallel stage.
parallel_branch_id:
type: ["string", "null"]
description: >
Durable identity of this branch within a parallel execution,
formatted as "{parallel_group_id}:{index}".
output:
type: ["string", "null"]
output_bytes:
@ -12658,6 +12663,23 @@ components:
StageId of the prior post-checkpoint execution superseded by this
replay after the run was resumed.
example: verify@1
parallel_group_id:
oneOf:
- $ref: "#/components/schemas/StageId"
- type: "null"
description: >-
Exact StageId of the parent parallel execution. Clients can compare
this directly with a parallel stage's `id`. Null for stages that
are not parallel branches.
example: review_fork@1
parallel_branch_index:
type: ["integer", "null"]
format: uint32
minimum: 0
description: >-
Zero-based outgoing-edge index within the parent parallel
execution. Null for stages that are not parallel branches.
example: 1
provider_used:
oneOf:
- $ref: "#/components/schemas/StageModelUsage"

View file

@ -1143,6 +1143,8 @@ mod runs {
started_at: None,
graph_visit: None,
resumed_from_stage_id: None,
parallel_group_id: None,
parallel_branch_index: None,
}
}

View file

@ -32,6 +32,11 @@ fn run_stage_from_projection(
.and_then(|node| node.handler_type()),
)
});
let (parallel_group_id, parallel_branch_index) = stage
.parallel_branch_id
.as_ref()
.map(|branch_id| (branch_id.group().clone(), branch_id.index()))
.unzip();
RunStage {
id: stage_id.clone(),
name: stage_id.node_id().to_owned(),
@ -45,6 +50,8 @@ fn run_stage_from_projection(
started_at: stage.started_at,
graph_visit: stage.graph_visit.and_then(std::num::NonZeroU32::new),
resumed_from_stage_id: stage.resumed_from_stage_id.clone(),
parallel_group_id,
parallel_branch_index,
}
}

View file

@ -26,8 +26,8 @@ use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph,
InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec,
SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
InterviewQuestionRecord, Node, Outcome, ParallelBranchId, QuestionType, RunBlobId, RunId,
RunSpec, SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind,
WorkflowSettings, fixtures, test_support,
@ -5503,6 +5503,81 @@ async fn list_run_stages_exposes_execution_identity_for_resumed_stage() {
assert_eq!(second["resumed_from_stage_id"], "work@1");
}
#[tokio::test]
async fn list_run_stages_exposes_parallel_branch_identity() {
let state = test_app_state_with_isolated_storage();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
])
.await;
append_scoped_stage_event(
&state,
run_id,
"ordinary",
1,
&workflow_event::Event::StageStarted {
graph_visit: Some(1),
resumed_from_stage_id: None,
node_id: "ordinary".to_string(),
name: "Ordinary".to_string(),
index: 0,
handler_type: "agent".to_string(),
attempt: 1,
max_attempts: 1,
},
)
.await;
let parallel_group_id = StageId::new("review_fork", 2);
let parallel_branch_id = ParallelBranchId::new(parallel_group_id.clone(), 4);
let branch_event = workflow_event::Event::ParallelBranchStarted {
parallel_group_id: parallel_group_id.clone(),
parallel_branch_id: parallel_branch_id.clone(),
branch: "review_glm".to_string(),
index: 4,
graph_visit: Some(3),
resumed_from_stage_id: None,
};
let branch_scope = workflow_event::StageScope::for_parallel_branch(
"review_glm",
3,
parallel_group_id,
parallel_branch_id,
);
let stored =
workflow_event::to_run_event_at(&run_id, &branch_event, Utc::now(), Some(&branch_scope));
let payload = workflow_event::build_redacted_event_payload(&stored, &run_id).unwrap();
let run_store = state.stores.runs.open_run(&run_id).await.unwrap();
run_store.append_event(&payload).await.unwrap();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/stages")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
let branch = stage_entry(&body, "review_glm@3");
assert_eq!(branch["parallel_group_id"], "review_fork@2");
assert_eq!(branch["parallel_branch_index"], 4);
let ordinary = stage_entry(&body, "ordinary@1");
assert!(ordinary.get("parallel_group_id").is_none());
assert!(ordinary.get("parallel_branch_index").is_none());
}
/// `checkpoint.completed_nodes` records every visit, so a looped node appears
/// once per re-entry. Billing must dedup so a retried node renders as one row
/// and `runtime_secs` is summed across all visits exactly once.

View file

@ -609,6 +609,9 @@ impl RunProjectionReducer for RunProjection {
stage
.resumed_from_stage_id
.clone_from(&props.resumed_from_stage_id);
stage
.parallel_branch_id
.clone_from(&stored.parallel_branch_id);
}
stage.state = StageState::Running;
}
@ -1548,9 +1551,9 @@ mod tests {
AgentBackend, AgentControlState, AutomationRef, BilledModelUsage, BilledTokenCounts,
BlockedReason, Checkpoint, CheckpointRecord, CommandTermination, EventBody,
FailureCategory, FailureDetail, FailureReason, Graph, McpServerStatus, Outcome,
PendingReason, PermissionLevel, PullRequestLink, QuestionType, ReasoningEffort,
RunApprovalState, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec,
RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
ParallelBranchId, PendingReason, PermissionLevel, PullRequestLink, QuestionType,
ReasoningEffort, RunApprovalState, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize,
RunSpec, RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
StageContextWindowWarning, StageModelUsage, StageOutcome, StageState, SubAgentStatus,
SuccessReason, WorkflowSettings, first_event_seq, fixtures, test_support,
@ -2277,6 +2280,56 @@ mod tests {
assert_eq!(stage.prompt.as_deref(), Some("prompt"));
}
#[test]
fn parallel_branch_started_projects_identity_without_churning_it() {
let mut state = initialized_projection();
let group_id = StageId::new("review_fork", 1);
let branch_stage_id = StageId::new("review_glm", 1);
let branch_id = ParallelBranchId::new(group_id.clone(), 0);
let mut started = test_stage_event(
3,
EventBody::ParallelBranchStarted(ParallelBranchStartedProps {
index: 0,
graph_visit: Some(1),
resumed_from_stage_id: None,
}),
branch_stage_id.clone(),
);
started.event.parallel_group_id = Some(group_id.clone());
started.event.parallel_branch_id = Some(branch_id.clone());
state.apply_event(&started).unwrap();
assert_eq!(
state
.stage(&branch_stage_id)
.unwrap()
.parallel_branch_id
.as_ref(),
Some(&branch_id)
);
let replacement_branch_id = ParallelBranchId::new(group_id.clone(), 1);
let mut reobserved = test_stage_event(
4,
EventBody::ParallelBranchStarted(ParallelBranchStartedProps {
index: 1,
graph_visit: Some(2),
resumed_from_stage_id: Some(StageId::new("review_glm", 2)),
}),
branch_stage_id.clone(),
);
reobserved.event.parallel_group_id = Some(group_id);
reobserved.event.parallel_branch_id = Some(replacement_branch_id);
state.apply_event(&reobserved).unwrap();
let stage = state.stage(&branch_stage_id).unwrap();
assert_eq!(stage.parallel_branch_id.as_ref(), Some(&branch_id));
assert_eq!(stage.graph_visit, Some(1));
assert!(stage.resumed_from_stage_id.is_none());
}
#[test]
fn parallel_branch_completed_finalizes_branch_stage() {
// A parallel branch never runs through the engine's StageStarted/

View file

@ -26,11 +26,11 @@ use fabro_types::{
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
AgentToolsAvailableProps, LlmOutputKind, McpServerProjection, McpServerStatus,
ParallelBranchResult, PermissionLevel, SkillsProjection, StageContextWindow,
ParallelBranchId, ParallelBranchResult, PermissionLevel, SkillsProjection, StageContextWindow,
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason,
StageContextWindowWarning, StageInferenceProjection, StageProjection, SubAgentProjection,
SubAgentStatus, TodoListKind, TodoListProjection,
StageContextWindowWarning, StageId, StageInferenceProjection, StageProjection,
SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection,
};
use serde_json::json;
@ -188,6 +188,7 @@ fn stage_projection_round_trips_representative_json() {
"context_updates": {}
}
],
"parallel_branch_id": "review_fork@3:1",
"output": "ok",
"termination": "exited",
"started_at": "2026-04-29T12:34:00Z",
@ -316,6 +317,10 @@ fn stage_projection_round_trips_representative_json() {
});
let state: StageProjection = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
state.parallel_branch_id,
Some(ParallelBranchId::new(StageId::new("review_fork", 3), 1))
);
assert_eq!(serde_json::to_value(state).unwrap(), value);
}

View file

@ -10,9 +10,10 @@ use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
use crate::{
AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
AgentToolSummary, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord,
InvalidTransition, LlmOutputKind, ModelRef, PermissionLevel, PullRequestLink, RunApproval,
RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, RunTiming, StageCompletion,
StageHandler, StageId, StageState, StageTiming, StartRecord, TodoListProjection,
InvalidTransition, LlmOutputKind, ModelRef, ParallelBranchId, PermissionLevel, PullRequestLink,
RunApproval, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, RunTiming,
StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord,
TodoListProjection,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -328,6 +329,8 @@ pub struct StageProjection {
pub script_invocation: Option<serde_json::Value>,
pub script_timing: Option<serde_json::Value>,
pub parallel_results: Option<Vec<crate::ParallelBranchResult>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel_branch_id: Option<ParallelBranchId>,
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_bytes: Option<u64>,
@ -522,6 +525,7 @@ impl StageProjection {
script_invocation: None,
script_timing: None,
parallel_results: None,
parallel_branch_id: None,
output: None,
output_bytes: None,
live_streaming: None,

View file

@ -57,6 +57,14 @@ export interface RunStage {
* Canonical stage execution identifier in `node_id@visit` form.
*/
'resumed_from_stage_id'?: string | null;
/**
* Canonical stage execution identifier in `node_id@visit` form.
*/
'parallel_group_id'?: string | null;
/**
* Zero-based outgoing-edge index within the parent parallel execution. Null for stages that are not parallel branches.
*/
'parallel_branch_index'?: number | null;
'provider_used'?: StageModelUsage | null;
/**
* Wall-clock time the latest attempt of this stage started, if known.

View file

@ -87,6 +87,10 @@ export interface StageProjection {
* Ordered per-branch results produced by a parallel stage.
*/
'parallel_results'?: Array<ParallelBranchResult> | null;
/**
* Durable identity of this branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\".
*/
'parallel_branch_id'?: string | null;
'output'?: string | null;
'output_bytes'?: number | null;
'live_streaming'?: boolean | null;