Fix live parallel branch refresh and simplify branch rendering

Branches bypass the engine's stage.started/stage.completed lifecycle, so
no SWR key invalidated the stages list while a fork ran. The new live
branch rows stayed frozen at their first observed state until an
incidental refetch. Map parallel.* events to the stages list, run events,
and graph keys.

Also:
- Label branch rows with formatStageLabel so a re-entered branch renders
  as `review_glm@2`, matching the sidebar and waterfall.
- Build branch rows in one pass and count live outcomes in one loop.
- Name ParallelBranchId in the OpenAPI spec and reuse fabro_types::
  ParallelBranchId, replacing two copies of an inline string format.
- Hoist makeStage and textContent into lib/test-utils so widening Stage
  cannot leave per-file fixtures stale (tests are excluded from
  typecheck, so the two component-test copies had already gone stale).
- Query stat tiles by data-stat instead of an exact Tailwind class.
- Reuse append_scoped_stage_event's body via append_event_with_scope and
  add test_branch_event instead of poking envelope fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-28 17:26:05 -04:00
parent c812274db8
commit 690ddd2a96
No known key found for this signature in database
16 changed files with 223 additions and 119 deletions

View file

@ -9,6 +9,7 @@ import { StagePopover } from "./stage-popover";
import { deriveStageSummary } from "./stage-popover-summary";
import type { Stage } from "../lib/stage-sidebar";
import { generatedAxios } from "../lib/api-client";
import { makeStage as baseMakeStage } from "../lib/test-utils";
function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
return {
@ -22,20 +23,13 @@ function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
}
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
return baseMakeStage({
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
...overrides,
};
});
}
describe("deriveStageSummary", () => {

View file

@ -4,7 +4,12 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import {
makeEventEnvelope,
makeStage as baseMakeStage,
setupReactTestEnv,
textContent,
} from "../../lib/test-utils";
import type { Stage } from "../stage-sidebar";
import { ParallelChildren } from "./parallel-children";
@ -15,22 +20,14 @@ beforeEach(() => {
afterEach(() => teardown());
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
return baseMakeStage({
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({
@ -113,12 +110,6 @@ function renderParallel(
return renderer;
}
function textContent(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => typeof child === "string" ? child : textContent(child))
.join("");
}
function branchRowText(renderer: TestRenderer.ReactTestRenderer): string[] {
return renderer.root.findAllByType("li").map(textContent);
}
@ -128,11 +119,7 @@ function hrefs(renderer: TestRenderer.ReactTestRenderer): string[] {
}
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]);
return textContent(renderer.root.findByProps({ "data-stat": label }));
}
describe("ParallelChildren", () => {
@ -171,6 +158,17 @@ describe("ParallelChildren", () => {
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]);
});
test("labels a re-entered branch with its visit, matching the sidebar", () => {
const renderer = renderParallel(
[startedEvent(1)],
[branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2)],
makeStage({ id: "fork@2", name: "fork", handler: "parallel", visit: 2 }),
);
expect(branchRowText(renderer)).toEqual(["Runningreview_glm@2"]);
expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@2"]);
});
test("keeps duplicate branch targets in index order and only links recorded stages", () => {
const renderer = renderParallel(
[

View file

@ -5,7 +5,7 @@ import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { formatDurationMs } from "../../lib/format";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
@ -34,7 +34,9 @@ function StatItem({
<span className="text-[10px] font-medium uppercase tracking-[0.16em] text-fg-muted">
{label}
</span>
<span className={`font-mono text-xl tabular-nums ${toneClass}`}>{value}</span>
<span data-stat={label} className={`font-mono text-xl tabular-nums ${toneClass}`}>
{value}
</span>
</div>
);
}
@ -108,20 +110,19 @@ export function ParallelChildren({
}, [allStages, stage.id]);
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 }) => {
const rows = Array.from({ length: branchCount }, (_, index): BranchRow => {
// A live branch stage is the freshest source; fall back to the completed
// event's result for runs whose branches predate parallel identity.
const branchStage = stagesByBranchIndex.get(index);
if (branchStage) {
return {
branchIndex: index,
id: branchStage.name,
id: formatStageLabel(branchStage),
status: branchStage.status,
stageHref: `/runs/${runId}/stages/${branchStage.id}`,
};
}
const result = overview.results[index];
if (result) {
return {
branchIndex: index,
@ -138,13 +139,12 @@ export function ParallelChildren({
};
});
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;
let liveSuccessCount = 0;
let liveFailureCount = 0;
for (const branchStage of stagesByBranchIndex.values()) {
if (branchStage.status === StageState.SUCCEEDED) liveSuccessCount += 1;
else if (branchStage.status === StageState.FAILED) liveFailureCount += 1;
}
const successCount = overview.isComplete
? overview.successCount ?? 0
: liveSuccessCount;

View file

@ -2,25 +2,9 @@ import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeStage } from "../lib/test-utils";
import { StageSidebar, type Stage } from "./stage-sidebar";
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
...overrides,
};
}
function renderSidebar(stages: Stage[]): string {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer!: TestRenderer.ReactTestRenderer;

View file

@ -92,6 +92,36 @@ describe("queryKeysForRunEvent", () => {
]);
});
test("parallel branch lifecycle invalidates the stages list backing live branch rows", () => {
// Branches bypass stage.started/stage.completed, so these events are the
// only signal that a branch row's status changed.
expect(queryKeysForRunEvent("run-1", "parallel.branch.started", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
expect(queryKeysForRunEvent("run-1", "parallel.branch.completed", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
});
test("fork lifecycle invalidates run-scoped resources without a stage id", () => {
for (const event of ["parallel.started", "parallel.completed"]) {
expect(queryKeysForRunEvent("run-1", event)).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
]);
}
});
test("cancel requests invalidate the durable run summary", () => {
expect(queryKeysForRunEvent("run-1", "run.cancel.requested")).toEqual([
queryKeys.runs.detail("run-1"),

View file

@ -88,6 +88,17 @@ export const STAGE_ACTIVITY_EVENT_TYPES = [
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
// Parallel branches bypass the engine's `stage.started` / `stage.completed`
// lifecycle (the parallel handler dispatches each branch directly), so
// `STAGE_EVENTS` never fires for them. Without this set the stages list never
// refetches while a fork runs and branch rows stay frozen at their first
// observed state.
const PARALLEL_EVENTS = new Set([
"parallel.started",
"parallel.branch.started",
"parallel.branch.completed",
"parallel.completed",
]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
@ -179,6 +190,19 @@ export function queryKeysForRunEvent(
return keys;
}
if (PARALLEL_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (STEERING_EVENTS.has(event)) {
const keys: Key[] = [queryKeys.runs.events(runId, 1000)];
if (AGENT_CONTROL_STATE_EVENTS.has(event)) {

View file

@ -3,23 +3,10 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa
import type { Stage } from "../components/stage-sidebar";
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
import { makeStage as baseMakeStage } from "./test-utils";
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
return {
id: `${nodeId}@${visit}`,
name: nodeId,
handler: "agent",
nodeId,
visit,
graphVisit: null,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
status,
duration: "--",
startedAt: null,
providerUsed: null,
};
return baseMakeStage({ id: `${nodeId}@${visit}`, name: nodeId, nodeId, visit, status });
}
describe("mapRunStagesToSidebarStages", () => {

View file

@ -2,6 +2,8 @@ import { createElement, type ReactNode } from "react";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const;
/**
@ -55,6 +57,37 @@ export function makeEventEnvelope(
} as EventEnvelope;
}
/** Flatten a rendered subtree to its visible text. */
export function textContent(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}
/**
* Build a sidebar `Stage` fixture; override any field via `overrides`. Kept
* here so widening `Stage` updates every fixture at once test files are
* excluded from typecheck, so a per-file copy silently goes stale instead.
*/
export function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
...overrides,
};
}
export function renderHook<T>(
hook: () => T,
options: { wrapper: React.ComponentType<{ children: ReactNode }> },

View file

@ -9951,10 +9951,9 @@ components:
Durable identity of one execution of a parallel node, formatted as
"{node_id}@{visit}".
parallel_branch_id:
type: ["string", "null"]
description: >
Durable identity of one branch within a parallel execution,
formatted as "{parallel_group_id}:{index}".
oneOf:
- $ref: "#/components/schemas/ParallelBranchId"
- type: "null"
session_id:
type: ["string", "null"]
parent_session_id:
@ -10653,10 +10652,9 @@ components:
$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}".
oneOf:
- $ref: "#/components/schemas/ParallelBranchId"
- type: "null"
output:
type: ["string", "null"]
output_bytes:
@ -12575,6 +12573,13 @@ components:
type: string
example: verify@2
ParallelBranchId:
description: >-
Durable identity of one branch within a parallel execution, in
`{parallel_group_id}:{index}` form.
type: string
example: review_fork@3:1
StageState:
description: Lifecycle projection state of a workflow stage.
type: string

View file

@ -4905,7 +4905,16 @@ async fn append_scoped_stage_event(
parallel_group_id: None,
parallel_branch_id: None,
};
let stored = fabro_workflow::event::to_run_event_at(&run_id, event, Utc::now(), Some(&scope));
append_event_with_scope(state, run_id, event, &scope).await;
}
async fn append_event_with_scope(
state: &Arc<AppState>,
run_id: RunId,
event: &workflow_event::Event,
scope: &fabro_workflow::event::StageScope,
) {
let stored = fabro_workflow::event::to_run_event_at(&run_id, event, Utc::now(), Some(scope));
let payload = fabro_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();
@ -5551,11 +5560,7 @@ async fn list_run_stages_exposes_parallel_branch_identity() {
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();
append_event_with_scope(&state, run_id, &branch_event, &branch_scope).await;
let response = app
.oneshot(

View file

@ -1589,6 +1589,18 @@ mod tests {
event
}
fn test_branch_event(
seq: u32,
body: EventBody,
stage_id: StageId,
branch_id: ParallelBranchId,
) -> EventEnvelope {
let mut event = test_stage_event(seq, body, stage_id);
event.event.parallel_group_id = Some(branch_id.group().clone());
event.event.parallel_branch_id = Some(branch_id);
event
}
fn test_stage_event_at(
seq: u32,
ts: &str,
@ -2286,7 +2298,7 @@ mod tests {
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(
let started = test_branch_event(
3,
EventBody::ParallelBranchStarted(ParallelBranchStartedProps {
index: 0,
@ -2294,9 +2306,8 @@ mod tests {
resumed_from_stage_id: None,
}),
branch_stage_id.clone(),
branch_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();
@ -2309,8 +2320,7 @@ mod tests {
Some(&branch_id)
);
let replacement_branch_id = ParallelBranchId::new(group_id.clone(), 1);
let mut reobserved = test_stage_event(
let reobserved = test_branch_event(
4,
EventBody::ParallelBranchStarted(ParallelBranchStartedProps {
index: 1,
@ -2318,9 +2328,8 @@ mod tests {
resumed_from_stage_id: Some(StageId::new("review_glm", 2)),
}),
branch_stage_id.clone(),
ParallelBranchId::new(group_id, 1),
);
reobserved.event.parallel_group_id = Some(group_id);
reobserved.event.parallel_branch_id = Some(replacement_branch_id);
state.apply_event(&reobserved).unwrap();

View file

@ -345,6 +345,7 @@ fn main() {
("Conclusion", "fabro_types::Conclusion", &[]),
("StageOutcome", "fabro_types::StageOutcome", &[]),
("StageId", "fabro_types::StageId", &[]),
("ParallelBranchId", "fabro_types::ParallelBranchId", &[]),
("StageHandler", "fabro_types::StageHandler", &[]),
("StageState", "fabro_types::StageState", &[]),
("AgentControlState", "fabro_types::AgentControlState", &[]),

View file

@ -52,19 +52,19 @@ pub mod types {
McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer,
McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,
PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,
PairTranscriptResponse, ParallelBranchResult, PendingInterviewRecord, PermissionLevel,
PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,
PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
QuestionType, ReasoningOutput, RepositoryRef, Role, Run, RunApproval, RunApprovalState,
RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse,
RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource,
RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan,
RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxInfo,
SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,
SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord,
PermissionLevel, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails,
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
PullRequestMeta, PullRequestResponse, QuestionType, ReasoningOutput, RepositoryRef, Role,
Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent,
RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunPairStatusResponse,
RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure,
RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance,
RunSize, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork,
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind,
SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse,
SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail,
SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
StageContextWindowStaleness, StageContextWindowUnavailableReason,

View file

@ -0,0 +1,34 @@
use std::any::{TypeId, type_name};
use fabro_api::types::ParallelBranchId as ApiParallelBranchId;
use fabro_types::{ParallelBranchId, StageId};
use serde_json::json;
#[test]
fn parallel_branch_id_reuses_canonical_type() {
assert_same_type::<ApiParallelBranchId, ParallelBranchId>();
}
#[test]
fn parallel_branch_id_round_trips_openapi_representation() {
let branch_id = ParallelBranchId::new(StageId::new("review_fork", 3), 1);
assert_eq!(
serde_json::to_value(&branch_id).unwrap(),
json!("review_fork@3:1")
);
assert_eq!(
serde_json::from_value::<ApiParallelBranchId>(json!("review_fork@3:1")).unwrap(),
branch_id
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -37,7 +37,7 @@ export interface RunEvent {
*/
'parallel_group_id'?: string | null;
/**
* Durable identity of one branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\".
* Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form.
*/
'parallel_branch_id'?: string | null;
'session_id'?: string | null;

View file

@ -88,7 +88,7 @@ export interface StageProjection {
*/
'parallel_results'?: Array<ParallelBranchResult> | null;
/**
* Durable identity of this branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\".
* Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form.
*/
'parallel_branch_id'?: string | null;
'output'?: string | null;