Skip to content

Show live status for parallel branches - #660

Merged
brynary merged 5 commits into
mainfrom
feat/parallel-branch-live-status
Jul 28, 2026
Merged

Show live status for parallel branches#660
brynary merged 5 commits into
mainfrom
feat/parallel-branch-live-status

Conversation

@brynary

@brynary brynary commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

  • persist each parallel branch's parent stage ID and zero-based branch index in the run projection
  • expose the branch identity through the OpenAPI contract, server response, and generated TypeScript client
  • render live branch names, statuses, links, and counts in the parallel-stage view, with index-aligned fallbacks for branches without a stage projection
  • cover loops, duplicate targets, missing stages, completed fallbacks, partial runs, and skipped branches

Screenshot

Live parallel-stage status while one branch is still running.

Parallel stage showing live branch statuses

Testing

  • cargo nextest run --workspace (7,402 passed; 200 skipped)
  • cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
  • cargo +nightly-2026-04-14 fmt --check --all
  • cd apps/fabro-web && bun run test (747 passed)
  • cd apps/fabro-web && bun run typecheck
  • cd lib/packages/fabro-api-client && bun run typecheck
  • cargo build -p fabro-api
  • git diff --check

Copilot AI review requested due to automatic review settings July 27, 2026 21:08

Copilot AI left a comment

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.

Pull request overview

This PR adds durable identity for parallel branches across the Rust run projection, OpenAPI contract, generated clients, and the web UI so the parallel-stage view can render accurate live branch rows (names/status/links) and stable per-branch stats even when branches are missing projections or have legacy/completed fallbacks.

Changes:

  • Persist parallel_branch_id in StageProjection and derive parallel_group_id + parallel_branch_index for RunStage responses.
  • Extend the OpenAPI schema + generated TypeScript client models to expose the new parallel branch identity fields.
  • Update the web parallel-stage renderer and tests to show live per-branch rows/links and compute status counts from live branch stages when the parallel stage isn’t complete.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/packages/fabro-api-client/src/models/stage-projection.ts Adds parallel_branch_id to generated StageProjection client model.
lib/packages/fabro-api-client/src/models/run-stage.ts Adds parallel_group_id and parallel_branch_index to RunStage client model.
lib/foundation/fabro-types/src/run_projection.rs Persists parallel_branch_id in the Rust run projection stage model.
lib/foundation/fabro-api/tests/stage_projection_round_trip.rs Validates JSON round-trip for parallel_branch_id via ParallelBranchId.
lib/components/fabro-store/src/run_state.rs Projects/stabilizes branch identity into stage projection during parallel branch start events.
lib/apps/fabro-server/src/server/tests.rs Adds server test ensuring /runs/{id}/stages exposes parallel branch identity fields.
lib/apps/fabro-server/src/server/handler/billing.rs Populates parallel_group_id/parallel_branch_index in RunStage from parallel_branch_id.
lib/apps/fabro-server/src/demo/mod.rs Updates demo RunStage construction to include the new parallel fields.
docs/public/api-reference/fabro-api.yaml Extends OpenAPI schemas for parallel_branch_id, parallel_group_id, parallel_branch_index.
apps/fabro-web/app/lib/stage-sidebar.ts Extends sidebar stage model + mapping to carry parallel identity fields.
apps/fabro-web/app/lib/stage-sidebar.test.ts Adds unit coverage for sidebar mapping of parallel identity fields.
apps/fabro-web/app/components/stage-renderers/parallel-children.tsx Renders parallel branches using parallelGroupId + parallelBranchIndex and live status counts/links.
apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx Expands UI tests for loops, duplicates, missing stages, completed fallbacks, and skipped/partial handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/packages/fabro-api-client/src/models/stage-projection.ts
Comment thread lib/packages/fabro-api-client/src/models/run-stage.ts
Comment thread apps/fabro-web/app/components/stage-renderers/parallel-children.tsx Outdated
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>
Copilot AI review requested due to automatic review settings July 28, 2026 21:26
@brynary

brynary commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Ran a review pass over this branch (reuse / quality / efficiency) and pushed fixes in 690ddd2.

One real bug: parallel branches bypass the engine's stage.started / stage.completed lifecycle, so nothing invalidated the stages-list SWR key while a fork was running. The live branch rows this PR adds stayed frozen at their first observed state until an incidental refetch (window refocus, cross-tab resync, or run completion). Added a PARALLEL_EVENTS mapping for parallel.* plus unit coverage.

Also fixed:

  • Branch rows used the raw node name, so a re-entered branch showed review_glm here but review_glm@2 in the sidebar and waterfall. Now uses formatStageLabel.
  • Named ParallelBranchId in the OpenAPI spec and reused fabro_types::ParallelBranchId via with_replacement, replacing two copies of an inline "{group}:{index}" string format. Added the type-identity/JSON-parity test.
  • Hoisted makeStage and textContent into lib/test-utils. Widening Stage had already left the stage-sidebar and stage-popover fixtures stale — test files are excluded from typecheck, so nothing caught it.
  • Stat tiles are queried by data-stat instead of an exact Tailwind class string.
  • Branch rows build in one pass; live outcome counts use one loop.
  • Server test reuses append_scoped_stage_event's body via append_event_with_scope; run_state test uses a test_branch_event builder instead of poking envelope fields.

Left alone deliberately: the parallel_group_id + parallel_branch_index split on RunStage is right — no web client parses composite ids, and mapRunStagesToSidebarStages stays a pure field mapper.

Verified: cargo nextest run --workspace, nightly fmt + clippy clean, bun test and bun run typecheck clean. Two pre-existing failures are unrelated and reproduce on a clean tree — the openapi_conformance::all_spec_routes_are_routable timeout under full parallel load, and 13 order-dependent web test failures.

@brynary
brynary marked this pull request as ready for review July 28, 2026 21:26
Branch indexes are sparse. A branch queued behind max_parallel reserves
no stage identity until it acquires the semaphore, tokio task order is
not index order, and a branch cancelled while queued never reserves one
at all. Sizing the row list by `stagesByBranchIndex.size` treated an
entry count as a dense index range, so a running branch at index 2 with
nothing at 0 or 1 rendered as a single "pending" placeholder and the
running branch disappeared. Size from the highest index observed.

Also:
- Derive the Succeeded/Failed tiles from the rendered rows instead of the
  completed-event rollup, so the tiles cannot contradict the list. This
  drops the isComplete fork and both live counters.
- Show the fallback branch count in the Branches tile, which previously
  read "-" above N rows in exactly the case the fallback exists for.
- BranchRow carries `label` and `stageId`; ChildRow owns the route it
  links to. `id` had become a display label on one path and a raw node id
  on the other, and a view model should not hold a URL.
- Drop `branchIndex`, which only ever served as the React key and always
  equalled the array index.
- Cover the sparse-index and pending-placeholder paths, and derive the
  rollup counts in `completedEvent` instead of passing contradictory ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brynary

brynary commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Follow-up in 6ad8ba2 — the quality review came back after my first pass and found a second real bug.

Late-starting branches could disappear. Branch indexes are sparse: run_branches reserves a stage identity only after the branch acquires the max_parallel semaphore, tokio task order is not index order, and a branch cancelled while queued never reserves one at all. Sizing the row list by stagesByBranchIndex.size treated an entry count as a dense index range — a running branch at index 2 with nothing at 0 or 1 rendered as one "pending" placeholder, with the running branch not shown. Now sized from the highest index observed. Covered by a test.

Also:

  • Succeeded/Failed tiles derive from the rendered rows instead of the completed-event rollup, so the tiles cannot contradict the list. Drops the isComplete fork and both live counters.
  • The Branches tile used overview.branchCount ?? "—" while rows used the fallback, so it read above N rows in exactly the case the fallback exists for.
  • BranchRow now carries label + stageId, and ChildRow owns the route it links to. After the formatStageLabel change, id held a display label on one path and a raw node id on the other; a view model also should not hold a URL.
  • Dropped branchIndex (only ever the React key, always equal to the array index).
  • Added coverage for the sparse-index and pending-placeholder paths; completedEvent derives its rollup counts instead of taking contradictory ones.

Not taken, deliberately: collapsing parallel_group_id + parallel_branch_index into a nested object on RunStage. The two reviews disagreed here; I went with keeping the split, since no web client parses composite ids and mapRunStagesToSidebarStages stays a pure field mapper. Also left alone: list_run_stages living in billing.rs (pre-existing misfiling, worth its own change), and the positional-index contract between parseParallelOverview and its consumer — the engine guarantees alignment at emit time, so it is latent rather than live, but the parser tolerates holes the consumer does not.

Verified: full workspace tests, nightly fmt + clippy, bun test and typecheck. Same two pre-existing failures noted above, unchanged on a clean tree.

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

apps/fabro-web/app/components/stage-renderers/parallel-children.tsx:161

  • The "Branches" stat shows "—" whenever overview.branchCount is null, even though this component already infers a branchCount from stagesByBranchIndex.size (and uses that to render branch rows). This can lead to the stat disagreeing with the rendered rows when events haven’t loaded yet or for older runs that don’t include parallel.started.
        <StatItem
          label="Failed"
          value={failureCount}

lib/packages/fabro-api-client/src/models/run-stage.ts:63

  • The doc comment for parallel_group_id duplicates the resumed_from_stage_id description ("Canonical stage execution identifier..."). parallel_group_id is the parent parallel stage’s StageId, so this comment is misleading for client users.
    /**
     * Canonical stage execution identifier in `node_id@visit` form.
     */
    'parallel_group_id'?: string | null;

Copilot AI review requested due to automatic review settings July 28, 2026 21:31

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

apps/fabro-web/app/lib/test-utils.tsx:65

  • textContent() assumes every non-string child is a ReactTestInstance, but react-test-renderer children can also be numbers, null, or booleans. As written, textContent(123 as any) will recurse and throw, making the helper brittle for components that render numeric text nodes (there are other test helpers in the repo that explicitly handle numbers).
/** 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("");
}

Conflicts were between this branch's parallel-branch identity work and
main's stage billing, review targets, and live stage timing.

- Stage fixtures: main added `billing` to each per-file `makeStage`; this
  branch had hoisted one builder into `lib/test-utils`. Kept the hoisted
  builder and gave it `billing: makeBilledTokenCounts()`, so both intents
  hold and the field list stays in one place. `stage-sidebar.test.ts` also
  builds raw `RunStage` wire payloads, so it keeps importing
  `makeBilledTokenCounts` directly.
- Import lists (`run_projection.rs`, `fabro-api/src/lib.rs`,
  `run_state.rs`, `stage_projection_round_trip.rs`): unioned both sides —
  `ParallelBranchId` alongside `timing`, `ReviewTarget`,
  `ReviewTargetKind`, `AttrValue`, `Node`, and
  `StageToolBatchProjection`.
- `fabro-server` tests: git interleaved two unrelated new tests into one
  body. Split them back into
  `list_run_stages_exposes_parallel_branch_identity` and
  `run_billing_includes_live_stage_timing_in_rows_and_totals`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 21:50

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

docs/public/api-reference/fabro-api.yaml:10663

  • StageProjection.parallel_branch_id is introduced as a oneOf ref but has no property-level description, unlike other oneOf fields in this schema (e.g. provider_used). Adding it improves the generated docs and client model comments.
        parallel_branch_id:
          oneOf:
            - $ref: "#/components/schemas/ParallelBranchId"
            - type: "null"

lib/packages/fabro-api-client/src/models/run-stage.ts:66

  • The JSDoc for parallel_group_id is copy/pasted from StageId and doesn’t explain that this is specifically the parent parallel stage’s StageId. This is the only client-side documentation for many consumers, so it should match the OpenAPI description.
    /**
     * Canonical stage execution identifier in `node_id@visit` form.
     */
    'parallel_group_id'?: string | null;

docs/public/api-reference/fabro-api.yaml:9962

  • RunEvent.parallel_branch_id switched to a oneOf ref, but the property-level description was dropped. This makes the event payload docs less self-describing (and some generators ignore the referenced schema description for property docs). Add an explicit description here, consistent with nearby stage_id / parallel_group_id.

This issue also appears on line 10660 of the same file.

        parallel_branch_id:
          oneOf:
            - $ref: "#/components/schemas/ParallelBranchId"
            - type: "null"

`parallel_group_id` used `oneOf: [$ref StageId, null]`, and the generator
drops a sibling description in that position, so the TypeScript client
documented the field as "Canonical stage execution identifier in
`node_id@visit` form" — the shared StageId text, which says nothing about
what this field means. Switching to `allOf` lets the field's own
description through.

Dropping `type: "null"` also makes the contract match the server, which
omits both fields rather than sending null (`skip_serializing_if` on
`Option`, pinned by list_run_stages_exposes_parallel_branch_identity).
The Rust types are unchanged — still `Option<StageId>` and `Option<u32>`,
which accept an explicit null on input either way — so this only narrows
what clients are told to expect on the wire. Wording updated to match,
and reworded to avoid an apostrophe the generator escapes into the
JSDoc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 22:05

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

apps/fabro-web/app/lib/run-events.ts:222

  • PARALLEL_EVENTS invalidation doesn’t include queryKeys.runs.billing(runId). Parallel branches bypass the normal STAGE_EVENTS lifecycle, so on the billing page (useRunBilling) the stage list/totals can stay stale while branches start/complete until some unrelated event triggers a billing invalidation (or the run ends). Consider invalidating billing for these parallel lifecycle events as well (and updating the corresponding run-events tests).
    const keys: Key[] = [
      queryKeys.runs.stages(runId),
      queryKeys.runs.events(runId, 1000),
      queryKeys.runs.graph(runId, "LR"),
      queryKeys.runs.graph(runId, "TB"),

@brynary
brynary merged commit 6c516ec into main Jul 28, 2026
17 checks passed
@brynary
brynary deleted the feat/parallel-branch-live-status branch July 28, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants