From 4032a1223c029ede9be90a3043b39e511a34361d Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Tue, 7 Jul 2026 23:00:42 +0200
Subject: [PATCH 01/14] FE-1141: Add readable run evidence and graph
traceability
---
...ecutor-replanning--web-inspect-metadata.md | 3 +-
...tor-run-observer--readable-run-evidence.md | 184 ++++++++++++++++++
src/executor/observer-read.ts | 102 ++++++++++
src/rpc/TOPOLOGY.md | 12 ++
src/rpc/__tests__/handlers.test.ts | 1 +
src/rpc/methods/__tests__/execute.test.ts | 91 +++++++++
src/rpc/methods/execute.ts | 43 +++-
src/web/TOPOLOGY.md | 10 +-
src/web/__tests__/app.test.tsx | 61 ++++++
.../__tests__/brunch-updates-execute.test.ts | 6 +-
src/web/__tests__/runs-route.test.tsx | 118 +++++++++++
.../features/graph/structured-list-view.tsx | 49 ++++-
src/web/queries/execute.ts | 10 +-
src/web/query-keys.ts | 1 +
src/web/routes/runs.tsx | 182 ++++++++++++++---
src/web/routes/spec.tsx | 11 +-
src/web/subscriptions/brunch-updates.ts | 4 +
17 files changed, 853 insertions(+), 35 deletions(-)
create mode 100644 memory/cards/executor-run-observer--readable-run-evidence.md
diff --git a/memory/cards/executor-replanning--web-inspect-metadata.md b/memory/cards/executor-replanning--web-inspect-metadata.md
index 7778c151d..48fcab51a 100644
--- a/memory/cards/executor-replanning--web-inspect-metadata.md
+++ b/memory/cards/executor-replanning--web-inspect-metadata.md
@@ -2,7 +2,7 @@
Frontier: executor-replanning
Linear: FE-1114
-Status: active
+Status: superseded
Mode: single
Created: 2026-07-07
@@ -11,6 +11,7 @@ Created: 2026-07-07
- Seam: web run observer presentation.
- Builds on: `executor-replanning--inspect-metadata.md`.
- Posture: proving.
+- Superseded by: `memory/cards/executor-run-observer--readable-run-evidence.md`, which absorbs this narrow metadata display into the broader readable run evidence + graph traceability scope to avoid overlapping `/runs` UI work.
## Target Behavior
diff --git a/memory/cards/executor-run-observer--readable-run-evidence.md b/memory/cards/executor-run-observer--readable-run-evidence.md
new file mode 100644
index 000000000..021f7d6f0
--- /dev/null
+++ b/memory/cards/executor-run-observer--readable-run-evidence.md
@@ -0,0 +1,184 @@
+# Executor Run Observer Readable Evidence
+
+Frontier: executor-run-observer
+Status: done
+Mode: slices
+Created: 2026-07-07
+
+## Orientation
+
+- Containing seam: executor run observation over `execute.run` projections and the web `/runs/$runId` route.
+- Relevant frontier item: `executor-run-observer` (FE-1141). The original run observer is code-complete, but live walkthrough evidence showed the log/evidence surface is too raw to diagnose a failed run.
+- Volatile handoff state: `executor-replanning--web-inspect-metadata.md` overlaps this UI surface and is superseded by this broader card; do not build both separately.
+- Main open risk: the current projection may not expose enough graph handles for node-level backlinks without widening `execute.run` or adding a small run-index projection.
+
+Posture: proving (inherited from executor-run-observer)
+
+## Sequence Discipline
+
+This file is a short sequence because both slices stay inside the run-observer frontier, are independently testable, and do not depend on implementation discoveries from each other. Stop after slice 1 if the stream normalization needs a new artifact schema rather than a web/projection presentation layer.
+
+## Cross-cutting obligations
+
+- Preserve web observer read-only authority: no run control, retry, promotion acceptance, or graph mutation from these UI affordances.
+- Preserve the `execute.*` projection firewall: the browser renders product-shaped projections, not `.brunch/cook/runs/**` file paths or raw artifact schemas as navigation contracts.
+- Preserve executor core purity: any new shaping belongs in `observer-read.ts` / RPC / web, not in lifecycle side-effect helpers unless the artifact writer already owns that fact.
+- Keep raw evidence available behind disclosure controls; readability must not hide failures or invent progress.
+
+---
+
+## Slice 1 — Readable Run Evidence Panels
+
+Status: done
+
+### Target Behavior
+
+`/runs/$runId` presents worker, verify, and lifecycle evidence as compact diagnostic panels with deduplicated readable logs and failure-first verify output.
+
+### Full-card cold-start reads
+
+- `memory/SPEC.md` — D23-L, D84-L, D112-L, I58-L; §Verification Policy; §Design Notes projection-handler discipline.
+- `memory/PLAN.md` — frontier: `executor-run-observer`; note the `web-driver-streaming` boundary for live streaming vs read projection.
+- `src/executor/TOPOLOGY.md` — `observer-read.ts`, stream artifacts, run lifecycle helpers, executor purity boundary.
+- `src/rpc/TOPOLOGY.md` — public RPC read projection boundary and `execute.run` surface.
+- `src/web/TOPOLOGY.md` — web read-only/query ownership and `/runs/$runId` route ownership.
+
+### Boundary Crossings
+
+```text
+.brunch/cook/runs/** reports + stream artifacts
+→ src/executor/observer-read.ts projection
+→ rpc execute.run schema
+→ web query cache
+→ /runs/$runId evidence panels
+```
+
+### Risks and Assumptions
+
+- RISK: collapsing worker stream deltas could hide important partial output. → MITIGATION: collapse only consecutive exact duplicates and obvious prefix-growth supersession, and keep raw expanded rows available.
+- RISK: stripping ANSI could remove useful pass/fail signal. → MITIGATION: preserve semantic channel (`stdout` / `stderr` / `status`) and add failure-first summary from stderr/final verify status.
+- ASSUMPTION: presentation-layer normalization is sufficient; stream artifact format does not need to change.
+ → IMPACT IF FALSE: this slice becomes a projection-shape change and should stop before widening artifact writers.
+ → VALIDATE: route tests use existing `agentStreamTail` / `verifyStreamTail` shapes and prove the UI output is readable.
+
+### Posture check
+
+This is a proving tracer because it turns the built run-observer substrate into usable evidence: a failed executor run should explain itself without requiring a human to inspect raw JSONL or ANSI dumps.
+
+### Acceptance Criteria
+
+✓ Web route test — consecutive duplicate worker messages render once with a repeat count and raw rows remain expandable.
+✓ Web route test — incremental worker message fragments render as the final useful message instead of dozens of prefixes.
+✓ Web route test — verify stdout/stderr ANSI escape codes are not displayed literally.
+✓ Web route test — stderr failure excerpts render before noisy passing stdout lines.
+✓ Web route test — lifecycle reports are grouped by slice with `started → agent → verify → completed` style progression while raw events remain accessible.
+
+### Verification Approach
+
+- Inner: focused `src/web/__tests__/runs-route.test.tsx` cases over fixture-shaped run detail objects.
+- Middle: `src/rpc/methods/__tests__/execute.test.ts` only if projection shaping moves out of the web component.
+- Gate: `npm run fix`; targeted route/RPC tests; `npm run verify` before commit unless unrelated environment failures block.
+
+### Build Note
+
+Implemented 2026-07-07 in `src/web/routes/runs.tsx` with presentation-layer normalization only: duplicate/prefix stream compaction, ANSI stripping, verify failure summary, raw stream disclosure, and grouped lifecycle progression. Verification: `npm run test -- src/web/__tests__/runs-route.test.tsx`; `npm run fix`.
+
+### Expected touched paths (tentative)
+
+```text
+src/web/
+├── routes/runs.tsx ~ evidence panel rendering, grouping, raw details
+├── __tests__/runs-route.test.tsx ~ readability and failure-first assertions
+└── TOPOLOGY.md ~ update route ownership note if panel semantics move
+src/executor/
+├── observer-read.ts ? only if stream tails need derived display fields
+└── __tests__/observer-read.test.ts ? only if projection shaping moves here
+src/rpc/
+├── methods/execute.ts ? only if RPC schema adds shaped evidence fields
+└── methods/__tests__/execute.test.ts ? matching schema/projection tests
+```
+
+---
+
+## Slice 2 — Graph Run Traceability Links
+
+Status: done
+
+### Target Behavior
+
+The graph/spec view and run-detail view cross-link executable graph nodes to the runs, slices, requirements, and criteria that exercised them.
+
+### Full-card cold-start reads
+
+- `memory/SPEC.md` — D56-L, D72-L, D84-L, D98-L, D100-L, D103-L, D112-L; I35-L, I58-L.
+- `memory/PLAN.md` — frontier: `executor-run-observer`; context from `executor-replanning` only for supersession/abandonment metadata, not mutation actions.
+- `src/graph/TOPOLOGY.md` — graph read projection boundaries and node-code semantics.
+- `src/executor/TOPOLOGY.md` — run observer projection and plan `derived_from` mapping.
+- `src/rpc/TOPOLOGY.md` — named product projection rule; avoid a generic run records API.
+- `src/web/TOPOLOGY.md` — graph view and run route ownership.
+
+### Boundary Crossings
+
+```text
+run plan derived_from / criterion verifies / reports verdicts
+→ execute.run / execute.runs product projection
+→ web run detail requirements panel
+→ web graph structured list node cards
+→ route links between /spec/$specId and /runs/$runId
+```
+
+### Risks and Assumptions
+
+- RISK: loading every run in the graph view could be slow or noisy. → MITIGATION: start with a bounded read projection/index from existing run summaries/details; add a dedicated `execute.runTraceIndex` only if tests show graph badges require it.
+- RISK: graph node references may drift if only `REQ1` / `AC5` strings are carried. → MITIGATION: prefer explicit node handles in the projection when available; otherwise keep the first slice to stable node-code refs and do not fabricate node ids.
+- ASSUMPTION: plan `spec.requirements[].item_id`, `spec.criteria[].verifies`, and slice `derived_from` are sufficient for initial requirement/criterion traceability.
+ → IMPACT IF FALSE: graph badges may need provenance added at plan projection time, not just observer reads.
+ → VALIDATE: fixture-shaped run projection maps `REQ*` and `AC*` nodes to slice/run links in web tests.
+
+### Posture check
+
+This is a proving tracer because it closes the evidence loop the run observer opened: a user can start from graph intent and see whether it executed, or start from a failed run and jump back to the graph node whose requirement/criterion produced that evidence.
+
+### Acceptance Criteria
+
+✓ Projection/RPC test — run detail exposes requirement/criterion trace fields without exposing raw artifact file shape.
+✓ Web route test — `/runs/$runId` requirement rows show status, mapped slices, criteria chips, `view in graph`, and `view slice log` affordances.
+✓ Web route test — replanning lineage fields already scoped by `executor-replanning--web-inspect-metadata.md` are rendered in the run list/detail when present, or explicitly left absent without breaking existing rows.
+✓ Graph route/component test — executable graph nodes (`REQ*`, `AC*`) show compact run badges such as `latest failed`, `task-8 failed`, or `2 runs` when run evidence exists.
+✓ Graph route/component test — run badges link to `/runs/$runId` focused enough for the user to find the mapped requirement/slice.
+
+### Verification Approach
+
+- Inner: focused projection tests for trace mapping and focused web tests with synthetic run+graph fixtures.
+- Middle: route integration test through `BrunchWebApp` proving `/spec/$specId` and `/runs/$runId` can both render the same trace facts.
+- Outer: manual browser walkthrough against a failed `cook-layered-todo` run, confirming graph node → run evidence → raw log path is understandable.
+
+### Build Note
+
+Implemented 2026-07-07 with `execute.runTraceIndex`, graph-node run badges, and run-detail backlinks to `/spec/$specId` plus slice-log anchors. Verification: `npm run test -- src/rpc/methods/__tests__/execute.test.ts`; `npm run test -- src/web/__tests__/runs-route.test.tsx src/web/__tests__/app.test.tsx`.
+
+### Expected touched paths (tentative)
+
+```text
+src/executor/
+├── observer-read.ts ~ trace fields / run evidence index if needed
+└── __tests__/observer-read.test.ts ~ trace mapping coverage
+src/rpc/
+├── methods/execute.ts ~ schema if trace fields are added
+└── methods/__tests__/execute.test.ts ~ projection contract
+src/web/
+├── query-keys.ts ? only if a new run-trace query is added
+├── queries/execute.ts ? only if a new run-trace query is added
+├── routes/runs.tsx ~ run-detail backlinks and lineage rows
+├── routes/spec.tsx ? pass run trace projection into graph view
+├── features/graph/structured-list-view.tsx ~ node run badges
+├── components/node-card.tsx ? reusable run/trace badge primitive
+├── __tests__/runs-route.test.tsx ~ run detail traceability
+└── __tests__/graph-route.test.tsx ? graph badge/link coverage if existing route tests live elsewhere
+src/web/TOPOLOGY.md ~ document graph/run traceability surface
+src/rpc/TOPOLOGY.md ~ document any new read projection, if added
+```
+
+## Routing
+
+Recommended next command: `ln-build memory/cards/executor-run-observer--readable-run-evidence.md`.
diff --git a/src/executor/observer-read.ts b/src/executor/observer-read.ts
index 19674547d..833167827 100644
--- a/src/executor/observer-read.ts
+++ b/src/executor/observer-read.ts
@@ -70,6 +70,20 @@ export interface RunDetail extends RunSummary {
readonly petriNet?: unknown;
}
+export interface RunTraceEntry {
+ readonly nodeCode: string;
+ readonly runId: string;
+ readonly specId: string;
+ readonly runStatus: RunMetadata['status'];
+ readonly sliceIds: readonly string[];
+ readonly failedSliceIds: readonly string[];
+ readonly completedSliceIds: readonly string[];
+}
+
+export interface RunTraceIndex {
+ readonly traces: readonly RunTraceEntry[];
+}
+
export const DEFAULT_REPORTS_TAIL_LIMIT = 50;
export const DEFAULT_AGENT_STREAM_TAIL_LIMIT = 50;
export const DEFAULT_VERIFY_STREAM_TAIL_LIMIT = 50;
@@ -149,6 +163,32 @@ export async function readRunDetail(
};
}
+export async function readRunTraceIndex(cwd: string, specId: string): Promise {
+ let entries;
+ try {
+ entries = await readdir(runsRootPath(cwd), { withFileTypes: true });
+ } catch {
+ return { traces: [] };
+ }
+
+ const traces: RunTraceEntry[] = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const runId = entry.name;
+ let metadata: RunMetadata | undefined;
+ try {
+ metadata = await readRunMetadata(runMetadataPath(cwd, runId));
+ } catch {
+ metadata = undefined;
+ }
+ if (!metadata || metadata.specId !== specId) continue;
+
+ const reports = await readReportsTail(reportsFilePath(cwd, runId, metadata), 0);
+ traces.push(...(await traceEntriesForRun(metadata, reports.events)));
+ }
+ return { traces };
+}
+
async function readVerifyStreamTail(
cwd: string,
runId: string,
@@ -243,6 +283,68 @@ interface ExecutedPlanPayload {
}[];
}
+async function traceEntriesForRun(
+ metadata: RunMetadata,
+ reports: readonly RunReportEvent[],
+): Promise {
+ let plan: ExecutedPlanPayload;
+ try {
+ plan = JSON.parse(
+ await readFile(metadata.populatedPlanPath ?? metadata.planPath, 'utf8'),
+ ) as ExecutedPlanPayload;
+ } catch {
+ return [];
+ }
+
+ const sliceIdsByRequirement = sliceIdsByRequirementId(plan);
+ const sliceIdsByNodeCode = new Map>();
+ for (const requirement of plan.spec?.requirements ?? []) {
+ if (typeof requirement.item_id !== 'string') continue;
+ const sliceIds = sliceIdsByRequirement.get(requirement.item_id) ?? [];
+ sliceIdsByNodeCode.set(requirement.item_id, new Set(sliceIds));
+ }
+ for (const criterion of plan.spec?.criteria ?? []) {
+ if (typeof criterion.item_id !== 'string' || !Array.isArray(criterion.verifies)) continue;
+ const sliceIds = new Set();
+ for (const requirementId of criterion.verifies) {
+ if (typeof requirementId !== 'string') continue;
+ for (const sliceId of sliceIdsByRequirement.get(requirementId) ?? []) sliceIds.add(sliceId);
+ }
+ sliceIdsByNodeCode.set(criterion.item_id, sliceIds);
+ }
+
+ const completed = new Set(metadata.completedSliceIds ?? []);
+ const latestVerdicts = latestSliceVerdicts(reports);
+ return [...sliceIdsByNodeCode]
+ .filter(([, sliceIds]) => sliceIds.size > 0)
+ .map(([nodeCode, sliceIds]) => {
+ const allSliceIds = [...sliceIds];
+ return {
+ nodeCode,
+ runId: metadata.runId,
+ specId: metadata.specId,
+ runStatus: metadata.status,
+ sliceIds: allSliceIds,
+ failedSliceIds: allSliceIds.filter((sliceId) => latestVerdicts.get(sliceId) === 'failed'),
+ completedSliceIds: allSliceIds.filter((sliceId) => completed.has(sliceId)),
+ };
+ });
+}
+
+function sliceIdsByRequirementId(plan: ExecutedPlanPayload): ReadonlyMap {
+ const byRequirement = new Map();
+ for (const slice of plan.slices ?? []) {
+ if (typeof slice.id !== 'string' || !Array.isArray(slice.derived_from)) continue;
+ for (const requirementId of slice.derived_from) {
+ if (typeof requirementId !== 'string') continue;
+ const existing = byRequirement.get(requirementId) ?? [];
+ existing.push(slice.id);
+ byRequirement.set(requirementId, existing);
+ }
+ }
+ return byRequirement;
+}
+
async function readRequirementStatuses(
planPath: string,
metadata: RunMetadata,
diff --git a/src/rpc/TOPOLOGY.md b/src/rpc/TOPOLOGY.md
index 6cea615c1..81a30b41d 100644
--- a/src/rpc/TOPOLOGY.md
+++ b/src/rpc/TOPOLOGY.md
@@ -88,6 +88,7 @@ full RPC host:
graph.nodeNeighborhood
execute.runs
execute.run
+ execute.runTraceIndex
writes:
workspace.activate
session.triggerExchange
@@ -106,6 +107,7 @@ TUI-started web sidecar without live driver handle:
graph.nodeNeighborhood
execute.runs
execute.run
+ execute.runTraceIndex
rejected as method-not-found:
workspace.activate
session.triggerExchange
@@ -125,6 +127,7 @@ TUI-started web sidecar observer connection (/rpc), even when live driver handle
graph.nodeNeighborhood
execute.runs
execute.run
+ execute.runTraceIndex
rejected as method-not-found:
workspace.activate
session.triggerExchange
@@ -271,6 +274,12 @@ execute.run
params: {runId}
result: recorded run snapshot, per-requirement status, artifact-presence flags, reports tail, normalized worker/verify stream tails, optional raw Petri net
source: .brunch/cook/runs/ read projection; raw artifact paths, Pi event payloads, and subprocess handles do not cross the RPC boundary
+
+execute.runTraceIndex
+ access: read
+ params: {specId}
+ result: graph node code -> run/slice trace entries for requirements and criteria exercised by executor runs
+ source: .brunch/cook/runs/* plan/report projections; raw artifact paths do not cross the RPC boundary
```
## Product update notifications
@@ -290,6 +299,7 @@ brunch.updated:
- graph.nodeNeighborhood
- execute.runs
- execute.run
+ - execute.runTraceIndex
updates:
- {topic, specId?, sessionId?, nodeId?, lsn?}
```
@@ -340,6 +350,7 @@ query key families:
session.runtimeState -> ['session.runtimeState', specId, sessionId]
graph.overview -> ['graph.overview', specId]
graph.nodeNeighborhood -> ['graph.nodeNeighborhood', specId, nodeId, hops]
+ execute.runTraceIndex -> ['execute.runTraceIndex', specId]
```
| RPC method | Web Query/Mutation mapping | Current web status | Invalidation source |
@@ -359,6 +370,7 @@ query key families:
| `graph.nodeNeighborhood` | `graphNodeNeighborhoodQueryOptions(rpc, specId, nodeId, hops?)` | implemented query option; graph panel selection not yet wired | exact/prefix neighborhood invalidation when `nodeId` is present; broad topic fallback otherwise |
| `execute.runs` | `executeRunsQueryOptions(rpc)` | implemented; run observer list route | exact `execute.runs` |
| `execute.run` | `executeRunQueryOptions(rpc, runId)` | implemented; run detail route incl. reports + worker/verify stream tails | exact `execute.run(runId)` |
+| `execute.runTraceIndex` | `executeRunTraceIndexQueryOptions(rpc, specId)` | implemented; spec graph run badges | exact `execute.runTraceIndex(specId)` when graph/run evidence changes |
Route/use pattern:
diff --git a/src/rpc/__tests__/handlers.test.ts b/src/rpc/__tests__/handlers.test.ts
index 00876ec3a..51414abd6 100644
--- a/src/rpc/__tests__/handlers.test.ts
+++ b/src/rpc/__tests__/handlers.test.ts
@@ -306,6 +306,7 @@ describe('JSON-RPC handlers', () => {
).methods;
expect(methods.map((entry) => entry.method).sort()).toEqual([
'execute.run',
+ 'execute.runTraceIndex',
'execute.runs',
'graph.nodeNeighborhood',
'graph.overview',
diff --git a/src/rpc/methods/__tests__/execute.test.ts b/src/rpc/methods/__tests__/execute.test.ts
index e107a2bd0..3bea0c7dc 100644
--- a/src/rpc/methods/__tests__/execute.test.ts
+++ b/src/rpc/methods/__tests__/execute.test.ts
@@ -300,3 +300,94 @@ describe('execute.run', () => {
expect(JSON.stringify(response)).not.toContain('verify.jsonl');
});
});
+
+describe('execute.runTraceIndex', () => {
+ it('rejects malformed params', async () => {
+ const definition = method('execute.runTraceIndex');
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-run-trace-params-'));
+ for (const params of [undefined, {}, { specId: '' }]) {
+ const response = await definition.handle(contextFor(cwd), request('execute.runTraceIndex', params));
+ expect(response).toMatchObject({ error: { code: -32602 } });
+ }
+ });
+
+ it('maps requirement and criterion graph codes to run slices without artifact paths', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-run-trace-'));
+ const planPath = join(cwd, 'plan.yaml');
+ await writeFile(
+ planPath,
+ `${JSON.stringify({
+ spec: {
+ requirements: [
+ { item_id: 'REQ1', content: 'Build type root.' },
+ { item_id: 'REQ2', content: 'Build command surface.' },
+ ],
+ criteria: [{ item_id: 'AC1', content: 'Type root works.', verifies: ['REQ1'] }],
+ },
+ slices: [
+ { id: 'task-1', derived_from: ['REQ1'] },
+ { id: 'task-2', derived_from: ['REQ2'] },
+ ],
+ })}\n`,
+ 'utf8',
+ );
+ await writeRun(cwd, 'run-1', { planPath });
+ await writeFile(
+ runMetadataPath(cwd, 'run-1'),
+ `${JSON.stringify({
+ runId: 'run-1',
+ specId: '42',
+ planPath,
+ status: 'slice_completed',
+ completedSliceIds: ['task-1', 'task-2'],
+ })}\n`,
+ 'utf8',
+ );
+ await writeFile(
+ join(runDirPath(cwd, 'run-1'), 'reports.jsonl'),
+ [
+ JSON.stringify({ event: 'slice_test_result', sliceId: 'task-1', status: 'failed' }),
+ JSON.stringify({ event: 'slice_test_result', sliceId: 'task-2', status: 'passed' }),
+ '',
+ ].join('\n'),
+ 'utf8',
+ );
+
+ const response = await method('execute.runTraceIndex').handle(
+ contextFor(cwd),
+ request('execute.runTraceIndex', { specId: 42 }),
+ );
+
+ expect(response).toMatchObject({
+ result: {
+ traces: [
+ {
+ nodeCode: 'REQ1',
+ runId: 'run-1',
+ runStatus: 'slice_completed',
+ sliceIds: ['task-1'],
+ failedSliceIds: ['task-1'],
+ completedSliceIds: ['task-1'],
+ },
+ {
+ nodeCode: 'REQ2',
+ runId: 'run-1',
+ runStatus: 'slice_completed',
+ sliceIds: ['task-2'],
+ failedSliceIds: [],
+ completedSliceIds: ['task-2'],
+ },
+ {
+ nodeCode: 'AC1',
+ runId: 'run-1',
+ runStatus: 'slice_completed',
+ sliceIds: ['task-1'],
+ failedSliceIds: ['task-1'],
+ completedSliceIds: ['task-1'],
+ },
+ ],
+ },
+ });
+ expect(JSON.stringify(response)).not.toContain(planPath);
+ });
+});
diff --git a/src/rpc/methods/execute.ts b/src/rpc/methods/execute.ts
index c41f5ccc0..fceae5f4e 100644
--- a/src/rpc/methods/execute.ts
+++ b/src/rpc/methods/execute.ts
@@ -1,7 +1,7 @@
import { Type } from 'typebox';
import { Value } from 'typebox/value';
-import { listRuns, readRunDetail } from '../../executor/observer-read.js';
+import { listRuns, readRunDetail, readRunTraceIndex } from '../../executor/observer-read.js';
import { assertSafeRunId } from '../../executor/run.js';
import { createJsonRpcFailure, createJsonRpcSuccess, jsonRpcRequestId } from '../protocol.js';
import type { RpcMethodContext, RpcMethodDefinition } from './registry.js';
@@ -10,6 +10,10 @@ import { NoParamsSchema, NonBlankStringSchema } from './schemas.js';
export const UNKNOWN_RUN_ID_MESSAGE = 'Unknown runId';
const ExecuteRunParamsSchema = Type.Object({ runId: NonBlankStringSchema }, { additionalProperties: false });
+const ExecuteRunTraceIndexParamsSchema = Type.Object(
+ { specId: Type.Integer({ minimum: 1 }) },
+ { additionalProperties: false },
+);
const RunPresenceSchema = Type.Object(
{
@@ -119,6 +123,24 @@ const ExecuteRunResultSchema = Type.Union([
Type.Object({ runId: Type.String(), unreadable: Type.Literal(true) }, { additionalProperties: false }),
]);
+const RunTraceEntrySchema = Type.Object(
+ {
+ nodeCode: Type.String(),
+ runId: Type.String(),
+ specId: Type.String(),
+ runStatus: Type.String(),
+ sliceIds: Type.Array(Type.String()),
+ failedSliceIds: Type.Array(Type.String()),
+ completedSliceIds: Type.Array(Type.String()),
+ },
+ { additionalProperties: false },
+);
+
+const ExecuteRunTraceIndexResultSchema = Type.Object(
+ { traces: Type.Array(RunTraceEntrySchema) },
+ { additionalProperties: false },
+);
+
export const executeRpcMethods: readonly RpcMethodDefinition[] = [
{
method: 'execute.runs',
@@ -161,4 +183,23 @@ export const executeRpcMethods: readonly RpcMethodDefinition[]
return createJsonRpcSuccess(requestId, detail);
},
},
+ {
+ method: 'execute.runTraceIndex',
+ access: 'read',
+ description:
+ 'Return graph-node-to-run traceability for one spec, derived from run plans and reports without exposing run artifact paths.',
+ paramsSchema: ExecuteRunTraceIndexParamsSchema,
+ resultSchema: ExecuteRunTraceIndexResultSchema,
+ examples: [{ jsonrpc: '2.0', id: 22, method: 'execute.runTraceIndex', params: { specId: 1 } }],
+ async handle(context, request) {
+ const requestId = jsonRpcRequestId(request);
+ if (!Value.Check(ExecuteRunTraceIndexParamsSchema, request.params)) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
+ return createJsonRpcSuccess(
+ requestId,
+ await readRunTraceIndex(context.cwd, String(request.params.specId)),
+ );
+ },
+ },
];
diff --git a/src/web/TOPOLOGY.md b/src/web/TOPOLOGY.md
index 428f00ba1..8987a89bc 100644
--- a/src/web/TOPOLOGY.md
+++ b/src/web/TOPOLOGY.md
@@ -39,12 +39,13 @@ web/
graph.nodeNeighborhood
execute.runs
execute.run
+ execute.runTraceIndex
queries/
workspace.ts -> workspace.state + workspace.selectionState query options
session.ts -> session.runtimeState query options
graph.ts -> graph overview/neighborhood query options
- execute.ts -> executor run list/detail query options (run observer)
+ execute.ts -> executor run list/detail/trace-index query options (run observer)
subscriptions/
brunch-updates.ts
@@ -70,14 +71,17 @@ web/
runs.tsx
`/runs` loader primes execute.runs; run list with presence flags
`/runs/$runId` loader primes execute.run; crank status, honest
- running indicators, requirement status panel, worker/verify stream tails, reports timeline
+ running indicators, requirement status panel, readable worker/verify evidence panels
+ (deduped display plus raw stream disclosure), grouped reports timeline
(events lead the run.json snapshot by design), unreadable-run marking
+ requirement rows link back to graph nodes and slice-log anchors
features/graph/
structured-list-view.tsx
read-only KnowledgeGraphView: counts sub-header + kind filter chips +
collapsible per-kind sections of node cards (ported from the prior
- trunk's -structured-list-view, minus chat/annotate/inline-edit)
+ trunk's -structured-list-view, minus chat/annotate/inline-edit), plus
+ read-only run badges from execute.runTraceIndex for executable nodes
kind-display.ts
presentation-only kind section ordering + plural section labels
diff --git a/src/web/__tests__/app.test.tsx b/src/web/__tests__/app.test.tsx
index 988747d83..23ffa555b 100644
--- a/src/web/__tests__/app.test.tsx
+++ b/src/web/__tests__/app.test.tsx
@@ -7,6 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
import type { GraphSlice, NodeNeighborhood } from '../../graph/queries.js';
import type { WorkspaceState } from '../../projections/workspace/workspace-state.js';
import { BrunchWebApp, createBrunchWebRuntime } from '../app.js';
+import type { RunTraceIndex } from '../queries/execute.js';
import { graphNodeNeighborhoodQueryOptions, graphOverviewQueryOptions } from '../queries/graph.js';
import { queryKeys } from '../query-keys.js';
import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from '../rpc-client.js';
@@ -122,6 +123,7 @@ function rpcClient(options?: {
selectionState?: unknown;
graphOverview?: GraphSlice;
nodeNeighborhood?: NodeNeighborhood;
+ runTraceIndex?: RunTraceIndex;
calls?: RpcCall[];
listeners?: Set;
close?: ReturnType;
@@ -147,6 +149,9 @@ function rpcClient(options?: {
if (method === 'graph.nodeNeighborhood') {
return (options?.nodeNeighborhood ?? foundNeighborhood) as T;
}
+ if (method === 'execute.runTraceIndex') {
+ return (options?.runTraceIndex ?? { traces: [] }) as T;
+ }
throw new Error(`unexpected RPC method ${method}`);
},
subscribe(listener: WebSocketRpcNotificationListener) {
@@ -226,6 +231,62 @@ describe('Brunch React web app', () => {
expect(screen.getAllByText('Requirements').length).toBeGreaterThan(0);
});
+ it('renders executor run badges on executable graph nodes', async () => {
+ window.history.pushState(null, '', '/spec/1');
+ const graphOverview = {
+ nodes: [
+ populatedGraphOverview.nodes[0]!,
+ {
+ id: 12,
+ specId: 1,
+ plane: 'intent',
+ kind: 'criterion',
+ kindOrdinal: 1,
+ title: 'Type root works',
+ basis: 'explicit',
+ settlement: 'settled',
+ createdAtLsn: 1,
+ updatedAtLsn: 1,
+ },
+ ],
+ edges: [],
+ lsn: 1,
+ } satisfies GraphSlice;
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ graphOverview,
+ runTraceIndex: {
+ traces: [
+ {
+ nodeCode: 'REQ1',
+ runId: 'run-1',
+ specId: '1',
+ runStatus: 'slice_completed',
+ sliceIds: ['task-1'],
+ failedSliceIds: ['task-1'],
+ completedSliceIds: ['task-1'],
+ },
+ {
+ nodeCode: 'AC1',
+ runId: 'run-1',
+ specId: '1',
+ runStatus: 'slice_completed',
+ sliceIds: ['task-1'],
+ failedSliceIds: ['task-1'],
+ completedSliceIds: ['task-1'],
+ },
+ ],
+ },
+ }),
+ });
+
+ render( );
+
+ const runLinks = await screen.findAllByRole('link', { name: /task-1 failed/u });
+ expect(runLinks).toHaveLength(2);
+ expect(runLinks[0]?.getAttribute('href')).toBe('/runs/run-1');
+ });
+
it('derives graph overview counts from GraphSlice arrays without count aliases', async () => {
window.history.pushState(null, '', '/spec/1');
const graphOverview = {
diff --git a/src/web/__tests__/brunch-updates-execute.test.ts b/src/web/__tests__/brunch-updates-execute.test.ts
index 84cd99768..2d83c25f0 100644
--- a/src/web/__tests__/brunch-updates-execute.test.ts
+++ b/src/web/__tests__/brunch-updates-execute.test.ts
@@ -8,7 +8,7 @@ function notification(updates: unknown[], topics: string[] = []) {
}
describe('brunch.updated execute topic invalidation', () => {
- it('invalidates the exact run detail and list keys and nothing else', () => {
+ it('invalidates the exact run detail/list keys and trace indexes', () => {
const queryClient = new QueryClient();
const invalidate = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue();
@@ -19,7 +19,8 @@ describe('brunch.updated execute topic invalidation', () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runs'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.run', 'run-1'], exact: true });
- expect(invalidate).toHaveBeenCalledTimes(2);
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runTraceIndex'] });
+ expect(invalidate).toHaveBeenCalledTimes(4);
});
it('falls back to broad run-detail invalidation for a bare execute.run topic', () => {
@@ -30,5 +31,6 @@ describe('brunch.updated execute topic invalidation', () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.run'] });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runs'], exact: true });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runTraceIndex'] });
});
});
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index 5cc487d1d..9eb6a9b37 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -205,6 +205,54 @@ describe('run detail route', () => {
expect(screen.getByText('Created src/types.ts')).toBeTruthy();
});
+ it('collapses noisy worker stream repeats while keeping raw events available', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ run: {
+ ...runDetail,
+ agentStreamTail: [
+ {
+ event: 'agent_stream',
+ runId: 'run-1',
+ epicId: 'frontier-1',
+ sliceId: 'task-8',
+ sequence: 10,
+ kind: 'message',
+ message: 'Now I',
+ },
+ {
+ event: 'agent_stream',
+ runId: 'run-1',
+ epicId: 'frontier-1',
+ sliceId: 'task-8',
+ sequence: 11,
+ kind: 'message',
+ message: "Now I'll write the result file to report completion.",
+ },
+ {
+ event: 'agent_stream',
+ runId: 'run-1',
+ epicId: 'frontier-1',
+ sliceId: 'task-8',
+ sequence: 12,
+ kind: 'message',
+ message: "Now I'll write the result file to report completion.",
+ },
+ ],
+ agentStreamTotal: 3,
+ },
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText("Now I'll write the result file to report completion.")).toBeTruthy();
+ expect(screen.getByText('x2')).toBeTruthy();
+ expect(screen.queryByText('Now I')).toBeNull();
+ expect(screen.getByText('Raw Worker stream events')).toBeTruthy();
+ });
+
it('renders projected requirement statuses when present', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({
@@ -243,6 +291,10 @@ describe('run detail route', () => {
expect(screen.getByText('passed')).toBeTruthy();
expect(screen.getByText('unverified')).toBeTruthy();
expect(screen.getByText('no criterion witness')).toBeTruthy();
+ expect(screen.getAllByRole('link', { name: 'view in graph' })[0]?.getAttribute('href')).toBe('/spec/1');
+ expect(screen.getAllByRole('link', { name: 'view slice log' })[0]?.getAttribute('href')).toBe(
+ '#slice-task-1',
+ );
});
it('renders normalized verify stream events when present', async () => {
@@ -282,6 +334,72 @@ describe('run detail route', () => {
expect(screen.getByText('tests passed')).toBeTruthy();
});
+ it('renders verify failures first and strips ANSI escape codes', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ run: {
+ ...runDetail,
+ verifyStreamTail: [
+ {
+ event: 'verify_stream',
+ runId: 'run-1',
+ epicId: 'frontier-1',
+ sliceId: 'task-8',
+ sequence: 0,
+ kind: 'stdout',
+ message: '\u001b[32m✓\u001b[39m src/types.test.ts (3 tests)',
+ },
+ {
+ event: 'verify_stream',
+ runId: 'run-1',
+ epicId: 'frontier-1',
+ sliceId: 'task-8',
+ sequence: 1,
+ kind: 'stderr',
+ message:
+ '\u001b[41m FAIL \u001b[49m src/app/__tests__/brunch-tui.test.ts\nError: missing cli.js',
+ },
+ ],
+ verifyStreamTotal: 2,
+ },
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('Verify failures')).toBeTruthy();
+ expect(screen.getAllByText(/FAIL\s+src\/app\/__tests__\/brunch-tui.test.ts/u).length).toBeGreaterThan(0);
+ expect(document.body.textContent).not.toContain('\u001b[');
+ expect(screen.getByText('Raw Verify stream events')).toBeTruthy();
+ });
+
+ it('groups lifecycle reports by slice progression while preserving raw events', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ run: {
+ ...runDetail,
+ reportsTail: [
+ { event: 'run_ready' },
+ { event: 'slice_started', sliceId: 'task-1' },
+ { event: 'slice_execution_requested', sliceId: 'task-1' },
+ { event: 'slice_agent_result', sliceId: 'task-1' },
+ { event: 'slice_test_result', sliceId: 'task-1', status: 'failed' },
+ { event: 'slice_completed', sliceId: 'task-1' },
+ ],
+ reportsTotal: 6,
+ },
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('task-1')).toBeTruthy();
+ expect(screen.getByText('started -> requested -> agent -> verify failed -> completed')).toBeTruthy();
+ expect(screen.getByText('Raw events')).toBeTruthy();
+ });
+
it('omits the petri block when the payload is absent', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() });
diff --git a/src/web/features/graph/structured-list-view.tsx b/src/web/features/graph/structured-list-view.tsx
index 2d7d57e5f..103236dec 100644
--- a/src/web/features/graph/structured-list-view.tsx
+++ b/src/web/features/graph/structured-list-view.tsx
@@ -1,9 +1,11 @@
+import { Link } from '@tanstack/react-router';
import { type ReactNode, useRef, useState } from 'react';
import type { GraphSlice } from '../../../graph/queries.js';
import { NODE_KIND_METADATA, type NodeKind } from '../../../graph/schema/nodes.js';
import { ChevronIcon, EyeIcon, EyeOffIcon } from '../../components/icons.js';
-import { NodeRefChip, planeAccent } from '../../components/node-card.js';
+import { NodeRefChip, nodeRefCode, planeAccent } from '../../components/node-card.js';
+import type { RunTraceEntry } from '../../queries/execute.js';
import { buildKindSections, type KindSection } from './kind-display.js';
type GraphNode = GraphSlice['nodes'][number];
@@ -15,7 +17,15 @@ type GraphNode = GraphSlice['nodes'][number];
// affordances (none are wired in the web sidecar). Retains the header +
// filter-bar + grouped-section shell. Accents are plane-based per D72-L.
-export function KnowledgeGraphView({ overview, specTitle }: { overview: GraphSlice; specTitle?: string }) {
+export function KnowledgeGraphView({
+ overview,
+ runTraces,
+ specTitle,
+}: {
+ overview: GraphSlice;
+ runTraces?: readonly RunTraceEntry[];
+ specTitle?: string;
+}) {
const sections = buildKindSections(overview.nodes);
const [hiddenKinds, setHiddenKinds] = useState>(new Set());
const sectionRefs = useRef(new Map());
@@ -115,6 +125,7 @@ export function KnowledgeGraphView({ overview, specTitle }: { overview: GraphSli
sectionRefs.current.set(section.kind, el)}
/>
))}
@@ -177,9 +188,11 @@ function KindChip({
function KindSectionBlock({
section,
+ runTraces,
registerRef,
}: {
section: KindSection;
+ runTraces: readonly RunTraceEntry[];
registerRef: (el: HTMLElement | null) => void;
}) {
const [open, setOpen] = useState(true);
@@ -200,7 +213,13 @@ function KindSectionBlock({
{open && (
{section.nodes.map((node) => (
-
+ trace.nodeCode === nodeRefCode(node.kind, node.kindOrdinal),
+ )}
+ />
))}
)}
@@ -208,7 +227,7 @@ function KindSectionBlock({
);
}
-function ItemRow({ node }: { node: GraphNode }) {
+function ItemRow({ node, runTraces }: { node: GraphNode; runTraces: readonly RunTraceEntry[] }) {
return (
{node.title}
{node.body ? {node.body}
: null}
+ {runTraces.length === 0 ? null : (
+
+ {runTraces.map((trace) => (
+
+ {traceLabel(trace)}
+
+ ))}
+
+ )}
);
}
+function traceLabel(trace: RunTraceEntry): string {
+ if (trace.failedSliceIds.length === 1) return `${trace.failedSliceIds[0]} failed`;
+ if (trace.failedSliceIds.length > 1) return `${trace.failedSliceIds.length} failed slices`;
+ if (trace.sliceIds.length === 1)
+ return `${trace.sliceIds[0]} ${trace.completedSliceIds.length > 0 ? 'completed' : 'pending'}`;
+ return `${trace.sliceIds.length} slices`;
+}
+
function EmptyState({
title,
description,
diff --git a/src/web/queries/execute.ts b/src/web/queries/execute.ts
index 6a3248cc3..190a4ff59 100644
--- a/src/web/queries/execute.ts
+++ b/src/web/queries/execute.ts
@@ -1,10 +1,11 @@
import { queryOptions } from '@tanstack/react-query';
-import type { RunDetail, RunListEntry, UnreadableRun } from '../../executor/observer-read.js';
+import type { RunDetail, RunListEntry, RunTraceIndex, UnreadableRun } from '../../executor/observer-read.js';
import { queryKeys } from '../query-keys.js';
import type { WebSocketRpcClient } from '../rpc-client.js';
export type { RunDetail, RunListEntry, RunSummary, UnreadableRun } from '../../executor/observer-read.js';
+export type { RunTraceEntry, RunTraceIndex } from '../../executor/observer-read.js';
export function executeRunsQueryOptions(rpcClient: WebSocketRpcClient) {
return queryOptions({
@@ -19,3 +20,10 @@ export function executeRunQueryOptions(rpcClient: WebSocketRpcClient, runId: str
queryFn: () => rpcClient.request('execute.run', { runId }),
});
}
+
+export function executeRunTraceIndexQueryOptions(rpcClient: WebSocketRpcClient, specId: number) {
+ return queryOptions({
+ queryKey: queryKeys.execute.runTraceIndex(specId),
+ queryFn: () => rpcClient.request('execute.runTraceIndex', { specId }),
+ });
+}
diff --git a/src/web/query-keys.ts b/src/web/query-keys.ts
index a7a235a7f..5b36c9073 100644
--- a/src/web/query-keys.ts
+++ b/src/web/query-keys.ts
@@ -15,5 +15,6 @@ export const queryKeys = {
execute: {
runs: () => ['execute.runs'] as const,
run: (runId: string) => ['execute.run', runId] as const,
+ runTraceIndex: (specId: number) => ['execute.runTraceIndex', specId] as const,
},
};
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index d03eb2dbf..945136cf9 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -20,6 +20,11 @@ const RUNNING_INDICATORS: Readonly>
agent_result_ingested: 'verify running…',
};
+const ANSI_ESCAPE_PATTERN = new RegExp(
+ String.raw`[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))`,
+ 'gu',
+);
+
export const runsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/runs',
@@ -174,6 +179,20 @@ function RequirementsPanel({ run }: { run: RunDetail }) {
) : (
criteria: {requirement.criterionIds.join(', ')}
)}
+
+
+ view in graph
+
+ {requirement.sliceIds[0] === undefined ? null : (
+
+ view slice log
+
+ )}
+
))}
@@ -200,6 +219,8 @@ function StreamPanel({
events: readonly StreamEventView[];
total: number;
}) {
+ const rows = compactStreamEvents(events);
+ const failures = label === 'Verify stream' ? verifyFailures(events) : [];
return (
@@ -208,23 +229,92 @@ function StreamPanel({
{events.length === 0 ? (
{emptyText}
) : (
-
- {events.map((event) => (
-
-
- {event.kind}
- {event.sliceId}
- #{event.sequence}
-
- {event.message}
-
- ))}
-
+
+ {failures.length === 0 ? null : (
+
+ Verify failures
+
+ {failures.map((failure) => (
+
+ {failure.message}
+
+ ))}
+
+
+ )}
+
+ {rows.map((event) => (
+
+
+ {event.kind}
+ {event.sliceId}
+ #{event.sequence}
+ {event.count > 1 ? (
+ x{event.count}
+ ) : null}
+
+ {event.message}
+
+ ))}
+
+
+ Raw {label} events
+
+ {events.map(
+ (event) =>
+ `${JSON.stringify({
+ kind: event.kind,
+ sliceId: event.sliceId,
+ sequence: event.sequence,
+ message: stripAnsi(event.message),
+ })}\n`,
+ )}
+
+
+
)}
);
}
+interface CompactStreamEventView extends StreamEventView {
+ readonly count: number;
+}
+
+function compactStreamEvents(events: readonly StreamEventView[]): readonly CompactStreamEventView[] {
+ const rows: CompactStreamEventView[] = [];
+ for (const event of events) {
+ const message = stripAnsi(event.message);
+ const previous = rows.at(-1);
+ if (previous && previous.sliceId === event.sliceId && previous.kind === event.kind) {
+ if (message === previous.message) {
+ rows[rows.length - 1] = { ...previous, count: previous.count + 1 };
+ continue;
+ }
+ if (message.startsWith(previous.message)) {
+ rows[rows.length - 1] = { ...event, message, count: previous.count };
+ continue;
+ }
+ }
+ rows.push({ ...event, message, count: 1 });
+ }
+ return rows;
+}
+
+function verifyFailures(events: readonly StreamEventView[]): readonly StreamEventView[] {
+ return events
+ .filter((event) => event.kind === 'stderr' || /\b(?:FAIL|Failed Tests|Error:)\b/u.test(event.message))
+ .map((event) => ({ ...event, message: stripAnsi(event.message).trim() }))
+ .filter((event) => event.message.length > 0);
+}
+
+function stripAnsi(value: string): string {
+ return value.replace(ANSI_ESCAPE_PATTERN, '');
+}
+
function PetriRawBlock({ net }: { net: unknown }) {
return (
@@ -244,6 +334,7 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
}
function ReportsTimeline({ run }: { run: RunDetail }) {
+ const sliceProgress = groupSliceProgress(run.reportsTail);
return (
@@ -252,21 +343,68 @@ function ReportsTimeline({ run }: { run: RunDetail }) {
{run.reportsTail.length === 0 ? (
No events yet.
) : (
-
- {run.reportsTail.map((event, index) => (
-
- {event.event}
- {typeof event['sliceId'] === 'string' ? (
- {event['sliceId']}
- ) : null}
-
- ))}
-
+
+
+ {sliceProgress.map((slice) => (
+
+
+ {slice.sliceId}
+ {slice.progress}
+
+
+ ))}
+
+
+ Raw events
+
+ {run.reportsTail.map((event, index) => (
+
+ {event.event}
+ {typeof event['sliceId'] === 'string' ? (
+ #{event['sliceId']}
+ ) : null}
+
+ ))}
+
+
+
)}
);
}
+function groupSliceProgress(events: RunDetail['reportsTail']): readonly {
+ readonly sliceId: string;
+ readonly progress: string;
+}[] {
+ const bySlice = new Map();
+ for (const event of events) {
+ if (typeof event['sliceId'] !== 'string') continue;
+ const current = bySlice.get(event['sliceId']) ?? [];
+ const stage = eventStage(event);
+ if (stage) current.push(stage);
+ bySlice.set(event['sliceId'], current);
+ }
+ return [...bySlice].map(([sliceId, stages]) => ({ sliceId, progress: stages.join(' -> ') }));
+}
+
+function eventStage(event: RunDetail['reportsTail'][number]): string | undefined {
+ switch (event.event) {
+ case 'slice_started':
+ return 'started';
+ case 'slice_execution_requested':
+ return 'requested';
+ case 'slice_agent_result':
+ return 'agent';
+ case 'slice_test_result':
+ return event['status'] === 'failed' ? 'verify failed' : 'verify passed';
+ case 'slice_completed':
+ return 'completed';
+ default:
+ return undefined;
+ }
+}
+
function PresenceFlags({ presence }: { presence: RunSummary['presence'] }) {
return (
diff --git a/src/web/routes/spec.tsx b/src/web/routes/spec.tsx
index 60c7864c3..adab0ec3d 100644
--- a/src/web/routes/spec.tsx
+++ b/src/web/routes/spec.tsx
@@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
import { createRoute } from '@tanstack/react-router';
import { KnowledgeGraphView } from '../features/graph/structured-list-view.js';
+import { executeRunTraceIndexQueryOptions } from '../queries/execute.js';
import { graphOverviewQueryOptions } from '../queries/graph.js';
import { workspaceStateQueryOptions } from '../queries/workspace.js';
import { parseSpecId } from '../spec-id.js';
@@ -18,6 +19,7 @@ export const specRoute = createRoute({
return Promise.all([
context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)),
context.queryClient.ensureQueryData(graphOverviewQueryOptions(context.rpcClient, specId)),
+ context.queryClient.ensureQueryData(executeRunTraceIndexQueryOptions(context.rpcClient, specId)),
]);
},
component: SpecRoutePage,
@@ -46,7 +48,14 @@ function ValidSpecRoutePage({ specId }: { specId: number }) {
const { rpcClient } = specRoute.useRouteContext();
const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient));
const { data: overview } = useSuspenseQuery(graphOverviewQueryOptions(rpcClient, specId));
+ const { data: runTraceIndex } = useSuspenseQuery(executeRunTraceIndexQueryOptions(rpcClient, specId));
const specTitle = state.spec?.id === specId ? state.spec.title : undefined;
- return ;
+ return (
+
+ );
}
diff --git a/src/web/subscriptions/brunch-updates.ts b/src/web/subscriptions/brunch-updates.ts
index 099d83d9b..1a038390f 100644
--- a/src/web/subscriptions/brunch-updates.ts
+++ b/src/web/subscriptions/brunch-updates.ts
@@ -78,10 +78,12 @@ function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate
}
if (update.topic === 'execute.runs') {
invalidateExact(queryClient, queryKeys.execute.runs());
+ void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
return;
}
if (update.topic === 'execute.run' && typeof update.runId === 'string') {
invalidateExact(queryClient, queryKeys.execute.run(update.runId));
+ void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
return;
}
if (typeof update.topic === 'string') {
@@ -112,10 +114,12 @@ function invalidateTopic(queryClient: QueryClient, topic: string): void {
}
if (topic === 'execute.runs') {
invalidateExact(queryClient, queryKeys.execute.runs());
+ void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
return;
}
if (topic === 'execute.run') {
void queryClient.invalidateQueries({ queryKey: ['execute.run'] });
+ void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
}
}
From 9c9113dbab17e69151ee0d064a9ae61e3eb9eeab Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Tue, 7 Jul 2026 23:08:54 +0200
Subject: [PATCH 02/14] FE-1141: Keep spec graph resilient without run traces
---
src/web/__tests__/app.test.tsx | 18 ++++++++++++++++++
src/web/routes/spec.tsx | 11 +++++++----
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/src/web/__tests__/app.test.tsx b/src/web/__tests__/app.test.tsx
index 23ffa555b..fd20e5d64 100644
--- a/src/web/__tests__/app.test.tsx
+++ b/src/web/__tests__/app.test.tsx
@@ -124,6 +124,7 @@ function rpcClient(options?: {
graphOverview?: GraphSlice;
nodeNeighborhood?: NodeNeighborhood;
runTraceIndex?: RunTraceIndex;
+ runTraceIndexError?: Error;
calls?: RpcCall[];
listeners?: Set;
close?: ReturnType;
@@ -150,6 +151,7 @@ function rpcClient(options?: {
return (options?.nodeNeighborhood ?? foundNeighborhood) as T;
}
if (method === 'execute.runTraceIndex') {
+ if (options?.runTraceIndexError) throw options.runTraceIndexError;
return (options?.runTraceIndex ?? { traces: [] }) as T;
}
throw new Error(`unexpected RPC method ${method}`);
@@ -287,6 +289,22 @@ describe('Brunch React web app', () => {
expect(runLinks[0]?.getAttribute('href')).toBe('/runs/run-1');
});
+ it('keeps the spec graph usable when run trace projection is unavailable', async () => {
+ window.history.pushState(null, '', '/spec/1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ graphOverview: populatedGraphOverview,
+ runTraceIndexError: new Error('Method not found'),
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('Knowledge Graph')).toBeTruthy();
+ expect(screen.getByText('Spec A requirement')).toBeTruthy();
+ expect(screen.queryByText('Something went wrong!')).toBeNull();
+ });
+
it('derives graph overview counts from GraphSlice arrays without count aliases', async () => {
window.history.pushState(null, '', '/spec/1');
const graphOverview = {
diff --git a/src/web/routes/spec.tsx b/src/web/routes/spec.tsx
index adab0ec3d..0e13d6889 100644
--- a/src/web/routes/spec.tsx
+++ b/src/web/routes/spec.tsx
@@ -1,4 +1,4 @@
-import { useSuspenseQuery } from '@tanstack/react-query';
+import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { createRoute } from '@tanstack/react-router';
import { KnowledgeGraphView } from '../features/graph/structured-list-view.js';
@@ -19,7 +19,6 @@ export const specRoute = createRoute({
return Promise.all([
context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)),
context.queryClient.ensureQueryData(graphOverviewQueryOptions(context.rpcClient, specId)),
- context.queryClient.ensureQueryData(executeRunTraceIndexQueryOptions(context.rpcClient, specId)),
]);
},
component: SpecRoutePage,
@@ -48,13 +47,17 @@ function ValidSpecRoutePage({ specId }: { specId: number }) {
const { rpcClient } = specRoute.useRouteContext();
const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient));
const { data: overview } = useSuspenseQuery(graphOverviewQueryOptions(rpcClient, specId));
- const { data: runTraceIndex } = useSuspenseQuery(executeRunTraceIndexQueryOptions(rpcClient, specId));
+ const { data: runTraceIndex } = useQuery({
+ ...executeRunTraceIndexQueryOptions(rpcClient, specId),
+ retry: false,
+ throwOnError: false,
+ });
const specTitle = state.spec?.id === specId ? state.spec.title : undefined;
return (
);
From c463946a7ff159865d892d2fd6579bf2bef39842 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Tue, 7 Jul 2026 23:24:49 +0200
Subject: [PATCH 03/14] FE-1141: Clean stream display markdown noise
---
src/web/__tests__/runs-route.test.tsx | 15 ++++++++++++---
src/web/routes/runs.tsx | 19 ++++++++++++++++---
2 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index 9eb6a9b37..89a53d904 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -237,7 +237,8 @@ describe('run detail route', () => {
sliceId: 'task-8',
sequence: 12,
kind: 'message',
- message: "Now I'll write the result file to report completion.",
+ message:
+ "Now I'll write the result file to report completion.\n\n**Slice task-8** (frontier-1)",
},
],
agentStreamTotal: 3,
@@ -247,8 +248,16 @@ describe('run detail route', () => {
render( );
- expect(await screen.findByText("Now I'll write the result file to report completion.")).toBeTruthy();
- expect(screen.getByText('x2')).toBeTruthy();
+ expect(
+ await screen.findByText((_, element) =>
+ Boolean(
+ element?.tagName === 'P' &&
+ element.textContent ===
+ "Now I'll write the result file to report completion.\n\nSlice task-8 (frontier-1)",
+ ),
+ ),
+ ).toBeTruthy();
+ expect(screen.getByText('x3')).toBeTruthy();
expect(screen.queryByText('Now I')).toBeNull();
expect(screen.getByText('Raw Worker stream events')).toBeTruthy();
});
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 945136cf9..2164c902b 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -287,7 +287,7 @@ interface CompactStreamEventView extends StreamEventView {
function compactStreamEvents(events: readonly StreamEventView[]): readonly CompactStreamEventView[] {
const rows: CompactStreamEventView[] = [];
for (const event of events) {
- const message = stripAnsi(event.message);
+ const message = cleanStreamMessage(event.message);
const previous = rows.at(-1);
if (previous && previous.sliceId === event.sliceId && previous.kind === event.kind) {
if (message === previous.message) {
@@ -295,7 +295,7 @@ function compactStreamEvents(events: readonly StreamEventView[]): readonly Compa
continue;
}
if (message.startsWith(previous.message)) {
- rows[rows.length - 1] = { ...event, message, count: previous.count };
+ rows[rows.length - 1] = { ...event, message, count: previous.count + 1 };
continue;
}
}
@@ -307,14 +307,27 @@ function compactStreamEvents(events: readonly StreamEventView[]): readonly Compa
function verifyFailures(events: readonly StreamEventView[]): readonly StreamEventView[] {
return events
.filter((event) => event.kind === 'stderr' || /\b(?:FAIL|Failed Tests|Error:)\b/u.test(event.message))
- .map((event) => ({ ...event, message: stripAnsi(event.message).trim() }))
+ .map((event) => ({ ...event, message: cleanStreamMessage(event.message).trim() }))
.filter((event) => event.message.length > 0);
}
+function cleanStreamMessage(value: string): string {
+ return stripMarkdownMarkers(stripAnsi(value));
+}
+
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, '');
}
+function stripMarkdownMarkers(value: string): string {
+ return value
+ .replace(/\*\*([^*]+)\*\*/gu, '$1')
+ .replace(/__([^_]+)__/gu, '$1')
+ .replace(/`([^`]+)`/gu, '$1')
+ .replace(/^#{1,6}\s+/gmu, '')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/gu, '$1');
+}
+
function PetriRawBlock({ net }: { net: unknown }) {
return (
From 3edb4472a3e911f56d9e4a675bf45368e4b56861 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 09:35:02 +0200
Subject: [PATCH 04/14] FE-1141: Fix run evidence review findings
---
src/executor/__tests__/observer-read.test.ts | 19 ++++++--
src/executor/observer-read.ts | 37 ++++++++++++++++
src/rpc/methods/execute.ts | 9 ++++
src/web/__tests__/runs-route.test.tsx | 46 +++++++++++++++++---
src/web/routes/runs.tsx | 44 +++----------------
5 files changed, 110 insertions(+), 45 deletions(-)
diff --git a/src/executor/__tests__/observer-read.test.ts b/src/executor/__tests__/observer-read.test.ts
index d004a0999..50aa566b3 100644
--- a/src/executor/__tests__/observer-read.test.ts
+++ b/src/executor/__tests__/observer-read.test.ts
@@ -137,6 +137,7 @@ describe('readRunDetail', () => {
runId: 'run-d',
planPath: '/plan.yaml',
reportsTotal: 3,
+ sliceProgress: [{ sliceId: 's1', progress: 'started -> requested' }],
presence: { worktree: false, reports: true, petri: false, promotion: false },
});
expect(detail && 'reportsTail' in detail ? detail.reportsTail.map((e) => e.event) : []).toEqual([
@@ -197,15 +198,27 @@ describe('readRunDetail', () => {
it('limits the reports tail while reporting the full total', async () => {
const cwd = await fixtureCwd('brunch-observer-tail-limit-');
const runDir = await writeRun(cwd, 'run-l', { status: 'run_completed' });
- const lines = Array.from({ length: 5 }, (_, i) => `{"event":"e${i}"}`);
+ const lines = [
+ '{"event":"slice_started","sliceId":"task-1"}',
+ '{"event":"slice_execution_requested","sliceId":"task-1"}',
+ '{"event":"slice_agent_result","sliceId":"task-1"}',
+ '{"event":"slice_test_result","sliceId":"task-1","status":"failed"}',
+ '{"event":"slice_completed","sliceId":"task-1"}',
+ ];
await writeFile(join(runDir, 'reports.jsonl'), `${lines.join('\n')}\n`, 'utf8');
const detail = await readRunDetail(cwd, 'run-l', { reportsTailLimit: 2 });
expect(detail).toMatchObject({ reportsTotal: 5 });
expect(detail && 'reportsTail' in detail ? detail.reportsTail.map((e) => e.event) : []).toEqual([
- 'e3',
- 'e4',
+ 'slice_test_result',
+ 'slice_completed',
+ ]);
+ expect(detail && 'sliceProgress' in detail ? detail.sliceProgress : []).toEqual([
+ {
+ sliceId: 'task-1',
+ progress: 'started -> requested -> agent -> verify failed -> completed',
+ },
]);
});
diff --git a/src/executor/observer-read.ts b/src/executor/observer-read.ts
index 833167827..c3f8fa4f1 100644
--- a/src/executor/observer-read.ts
+++ b/src/executor/observer-read.ts
@@ -57,6 +57,11 @@ export interface RunRequirementStatus {
readonly criterionIds: readonly string[];
}
+export interface RunSliceProgress {
+ readonly sliceId: string;
+ readonly progress: string;
+}
+
export interface RunDetail extends RunSummary {
readonly planPath: string;
readonly reportsTail: readonly RunReportEvent[];
@@ -65,6 +70,7 @@ export interface RunDetail extends RunSummary {
readonly agentStreamTotal: number;
readonly verifyStreamTail: readonly VerifyStreamEvent[];
readonly verifyStreamTotal: number;
+ readonly sliceProgress: readonly RunSliceProgress[];
readonly requirements: readonly RunRequirementStatus[];
/** Raw parsed petrinaut/net.json — deliberately unshaped (frontier: raw view only). */
readonly petriNet?: unknown;
@@ -154,6 +160,7 @@ export async function readRunDetail(
agentStreamTotal: agentStream.total,
verifyStreamTail: verifyStream.tail,
verifyStreamTotal: verifyStream.total,
+ sliceProgress: groupSliceProgress(reports.events),
requirements: await readRequirementStatuses(
metadata.populatedPlanPath ?? metadata.planPath,
metadata,
@@ -163,6 +170,36 @@ export async function readRunDetail(
};
}
+function groupSliceProgress(events: readonly RunReportEvent[]): readonly RunSliceProgress[] {
+ const bySlice = new Map();
+ for (const event of events) {
+ if (typeof event['sliceId'] !== 'string') continue;
+ const stage = eventStage(event);
+ if (!stage) continue;
+ const current = bySlice.get(event['sliceId']) ?? [];
+ current.push(stage);
+ bySlice.set(event['sliceId'], current);
+ }
+ return [...bySlice].map(([sliceId, stages]) => ({ sliceId, progress: stages.join(' -> ') }));
+}
+
+function eventStage(event: RunReportEvent): string | undefined {
+ switch (event.event) {
+ case 'slice_started':
+ return 'started';
+ case 'slice_execution_requested':
+ return 'requested';
+ case 'slice_agent_result':
+ return 'agent';
+ case 'slice_test_result':
+ return event['status'] === 'failed' ? 'verify failed' : 'verify passed';
+ case 'slice_completed':
+ return 'completed';
+ default:
+ return undefined;
+ }
+}
+
export async function readRunTraceIndex(cwd: string, specId: string): Promise {
let entries;
try {
diff --git a/src/rpc/methods/execute.ts b/src/rpc/methods/execute.ts
index fceae5f4e..c65e30c88 100644
--- a/src/rpc/methods/execute.ts
+++ b/src/rpc/methods/execute.ts
@@ -93,6 +93,15 @@ const ExecuteRunResultSchema = Type.Union([
),
),
verifyStreamTotal: Type.Integer({ minimum: 0 }),
+ sliceProgress: Type.Array(
+ Type.Object(
+ {
+ sliceId: Type.String(),
+ progress: Type.String(),
+ },
+ { additionalProperties: false },
+ ),
+ ),
requirements: Type.Array(
Type.Object(
{
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index 89a53d904..0d17f33d9 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -49,6 +49,7 @@ const runDetail: RunDetail = {
agentStreamTotal: 0,
verifyStreamTail: [],
verifyStreamTotal: 0,
+ sliceProgress: [{ sliceId: 's1', progress: 'started -> requested' }],
requirements: [],
};
@@ -257,7 +258,7 @@ describe('run detail route', () => {
),
),
).toBeTruthy();
- expect(screen.getByText('x3')).toBeTruthy();
+ expect(screen.queryByText('x3')).toBeNull();
expect(screen.queryByText('Now I')).toBeNull();
expect(screen.getByText('Raw Worker stream events')).toBeTruthy();
});
@@ -268,6 +269,10 @@ describe('run detail route', () => {
rpcClient: rpcClient({
run: {
...runDetail,
+ sliceProgress: [
+ { sliceId: 'task-1', progress: 'completed' },
+ { sliceId: 'task-2', progress: 'completed' },
+ ],
requirements: [
{
requirementId: 'REQ1',
@@ -390,14 +395,16 @@ describe('run detail route', () => {
run: {
...runDetail,
reportsTail: [
- { event: 'run_ready' },
- { event: 'slice_started', sliceId: 'task-1' },
- { event: 'slice_execution_requested', sliceId: 'task-1' },
- { event: 'slice_agent_result', sliceId: 'task-1' },
{ event: 'slice_test_result', sliceId: 'task-1', status: 'failed' },
{ event: 'slice_completed', sliceId: 'task-1' },
],
reportsTotal: 6,
+ sliceProgress: [
+ {
+ sliceId: 'task-1',
+ progress: 'started -> requested -> agent -> verify failed -> completed',
+ },
+ ],
},
}),
});
@@ -409,6 +416,35 @@ describe('run detail route', () => {
expect(screen.getByText('Raw events')).toBeTruthy();
});
+ it('omits slice-log links when no full-log slice progress anchor exists', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ run: {
+ ...runDetail,
+ sliceProgress: [],
+ requirements: [
+ {
+ requirementId: 'REQ1',
+ content: 'Build the type root.',
+ status: 'pending',
+ sliceIds: ['task-missing'],
+ completedSliceIds: [],
+ failedSliceIds: [],
+ missingVerificationSliceIds: [],
+ criterionIds: [],
+ },
+ ],
+ },
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('REQ1')).toBeTruthy();
+ expect(screen.queryByRole('link', { name: 'view slice log' })).toBeNull();
+ });
+
it('omits the petri block when the payload is absent', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() });
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 2164c902b..5bf07b911 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -155,6 +155,7 @@ function RunDetailPage() {
}
function RequirementsPanel({ run }: { run: RunDetail }) {
+ const linkedSliceIds = new Set(run.sliceProgress.map((slice) => slice.sliceId));
return (
Requirements
@@ -187,7 +188,8 @@ function RequirementsPanel({ run }: { run: RunDetail }) {
>
view in graph
- {requirement.sliceIds[0] === undefined ? null : (
+ {requirement.sliceIds[0] === undefined ||
+ !linkedSliceIds.has(requirement.sliceIds[0]) ? null : (
view slice log
@@ -295,7 +297,7 @@ function compactStreamEvents(events: readonly StreamEventView[]): readonly Compa
continue;
}
if (message.startsWith(previous.message)) {
- rows[rows.length - 1] = { ...event, message, count: previous.count + 1 };
+ rows[rows.length - 1] = { ...event, message, count: 1 };
continue;
}
}
@@ -347,18 +349,18 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
}
function ReportsTimeline({ run }: { run: RunDetail }) {
- const sliceProgress = groupSliceProgress(run.reportsTail);
+ const hasEvents = run.reportsTail.length > 0 || run.sliceProgress.length > 0;
return (
{`Events — showing ${run.reportsTail.length} of ${run.reportsTotal} events`}
- {run.reportsTail.length === 0 ? (
+ {!hasEvents ? (
No events yet.
) : (
- {sliceProgress.map((slice) => (
+ {run.sliceProgress.map((slice) => (
{slice.sliceId}
@@ -386,38 +388,6 @@ function ReportsTimeline({ run }: { run: RunDetail }) {
);
}
-function groupSliceProgress(events: RunDetail['reportsTail']): readonly {
- readonly sliceId: string;
- readonly progress: string;
-}[] {
- const bySlice = new Map
();
- for (const event of events) {
- if (typeof event['sliceId'] !== 'string') continue;
- const current = bySlice.get(event['sliceId']) ?? [];
- const stage = eventStage(event);
- if (stage) current.push(stage);
- bySlice.set(event['sliceId'], current);
- }
- return [...bySlice].map(([sliceId, stages]) => ({ sliceId, progress: stages.join(' -> ') }));
-}
-
-function eventStage(event: RunDetail['reportsTail'][number]): string | undefined {
- switch (event.event) {
- case 'slice_started':
- return 'started';
- case 'slice_execution_requested':
- return 'requested';
- case 'slice_agent_result':
- return 'agent';
- case 'slice_test_result':
- return event['status'] === 'failed' ? 'verify failed' : 'verify passed';
- case 'slice_completed':
- return 'completed';
- default:
- return undefined;
- }
-}
-
function PresenceFlags({ presence }: { presence: RunSummary['presence'] }) {
return (
From 45fa4120438499e2d5d41987b499476fabcd53cc Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 09:43:54 +0200
Subject: [PATCH 05/14] FE-1141: Link failed requirement slices
---
src/web/__tests__/runs-route.test.tsx | 32 +++++++++++
src/web/routes/runs.tsx | 76 ++++++++++++++++-----------
2 files changed, 77 insertions(+), 31 deletions(-)
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index 0d17f33d9..b19f1ba68 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -311,6 +311,38 @@ describe('run detail route', () => {
);
});
+ it('links failed multi-slice requirements to the failed slice log', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ run: {
+ ...runDetail,
+ sliceProgress: [
+ { sliceId: 'task-1', progress: 'completed' },
+ { sliceId: 'task-2', progress: 'verify failed' },
+ ],
+ requirements: [
+ {
+ requirementId: 'REQ1',
+ content: 'Build the type root.',
+ status: 'failed',
+ sliceIds: ['task-1', 'task-2'],
+ completedSliceIds: ['task-1'],
+ failedSliceIds: ['task-2'],
+ missingVerificationSliceIds: [],
+ criterionIds: ['AC1'],
+ },
+ ],
+ },
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('REQ1')).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'view slice log' }).getAttribute('href')).toBe('#slice-task-2');
+ });
+
it('renders normalized verify stream events when present', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 5bf07b911..61be6fa2a 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -165,44 +165,58 @@ function RequirementsPanel({ run }: { run: RunDetail }) {
) : (
- {run.requirements.map((requirement) => (
-
-
- {requirement.requirementId}
- {requirement.status}
- {requirement.sliceIds.length === 0 ? null : (
- {requirement.sliceIds.join(', ')}
- )}
-
- {requirement.content}
- {requirement.criterionIds.length === 0 ? (
- no criterion witness
- ) : (
- criteria: {requirement.criterionIds.join(', ')}
- )}
-
-
- view in graph
-
- {requirement.sliceIds[0] === undefined ||
- !linkedSliceIds.has(requirement.sliceIds[0]) ? null : (
-
- view slice log
-
+ {run.requirements.map((requirement) => {
+ const sliceLogTarget = requirementSliceLogTarget(requirement, linkedSliceIds);
+ return (
+
+
+ {requirement.requirementId}
+ {requirement.status}
+ {requirement.sliceIds.length === 0 ? null : (
+ {requirement.sliceIds.join(', ')}
+ )}
+
+ {requirement.content}
+ {requirement.criterionIds.length === 0 ? (
+ no criterion witness
+ ) : (
+ criteria: {requirement.criterionIds.join(', ')}
)}
-
-
- ))}
+
+
+ view in graph
+
+ {sliceLogTarget === undefined ? null : (
+
+ view slice log
+
+ )}
+
+
+ );
+ })}
)}
);
}
+function requirementSliceLogTarget(
+ requirement: RunDetail['requirements'][number],
+ linkedSliceIds: ReadonlySet,
+): string | undefined {
+ const priority = [
+ ...requirement.failedSliceIds,
+ ...requirement.missingVerificationSliceIds,
+ ...requirement.sliceIds,
+ ];
+ return priority.find((sliceId) => linkedSliceIds.has(sliceId));
+}
+
interface StreamEventView {
readonly sliceId: string;
readonly sequence: number;
From 30132a903ddec13aa047e391c3661748dd090a92 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 10:08:27 +0200
Subject: [PATCH 06/14] promote run-mrbs97w9
---
.../cards/executor-replanning--auto-policy.md | 88 ++++++
.../cards/executor-replanning--web-actions.md | 155 ++++++++++
src/executor/TOPOLOGY.md | 3 +
.../__tests__/run-auto-replan-policy.test.ts | 123 ++++++++
src/executor/run-auto-replan-policy.ts | 70 +++++
src/rpc/TOPOLOGY.md | 6 +
src/rpc/__tests__/handlers.test.ts | 4 +
src/rpc/methods/__tests__/execute.test.ts | 199 ++++++++++++-
src/rpc/methods/execute.ts | 270 +++++++++++++++++-
src/web/TOPOLOGY.md | 4 +-
src/web/__tests__/runs-route.test.tsx | 86 +++++-
src/web/queries/execute.ts | 52 ++++
src/web/query-keys.ts | 2 +
src/web/routes/runs.tsx | 128 ++++++++-
14 files changed, 1180 insertions(+), 10 deletions(-)
create mode 100644 memory/cards/executor-replanning--auto-policy.md
create mode 100644 memory/cards/executor-replanning--web-actions.md
create mode 100644 src/executor/__tests__/run-auto-replan-policy.test.ts
create mode 100644 src/executor/run-auto-replan-policy.ts
diff --git a/memory/cards/executor-replanning--auto-policy.md b/memory/cards/executor-replanning--auto-policy.md
new file mode 100644
index 000000000..20bbb718d
--- /dev/null
+++ b/memory/cards/executor-replanning--auto-policy.md
@@ -0,0 +1,88 @@
+# Executor Replanning Auto Policy
+
+Frontier: executor-replanning
+Linear: FE-1114
+Status: done
+Mode: single
+Created: 2026-07-08
+
+## Orientation
+
+- Containing seam: executor replanning over explicit run-state diagnosis plus bounded recovery mutations.
+- Relevant frontier item: `executor-replanning` / FE-1114 exists as the active card family and Graphite branch (`ka/fe-1114-executor-replanning`); `memory/PLAN.md` needs later reconciliation because the frontier is not currently defined there as a first-class entry.
+- Volatile handoff state: FE-1141 makes stale/evidence/lineage state visible, but automation must not rely on the web UI and must remain safe in headless executor use.
+- Main open risk: “automatic replanner” can easily erase HITL evidence discipline unless the first policy is conservative and refuses supersession-by-default.
+
+Posture: proving (inherited from `executor-replanning`).
+
+## Target Behavior
+
+A conservative auto-replan policy executes only evidence-preserving safe recovery actions for one run.
+
+## Full-card cold-start reads
+
+- `memory/SPEC.md` — D98-L, D111-L, D112-L, I58-L.
+- `memory/PLAN.md` — indirect `executor-replanning` branch reference; `executor-run-observer`; `orchestrator-tool-port`.
+- `src/executor/TOPOLOGY.md` — run freshness, retry eligibility, recommendation, side-effect boundaries, and run driver rules.
+- Existing cards: `memory/cards/executor-replanning--retry-eligibility.md`, `memory/cards/executor-replanning--recommendation.md`, `memory/cards/executor-replanning--retry-current-step-tool.md`, `memory/cards/executor-replanning--regenerate-plan-tool.md`, `memory/cards/executor-replanning--start-new-run-tool.md`, `memory/cards/executor-replanning--abandon-run-tool.md`.
+
+## Boundary Crossings
+
+```text
+→ execute auto-replan entry point
+→ recommendation / retry eligibility
+→ conservative policy decision
+→ one existing bounded replanning action or refusal
+→ run update publication
+```
+
+## Risks and Assumptions
+
+- RISK: automatic mode silently creates a replacement run and surprises the user. → MITIGATION: first policy auto-executes only `retry_current_step` and early-run `regenerate_plan`; stale started runs return `needs_human_start_new_run` unless an explicit future setting is scoped.
+- RISK: retry loops can burn compute or hide repeated failures. → MITIGATION: require an explicit small retry budget parameter or policy default and report `retry_budget_exhausted` without mutation.
+- RISK: automation duplicates lifecycle-driver orchestration. → MITIGATION: delegate retry to the existing one-step retry helper; do not add a new scheduler path.
+- ASSUMPTION: `retry_current_step` and early `regenerate_plan` are safe automatic actions because they preserve or refresh pre-evidence state under existing guards.
+ → IMPACT IF FALSE: the policy must downgrade to recommendation-only or ask-human for every action.
+ → VALIDATE: core policy tests assert no mutation for stale-started, terminal, missing, blocked, or budget-exhausted runs.
+
+## Posture check
+
+This is a proving tracer: it tells whether automatic replanning can exist without weakening the existing evidence-preserving HITL invariant. Landing it should prove the safe subset is useful and sharply name what remains human-gated.
+
+## Acceptance Criteria
+
+✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — fresh active runs with budget recommend and execute exactly one retry-current-step action through the injected retry delegate.
+✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — stale early runs execute exactly one regenerate-plan action through the injected regenerate delegate.
+✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — stale started runs refuse with `needs_human_start_new_run` and never call supersession.
+✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — terminal, abandoned, missing, blocked, and budget-exhausted states perform no mutation.
+✓ `src/.pi/extensions/__tests__/registry.test.ts` — any exposed `execute_replan_auto` tool is executor-only, reports side effects honestly, and returns the policy decision.
+
+Implemented 2026-07-08 as pure executor core in `run-auto-replan-policy.ts`, with no Pi tool exposed in this slice. The policy delegates only `retry_current_step` and `regenerate_plan`, refuses stale-started/missing runs with human start-new-run, and keeps terminal/blocked/budget-exhausted runs mutation-free. Verification: `npm run test -- src/executor/__tests__/run-auto-replan-policy.test.ts`; `npm run verify`.
+
+## Verification Approach
+
+- Inner: pure executor policy tests with injected delegates — proves action selection without filesystem coupling.
+- Inner: registry/tool tests if `execute_replan_auto` is exposed — proves executor-only tool admission and honest side-effect reporting.
+- Middle: existing replanning core/helper tests — prove each delegated action keeps its state guard.
+
+## Cross-cutting obligations
+
+- Do not auto-supersede stale started runs in this slice.
+- Do not delete artifacts, mutate graph state, or rewrite run plans after execution evidence exists.
+- Preserve run-driver separation: automatic replanning chooses a bounded recovery action; it does not become a second orchestrator.
+- If Slice 1 from `executor-replanning--web-actions.md` lands first, keep web auto-action wiring out of this card unless separately scoped.
+
+## Expected touched paths (tentative)
+
+```text
+src/executor/
+├── run-auto-replan-policy.ts +
+├── __tests__/run-auto-replan-policy.test.ts +
+└── TOPOLOGY.md ~
+src/.pi/extensions/executor/
+├── execute-replan-auto/index.ts ?
+└── TOPOLOGY.md ?
+src/.pi/extensions/__tests__/registry.test.ts ?
+src/agents/runtime/executor/active-tools.ts ?
+src/session/schema/tool-names.ts ?
+```
diff --git a/memory/cards/executor-replanning--web-actions.md b/memory/cards/executor-replanning--web-actions.md
new file mode 100644
index 000000000..3dbe0a05a
--- /dev/null
+++ b/memory/cards/executor-replanning--web-actions.md
@@ -0,0 +1,155 @@
+# Executor Replanning Web Actions
+
+Frontier: executor-replanning
+Linear: FE-1114
+Status: done
+Mode: slices
+Created: 2026-07-08
+
+## Orientation
+
+- Containing seam: executor replanning over run freshness, retry eligibility, recommendation, and bounded explicit run mutations.
+- Relevant frontier item: `executor-replanning` / FE-1114 exists as the active card family and Graphite branch (`ka/fe-1114-executor-replanning`); `memory/PLAN.md` currently references it only indirectly through FE-1166, so final tie-off should reconcile PLAN rather than inventing a new frontier.
+- Volatile handoff state: FE-1141 (`executor-run-observer`) already owns `/runs` and `/runs/$runId` read surfaces, including lineage and graph/run traceability; this card must extend that surface without making the browser read run-bundle files directly.
+- Main open risk: browser actions require a Brunch public RPC mutation seam; `retry_current_step` additionally requires `ExecutionPorts` and model runtime, so it is not the same class as recommendation, regenerate-plan, start-new-run, or abandon.
+
+Posture: proving (inherited from `executor-replanning`).
+
+## Slice 1 — Web-Safe Replanning RPC Mutations
+
+Status: done
+Weight: full
+
+### Target Behavior
+
+The public RPC surface exposes the web-safe replanning recommendation and metadata/file mutation helpers as product-shaped execute methods.
+
+### Full-card cold-start reads
+
+- `memory/SPEC.md` — D98-L, D111-L, D112-L, I58-L.
+- `memory/PLAN.md` — `executor-run-observer`, `orchestrator-tool-port`, indirect `executor-replanning` branch reference.
+- `src/executor/TOPOLOGY.md` — replanning helper boundaries and side-effect rules.
+- `src/rpc/TOPOLOGY.md` — execute read-projection surface and web query/mutation mapping table.
+- `src/web/TOPOLOGY.md` — web sidecar boundary and query/mutation ownership.
+
+### Boundary Crossings
+
+```text
+→ web/RPC client request
+→ rpc execute.* public method
+→ executor replanning helper or web-safe bounded mutation
+→ product update invalidation for execute.runs / execute.run
+```
+
+### Risks and Assumptions
+
+- RISK: duplicating Pi tool behavior in RPC causes two action semantics. → MITIGATION: RPC methods delegate to the same executor core helpers used by the tools and assert the same refusal classes in tests.
+- RISK: browser write access weakens the read-only observer boundary from FE-1141. → MITIGATION: keep methods narrow, run-scoped, and explicit; no generic executor tool bridge, no graph mutation, no artifact deletion.
+- RISK: `retry_current_step` looks like another replanning mutation but actually needs `ExecutionPorts` and model runtime. → MITIGATION: exclude web retry from this slice; expose it only through the existing executor tool until a separate host-authority slice wires execution ports into RPC/web-host context deliberately.
+- ASSUMPTION: Web-side replanning actions are product-host mutations, not Pi tool invocations.
+ → IMPACT IF FALSE: the browser would need a command broker into the active executor agent rather than direct host RPC methods.
+ → VALIDATE: route-level and RPC tests prove the methods work without an active assistant turn.
+
+### Posture check
+
+This is a proving slice: it lights up the first browser-callable executor replanning mutation path and stabilizes which replanning actions are host metadata/file actions versus executor-runtime actions.
+
+### Acceptance Criteria
+
+✓ `src/rpc/methods/__tests__/execute.test.ts` — `execute.replanRecommendation` returns the same recommendation classes as `run-replan-recommendation` for fresh, stale, and terminal fixture runs.
+✓ `src/rpc/methods/__tests__/execute.test.ts` — `execute.replanRegeneratePlan`, `execute.replanStartNewRun`, and `execute.replanAbandonRun` refuse disallowed run states with the same typed status as the executor helper/tool path.
+✓ `src/rpc/methods/__tests__/execute.test.ts` — `retry_current_step` is not exposed as a web mutation and is documented as requiring executor-runtime authority.
+✓ `src/rpc/__tests__/handlers.test.ts` — the new execute replanning methods are discoverable and carry declared side-effect metadata.
+✓ Product-update test — successful replanning mutations publish `execute.runs` and exact `execute.run(runId)` invalidations.
+
+Implemented 2026-07-08 as `execute.replanRecommendation`, `execute.replanRegeneratePlan`, `execute.replanStartNewRun`, and `execute.replanAbandonRun`. `execute.replanRetryCurrentStep` remains intentionally unexposed through web RPC because it requires `ExecutionPorts` and executor runtime context. Verification: `npm run test -- src/rpc/methods/__tests__/execute.test.ts src/rpc/__tests__/handlers.test.ts`; `npm run fix`.
+
+### Verification Approach
+
+- Inner: focused RPC method tests over temp run bundles — proves method shape, refusal parity, and update publication.
+- Middle: existing executor core/tool tests — prove helpers remain the single semantic source.
+
+### Cross-cutting obligations
+
+- Preserve I58-L side-effect honesty: every mutation has one explicit run-bundle side effect and no graph mutation.
+- Preserve FE-1141 projection firewall: browser receives product-shaped RPC results, never raw `.brunch/cook/runs/**` path contracts.
+- Preserve replanning evidence honesty: never rewrite a run plan after execution evidence exists; supersession creates a linked run.
+- Preserve executor-runtime authority: web retry is out of this slice unless `ExecutionPorts` are deliberately added to RPC context by a later card.
+
+### Expected touched paths (tentative)
+
+```text
+src/rpc/
+├── methods/
+│ ├── execute.ts ~
+│ └── __tests__/execute.test.ts ~
+├── __tests__/handlers.test.ts ~
+├── product-updates.ts ~
+└── TOPOLOGY.md ~
+src/web/
+├── queries/execute.ts ~
+└── query-keys.ts ?
+```
+
+## Slice 2 — Run Detail Replanning Panel
+
+Status: done
+Weight: light
+
+### Objective
+
+Show the replanning diagnosis and explicit allowed actions on `/runs/$runId` so a human can choose a safe recovery path from the run observer.
+
+### Light-card cold-start reads
+
+- `memory/SPEC.md` — D98-L, D111-L, D112-L, I58-L.
+- `memory/PLAN.md` — `executor-run-observer`; indirect `executor-replanning` branch reference.
+- `src/web/TOPOLOGY.md` — `/runs/$runId` route ownership and web sidecar rules.
+- This scope file — Slice 1 RPC method names and result shape.
+
+### Acceptance Criteria
+
+✓ `/runs/$runId` renders recommendation status, diagnosis text, recommended action, and allowed alternatives when the recommendation method succeeds.
+✓ `/runs/$runId` renders disabled/explanatory states for actions not in `allowedActions` or not web-callable, including `retry_current_step`.
+✓ Clicking a web-callable allowed action calls the matching RPC mutation, invalidates/refetches run list/detail, and leaves the user on a readable result state.
+✓ Route tests cover fresh retry, stale early regenerate, stale started start-new-run, and abandon flows using fixture-shaped RPC responses.
+
+Implemented 2026-07-08 on `/runs/$runId` as a replanning panel that renders diagnosis, recommended action, allowed actions, and web-callable mutation buttons. `retry_current_step` renders disabled with executor-runtime copy. Verification: `npm run test -- src/web/__tests__/runs-route.test.tsx`; `npm run fix`.
+
+### Verification Approach
+
+- Inner: `src/web/__tests__/runs-route.test.tsx` route/component tests with fake RPC client responses.
+- Middle: optional `src/web/__tests__/app.test.tsx` integration route test if query/mutation wiring crosses route loader assumptions.
+- Outer: manual browser smoke per `docs/praxis/manual-testing.md` on one fixture or scratch run after Slice 1 lands.
+
+### Cross-cutting obligations
+
+- Browser remains a product RPC client only; no direct filesystem reads and no generic tool bridge.
+- UI copy must make replanning evidence-preserving behavior explicit: stale started runs create linked runs instead of rewriting old evidence.
+
+### Assumption dependency
+
+None — this slice depends on Slice 1's RPC surface, not on a live SPEC assumption.
+
+### Expected touched paths (tentative)
+
+```text
+src/web/
+├── routes/runs.tsx ~
+├── queries/execute.ts ~
+├── __tests__/runs-route.test.tsx ~
+├── __tests__/app.test.tsx ?
+└── TOPOLOGY.md ~
+```
+
+### Promotion checklist
+
+- [ ] Does this change a requirement?
+- [ ] Does this create, retire, or invalidate an assumption?
+- [ ] Does this slice depend on an unvalidated high-impact assumption?
+- [ ] Does this make or reverse a non-trivial design decision?
+- [ ] Does this establish a new seam-level invariant?
+- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer?
+- [ ] Does it cross more than two major seams?
+- [ ] Is this the first touch in an unfamiliar seam from a fresh thread?
+- [ ] Can you not name the containing seam or current rationale from the live docs?
diff --git a/src/executor/TOPOLOGY.md b/src/executor/TOPOLOGY.md
index 0c695a5b4..e93fb5f45 100644
--- a/src/executor/TOPOLOGY.md
+++ b/src/executor/TOPOLOGY.md
@@ -23,6 +23,7 @@ executor/
├── report.ts source-copied run -> reports.jsonl initialization
├── report-verdict.ts reports.jsonl -> latest per-slice verification verdicts
├── run-complete.ts completed slices -> run completion marker
+├── run-auto-replan-policy.ts recommendation + retry budget -> conservative delegated action/refusal
├── run-freshness.ts run metadata + plan provenance -> retry/replan freshness diagnosis
├── run-replan-recommendation.ts run retry eligibility -> human-readable recommendation
├── run-retry-eligibility.ts run freshness + lifecycle status -> safe HITL action set
@@ -84,6 +85,8 @@ rules:
`run-replan-recommendation.ts` wraps retry eligibility in concise human-facing diagnosis text plus one recommended action. It is still read-only core: no prompts, no tool registration, no action execution, and no run mutation.
+`run-auto-replan-policy.ts` is the conservative automation policy over a precomputed recommendation. It auto-delegates only fresh-run `retry_current_step` when retry budget remains and stale-early `regenerate_plan`; stale started/missing runs require human start-new-run, and terminal/blocked runs stay inspect-only. It owns no filesystem, driver, tool, or graph effects: retry/regenerate are injected delegates, and the policy never auto-supersedes.
+
`run-supersession.ts` is the first bounded mutation helper in the HITL replanning family. Given an existing run and a fresh launch-ready plan, it creates a new `created` run with `supersedesRunId` pointing to the prior run. It refuses missing prior runs, stale/non-ready plans, and target run id collisions, and it never mutates the prior run.
`worktree.ts` creates the run workspace from the metadata substrate. The default `git_worktree` substrate creates a real git worktree through the injected `GitWorktreePort` (app-layer `git worktree add --detach HEAD`) and updates `run.json` to `status:"worktree_created"`. The `empty_dir` substrate creates only the isolated run directory and never invokes the git port. If the selected port operation fails, run metadata is not advanced (`status:"worktree_create_failed"`). Source population, scaffold templating, agent execution, Petri artifacts, report logs, promotion refs, and land branches remain deferred.
diff --git a/src/executor/__tests__/run-auto-replan-policy.test.ts b/src/executor/__tests__/run-auto-replan-policy.test.ts
new file mode 100644
index 000000000..aac0c8e19
--- /dev/null
+++ b/src/executor/__tests__/run-auto-replan-policy.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { applyAutoReplanPolicy, type AutoReplanDelegates } from '../run-auto-replan-policy.js';
+import type { RunReplanRecommendation } from '../run-replan-recommendation.js';
+
+function recommendation(
+ status: RunReplanRecommendation['status'],
+ overrides: Partial = {},
+): RunReplanRecommendation {
+ return {
+ runId: 'run-1',
+ status,
+ runStatus: status === 'missing_run' ? 'not_started' : 'created',
+ diagnosis: status,
+ recommendedAction: 'inspect_run',
+ allowedActions: ['inspect_run'],
+ eligibility: {} as RunReplanRecommendation['eligibility'],
+ sideEffects: [],
+ ...overrides,
+ };
+}
+
+function delegates(): AutoReplanDelegates & {
+ retryCurrentStep: ReturnType>;
+ regeneratePlan: ReturnType>;
+} {
+ return {
+ retryCurrentStep: vi.fn(async () => ({
+ status: 'retried_current_step',
+ sideEffects: [{ kind: 'delegated_retry' }],
+ })),
+ regeneratePlan: vi.fn(async () => ({
+ status: 'regenerated_plan',
+ sideEffects: [{ kind: 'write_file' }],
+ })),
+ };
+}
+
+describe('applyAutoReplanPolicy', () => {
+ it('executes one retry-current-step action for fresh active runs with budget', async () => {
+ const ports = delegates();
+
+ const result = await applyAutoReplanPolicy({
+ recommendation: recommendation('retry_current_run', {
+ recommendedAction: 'retry_current_step',
+ allowedActions: ['retry_current_step', 'inspect_run', 'abandon_run'],
+ }),
+ retryBudgetRemaining: 1,
+ delegates: ports,
+ });
+
+ expect(result).toMatchObject({
+ status: 'retried_current_step',
+ result: { status: 'retried_current_step' },
+ sideEffects: [{ kind: 'delegated_retry' }],
+ });
+ expect(ports.retryCurrentStep).toHaveBeenCalledTimes(1);
+ expect(ports.regeneratePlan).not.toHaveBeenCalled();
+ });
+
+ it('executes one regenerate-plan action for stale early runs', async () => {
+ const ports = delegates();
+
+ const result = await applyAutoReplanPolicy({
+ recommendation: recommendation('replan_before_retry', {
+ recommendedAction: 'regenerate_plan',
+ allowedActions: ['regenerate_plan', 'start_new_run', 'abandon_run'],
+ }),
+ retryBudgetRemaining: 0,
+ delegates: ports,
+ });
+
+ expect(result).toMatchObject({
+ status: 'regenerated_plan',
+ result: { status: 'regenerated_plan' },
+ sideEffects: [{ kind: 'write_file' }],
+ });
+ expect(ports.regeneratePlan).toHaveBeenCalledTimes(1);
+ expect(ports.retryCurrentStep).not.toHaveBeenCalled();
+ });
+
+ it('refuses stale started runs instead of auto-superseding', async () => {
+ const ports = delegates();
+
+ const result = await applyAutoReplanPolicy({
+ recommendation: recommendation('start_new_run_required', {
+ recommendedAction: 'start_new_run',
+ allowedActions: ['start_new_run', 'inspect_run', 'abandon_run'],
+ }),
+ retryBudgetRemaining: 1,
+ delegates: ports,
+ });
+
+ expect(result).toEqual({
+ status: 'needs_human_start_new_run',
+ recommendation: result.recommendation,
+ sideEffects: [],
+ });
+ expect(ports.retryCurrentStep).not.toHaveBeenCalled();
+ expect(ports.regeneratePlan).not.toHaveBeenCalled();
+ });
+
+ it('does not mutate for terminal, missing, blocked, or budget-exhausted runs', async () => {
+ for (const item of [
+ { status: 'terminal_run' as const, expected: 'inspect_only' },
+ { status: 'missing_run' as const, expected: 'needs_human_start_new_run' },
+ { status: 'projection_blocked' as const, expected: 'inspect_only' },
+ { status: 'retry_current_run' as const, expected: 'retry_budget_exhausted' },
+ ]) {
+ const ports = delegates();
+ const result = await applyAutoReplanPolicy({
+ recommendation: recommendation(item.status),
+ retryBudgetRemaining: 0,
+ delegates: ports,
+ });
+
+ expect(result.status).toBe(item.expected);
+ expect(result.sideEffects).toEqual([]);
+ expect(ports.retryCurrentStep).not.toHaveBeenCalled();
+ expect(ports.regeneratePlan).not.toHaveBeenCalled();
+ }
+ });
+});
diff --git a/src/executor/run-auto-replan-policy.ts b/src/executor/run-auto-replan-policy.ts
new file mode 100644
index 000000000..c39d8a9d4
--- /dev/null
+++ b/src/executor/run-auto-replan-policy.ts
@@ -0,0 +1,70 @@
+import type { RunReplanRecommendation } from './run-replan-recommendation.js';
+
+export interface AutoReplanActionResult {
+ readonly status: string;
+ readonly sideEffects: readonly unknown[];
+}
+
+export interface AutoReplanDelegates {
+ retryCurrentStep(): Promise;
+ regeneratePlan(): Promise;
+}
+
+export type AutoReplanPolicyResult =
+ | {
+ readonly status: 'retried_current_step';
+ readonly recommendation: RunReplanRecommendation;
+ readonly result: AutoReplanActionResult;
+ readonly sideEffects: AutoReplanActionResult['sideEffects'];
+ }
+ | {
+ readonly status: 'regenerated_plan';
+ readonly recommendation: RunReplanRecommendation;
+ readonly result: AutoReplanActionResult;
+ readonly sideEffects: AutoReplanActionResult['sideEffects'];
+ }
+ | {
+ readonly status:
+ | 'retry_budget_exhausted'
+ | 'needs_human_start_new_run'
+ | 'inspect_only'
+ | 'automation_not_allowed';
+ readonly recommendation: RunReplanRecommendation;
+ readonly sideEffects: readonly [];
+ };
+
+export async function applyAutoReplanPolicy(args: {
+ readonly recommendation: RunReplanRecommendation;
+ readonly retryBudgetRemaining: number;
+ readonly delegates: AutoReplanDelegates;
+}): Promise {
+ switch (args.recommendation.status) {
+ case 'retry_current_run': {
+ if (args.retryBudgetRemaining <= 0) {
+ return { status: 'retry_budget_exhausted', recommendation: args.recommendation, sideEffects: [] };
+ }
+ const result = await args.delegates.retryCurrentStep();
+ return {
+ status: 'retried_current_step',
+ recommendation: args.recommendation,
+ result,
+ sideEffects: result.sideEffects,
+ };
+ }
+ case 'replan_before_retry': {
+ const result = await args.delegates.regeneratePlan();
+ return {
+ status: 'regenerated_plan',
+ recommendation: args.recommendation,
+ result,
+ sideEffects: result.sideEffects,
+ };
+ }
+ case 'start_new_run_required':
+ case 'missing_run':
+ return { status: 'needs_human_start_new_run', recommendation: args.recommendation, sideEffects: [] };
+ case 'terminal_run':
+ case 'projection_blocked':
+ return { status: 'inspect_only', recommendation: args.recommendation, sideEffects: [] };
+ }
+}
diff --git a/src/rpc/TOPOLOGY.md b/src/rpc/TOPOLOGY.md
index 81a30b41d..2d1146fda 100644
--- a/src/rpc/TOPOLOGY.md
+++ b/src/rpc/TOPOLOGY.md
@@ -371,6 +371,12 @@ query key families:
| `execute.runs` | `executeRunsQueryOptions(rpc)` | implemented; run observer list route | exact `execute.runs` |
| `execute.run` | `executeRunQueryOptions(rpc, runId)` | implemented; run detail route incl. reports + worker/verify stream tails | exact `execute.run(runId)` |
| `execute.runTraceIndex` | `executeRunTraceIndexQueryOptions(rpc, specId)` | implemented; spec graph run badges | exact `execute.runTraceIndex(specId)` when graph/run evidence changes |
+| `execute.replanRecommendation` | target query helper | implemented; web-safe replanning diagnosis | none |
+| `execute.replanRegeneratePlan` | target mutation helper | implemented; stale early-run plan/provenance regeneration | exact `execute.runs` + `execute.run(runId)` on write |
+| `execute.replanStartNewRun` | target mutation helper | implemented; create linked superseding run | exact old/new `execute.run(runId)` + `execute.runs` on write |
+| `execute.replanAbandonRun` | target mutation helper | implemented; evidence-preserving abandon | exact `execute.runs` + `execute.run(runId)` on write |
+
+`execute.replanRetryCurrentStep` is deliberately **not** a public web RPC method: retrying a lifecycle step requires `ExecutionPorts` and executor runtime/model context, so it remains on the executor tool surface until a separate host-authority slice deliberately wires those ports into RPC/web-host context.
Route/use pattern:
diff --git a/src/rpc/__tests__/handlers.test.ts b/src/rpc/__tests__/handlers.test.ts
index 51414abd6..d8275040b 100644
--- a/src/rpc/__tests__/handlers.test.ts
+++ b/src/rpc/__tests__/handlers.test.ts
@@ -305,6 +305,10 @@ describe('JSON-RPC handlers', () => {
}
).methods;
expect(methods.map((entry) => entry.method).sort()).toEqual([
+ 'execute.replanAbandonRun',
+ 'execute.replanRecommendation',
+ 'execute.replanRegeneratePlan',
+ 'execute.replanStartNewRun',
'execute.run',
'execute.runTraceIndex',
'execute.runs',
diff --git a/src/rpc/methods/__tests__/execute.test.ts b/src/rpc/methods/__tests__/execute.test.ts
index 3bea0c7dc..494e93d9d 100644
--- a/src/rpc/methods/__tests__/execute.test.ts
+++ b/src/rpc/methods/__tests__/execute.test.ts
@@ -1,10 +1,14 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
-import { runDirPath, runMetadataPath } from '../../../executor/run.js';
+import { planFilePath, planProvenancePath } from '../../../executor/plan-file.js';
+import { runDirPath, runMetadataPath, type RunMetadata } from '../../../executor/run.js';
+import type { GraphEdge } from '../../../graph/schema/edges.js';
+import type { GraphNode } from '../../../graph/schema/nodes.js';
+import { createProductUpdatePublisher, type ProductUpdate } from '../../product-updates.js';
import type { JsonRpcRequest } from '../../protocol.js';
import { executeRpcMethods, UNKNOWN_RUN_ID_MESSAGE } from '../execute.js';
import type { RpcMethodContext } from '../registry.js';
@@ -15,6 +19,30 @@ function contextFor(cwd: string): RpcMethodContext {
return { cwd } as RpcMethodContext;
}
+function contextForSpec(
+ cwd: string,
+ graph: { readonly nodes: readonly GraphNode[]; readonly edges: readonly GraphEdge[]; readonly lsn: number },
+ updates?: ProductUpdate[],
+): RpcMethodContext {
+ const productUpdates = createProductUpdatePublisher();
+ productUpdates.subscribe((published) => updates?.push(...published));
+ return {
+ cwd,
+ productUpdates,
+ getGraphRuntime: async () => ({
+ commandExecutor: {} as never,
+ forSpec: () => ({
+ queryGraph: () => graph,
+ getNodes: () => [],
+ resolveNodeCode: () => undefined,
+ resolveEdgeId: () => undefined,
+ getOpenReconciliationNeeds: () => [],
+ latestLsn: () => graph.lsn,
+ }),
+ }),
+ } as unknown as RpcMethodContext;
+}
+
function method(name: string) {
const definition = executeRpcMethods.find((entry) => entry.method === name);
if (!definition) throw new Error(`missing method ${name}`);
@@ -33,14 +61,82 @@ function request(name: string, params?: unknown): JsonRpcRequest {
async function writeRun(
cwd: string,
runId: string,
- options: { readonly planPath?: string } = {},
+ options: {
+ readonly planPath?: string;
+ readonly status?: RunMetadata['status'];
+ readonly specId?: string;
+ } = {},
): Promise {
await mkdir(runDirPath(cwd, runId), { recursive: true });
await writeFile(
runMetadataPath(cwd, runId),
- `${JSON.stringify({ runId, specId: '42', planPath: options.planPath ?? '/plan.yaml', status: 'created' })}\n`,
+ `${JSON.stringify({ runId, specId: options.specId ?? '42', planPath: options.planPath ?? '/plan.yaml', status: options.status ?? 'created' })}\n`,
+ 'utf8',
+ );
+}
+
+async function writePlan(cwd: string, specId = '42', graphLsn = 11): Promise {
+ const path = planFilePath(cwd, specId);
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(
+ path,
+ `${JSON.stringify({ mode: 'greenfield', spec: { spec_id: specId }, epics: [], slices: [] })}\n`,
'utf8',
);
+ await writeFile(
+ planProvenancePath(cwd, specId),
+ `${JSON.stringify({ schemaVersion: 1, specId, mode: 'greenfield', source: { graphLsn, visibility: 'active' } })}\n`,
+ 'utf8',
+ );
+}
+
+function executableGraph(lsn = 11): {
+ readonly nodes: readonly GraphNode[];
+ readonly edges: readonly GraphEdge[];
+ readonly lsn: number;
+} {
+ const requirement: GraphNode = {
+ id: 1,
+ specId: 42,
+ plane: 'intent',
+ kind: 'requirement',
+ kindOrdinal: 1,
+ title: 'Build run observer actions',
+ basis: 'explicit',
+ settlement: 'settled',
+ createdAtLsn: 1,
+ updatedAtLsn: 1,
+ };
+ const criterion: GraphNode = {
+ id: 2,
+ specId: 42,
+ plane: 'intent',
+ kind: 'criterion',
+ kindOrdinal: 1,
+ title: 'Actions are safe',
+ basis: 'explicit',
+ settlement: 'settled',
+ createdAtLsn: 1,
+ updatedAtLsn: 1,
+ };
+ return {
+ lsn,
+ nodes: [requirement, criterion],
+ edges: [
+ {
+ id: 1,
+ specId: 42,
+ category: 'witness',
+ sourceId: criterion.id,
+ targetId: requirement.id,
+ stance: 'for',
+ basis: 'explicit',
+ settlement: 'settled',
+ createdAtLsn: 1,
+ updatedAtLsn: 1,
+ },
+ ],
+ };
}
describe('execute.runs', () => {
@@ -391,3 +487,98 @@ describe('execute.runTraceIndex', () => {
expect(JSON.stringify(response)).not.toContain(planPath);
});
});
+
+describe('execute replanning methods', () => {
+ it('returns the same recommendation classes as executor core', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-recommend-'));
+ await writePlan(cwd, '42', 10);
+ await writeRun(cwd, 'run-1', { planPath: planFilePath(cwd, '42'), status: 'worktree_created' });
+
+ const response = await method('execute.replanRecommendation').handle(
+ contextForSpec(cwd, executableGraph(11)),
+ request('execute.replanRecommendation', { runId: 'run-1', specId: 42 }),
+ );
+
+ expect(response).toMatchObject({
+ result: {
+ status: 'replan_before_retry',
+ recommendedAction: 'regenerate_plan',
+ allowedActions: ['regenerate_plan', 'start_new_run', 'abandon_run'],
+ },
+ });
+ });
+
+ it('regenerates a stale early-run plan and publishes run updates', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-regenerate-'));
+ const updates: ProductUpdate[] = [];
+ await writePlan(cwd, '42', 10);
+ await writeRun(cwd, 'run-1', { planPath: planFilePath(cwd, '42'), status: 'worktree_created' });
+
+ const response = await method('execute.replanRegeneratePlan').handle(
+ contextForSpec(cwd, executableGraph(11), updates),
+ request('execute.replanRegeneratePlan', { runId: 'run-1', specId: 42 }),
+ );
+
+ expect(response).toMatchObject({
+ result: {
+ status: 'regenerated_plan',
+ eligibility: { status: 'replan_before_retry' },
+ sideEffects: [{ kind: 'write_file' }, { kind: 'write_file' }],
+ },
+ });
+ expect(updates).toEqual([{ topic: 'execute.runs' }, { topic: 'execute.run', runId: 'run-1' }]);
+ });
+
+ it('refuses to expose retry-current-step through web RPC', () => {
+ expect(
+ executeRpcMethods.find((entry) => entry.method === 'execute.replanRetryCurrentStep'),
+ ).toBeUndefined();
+ });
+
+ it('creates a linked superseding run and publishes old and new run updates', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-new-run-'));
+ const updates: ProductUpdate[] = [];
+ await writePlan(cwd, '42', 11);
+ await writeRun(cwd, 'run-old', { planPath: planFilePath(cwd, '42'), status: 'worktree_populated' });
+
+ const response = await method('execute.replanStartNewRun').handle(
+ contextForSpec(cwd, executableGraph(11), updates),
+ request('execute.replanStartNewRun', { previousRunId: 'run-old', runId: 'run-new', specId: 42 }),
+ );
+
+ expect(response).toMatchObject({
+ result: {
+ status: 'created',
+ previousRunId: 'run-old',
+ runId: 'run-new',
+ sideEffects: [{ kind: 'mkdir' }, { kind: 'write_file' }],
+ },
+ });
+ expect(updates).toEqual([
+ { topic: 'execute.runs' },
+ { topic: 'execute.run', runId: 'run-old' },
+ { topic: 'execute.runs' },
+ { topic: 'execute.run', runId: 'run-new' },
+ ]);
+ });
+
+ it('marks a run abandoned and publishes exact run updates', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-abandon-'));
+ const updates: ProductUpdate[] = [];
+ await writeRun(cwd, 'run-1', { status: 'agent_result_ingested' });
+
+ const response = await method('execute.replanAbandonRun').handle(
+ contextForSpec(cwd, executableGraph(), updates),
+ request('execute.replanAbandonRun', { runId: 'run-1', reason: 'User chose to replan' }),
+ );
+
+ expect(response).toMatchObject({
+ result: {
+ status: 'abandoned',
+ runStatus: 'abandoned',
+ sideEffects: [{ kind: 'write_file' }],
+ },
+ });
+ expect(updates).toEqual([{ topic: 'execute.runs' }, { topic: 'execute.run', runId: 'run-1' }]);
+ });
+});
diff --git a/src/rpc/methods/execute.ts b/src/rpc/methods/execute.ts
index c65e30c88..cb2072c2a 100644
--- a/src/rpc/methods/execute.ts
+++ b/src/rpc/methods/execute.ts
@@ -1,11 +1,18 @@
-import { Type } from 'typebox';
+import { Type, type Static, type TSchema } from 'typebox';
import { Value } from 'typebox/value';
+import { projectExecuteGraph } from '../../executor/execute-projection.js';
import { listRuns, readRunDetail, readRunTraceIndex } from '../../executor/observer-read.js';
+import { writePlanFile, type PlanFileWriteResult } from '../../executor/plan-file.js';
+import { abandonRun } from '../../executor/run-abandon.js';
+import { recommendRunReplan } from '../../executor/run-replan-recommendation.js';
+import { type RunRetryEligibilityResult } from '../../executor/run-retry-eligibility.js';
+import { createSupersedingRun } from '../../executor/run-supersession.js';
import { assertSafeRunId } from '../../executor/run.js';
+import { executeRunProductUpdates } from '../product-updates.js';
import { createJsonRpcFailure, createJsonRpcSuccess, jsonRpcRequestId } from '../protocol.js';
import type { RpcMethodContext, RpcMethodDefinition } from './registry.js';
-import { NoParamsSchema, NonBlankStringSchema } from './schemas.js';
+import { NoParamsSchema, NonBlankStringSchema, PositiveIntegerSchema } from './schemas.js';
export const UNKNOWN_RUN_ID_MESSAGE = 'Unknown runId';
@@ -14,6 +21,41 @@ const ExecuteRunTraceIndexParamsSchema = Type.Object(
{ specId: Type.Integer({ minimum: 1 }) },
{ additionalProperties: false },
);
+const ExecuteModeSchema = Type.Union([Type.Literal('greenfield'), Type.Literal('brownfield')]);
+const ExecuteReplanRecommendationParamsSchema = Type.Object(
+ {
+ runId: NonBlankStringSchema,
+ specId: PositiveIntegerSchema,
+ mode: Type.Optional(ExecuteModeSchema),
+ },
+ { additionalProperties: false },
+);
+const ExecuteReplanRegeneratePlanParamsSchema = Type.Object(
+ {
+ runId: NonBlankStringSchema,
+ specId: PositiveIntegerSchema,
+ mode: Type.Optional(ExecuteModeSchema),
+ },
+ { additionalProperties: false },
+);
+const ExecuteReplanStartNewRunParamsSchema = Type.Object(
+ {
+ previousRunId: NonBlankStringSchema,
+ specId: PositiveIntegerSchema,
+ runId: Type.Optional(NonBlankStringSchema),
+ mode: Type.Optional(ExecuteModeSchema),
+ },
+ { additionalProperties: false },
+);
+const ExecuteReplanAbandonRunParamsSchema = Type.Object(
+ {
+ runId: NonBlankStringSchema,
+ reason: Type.Optional(NonBlankStringSchema),
+ },
+ { additionalProperties: false },
+);
+
+type ExecuteReplanRegeneratePlanParams = Static;
const RunPresenceSchema = Type.Object(
{
@@ -149,6 +191,26 @@ const ExecuteRunTraceIndexResultSchema = Type.Object(
{ traces: Type.Array(RunTraceEntrySchema) },
{ additionalProperties: false },
);
+const ExecuteReplanRecommendationResultSchema = Type.Object({}, { additionalProperties: true });
+const ExecuteReplanMutationResultSchema = Type.Object({}, { additionalProperties: true });
+
+type ExecuteReplanRegeneratePlanResult =
+ | {
+ readonly status: 'regenerate_not_allowed';
+ readonly eligibility: RunRetryEligibilityResult;
+ readonly sideEffects: readonly [];
+ }
+ | {
+ readonly status: 'projection_blocked';
+ readonly eligibility: RunRetryEligibilityResult;
+ readonly sideEffects: readonly [];
+ }
+ | {
+ readonly status: 'regenerated_plan';
+ readonly eligibility: RunRetryEligibilityResult;
+ readonly artifact: PlanFileWriteResult;
+ readonly sideEffects: PlanFileWriteResult['sideEffects'];
+ };
export const executeRpcMethods: readonly RpcMethodDefinition[] = [
{
@@ -211,4 +273,208 @@ export const executeRpcMethods: readonly RpcMethodDefinition[]
);
},
},
+ {
+ method: 'execute.replanRecommendation',
+ access: 'read',
+ description:
+ 'Return a human-readable retry/replan diagnosis and allowed action set for one executor run, without mutating run state.',
+ paramsSchema: ExecuteReplanRecommendationParamsSchema,
+ resultSchema: ExecuteReplanRecommendationResultSchema,
+ examples: [
+ {
+ jsonrpc: '2.0',
+ id: 23,
+ method: 'execute.replanRecommendation',
+ params: { runId: 'run-1', specId: 1 },
+ },
+ ],
+ async handle(context, request) {
+ const requestId = jsonRpcRequestId(request);
+ const params = parseParams(ExecuteReplanRecommendationParamsSchema, request.params);
+ if (!params || !safeRunId(params.runId)) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
+ const result = await recommendRunReplan({
+ cwd: context.cwd,
+ runId: params.runId,
+ current: await currentProjection(context, params),
+ });
+ return createJsonRpcSuccess(requestId, result);
+ },
+ },
+ {
+ method: 'execute.replanRegeneratePlan',
+ access: 'write',
+ description:
+ 'Regenerate plan.yaml and provenance for a stale early executor run when current graph projection is plan-ready. Does not mutate run metadata.',
+ paramsSchema: ExecuteReplanRegeneratePlanParamsSchema,
+ resultSchema: ExecuteReplanMutationResultSchema,
+ examples: [
+ {
+ jsonrpc: '2.0',
+ id: 24,
+ method: 'execute.replanRegeneratePlan',
+ params: { runId: 'run-1', specId: 1 },
+ },
+ ],
+ async handle(context, request) {
+ const requestId = jsonRpcRequestId(request);
+ const params = parseParams(ExecuteReplanRegeneratePlanParamsSchema, request.params);
+ if (!params || !safeRunId(params.runId)) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
+ const result = await regeneratePlan(context, params);
+ if (result.sideEffects.length > 0) {
+ context.productUpdates?.publish(executeRunProductUpdates(params.runId));
+ }
+ return createJsonRpcSuccess(requestId, result);
+ },
+ },
+ {
+ method: 'execute.replanStartNewRun',
+ access: 'write',
+ description:
+ 'Create a fresh executor run that supersedes a prior run when the current plan is launch-ready. Does not mutate the prior run or execute the new run.',
+ paramsSchema: ExecuteReplanStartNewRunParamsSchema,
+ resultSchema: ExecuteReplanMutationResultSchema,
+ examples: [
+ {
+ jsonrpc: '2.0',
+ id: 25,
+ method: 'execute.replanStartNewRun',
+ params: { previousRunId: 'run-1', specId: 1 },
+ },
+ ],
+ async handle(context, request) {
+ const requestId = jsonRpcRequestId(request);
+ const params = parseParams(ExecuteReplanStartNewRunParamsSchema, request.params);
+ if (
+ !params ||
+ !safeRunId(params.previousRunId) ||
+ (params.runId !== undefined && !safeRunId(params.runId))
+ ) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
+ const result = await createSupersedingRun({
+ cwd: context.cwd,
+ previousRunId: params.previousRunId,
+ current: await currentProjection(context, params),
+ ...(params.runId ? { runId: params.runId } : {}),
+ });
+ if (result.sideEffects.length > 0) {
+ context.productUpdates?.publish([
+ ...executeRunProductUpdates(params.previousRunId),
+ ...executeRunProductUpdates(result.status === 'created' ? result.runId : undefined),
+ ]);
+ }
+ return createJsonRpcSuccess(requestId, result);
+ },
+ },
+ {
+ method: 'execute.replanAbandonRun',
+ access: 'write',
+ description:
+ 'Mark a non-terminal executor run abandoned without deleting worktree, reports, Petri, promotion, or graph state.',
+ paramsSchema: ExecuteReplanAbandonRunParamsSchema,
+ resultSchema: ExecuteReplanMutationResultSchema,
+ examples: [
+ {
+ jsonrpc: '2.0',
+ id: 26,
+ method: 'execute.replanAbandonRun',
+ params: { runId: 'run-1', reason: 'User chose to replan' },
+ },
+ ],
+ async handle(context, request) {
+ const requestId = jsonRpcRequestId(request);
+ const params = parseParams(ExecuteReplanAbandonRunParamsSchema, request.params);
+ if (!params || !safeRunId(params.runId)) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
+ const result = await abandonRun({
+ cwd: context.cwd,
+ runId: params.runId,
+ ...(params.reason ? { reason: params.reason } : {}),
+ });
+ if (result.sideEffects.length > 0) {
+ context.productUpdates?.publish(executeRunProductUpdates(params.runId));
+ }
+ return createJsonRpcSuccess(requestId, result);
+ },
+ },
];
+
+function parseParams(schema: Schema, value: unknown): Static | undefined {
+ if (!Value.Check(schema, value)) return undefined;
+ return Value.Parse(schema, value) as Static;
+}
+
+function safeRunId(runId: string): boolean {
+ try {
+ assertSafeRunId(runId);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function currentProjection(
+ context: RpcMethodContext,
+ params: { readonly specId: number; readonly mode?: 'greenfield' | 'brownfield' },
+) {
+ const graph = (await context.getGraphRuntime()).forSpec(params.specId).queryGraph(undefined, {
+ visibility: 'active',
+ });
+ const mode = params.mode ?? 'greenfield';
+ const projection = projectExecuteGraph({
+ specId: params.specId,
+ mode,
+ graphLsn: graph.lsn,
+ nodes: graph.nodes,
+ edges: graph.edges,
+ });
+ return {
+ specId: String(params.specId),
+ mode,
+ source: projection.source,
+ checkStatus: projection.check.status,
+ } as const;
+}
+
+async function regeneratePlan(
+ context: RpcMethodContext,
+ params: ExecuteReplanRegeneratePlanParams,
+): Promise {
+ const graph = (await context.getGraphRuntime()).forSpec(params.specId).queryGraph(undefined, {
+ visibility: 'active',
+ });
+ const mode = params.mode ?? 'greenfield';
+ const projection = projectExecuteGraph({
+ specId: params.specId,
+ mode,
+ graphLsn: graph.lsn,
+ nodes: graph.nodes,
+ edges: graph.edges,
+ });
+ const current = {
+ specId: String(params.specId),
+ mode,
+ source: projection.source,
+ checkStatus: projection.check.status,
+ } as const;
+ const eligibility = await recommendRunReplan({ cwd: context.cwd, runId: params.runId, current }).then(
+ (recommendation) => recommendation.eligibility,
+ );
+ if (eligibility.status !== 'replan_before_retry') {
+ return { status: 'regenerate_not_allowed', eligibility, sideEffects: [] };
+ }
+ if (projection.check.status !== 'ok') {
+ return { status: 'projection_blocked', eligibility, sideEffects: [] };
+ }
+ const artifact = await writePlanFile({
+ cwd: context.cwd,
+ preview: projection.planPreview,
+ source: projection.source,
+ });
+ return { status: 'regenerated_plan', eligibility, artifact, sideEffects: artifact.sideEffects };
+}
diff --git a/src/web/TOPOLOGY.md b/src/web/TOPOLOGY.md
index 8987a89bc..f0304335c 100644
--- a/src/web/TOPOLOGY.md
+++ b/src/web/TOPOLOGY.md
@@ -40,6 +40,7 @@ web/
execute.runs
execute.run
execute.runTraceIndex
+ execute.replanRecommendation
queries/
workspace.ts -> workspace.state + workspace.selectionState query options
@@ -71,7 +72,8 @@ web/
runs.tsx
`/runs` loader primes execute.runs; run list with presence flags
`/runs/$runId` loader primes execute.run; crank status, honest
- running indicators, requirement status panel, readable worker/verify evidence panels
+ running indicators, replanning recommendation/action panel, requirement status panel,
+ readable worker/verify evidence panels
(deduped display plus raw stream disclosure), grouped reports timeline
(events lead the run.json snapshot by design), unreadable-run marking
requirement rows link back to graph nodes and slice-log anchors
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index b19f1ba68..f1b6e957c 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
-import { cleanup, render, screen } from '@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type { WorkspaceState } from '../../projections/workspace/workspace-state.js';
import { BrunchWebApp, createBrunchWebRuntime } from '../app.js';
-import type { RunDetail, RunListEntry } from '../queries/execute.js';
+import type { ReplanRecommendation, RunDetail, RunListEntry } from '../queries/execute.js';
import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from '../rpc-client.js';
interface RpcCall {
@@ -56,6 +56,7 @@ const runDetail: RunDetail = {
function rpcClient(options?: {
runs?: RunListEntry[];
run?: RunDetail | { runId: string; unreadable: true };
+ replanRecommendation?: ReplanRecommendation;
runError?: Error;
calls?: RpcCall[];
}): WebSocketRpcClient {
@@ -75,6 +76,25 @@ function rpcClient(options?: {
}
return (options?.run ?? runDetail) as T;
}
+ if (method === 'execute.replanRecommendation') {
+ return (options?.replanRecommendation ?? {
+ runId: 'run-1',
+ status: 'retry_current_run',
+ runStatus: 'slice_execution_requested',
+ diagnosis: 'Run run-1 is fresh. Retry the current step.',
+ recommendedAction: 'retry_current_step',
+ allowedActions: ['retry_current_step', 'inspect_run', 'abandon_run'],
+ }) as T;
+ }
+ if (method === 'execute.replanRegeneratePlan') {
+ return { status: 'regenerated_plan', sideEffects: [{ kind: 'write_file' }] } as T;
+ }
+ if (method === 'execute.replanStartNewRun') {
+ return { status: 'created', sideEffects: [{ kind: 'write_file' }] } as T;
+ }
+ if (method === 'execute.replanAbandonRun') {
+ return { status: 'abandoned', sideEffects: [{ kind: 'write_file' }] } as T;
+ }
throw new Error(`unexpected RPC method ${method}`);
},
subscribe(listener: WebSocketRpcNotificationListener) {
@@ -149,6 +169,68 @@ describe('run detail route', () => {
expect(screen.getByText('worktree')).toBeTruthy();
expect(screen.getByText('petri')).toBeTruthy();
expect(calls).toContainEqual({ method: 'execute.run', params: { runId: 'run-1' } });
+ expect(calls).toContainEqual({
+ method: 'execute.replanRecommendation',
+ params: { runId: 'run-1', specId: 1 },
+ });
+ });
+
+ it('renders replanning recommendation and disables executor-runtime retry', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() });
+
+ render( );
+
+ expect(await screen.findByText('retry_current_run')).toBeTruthy();
+ expect(screen.getByText('recommended: retry current step')).toBeTruthy();
+ expect(screen.getByText(/requires executor runtime authority/u)).toBeTruthy();
+ expect((screen.getByRole('button', { name: 'Retry current step' }) as HTMLButtonElement).disabled).toBe(
+ true,
+ );
+ expect((screen.getByRole('button', { name: 'Abandon run' }) as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('calls a web-callable replanning action and shows the result status', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const calls: RpcCall[] = [];
+ const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ calls }) });
+
+ render( );
+
+ const abandonButton = await screen.findByRole('button', { name: 'Abandon run' });
+ await waitFor(() => expect((abandonButton as HTMLButtonElement).disabled).toBe(false));
+ fireEvent.click(abandonButton);
+
+ await waitFor(() => expect(screen.getByText('Last replanning action: abandoned')).toBeTruthy());
+ expect(calls).toContainEqual({
+ method: 'execute.replanAbandonRun',
+ params: { runId: 'run-1', reason: 'Abandoned from the run observer replanning panel' },
+ });
+ });
+
+ it('renders start-new-run as the recommended stale-run recovery path', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ replanRecommendation: {
+ runId: 'run-1',
+ status: 'start_new_run_required',
+ runStatus: 'worktree_populated',
+ diagnosis: 'Run run-1 is not safe to replan in place. Start a new run.',
+ recommendedAction: 'start_new_run',
+ allowedActions: ['start_new_run', 'inspect_run', 'abandon_run'],
+ },
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('start_new_run_required')).toBeTruthy();
+ expect(screen.getByText('recommended: start new run')).toBeTruthy();
+ expect((screen.getByRole('button', { name: 'Start new run' }) as HTMLButtonElement).disabled).toBe(false);
+ expect((screen.getByRole('button', { name: 'Regenerate plan' }) as HTMLButtonElement).disabled).toBe(
+ true,
+ );
});
it('renders the raw petri net payload in a collapsed block when present', async () => {
diff --git a/src/web/queries/execute.ts b/src/web/queries/execute.ts
index 190a4ff59..d1a08fbf7 100644
--- a/src/web/queries/execute.ts
+++ b/src/web/queries/execute.ts
@@ -7,6 +7,27 @@ import type { WebSocketRpcClient } from '../rpc-client.js';
export type { RunDetail, RunListEntry, RunSummary, UnreadableRun } from '../../executor/observer-read.js';
export type { RunTraceEntry, RunTraceIndex } from '../../executor/observer-read.js';
+export type RunRetryAction =
+ | 'retry_current_step'
+ | 'regenerate_plan'
+ | 'start_new_run'
+ | 'inspect_run'
+ | 'abandon_run';
+
+export interface ReplanRecommendation {
+ readonly runId: string;
+ readonly status: string;
+ readonly runStatus: string;
+ readonly diagnosis: string;
+ readonly recommendedAction: RunRetryAction;
+ readonly allowedActions: readonly RunRetryAction[];
+}
+
+export interface ReplanMutationResult {
+ readonly status: string;
+ readonly sideEffects: readonly unknown[];
+}
+
export function executeRunsQueryOptions(rpcClient: WebSocketRpcClient) {
return queryOptions({
queryKey: queryKeys.execute.runs(),
@@ -27,3 +48,34 @@ export function executeRunTraceIndexQueryOptions(rpcClient: WebSocketRpcClient,
queryFn: () => rpcClient.request('execute.runTraceIndex', { specId }),
});
}
+
+export function executeReplanRecommendationQueryOptions(
+ rpcClient: WebSocketRpcClient,
+ params: { readonly runId: string; readonly specId: number },
+) {
+ return queryOptions({
+ queryKey: queryKeys.execute.replanRecommendation(params.runId, params.specId),
+ queryFn: () => rpcClient.request('execute.replanRecommendation', params),
+ });
+}
+
+export function executeReplanRegeneratePlan(
+ rpcClient: WebSocketRpcClient,
+ params: { readonly runId: string; readonly specId: number },
+) {
+ return rpcClient.request('execute.replanRegeneratePlan', params);
+}
+
+export function executeReplanStartNewRun(
+ rpcClient: WebSocketRpcClient,
+ params: { readonly previousRunId: string; readonly specId: number },
+) {
+ return rpcClient.request('execute.replanStartNewRun', params);
+}
+
+export function executeReplanAbandonRun(
+ rpcClient: WebSocketRpcClient,
+ params: { readonly runId: string; readonly reason: string },
+) {
+ return rpcClient.request('execute.replanAbandonRun', params);
+}
diff --git a/src/web/query-keys.ts b/src/web/query-keys.ts
index 5b36c9073..662bca264 100644
--- a/src/web/query-keys.ts
+++ b/src/web/query-keys.ts
@@ -16,5 +16,7 @@ export const queryKeys = {
runs: () => ['execute.runs'] as const,
run: (runId: string) => ['execute.run', runId] as const,
runTraceIndex: (specId: number) => ['execute.runTraceIndex', specId] as const,
+ replanRecommendation: (runId: string, specId: number) =>
+ ['execute.replanRecommendation', runId, specId] as const,
},
};
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 61be6fa2a..8fbcb585c 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -1,9 +1,15 @@
-import { useSuspenseQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Link, createRoute, type ErrorComponentProps } from '@tanstack/react-router';
import {
executeRunQueryOptions,
+ executeReplanAbandonRun,
+ executeReplanRecommendationQueryOptions,
+ executeReplanRegeneratePlan,
+ executeReplanStartNewRun,
executeRunsQueryOptions,
+ type ReplanRecommendation,
+ type RunRetryAction,
type RunDetail,
type RunListEntry,
type RunSummary,
@@ -134,6 +140,7 @@ function RunDetailPage() {
+
0;
+ const recommendation = useQuery({
+ ...executeReplanRecommendationQueryOptions(rpcClient, { runId: run.runId, specId }),
+ enabled: canReadRecommendation,
+ });
+ const actionMutation = useMutation({
+ mutationFn: (action: WebReplanAction) => executeWebReplanAction(rpcClient, run, specId, action),
+ async onSuccess() {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['execute.runs'] }),
+ queryClient.invalidateQueries({ queryKey: ['execute.run', run.runId] }),
+ ]);
+ },
+ });
+
+ return (
+
+ Replanning
+
+ {!canReadRecommendation ? (
+
This run has an invalid spec id for replanning.
+ ) : recommendation.isPending ? (
+
Checking replanning options…
+ ) : recommendation.isError ? (
+
Unable to load replanning recommendation.
+ ) : (
+
+ )}
+
+ {WEB_REPLAN_ACTIONS.map((action) => {
+ const available = recommendation.data?.allowedActions.includes(action.id) ?? false;
+ const webCallable = isWebCallableReplanAction(action.id);
+ const disabled = !canReadRecommendation || !available || !webCallable || actionMutation.isPending;
+ return (
+ {
+ if (isWebCallableReplanAction(action.id)) actionMutation.mutate(action.id);
+ }}
+ className="border-rule disabled:text-hint disabled:bg-tint text-ink rounded-lg border px-3 py-1.5 text-xs disabled:cursor-not-allowed"
+ >
+ {action.label}
+
+ );
+ })}
+
+ {recommendation.data?.allowedActions.includes('retry_current_step') ? (
+
+ Retry current step requires executor runtime authority and is available through the Execute tool,
+ not web RPC.
+
+ ) : null}
+ {actionMutation.data === undefined ? null : (
+
Last replanning action: {actionMutation.data.status}
+ )}
+ {actionMutation.isError ?
Replanning action failed.
: null}
+
+
+ );
+}
+
+function ReplanningRecommendationView({ recommendation }: { recommendation: ReplanRecommendation }) {
+ return (
+
+
+ {recommendation.status}
+
+ recommended: {formatReplanAction(recommendation.recommendedAction)}
+
+
+
{recommendation.diagnosis}
+
+ allowed: {recommendation.allowedActions.map(formatReplanAction).join(', ')}
+
+
+ );
+}
+
+type WebReplanAction = 'regenerate_plan' | 'start_new_run' | 'abandon_run';
+
+const WEB_REPLAN_ACTIONS: readonly { readonly id: RunRetryAction; readonly label: string }[] = [
+ { id: 'retry_current_step', label: 'Retry current step' },
+ { id: 'regenerate_plan', label: 'Regenerate plan' },
+ { id: 'start_new_run', label: 'Start new run' },
+ { id: 'abandon_run', label: 'Abandon run' },
+];
+
+function isWebCallableReplanAction(action: RunRetryAction): action is WebReplanAction {
+ return action === 'regenerate_plan' || action === 'start_new_run' || action === 'abandon_run';
+}
+
+function executeWebReplanAction(
+ rpcClient: Parameters[0],
+ run: RunDetail,
+ specId: number,
+ action: WebReplanAction,
+) {
+ switch (action) {
+ case 'regenerate_plan':
+ return executeReplanRegeneratePlan(rpcClient, { runId: run.runId, specId });
+ case 'start_new_run':
+ return executeReplanStartNewRun(rpcClient, { previousRunId: run.runId, specId });
+ case 'abandon_run':
+ return executeReplanAbandonRun(rpcClient, {
+ runId: run.runId,
+ reason: 'Abandoned from the run observer replanning panel',
+ });
+ }
+}
+
+function formatReplanAction(action: RunRetryAction): string {
+ return action.replace(/_/gu, ' ');
+}
+
function RequirementsPanel({ run }: { run: RunDetail }) {
const linkedSliceIds = new Set(run.sliceProgress.map((slice) => slice.sliceId));
return (
From 7e89cff6ec2b516ae007ea5a8fb32a8f393e314b Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 10:12:11 +0200
Subject: [PATCH 07/14] FE-1141: Remove consumed replanning scopes
---
.../cards/executor-replanning--auto-policy.md | 88 ----------
.../cards/executor-replanning--web-actions.md | 155 ------------------
2 files changed, 243 deletions(-)
delete mode 100644 memory/cards/executor-replanning--auto-policy.md
delete mode 100644 memory/cards/executor-replanning--web-actions.md
diff --git a/memory/cards/executor-replanning--auto-policy.md b/memory/cards/executor-replanning--auto-policy.md
deleted file mode 100644
index 20bbb718d..000000000
--- a/memory/cards/executor-replanning--auto-policy.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Executor Replanning Auto Policy
-
-Frontier: executor-replanning
-Linear: FE-1114
-Status: done
-Mode: single
-Created: 2026-07-08
-
-## Orientation
-
-- Containing seam: executor replanning over explicit run-state diagnosis plus bounded recovery mutations.
-- Relevant frontier item: `executor-replanning` / FE-1114 exists as the active card family and Graphite branch (`ka/fe-1114-executor-replanning`); `memory/PLAN.md` needs later reconciliation because the frontier is not currently defined there as a first-class entry.
-- Volatile handoff state: FE-1141 makes stale/evidence/lineage state visible, but automation must not rely on the web UI and must remain safe in headless executor use.
-- Main open risk: “automatic replanner” can easily erase HITL evidence discipline unless the first policy is conservative and refuses supersession-by-default.
-
-Posture: proving (inherited from `executor-replanning`).
-
-## Target Behavior
-
-A conservative auto-replan policy executes only evidence-preserving safe recovery actions for one run.
-
-## Full-card cold-start reads
-
-- `memory/SPEC.md` — D98-L, D111-L, D112-L, I58-L.
-- `memory/PLAN.md` — indirect `executor-replanning` branch reference; `executor-run-observer`; `orchestrator-tool-port`.
-- `src/executor/TOPOLOGY.md` — run freshness, retry eligibility, recommendation, side-effect boundaries, and run driver rules.
-- Existing cards: `memory/cards/executor-replanning--retry-eligibility.md`, `memory/cards/executor-replanning--recommendation.md`, `memory/cards/executor-replanning--retry-current-step-tool.md`, `memory/cards/executor-replanning--regenerate-plan-tool.md`, `memory/cards/executor-replanning--start-new-run-tool.md`, `memory/cards/executor-replanning--abandon-run-tool.md`.
-
-## Boundary Crossings
-
-```text
-→ execute auto-replan entry point
-→ recommendation / retry eligibility
-→ conservative policy decision
-→ one existing bounded replanning action or refusal
-→ run update publication
-```
-
-## Risks and Assumptions
-
-- RISK: automatic mode silently creates a replacement run and surprises the user. → MITIGATION: first policy auto-executes only `retry_current_step` and early-run `regenerate_plan`; stale started runs return `needs_human_start_new_run` unless an explicit future setting is scoped.
-- RISK: retry loops can burn compute or hide repeated failures. → MITIGATION: require an explicit small retry budget parameter or policy default and report `retry_budget_exhausted` without mutation.
-- RISK: automation duplicates lifecycle-driver orchestration. → MITIGATION: delegate retry to the existing one-step retry helper; do not add a new scheduler path.
-- ASSUMPTION: `retry_current_step` and early `regenerate_plan` are safe automatic actions because they preserve or refresh pre-evidence state under existing guards.
- → IMPACT IF FALSE: the policy must downgrade to recommendation-only or ask-human for every action.
- → VALIDATE: core policy tests assert no mutation for stale-started, terminal, missing, blocked, or budget-exhausted runs.
-
-## Posture check
-
-This is a proving tracer: it tells whether automatic replanning can exist without weakening the existing evidence-preserving HITL invariant. Landing it should prove the safe subset is useful and sharply name what remains human-gated.
-
-## Acceptance Criteria
-
-✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — fresh active runs with budget recommend and execute exactly one retry-current-step action through the injected retry delegate.
-✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — stale early runs execute exactly one regenerate-plan action through the injected regenerate delegate.
-✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — stale started runs refuse with `needs_human_start_new_run` and never call supersession.
-✓ `src/executor/__tests__/run-auto-replan-policy.test.ts` — terminal, abandoned, missing, blocked, and budget-exhausted states perform no mutation.
-✓ `src/.pi/extensions/__tests__/registry.test.ts` — any exposed `execute_replan_auto` tool is executor-only, reports side effects honestly, and returns the policy decision.
-
-Implemented 2026-07-08 as pure executor core in `run-auto-replan-policy.ts`, with no Pi tool exposed in this slice. The policy delegates only `retry_current_step` and `regenerate_plan`, refuses stale-started/missing runs with human start-new-run, and keeps terminal/blocked/budget-exhausted runs mutation-free. Verification: `npm run test -- src/executor/__tests__/run-auto-replan-policy.test.ts`; `npm run verify`.
-
-## Verification Approach
-
-- Inner: pure executor policy tests with injected delegates — proves action selection without filesystem coupling.
-- Inner: registry/tool tests if `execute_replan_auto` is exposed — proves executor-only tool admission and honest side-effect reporting.
-- Middle: existing replanning core/helper tests — prove each delegated action keeps its state guard.
-
-## Cross-cutting obligations
-
-- Do not auto-supersede stale started runs in this slice.
-- Do not delete artifacts, mutate graph state, or rewrite run plans after execution evidence exists.
-- Preserve run-driver separation: automatic replanning chooses a bounded recovery action; it does not become a second orchestrator.
-- If Slice 1 from `executor-replanning--web-actions.md` lands first, keep web auto-action wiring out of this card unless separately scoped.
-
-## Expected touched paths (tentative)
-
-```text
-src/executor/
-├── run-auto-replan-policy.ts +
-├── __tests__/run-auto-replan-policy.test.ts +
-└── TOPOLOGY.md ~
-src/.pi/extensions/executor/
-├── execute-replan-auto/index.ts ?
-└── TOPOLOGY.md ?
-src/.pi/extensions/__tests__/registry.test.ts ?
-src/agents/runtime/executor/active-tools.ts ?
-src/session/schema/tool-names.ts ?
-```
diff --git a/memory/cards/executor-replanning--web-actions.md b/memory/cards/executor-replanning--web-actions.md
deleted file mode 100644
index 3dbe0a05a..000000000
--- a/memory/cards/executor-replanning--web-actions.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# Executor Replanning Web Actions
-
-Frontier: executor-replanning
-Linear: FE-1114
-Status: done
-Mode: slices
-Created: 2026-07-08
-
-## Orientation
-
-- Containing seam: executor replanning over run freshness, retry eligibility, recommendation, and bounded explicit run mutations.
-- Relevant frontier item: `executor-replanning` / FE-1114 exists as the active card family and Graphite branch (`ka/fe-1114-executor-replanning`); `memory/PLAN.md` currently references it only indirectly through FE-1166, so final tie-off should reconcile PLAN rather than inventing a new frontier.
-- Volatile handoff state: FE-1141 (`executor-run-observer`) already owns `/runs` and `/runs/$runId` read surfaces, including lineage and graph/run traceability; this card must extend that surface without making the browser read run-bundle files directly.
-- Main open risk: browser actions require a Brunch public RPC mutation seam; `retry_current_step` additionally requires `ExecutionPorts` and model runtime, so it is not the same class as recommendation, regenerate-plan, start-new-run, or abandon.
-
-Posture: proving (inherited from `executor-replanning`).
-
-## Slice 1 — Web-Safe Replanning RPC Mutations
-
-Status: done
-Weight: full
-
-### Target Behavior
-
-The public RPC surface exposes the web-safe replanning recommendation and metadata/file mutation helpers as product-shaped execute methods.
-
-### Full-card cold-start reads
-
-- `memory/SPEC.md` — D98-L, D111-L, D112-L, I58-L.
-- `memory/PLAN.md` — `executor-run-observer`, `orchestrator-tool-port`, indirect `executor-replanning` branch reference.
-- `src/executor/TOPOLOGY.md` — replanning helper boundaries and side-effect rules.
-- `src/rpc/TOPOLOGY.md` — execute read-projection surface and web query/mutation mapping table.
-- `src/web/TOPOLOGY.md` — web sidecar boundary and query/mutation ownership.
-
-### Boundary Crossings
-
-```text
-→ web/RPC client request
-→ rpc execute.* public method
-→ executor replanning helper or web-safe bounded mutation
-→ product update invalidation for execute.runs / execute.run
-```
-
-### Risks and Assumptions
-
-- RISK: duplicating Pi tool behavior in RPC causes two action semantics. → MITIGATION: RPC methods delegate to the same executor core helpers used by the tools and assert the same refusal classes in tests.
-- RISK: browser write access weakens the read-only observer boundary from FE-1141. → MITIGATION: keep methods narrow, run-scoped, and explicit; no generic executor tool bridge, no graph mutation, no artifact deletion.
-- RISK: `retry_current_step` looks like another replanning mutation but actually needs `ExecutionPorts` and model runtime. → MITIGATION: exclude web retry from this slice; expose it only through the existing executor tool until a separate host-authority slice wires execution ports into RPC/web-host context deliberately.
-- ASSUMPTION: Web-side replanning actions are product-host mutations, not Pi tool invocations.
- → IMPACT IF FALSE: the browser would need a command broker into the active executor agent rather than direct host RPC methods.
- → VALIDATE: route-level and RPC tests prove the methods work without an active assistant turn.
-
-### Posture check
-
-This is a proving slice: it lights up the first browser-callable executor replanning mutation path and stabilizes which replanning actions are host metadata/file actions versus executor-runtime actions.
-
-### Acceptance Criteria
-
-✓ `src/rpc/methods/__tests__/execute.test.ts` — `execute.replanRecommendation` returns the same recommendation classes as `run-replan-recommendation` for fresh, stale, and terminal fixture runs.
-✓ `src/rpc/methods/__tests__/execute.test.ts` — `execute.replanRegeneratePlan`, `execute.replanStartNewRun`, and `execute.replanAbandonRun` refuse disallowed run states with the same typed status as the executor helper/tool path.
-✓ `src/rpc/methods/__tests__/execute.test.ts` — `retry_current_step` is not exposed as a web mutation and is documented as requiring executor-runtime authority.
-✓ `src/rpc/__tests__/handlers.test.ts` — the new execute replanning methods are discoverable and carry declared side-effect metadata.
-✓ Product-update test — successful replanning mutations publish `execute.runs` and exact `execute.run(runId)` invalidations.
-
-Implemented 2026-07-08 as `execute.replanRecommendation`, `execute.replanRegeneratePlan`, `execute.replanStartNewRun`, and `execute.replanAbandonRun`. `execute.replanRetryCurrentStep` remains intentionally unexposed through web RPC because it requires `ExecutionPorts` and executor runtime context. Verification: `npm run test -- src/rpc/methods/__tests__/execute.test.ts src/rpc/__tests__/handlers.test.ts`; `npm run fix`.
-
-### Verification Approach
-
-- Inner: focused RPC method tests over temp run bundles — proves method shape, refusal parity, and update publication.
-- Middle: existing executor core/tool tests — prove helpers remain the single semantic source.
-
-### Cross-cutting obligations
-
-- Preserve I58-L side-effect honesty: every mutation has one explicit run-bundle side effect and no graph mutation.
-- Preserve FE-1141 projection firewall: browser receives product-shaped RPC results, never raw `.brunch/cook/runs/**` path contracts.
-- Preserve replanning evidence honesty: never rewrite a run plan after execution evidence exists; supersession creates a linked run.
-- Preserve executor-runtime authority: web retry is out of this slice unless `ExecutionPorts` are deliberately added to RPC context by a later card.
-
-### Expected touched paths (tentative)
-
-```text
-src/rpc/
-├── methods/
-│ ├── execute.ts ~
-│ └── __tests__/execute.test.ts ~
-├── __tests__/handlers.test.ts ~
-├── product-updates.ts ~
-└── TOPOLOGY.md ~
-src/web/
-├── queries/execute.ts ~
-└── query-keys.ts ?
-```
-
-## Slice 2 — Run Detail Replanning Panel
-
-Status: done
-Weight: light
-
-### Objective
-
-Show the replanning diagnosis and explicit allowed actions on `/runs/$runId` so a human can choose a safe recovery path from the run observer.
-
-### Light-card cold-start reads
-
-- `memory/SPEC.md` — D98-L, D111-L, D112-L, I58-L.
-- `memory/PLAN.md` — `executor-run-observer`; indirect `executor-replanning` branch reference.
-- `src/web/TOPOLOGY.md` — `/runs/$runId` route ownership and web sidecar rules.
-- This scope file — Slice 1 RPC method names and result shape.
-
-### Acceptance Criteria
-
-✓ `/runs/$runId` renders recommendation status, diagnosis text, recommended action, and allowed alternatives when the recommendation method succeeds.
-✓ `/runs/$runId` renders disabled/explanatory states for actions not in `allowedActions` or not web-callable, including `retry_current_step`.
-✓ Clicking a web-callable allowed action calls the matching RPC mutation, invalidates/refetches run list/detail, and leaves the user on a readable result state.
-✓ Route tests cover fresh retry, stale early regenerate, stale started start-new-run, and abandon flows using fixture-shaped RPC responses.
-
-Implemented 2026-07-08 on `/runs/$runId` as a replanning panel that renders diagnosis, recommended action, allowed actions, and web-callable mutation buttons. `retry_current_step` renders disabled with executor-runtime copy. Verification: `npm run test -- src/web/__tests__/runs-route.test.tsx`; `npm run fix`.
-
-### Verification Approach
-
-- Inner: `src/web/__tests__/runs-route.test.tsx` route/component tests with fake RPC client responses.
-- Middle: optional `src/web/__tests__/app.test.tsx` integration route test if query/mutation wiring crosses route loader assumptions.
-- Outer: manual browser smoke per `docs/praxis/manual-testing.md` on one fixture or scratch run after Slice 1 lands.
-
-### Cross-cutting obligations
-
-- Browser remains a product RPC client only; no direct filesystem reads and no generic tool bridge.
-- UI copy must make replanning evidence-preserving behavior explicit: stale started runs create linked runs instead of rewriting old evidence.
-
-### Assumption dependency
-
-None — this slice depends on Slice 1's RPC surface, not on a live SPEC assumption.
-
-### Expected touched paths (tentative)
-
-```text
-src/web/
-├── routes/runs.tsx ~
-├── queries/execute.ts ~
-├── __tests__/runs-route.test.tsx ~
-├── __tests__/app.test.tsx ?
-└── TOPOLOGY.md ~
-```
-
-### Promotion checklist
-
-- [ ] Does this change a requirement?
-- [ ] Does this create, retire, or invalidate an assumption?
-- [ ] Does this slice depend on an unvalidated high-impact assumption?
-- [ ] Does this make or reverse a non-trivial design decision?
-- [ ] Does this establish a new seam-level invariant?
-- [ ] Does this change a frontier-level cross-cutting obligation or verification architecture layer?
-- [ ] Does it cross more than two major seams?
-- [ ] Is this the first touch in an unfamiliar seam from a fresh thread?
-- [ ] Can you not name the containing seam or current rationale from the live docs?
From ecbe14bdcc5cff862085fc64ddfcbc7939928f36 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 11:28:46 +0200
Subject: [PATCH 08/14] FE-1141: Harden run detail evidence defaults
---
.../__tests__/brunch-updates-execute.test.ts | 5 +-
src/web/__tests__/runs-route.test.tsx | 20 +++++++
src/web/routes/runs.tsx | 52 ++++++++++++-------
src/web/subscriptions/brunch-updates.ts | 4 ++
4 files changed, 62 insertions(+), 19 deletions(-)
diff --git a/src/web/__tests__/brunch-updates-execute.test.ts b/src/web/__tests__/brunch-updates-execute.test.ts
index 2d83c25f0..1e0d2ba3f 100644
--- a/src/web/__tests__/brunch-updates-execute.test.ts
+++ b/src/web/__tests__/brunch-updates-execute.test.ts
@@ -20,7 +20,9 @@ describe('brunch.updated execute topic invalidation', () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runs'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.run', 'run-1'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runTraceIndex'] });
- expect(invalidate).toHaveBeenCalledTimes(4);
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.replanRecommendation'] });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.replanRecommendation', 'run-1'] });
+ expect(invalidate).toHaveBeenCalledTimes(6);
});
it('falls back to broad run-detail invalidation for a bare execute.run topic', () => {
@@ -32,5 +34,6 @@ describe('brunch.updated execute topic invalidation', () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.run'] });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runs'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runTraceIndex'] });
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.replanRecommendation'] });
});
});
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index f1b6e957c..97949eae8 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -175,6 +175,26 @@ describe('run detail route', () => {
});
});
+ it('renders older run detail payloads that omit evidence arrays', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const sparseRun = {
+ runId: 'run-1',
+ specId: '1',
+ status: 'worktree_created',
+ presence: { worktree: true, reports: false, petri: false, promotion: false },
+ planPath: '/plan.yaml',
+ } as RunDetail;
+ const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ run: sparseRun }) });
+
+ render( );
+
+ expect(await screen.findByText('worktree_created')).toBeTruthy();
+ expect(screen.getByText('No requirements projected.')).toBeTruthy();
+ expect(screen.getByText('No worker stream yet.')).toBeTruthy();
+ expect(screen.getByText('No verify stream yet.')).toBeTruthy();
+ expect(screen.getAllByText(/0 of 0 events/u)).toHaveLength(3);
+ });
+
it('renders replanning recommendation and disables executor-runtime retry', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() });
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 8fbcb585c..19e033a68 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -118,49 +118,64 @@ function RunDetailPage() {
);
}
- const indicator = RUNNING_INDICATORS[run.status];
+ const detail = normalizeRunDetail(run);
+ const indicator = RUNNING_INDICATORS[detail.status];
return (
- {run.runId}
- {run.status}
+ {detail.runId}
+ {detail.status}
{indicator === undefined ? null : {indicator} }
- {run.specId}
- {run.planPath}
- {run.activeSliceId === undefined ? null : (
- {run.activeSliceId}
+ {detail.specId}
+ {detail.planPath}
+ {detail.activeSliceId === undefined ? null : (
+ {detail.activeSliceId}
)}
- {run.completedSliceIds === undefined || run.completedSliceIds.length === 0 ? null : (
- {run.completedSliceIds.join(', ')}
+ {detail.completedSliceIds === undefined || detail.completedSliceIds.length === 0 ? null : (
+ {detail.completedSliceIds.join(', ')}
)}
-
+
-
-
+
+
-
- {run.petriNet === undefined ? null :
}
+
+ {detail.petriNet === undefined ? null :
}
);
}
+function normalizeRunDetail(run: RunDetail): RunDetail {
+ return {
+ ...run,
+ reportsTail: run.reportsTail ?? [],
+ reportsTotal: run.reportsTotal ?? 0,
+ agentStreamTail: run.agentStreamTail ?? [],
+ agentStreamTotal: run.agentStreamTotal ?? 0,
+ verifyStreamTail: run.verifyStreamTail ?? [],
+ verifyStreamTotal: run.verifyStreamTotal ?? 0,
+ sliceProgress: run.sliceProgress ?? [],
+ requirements: run.requirements ?? [],
+ };
+}
+
function ReplanningPanel({ run }: { run: RunDetail }) {
const { queryClient, rpcClient } = runDetailRoute.useRouteContext();
const specId = Number(run.specId);
@@ -175,6 +190,7 @@ function ReplanningPanel({ run }: { run: RunDetail }) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['execute.runs'] }),
queryClient.invalidateQueries({ queryKey: ['execute.run', run.runId] }),
+ queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation', run.runId] }),
]);
},
});
diff --git a/src/web/subscriptions/brunch-updates.ts b/src/web/subscriptions/brunch-updates.ts
index 1a038390f..ac329ab18 100644
--- a/src/web/subscriptions/brunch-updates.ts
+++ b/src/web/subscriptions/brunch-updates.ts
@@ -79,11 +79,13 @@ function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate
if (update.topic === 'execute.runs') {
invalidateExact(queryClient, queryKeys.execute.runs());
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
+ void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation'] });
return;
}
if (update.topic === 'execute.run' && typeof update.runId === 'string') {
invalidateExact(queryClient, queryKeys.execute.run(update.runId));
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
+ void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation', update.runId] });
return;
}
if (typeof update.topic === 'string') {
@@ -115,11 +117,13 @@ function invalidateTopic(queryClient: QueryClient, topic: string): void {
if (topic === 'execute.runs') {
invalidateExact(queryClient, queryKeys.execute.runs());
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
+ void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation'] });
return;
}
if (topic === 'execute.run') {
void queryClient.invalidateQueries({ queryKey: ['execute.run'] });
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
+ void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation'] });
}
}
From 655d5d5bb5122e3b86c1edd249f7c6ce2404ea70 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 16:01:37 +0200
Subject: [PATCH 09/14] FE-1141: Improve run observer dashboard
---
src/web/__tests__/app.test.tsx | 5 +
src/web/routes/root.tsx | 206 +++++++++++++++++++++++--
src/web/routes/runs.tsx | 266 +++++++++++++++++++++++++--------
3 files changed, 403 insertions(+), 74 deletions(-)
diff --git a/src/web/__tests__/app.test.tsx b/src/web/__tests__/app.test.tsx
index fd20e5d64..821434493 100644
--- a/src/web/__tests__/app.test.tsx
+++ b/src/web/__tests__/app.test.tsx
@@ -8,6 +8,7 @@ import type { GraphSlice, NodeNeighborhood } from '../../graph/queries.js';
import type { WorkspaceState } from '../../projections/workspace/workspace-state.js';
import { BrunchWebApp, createBrunchWebRuntime } from '../app.js';
import type { RunTraceIndex } from '../queries/execute.js';
+import type { RunListEntry } from '../queries/execute.js';
import { graphNodeNeighborhoodQueryOptions, graphOverviewQueryOptions } from '../queries/graph.js';
import { queryKeys } from '../query-keys.js';
import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from '../rpc-client.js';
@@ -124,6 +125,7 @@ function rpcClient(options?: {
graphOverview?: GraphSlice;
nodeNeighborhood?: NodeNeighborhood;
runTraceIndex?: RunTraceIndex;
+ runs?: readonly RunListEntry[];
runTraceIndexError?: Error;
calls?: RpcCall[];
listeners?: Set;
@@ -154,6 +156,9 @@ function rpcClient(options?: {
if (options?.runTraceIndexError) throw options.runTraceIndexError;
return (options?.runTraceIndex ?? { traces: [] }) as T;
}
+ if (method === 'execute.runs') {
+ return { runs: options?.runs ?? [] } as T;
+ }
throw new Error(`unexpected RPC method ${method}`);
},
subscribe(listener: WebSocketRpcNotificationListener) {
diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx
index 54fe11b4b..3b9a44cf4 100644
--- a/src/web/routes/root.tsx
+++ b/src/web/routes/root.tsx
@@ -1,7 +1,10 @@
-import { useSuspenseQuery, type QueryClient } from '@tanstack/react-query';
+import { useQuery, useSuspenseQuery, type QueryClient } from '@tanstack/react-query';
import { Link, Outlet, createRootRouteWithContext, createRoute } from '@tanstack/react-router';
+import type { GraphSlice } from '../../graph/queries.js';
import { AppHeader } from '../components/app-header.js';
+import { executeRunsQueryOptions, type RunListEntry, type RunSummary } from '../queries/execute.js';
+import { graphOverviewQueryOptions } from '../queries/graph.js';
import {
workspaceSelectionStateQueryOptions,
workspaceStateQueryOptions,
@@ -28,6 +31,7 @@ export const indexRoute = createRoute({
Promise.all([
context.queryClient.ensureQueryData(workspaceStateQueryOptions(context.rpcClient)),
context.queryClient.ensureQueryData(workspaceSelectionStateQueryOptions(context.rpcClient)),
+ context.queryClient.ensureQueryData(executeRunsQueryOptions(context.rpcClient)),
]),
component: WorkspaceStatePage,
});
@@ -50,40 +54,129 @@ function RootLayout() {
function WorkspaceStatePage() {
const { rpcClient } = indexRoute.useRouteContext();
const { data: selection } = useSuspenseQuery(workspaceSelectionStateQueryOptions(rpcClient));
+ const { data: runs } = useSuspenseQuery(executeRunsQueryOptions(rpcClient));
+ const currentSpec = selection.specs[0]?.spec;
+ const currentSpecId = currentSpec?.id;
+ const { data: graphOverview } = useQuery({
+ ...graphOverviewQueryOptions(rpcClient, currentSpecId ?? 0),
+ enabled: currentSpecId !== undefined,
+ retry: false,
+ throwOnError: false,
+ });
+ const specRuns =
+ currentSpecId === undefined ? [] : runs.runs.filter((run) => runSpecId(run) === currentSpecId);
+ const latestRuns = [...specRuns].reverse().slice(0, 3);
return (
-
-
-
-
- Executor runs
-
-
+
+
+
);
}
-function SpecList(options: { specs: WorkspaceSelectionState['specs'] }) {
+function runSpecId(run: RunListEntry): number | undefined {
+ if ('unreadable' in run) return undefined;
+ const specId = Number(run.specId);
+ return Number.isInteger(specId) ? specId : undefined;
+}
+
+function LatestRunsPreview({ runs, total }: { runs: readonly RunListEntry[]; total: number }) {
+ return (
+
+
+
+
Runs
+
+ {total} executor {total === 1 ? 'run' : 'runs'}
+
+
+
+ view all runs
+
+
+ {runs.length === 0 ? (
+ No executor runs.
+ ) : (
+
+ {runs.map((run) => (
+
+ {'unreadable' in run ? : }
+
+ ))}
+
+ )}
+
+ );
+}
+
+function RunPreview({ run }: { run: RunSummary }) {
+ return (
+
+
{run.runId}
+
{run.status}
+
spec {run.specId}
+
+ );
+}
+
+function UnreadableRunPreview({ run }: { run: Extract
}) {
+ return (
+
+ {run.runId}
+ unreadable metadata
+
+ );
+}
+
+function SpecList(options: {
+ specs: WorkspaceSelectionState['specs'];
+ graphOverview: GraphSlice | undefined;
+ runCount: number;
+}) {
+ const signals = specSignals(options.graphOverview);
return (
- Specifications
+
+
+
Specs
+
+ {options.specs.length} product {options.specs.length === 1 ? 'spec' : 'specs'}
+
+
+
{options.specs.length === 0 ? (
-
+
No specs in this workspace.
) : (
-
+
{options.specs.map(({ spec }) => (
- {`Spec ${spec.id}`}
- {spec.title}
+
+ {`Spec ${spec.id}`}
+ {spec.title}
+
+
))}
@@ -92,3 +185,86 @@ function SpecList(options: { specs: WorkspaceSelectionState['specs'] }) {
);
}
+
+function SpecProgress({
+ technicalSolution,
+ validation,
+ runs,
+}: {
+ technicalSolution: number;
+ validation: number;
+ runs: number;
+}) {
+ const steps = [
+ { label: 'Technical Design', count: technicalSolution, unit: 'node' },
+ { label: 'Validation', count: validation, unit: 'node' },
+ { label: 'Runs', count: runs, unit: 'run' },
+ ] as const;
+
+ return (
+
+
+ {steps.map((step) => (
+ 0 ? 'bg-emerald-500' : 'bg-wash'}`}
+ />
+ ))}
+
+
+ {steps.map((step) => (
+
+ ))}
+
+
+ );
+}
+
+function SpecProgressStep({ label, count, unit }: { label: string; count: number; unit: string }) {
+ const done = count > 0;
+ return (
+
+
+ {done ? '✓' : '-'}
+
+
+ {label}
+
+ {done ? `${count} ${unit}${count === 1 ? '' : 's'}` : 'not detected'}
+
+
+
+ );
+}
+
+function specSignals(overview: GraphSlice | undefined): { technicalSolution: number; validation: number } {
+ if (overview === undefined) return { technicalSolution: 0, validation: 0 };
+ let technicalSolution = 0;
+ let validation = 0;
+ for (const node of overview.nodes) {
+ if (
+ node.kind === 'module' ||
+ node.kind === 'interface' ||
+ node.kind === 'entity' ||
+ node.kind === 'sketch'
+ ) {
+ technicalSolution += 1;
+ }
+ if (
+ node.kind === 'criterion' ||
+ node.kind === 'check' ||
+ node.kind === 'vv_method' ||
+ node.kind === 'vv_obligation'
+ ) {
+ validation += 1;
+ }
+ }
+ return { technicalSolution, validation };
+}
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 19e033a68..80a67dbd6 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Link, createRoute, type ErrorComponentProps } from '@tanstack/react-router';
+import { useState } from 'react';
import {
executeRunQueryOptions,
@@ -57,8 +58,16 @@ function RunsPage() {
return (
-
- Runs
+
+
+
+
+ {data.runs.length} {data.runs.length === 1 ? 'run' : 'runs'}
+
+
{data.runs.length === 0 ? (
No executor runs.
) : (
@@ -80,18 +89,30 @@ function RunsPage() {
}
function RunSummaryLink({ run }: { run: RunSummary }) {
+ const indicator = RUNNING_INDICATORS[run.status];
return (
- {run.runId}
- {run.status}
- {RUNNING_INDICATORS[run.status] === undefined ? null : (
- {RUNNING_INDICATORS[run.status]}
- )}
-
+
+ {run.runId}
+
+
+
+ spec {run.specId}
+ {run.activeSliceId === undefined ? null : (
+ active {run.activeSliceId}
+ )}
+ {run.completedSliceIds === undefined || run.completedSliceIds.length === 0 ? null : (
+ {run.completedSliceIds.length} completed
+ )}
+ {indicator === undefined ? null : {indicator} }
+
+
);
}
@@ -122,13 +143,24 @@ function RunDetailPage() {
const indicator = RUNNING_INDICATORS[detail.status];
return (
-
-
- {detail.runId}
- {detail.status}
+
+
-
+
{detail.specId}
{detail.planPath}
{detail.activeSliceId === undefined ? null : (
@@ -138,7 +170,7 @@ function RunDetailPage() {
{detail.completedSliceIds.join(', ')}
)}
-
+
@@ -387,38 +419,46 @@ function StreamPanel({
{events.length === 0 ? (
{emptyText}
) : (
-
- {failures.length === 0 ? null : (
-
- Verify failures
-
- {failures.map((failure) => (
-
- {failure.message}
-
- ))}
-
-
- )}
-
- {rows.map((event) => (
-
-
- {event.kind}
- {event.sliceId}
- #{event.sequence}
- {event.count > 1 ? (
- x{event.count}
- ) : null}
-
- {event.message}
-
- ))}
-
-
+
+
+ {`${label}: show ${rows.length} compacted log rows`}
+ {failures.length === 0 ? null : (
+ {failures.length} failure lines
+ )}
+
+
+ {failures.length === 0 ? null : (
+
+ Verify failures
+
+ {failures.map((failure) => (
+
+ {failure.message}
+
+ ))}
+
+
+ )}
+
+ {rows.map((event) => (
+
+
+ {event.kind}
+ {event.sliceId}
+ #{event.sequence}
+ {event.count > 1 ? (
+ x{event.count}
+ ) : null}
+
+ {event.message}
+
+ ))}
+
+
+
Raw {label} events
{events.map(
@@ -432,7 +472,7 @@ function StreamPanel({
)}
-
+
)}
);
@@ -504,6 +544,31 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
);
}
+function RunStatusPill({ status }: { status: RunSummary['status'] }) {
+ const className = runStatusClassName(status);
+ return (
+ {status}
+ );
+}
+
+function runStatusClassName(status: RunSummary['status']): string {
+ if (status === 'slice_execution_requested') return 'bg-blue-50 text-blue-700';
+ if (status === 'agent_result_ingested') return 'bg-violet-50 text-violet-700';
+ if (status === 'abandoned') return 'bg-red-50 text-red-700';
+ if (status === 'run_completed' || status === 'promotion_prepared') return 'bg-emerald-50 text-emerald-700';
+ if (status === 'test_result_ingested' || status === 'slice_completed') return 'bg-amber-50 text-amber-700';
+ return 'bg-wash text-sub';
+}
+
+function SmallFact({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
function ReportsTimeline({ run }: { run: RunDetail }) {
const hasEvents = run.reportsTail.length > 0 || run.sliceProgress.length > 0;
return (
@@ -544,23 +609,106 @@ function ReportsTimeline({ run }: { run: RunDetail }) {
);
}
-function PresenceFlags({ presence }: { presence: RunSummary['presence'] }) {
+function PresenceFlags({ runId, presence }: { runId: string; presence: RunSummary['presence'] }) {
+ const [copyMessage, setCopyMessage] = useState();
+
+ async function copyArtifactPath(artifact: RunArtifact, event: React.MouseEvent | React.KeyboardEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+ const path = artifactPath(runId, artifact.id);
+ try {
+ await navigator.clipboard.writeText(path);
+ setCopyMessage(`copied ${artifact.label} path`);
+ } catch {
+ setCopyMessage('copy unavailable');
+ }
+ }
+
return (
-
- {(['worktree', 'reports', 'petri', 'promotion'] as const).map((artifact) => (
-
- {artifact}
+
+ Artifacts
+ {RUN_ARTIFACTS.map((artifact) => {
+ const present = presence[artifact.id];
+ const path = artifactPath(runId, artifact.id);
+ return (
+ void copyArtifactPath(artifact, event)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') void copyArtifactPath(artifact, event);
+ }}
+ className={`inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs ${
+ present ? 'border-rule text-ink bg-white' : 'border-rule bg-tint text-hint'
+ } cursor-pointer`}
+ >
+
+ {artifact.icon}
+
+ {artifact.label}
+ {artifact.id}
+
+ );
+ })}
+ {copyMessage === undefined ? null : (
+
+ {copyMessage}
- ))}
+ )}
);
}
+type RunArtifact = (typeof RUN_ARTIFACTS)[number];
+
+const RUN_ARTIFACTS = [
+ {
+ id: 'worktree',
+ icon: 'W',
+ label: 'workspace',
+ description: 'Run working directory exists',
+ },
+ {
+ id: 'reports',
+ icon: 'E',
+ label: 'events',
+ description: 'Lifecycle reports.jsonl exists',
+ },
+ {
+ id: 'petri',
+ icon: 'F',
+ label: 'flow',
+ description: 'Petrinaut/Petri flow artifact exists',
+ },
+ {
+ id: 'promotion',
+ icon: 'P',
+ label: 'promotion',
+ description: 'Promotion artifact exists',
+ },
+] as const satisfies readonly {
+ readonly id: keyof RunSummary['presence'];
+ readonly icon: string;
+ readonly label: string;
+ readonly description: string;
+}[];
+
+function artifactPath(runId: string, artifact: keyof RunSummary['presence']): string {
+ const runRoot = `.brunch/cook/runs/${runId}`;
+ switch (artifact) {
+ case 'worktree':
+ return `${runRoot}/worktree`;
+ case 'reports':
+ return `${runRoot}/reports.jsonl`;
+ case 'petri':
+ return `${runRoot}/petrinaut/net.json`;
+ case 'promotion':
+ return `${runRoot}/promotion/promotion.json`;
+ }
+}
+
function RunLoadErrorPage(props: ErrorComponentProps) {
return (
From 57f37f9c5979793c4127175c81d8a24a8650539c Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 13:08:37 +0200
Subject: [PATCH 10/14] FE-1141: Address run observer review comments
---
src/web/__tests__/runs-route.test.tsx | 25 +++++++++++++++
src/web/routes/root.tsx | 46 +++++++++++++++++----------
src/web/routes/runs.tsx | 4 ++-
3 files changed, 57 insertions(+), 18 deletions(-)
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index 97949eae8..54d4337a3 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -57,6 +57,7 @@ function rpcClient(options?: {
runs?: RunListEntry[];
run?: RunDetail | { runId: string; unreadable: true };
replanRecommendation?: ReplanRecommendation;
+ replanRecommendationError?: Error;
runError?: Error;
calls?: RpcCall[];
}): WebSocketRpcClient {
@@ -77,6 +78,9 @@ function rpcClient(options?: {
return (options?.run ?? runDetail) as T;
}
if (method === 'execute.replanRecommendation') {
+ if (options?.replanRecommendationError) {
+ throw options.replanRecommendationError;
+ }
return (options?.replanRecommendation ?? {
runId: 'run-1',
status: 'retry_current_run',
@@ -228,6 +232,27 @@ describe('run detail route', () => {
});
});
+ it('keeps abandon available when replanning recommendation fails', async () => {
+ window.history.pushState(null, '', '/runs/run-1');
+ const calls: RpcCall[] = [];
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({ calls, replanRecommendationError: new Error('recommendation unavailable') }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('Unable to load replanning recommendation.')).toBeTruthy();
+ const abandonButton = screen.getByRole('button', { name: 'Abandon run' }) as HTMLButtonElement;
+ expect(abandonButton.disabled).toBe(false);
+ fireEvent.click(abandonButton);
+
+ await waitFor(() => expect(screen.getByText('Last replanning action: abandoned')).toBeTruthy());
+ expect(calls).toContainEqual({
+ method: 'execute.replanAbandonRun',
+ params: { runId: 'run-1', reason: 'Abandoned from the run observer replanning panel' },
+ });
+ });
+
it('renders start-new-run as the recommended stale-run recovery path', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({
diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx
index 3b9a44cf4..3a02df2e8 100644
--- a/src/web/routes/root.tsx
+++ b/src/web/routes/root.tsx
@@ -53,9 +53,10 @@ function RootLayout() {
function WorkspaceStatePage() {
const { rpcClient } = indexRoute.useRouteContext();
+ const { data: state } = useSuspenseQuery(workspaceStateQueryOptions(rpcClient));
const { data: selection } = useSuspenseQuery(workspaceSelectionStateQueryOptions(rpcClient));
const { data: runs } = useSuspenseQuery(executeRunsQueryOptions(rpcClient));
- const currentSpec = selection.specs[0]?.spec;
+ const currentSpec = state.spec ?? selection.specs[0]?.spec;
const currentSpecId = currentSpec?.id;
const { data: graphOverview } = useQuery({
...graphOverviewQueryOptions(rpcClient, currentSpecId ?? 0),
@@ -63,15 +64,24 @@ function WorkspaceStatePage() {
retry: false,
throwOnError: false,
});
- const specRuns =
- currentSpecId === undefined ? [] : runs.runs.filter((run) => runSpecId(run) === currentSpecId);
- const latestRuns = [...specRuns].reverse().slice(0, 3);
+ const activeSpecRunCount =
+ currentSpecId === undefined
+ ? 0
+ : selection.specs.length <= 1
+ ? runs.runs.length
+ : runs.runs.filter((run) => runSpecId(run) === currentSpecId).length;
+ const latestRuns = [...runs.runs].reverse().slice(0, 3);
return (
);
@@ -141,6 +151,7 @@ function UnreadableRunPreview({ run }: { run: Extract{`Spec ${spec.id}`}
{spec.title}
-
+ {spec.id === options.activeSpecId ? (
+
+ ) : null}
))}
@@ -249,12 +262,7 @@ function specSignals(overview: GraphSlice | undefined): { technicalSolution: num
let technicalSolution = 0;
let validation = 0;
for (const node of overview.nodes) {
- if (
- node.kind === 'module' ||
- node.kind === 'interface' ||
- node.kind === 'entity' ||
- node.kind === 'sketch'
- ) {
+ if (node.plane === 'design' || isTechnicalDesignKind(node.kind)) {
technicalSolution += 1;
}
if (
@@ -268,3 +276,7 @@ function specSignals(overview: GraphSlice | undefined): { technicalSolution: num
}
return { technicalSolution, validation };
}
+
+function isTechnicalDesignKind(kind: GraphSlice['nodes'][number]['kind']): boolean {
+ return kind === 'module' || kind === 'interface' || kind === 'entity' || kind === 'sketch';
+}
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 80a67dbd6..61a3b48ae 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -242,8 +242,10 @@ function ReplanningPanel({ run }: { run: RunDetail }) {
)}
{WEB_REPLAN_ACTIONS.map((action) => {
- const available = recommendation.data?.allowedActions.includes(action.id) ?? false;
const webCallable = isWebCallableReplanAction(action.id);
+ const available =
+ recommendation.data?.allowedActions.includes(action.id) ??
+ (recommendation.isError && webCallable && action.id === 'abandon_run');
const disabled = !canReadRecommendation || !available || !webCallable || actionMutation.isPending;
return (
Date: Wed, 8 Jul 2026 14:08:57 +0200
Subject: [PATCH 11/14] FE-1141: Keep web run observer read-only
---
src/web/TOPOLOGY.md | 3 +-
.../__tests__/brunch-updates-execute.test.ts | 5 +-
src/web/__tests__/runs-route.test.tsx | 111 +--------------
src/web/queries/execute.ts | 52 -------
src/web/query-keys.ts | 2 -
src/web/routes/runs.tsx | 131 +-----------------
src/web/subscriptions/brunch-updates.ts | 4 -
7 files changed, 5 insertions(+), 303 deletions(-)
diff --git a/src/web/TOPOLOGY.md b/src/web/TOPOLOGY.md
index f0304335c..1f458221d 100644
--- a/src/web/TOPOLOGY.md
+++ b/src/web/TOPOLOGY.md
@@ -40,7 +40,6 @@ web/
execute.runs
execute.run
execute.runTraceIndex
- execute.replanRecommendation
queries/
workspace.ts -> workspace.state + workspace.selectionState query options
@@ -72,7 +71,7 @@ web/
runs.tsx
`/runs` loader primes execute.runs; run list with presence flags
`/runs/$runId` loader primes execute.run; crank status, honest
- running indicators, replanning recommendation/action panel, requirement status panel,
+ running indicators, requirement status panel,
readable worker/verify evidence panels
(deduped display plus raw stream disclosure), grouped reports timeline
(events lead the run.json snapshot by design), unreadable-run marking
diff --git a/src/web/__tests__/brunch-updates-execute.test.ts b/src/web/__tests__/brunch-updates-execute.test.ts
index 1e0d2ba3f..2d83c25f0 100644
--- a/src/web/__tests__/brunch-updates-execute.test.ts
+++ b/src/web/__tests__/brunch-updates-execute.test.ts
@@ -20,9 +20,7 @@ describe('brunch.updated execute topic invalidation', () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runs'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.run', 'run-1'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runTraceIndex'] });
- expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.replanRecommendation'] });
- expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.replanRecommendation', 'run-1'] });
- expect(invalidate).toHaveBeenCalledTimes(6);
+ expect(invalidate).toHaveBeenCalledTimes(4);
});
it('falls back to broad run-detail invalidation for a bare execute.run topic', () => {
@@ -34,6 +32,5 @@ describe('brunch.updated execute topic invalidation', () => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.run'] });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runs'], exact: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.runTraceIndex'] });
- expect(invalidate).toHaveBeenCalledWith({ queryKey: ['execute.replanRecommendation'] });
});
});
diff --git a/src/web/__tests__/runs-route.test.tsx b/src/web/__tests__/runs-route.test.tsx
index 54d4337a3..aae61bf87 100644
--- a/src/web/__tests__/runs-route.test.tsx
+++ b/src/web/__tests__/runs-route.test.tsx
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { cleanup, render, screen } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type { WorkspaceState } from '../../projections/workspace/workspace-state.js';
import { BrunchWebApp, createBrunchWebRuntime } from '../app.js';
-import type { ReplanRecommendation, RunDetail, RunListEntry } from '../queries/execute.js';
+import type { RunDetail, RunListEntry } from '../queries/execute.js';
import type { WebSocketRpcClient, WebSocketRpcNotificationListener } from '../rpc-client.js';
interface RpcCall {
@@ -56,8 +56,6 @@ const runDetail: RunDetail = {
function rpcClient(options?: {
runs?: RunListEntry[];
run?: RunDetail | { runId: string; unreadable: true };
- replanRecommendation?: ReplanRecommendation;
- replanRecommendationError?: Error;
runError?: Error;
calls?: RpcCall[];
}): WebSocketRpcClient {
@@ -77,28 +75,6 @@ function rpcClient(options?: {
}
return (options?.run ?? runDetail) as T;
}
- if (method === 'execute.replanRecommendation') {
- if (options?.replanRecommendationError) {
- throw options.replanRecommendationError;
- }
- return (options?.replanRecommendation ?? {
- runId: 'run-1',
- status: 'retry_current_run',
- runStatus: 'slice_execution_requested',
- diagnosis: 'Run run-1 is fresh. Retry the current step.',
- recommendedAction: 'retry_current_step',
- allowedActions: ['retry_current_step', 'inspect_run', 'abandon_run'],
- }) as T;
- }
- if (method === 'execute.replanRegeneratePlan') {
- return { status: 'regenerated_plan', sideEffects: [{ kind: 'write_file' }] } as T;
- }
- if (method === 'execute.replanStartNewRun') {
- return { status: 'created', sideEffects: [{ kind: 'write_file' }] } as T;
- }
- if (method === 'execute.replanAbandonRun') {
- return { status: 'abandoned', sideEffects: [{ kind: 'write_file' }] } as T;
- }
throw new Error(`unexpected RPC method ${method}`);
},
subscribe(listener: WebSocketRpcNotificationListener) {
@@ -173,10 +149,6 @@ describe('run detail route', () => {
expect(screen.getByText('worktree')).toBeTruthy();
expect(screen.getByText('petri')).toBeTruthy();
expect(calls).toContainEqual({ method: 'execute.run', params: { runId: 'run-1' } });
- expect(calls).toContainEqual({
- method: 'execute.replanRecommendation',
- params: { runId: 'run-1', specId: 1 },
- });
});
it('renders older run detail payloads that omit evidence arrays', async () => {
@@ -199,85 +171,6 @@ describe('run detail route', () => {
expect(screen.getAllByText(/0 of 0 events/u)).toHaveLength(3);
});
- it('renders replanning recommendation and disables executor-runtime retry', async () => {
- window.history.pushState(null, '', '/runs/run-1');
- const runtime = createBrunchWebRuntime({ rpcClient: rpcClient() });
-
- render( );
-
- expect(await screen.findByText('retry_current_run')).toBeTruthy();
- expect(screen.getByText('recommended: retry current step')).toBeTruthy();
- expect(screen.getByText(/requires executor runtime authority/u)).toBeTruthy();
- expect((screen.getByRole('button', { name: 'Retry current step' }) as HTMLButtonElement).disabled).toBe(
- true,
- );
- expect((screen.getByRole('button', { name: 'Abandon run' }) as HTMLButtonElement).disabled).toBe(false);
- });
-
- it('calls a web-callable replanning action and shows the result status', async () => {
- window.history.pushState(null, '', '/runs/run-1');
- const calls: RpcCall[] = [];
- const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ calls }) });
-
- render( );
-
- const abandonButton = await screen.findByRole('button', { name: 'Abandon run' });
- await waitFor(() => expect((abandonButton as HTMLButtonElement).disabled).toBe(false));
- fireEvent.click(abandonButton);
-
- await waitFor(() => expect(screen.getByText('Last replanning action: abandoned')).toBeTruthy());
- expect(calls).toContainEqual({
- method: 'execute.replanAbandonRun',
- params: { runId: 'run-1', reason: 'Abandoned from the run observer replanning panel' },
- });
- });
-
- it('keeps abandon available when replanning recommendation fails', async () => {
- window.history.pushState(null, '', '/runs/run-1');
- const calls: RpcCall[] = [];
- const runtime = createBrunchWebRuntime({
- rpcClient: rpcClient({ calls, replanRecommendationError: new Error('recommendation unavailable') }),
- });
-
- render( );
-
- expect(await screen.findByText('Unable to load replanning recommendation.')).toBeTruthy();
- const abandonButton = screen.getByRole('button', { name: 'Abandon run' }) as HTMLButtonElement;
- expect(abandonButton.disabled).toBe(false);
- fireEvent.click(abandonButton);
-
- await waitFor(() => expect(screen.getByText('Last replanning action: abandoned')).toBeTruthy());
- expect(calls).toContainEqual({
- method: 'execute.replanAbandonRun',
- params: { runId: 'run-1', reason: 'Abandoned from the run observer replanning panel' },
- });
- });
-
- it('renders start-new-run as the recommended stale-run recovery path', async () => {
- window.history.pushState(null, '', '/runs/run-1');
- const runtime = createBrunchWebRuntime({
- rpcClient: rpcClient({
- replanRecommendation: {
- runId: 'run-1',
- status: 'start_new_run_required',
- runStatus: 'worktree_populated',
- diagnosis: 'Run run-1 is not safe to replan in place. Start a new run.',
- recommendedAction: 'start_new_run',
- allowedActions: ['start_new_run', 'inspect_run', 'abandon_run'],
- },
- }),
- });
-
- render( );
-
- expect(await screen.findByText('start_new_run_required')).toBeTruthy();
- expect(screen.getByText('recommended: start new run')).toBeTruthy();
- expect((screen.getByRole('button', { name: 'Start new run' }) as HTMLButtonElement).disabled).toBe(false);
- expect((screen.getByRole('button', { name: 'Regenerate plan' }) as HTMLButtonElement).disabled).toBe(
- true,
- );
- });
-
it('renders the raw petri net payload in a collapsed block when present', async () => {
window.history.pushState(null, '', '/runs/run-1');
const runtime = createBrunchWebRuntime({
diff --git a/src/web/queries/execute.ts b/src/web/queries/execute.ts
index d1a08fbf7..190a4ff59 100644
--- a/src/web/queries/execute.ts
+++ b/src/web/queries/execute.ts
@@ -7,27 +7,6 @@ import type { WebSocketRpcClient } from '../rpc-client.js';
export type { RunDetail, RunListEntry, RunSummary, UnreadableRun } from '../../executor/observer-read.js';
export type { RunTraceEntry, RunTraceIndex } from '../../executor/observer-read.js';
-export type RunRetryAction =
- | 'retry_current_step'
- | 'regenerate_plan'
- | 'start_new_run'
- | 'inspect_run'
- | 'abandon_run';
-
-export interface ReplanRecommendation {
- readonly runId: string;
- readonly status: string;
- readonly runStatus: string;
- readonly diagnosis: string;
- readonly recommendedAction: RunRetryAction;
- readonly allowedActions: readonly RunRetryAction[];
-}
-
-export interface ReplanMutationResult {
- readonly status: string;
- readonly sideEffects: readonly unknown[];
-}
-
export function executeRunsQueryOptions(rpcClient: WebSocketRpcClient) {
return queryOptions({
queryKey: queryKeys.execute.runs(),
@@ -48,34 +27,3 @@ export function executeRunTraceIndexQueryOptions(rpcClient: WebSocketRpcClient,
queryFn: () => rpcClient.request('execute.runTraceIndex', { specId }),
});
}
-
-export function executeReplanRecommendationQueryOptions(
- rpcClient: WebSocketRpcClient,
- params: { readonly runId: string; readonly specId: number },
-) {
- return queryOptions({
- queryKey: queryKeys.execute.replanRecommendation(params.runId, params.specId),
- queryFn: () => rpcClient.request('execute.replanRecommendation', params),
- });
-}
-
-export function executeReplanRegeneratePlan(
- rpcClient: WebSocketRpcClient,
- params: { readonly runId: string; readonly specId: number },
-) {
- return rpcClient.request('execute.replanRegeneratePlan', params);
-}
-
-export function executeReplanStartNewRun(
- rpcClient: WebSocketRpcClient,
- params: { readonly previousRunId: string; readonly specId: number },
-) {
- return rpcClient.request('execute.replanStartNewRun', params);
-}
-
-export function executeReplanAbandonRun(
- rpcClient: WebSocketRpcClient,
- params: { readonly runId: string; readonly reason: string },
-) {
- return rpcClient.request('execute.replanAbandonRun', params);
-}
diff --git a/src/web/query-keys.ts b/src/web/query-keys.ts
index 662bca264..5b36c9073 100644
--- a/src/web/query-keys.ts
+++ b/src/web/query-keys.ts
@@ -16,7 +16,5 @@ export const queryKeys = {
runs: () => ['execute.runs'] as const,
run: (runId: string) => ['execute.run', runId] as const,
runTraceIndex: (specId: number) => ['execute.runTraceIndex', specId] as const,
- replanRecommendation: (runId: string, specId: number) =>
- ['execute.replanRecommendation', runId, specId] as const,
},
};
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 61a3b48ae..322585eb0 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -1,16 +1,10 @@
-import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
+import { useSuspenseQuery } from '@tanstack/react-query';
import { Link, createRoute, type ErrorComponentProps } from '@tanstack/react-router';
import { useState } from 'react';
import {
executeRunQueryOptions,
- executeReplanAbandonRun,
- executeReplanRecommendationQueryOptions,
- executeReplanRegeneratePlan,
- executeReplanStartNewRun,
executeRunsQueryOptions,
- type ReplanRecommendation,
- type RunRetryAction,
type RunDetail,
type RunListEntry,
type RunSummary,
@@ -173,7 +167,6 @@ function RunDetailPage() {
-
0;
- const recommendation = useQuery({
- ...executeReplanRecommendationQueryOptions(rpcClient, { runId: run.runId, specId }),
- enabled: canReadRecommendation,
- });
- const actionMutation = useMutation({
- mutationFn: (action: WebReplanAction) => executeWebReplanAction(rpcClient, run, specId, action),
- async onSuccess() {
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: ['execute.runs'] }),
- queryClient.invalidateQueries({ queryKey: ['execute.run', run.runId] }),
- queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation', run.runId] }),
- ]);
- },
- });
-
- return (
-
- Replanning
-
- {!canReadRecommendation ? (
-
This run has an invalid spec id for replanning.
- ) : recommendation.isPending ? (
-
Checking replanning options…
- ) : recommendation.isError ? (
-
Unable to load replanning recommendation.
- ) : (
-
- )}
-
- {WEB_REPLAN_ACTIONS.map((action) => {
- const webCallable = isWebCallableReplanAction(action.id);
- const available =
- recommendation.data?.allowedActions.includes(action.id) ??
- (recommendation.isError && webCallable && action.id === 'abandon_run');
- const disabled = !canReadRecommendation || !available || !webCallable || actionMutation.isPending;
- return (
- {
- if (isWebCallableReplanAction(action.id)) actionMutation.mutate(action.id);
- }}
- className="border-rule disabled:text-hint disabled:bg-tint text-ink rounded-lg border px-3 py-1.5 text-xs disabled:cursor-not-allowed"
- >
- {action.label}
-
- );
- })}
-
- {recommendation.data?.allowedActions.includes('retry_current_step') ? (
-
- Retry current step requires executor runtime authority and is available through the Execute tool,
- not web RPC.
-
- ) : null}
- {actionMutation.data === undefined ? null : (
-
Last replanning action: {actionMutation.data.status}
- )}
- {actionMutation.isError ?
Replanning action failed.
: null}
-
-
- );
-}
-
-function ReplanningRecommendationView({ recommendation }: { recommendation: ReplanRecommendation }) {
- return (
-
-
- {recommendation.status}
-
- recommended: {formatReplanAction(recommendation.recommendedAction)}
-
-
-
{recommendation.diagnosis}
-
- allowed: {recommendation.allowedActions.map(formatReplanAction).join(', ')}
-
-
- );
-}
-
-type WebReplanAction = 'regenerate_plan' | 'start_new_run' | 'abandon_run';
-
-const WEB_REPLAN_ACTIONS: readonly { readonly id: RunRetryAction; readonly label: string }[] = [
- { id: 'retry_current_step', label: 'Retry current step' },
- { id: 'regenerate_plan', label: 'Regenerate plan' },
- { id: 'start_new_run', label: 'Start new run' },
- { id: 'abandon_run', label: 'Abandon run' },
-];
-
-function isWebCallableReplanAction(action: RunRetryAction): action is WebReplanAction {
- return action === 'regenerate_plan' || action === 'start_new_run' || action === 'abandon_run';
-}
-
-function executeWebReplanAction(
- rpcClient: Parameters[0],
- run: RunDetail,
- specId: number,
- action: WebReplanAction,
-) {
- switch (action) {
- case 'regenerate_plan':
- return executeReplanRegeneratePlan(rpcClient, { runId: run.runId, specId });
- case 'start_new_run':
- return executeReplanStartNewRun(rpcClient, { previousRunId: run.runId, specId });
- case 'abandon_run':
- return executeReplanAbandonRun(rpcClient, {
- runId: run.runId,
- reason: 'Abandoned from the run observer replanning panel',
- });
- }
-}
-
-function formatReplanAction(action: RunRetryAction): string {
- return action.replace(/_/gu, ' ');
-}
-
function RequirementsPanel({ run }: { run: RunDetail }) {
const linkedSliceIds = new Set(run.sliceProgress.map((slice) => slice.sliceId));
return (
diff --git a/src/web/subscriptions/brunch-updates.ts b/src/web/subscriptions/brunch-updates.ts
index ac329ab18..1a038390f 100644
--- a/src/web/subscriptions/brunch-updates.ts
+++ b/src/web/subscriptions/brunch-updates.ts
@@ -79,13 +79,11 @@ function invalidateProductUpdate(queryClient: QueryClient, update: ProductUpdate
if (update.topic === 'execute.runs') {
invalidateExact(queryClient, queryKeys.execute.runs());
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
- void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation'] });
return;
}
if (update.topic === 'execute.run' && typeof update.runId === 'string') {
invalidateExact(queryClient, queryKeys.execute.run(update.runId));
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
- void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation', update.runId] });
return;
}
if (typeof update.topic === 'string') {
@@ -117,13 +115,11 @@ function invalidateTopic(queryClient: QueryClient, topic: string): void {
if (topic === 'execute.runs') {
invalidateExact(queryClient, queryKeys.execute.runs());
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
- void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation'] });
return;
}
if (topic === 'execute.run') {
void queryClient.invalidateQueries({ queryKey: ['execute.run'] });
void queryClient.invalidateQueries({ queryKey: ['execute.runTraceIndex'] });
- void queryClient.invalidateQueries({ queryKey: ['execute.replanRecommendation'] });
}
}
From c3305fe2a089c8f61aed3383dc72f88daf30d6d3 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 14:16:50 +0200
Subject: [PATCH 12/14] FE-1141: Keep git promotion tests in repo scratch
---
.../__tests__/git-host-promotion-port.test.ts | 73 +++++++++++--------
1 file changed, 41 insertions(+), 32 deletions(-)
diff --git a/src/app/__tests__/git-host-promotion-port.test.ts b/src/app/__tests__/git-host-promotion-port.test.ts
index 813ccca28..a9b0598b3 100644
--- a/src/app/__tests__/git-host-promotion-port.test.ts
+++ b/src/app/__tests__/git-host-promotion-port.test.ts
@@ -1,6 +1,5 @@
import { execFile } from 'node:child_process';
-import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
@@ -9,6 +8,7 @@ import { describe, expect, it } from 'vitest';
import { createGitHostPromotionPort } from '../git-host-promotion-port.js';
const execFileAsync = promisify(execFile);
+const testScratchRoot = join(process.cwd(), 'tmp', 'git-host-promotion-port');
async function git(cwd: string, args: readonly string[]): Promise {
const result = await execFileAsync('git', [...args], { cwd });
@@ -22,7 +22,8 @@ async function createHostAndRunRepos(prefix: string): Promise<{
readonly baseSha: string;
readonly commitSha: string;
}> {
- const root = await mkdtemp(join(tmpdir(), prefix));
+ await mkdir(testScratchRoot, { recursive: true });
+ const root = await mkdtemp(join(testScratchRoot, prefix));
const hostDir = join(root, 'host');
const worktreeDir = join(root, 'worktree');
await git(root, ['init', hostDir]);
@@ -228,45 +229,53 @@ describe('createGitHostPromotionPort', () => {
});
it('applies a real promoted git patch to host files without staging or committing', async () => {
- const { hostDir, worktreeDir, baseSha, commitSha } = await createHostAndRunRepos(
+ const { root, hostDir, worktreeDir, baseSha, commitSha } = await createHostAndRunRepos(
'brunch-host-promotion-real-apply-',
);
- const beforeHead = await git(hostDir, ['rev-parse', 'HEAD']);
+ try {
+ const beforeHead = await git(hostDir, ['rev-parse', 'HEAD']);
- const result = await createGitHostPromotionPort().apply({
- cwd: hostDir,
- worktreeDir,
- baseSha,
- commitSha,
- changedFiles: ['host-proof.txt'],
- });
+ const result = await createGitHostPromotionPort().apply({
+ cwd: hostDir,
+ worktreeDir,
+ baseSha,
+ commitSha,
+ changedFiles: ['host-proof.txt'],
+ });
- expect(result).toEqual({ status: 'applied', changedFiles: ['host-proof.txt'] });
- expect(await readFile(join(hostDir, 'host-proof.txt'), 'utf8')).toBe('promoted content\n');
- expect(await git(hostDir, ['rev-parse', 'HEAD'])).toBe(beforeHead);
- expect(await git(hostDir, ['diff', '--cached', '--name-only'])).toBe('');
- expect(await git(hostDir, ['status', '--short'])).toBe('M host-proof.txt');
+ expect(result).toEqual({ status: 'applied', changedFiles: ['host-proof.txt'] });
+ expect(await readFile(join(hostDir, 'host-proof.txt'), 'utf8')).toBe('promoted content\n');
+ expect(await git(hostDir, ['rev-parse', 'HEAD'])).toBe(beforeHead);
+ expect(await git(hostDir, ['diff', '--cached', '--name-only'])).toBe('');
+ expect(await git(hostDir, ['status', '--short'])).toBe('M host-proof.txt');
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
});
it('fails real patch check without mutating conflicting host files', async () => {
- const { hostDir, worktreeDir, baseSha, commitSha } = await createHostAndRunRepos(
+ const { root, hostDir, worktreeDir, baseSha, commitSha } = await createHostAndRunRepos(
'brunch-host-promotion-real-conflict-',
);
- await writeFile(join(hostDir, 'host-proof.txt'), 'conflicting host edit\n', 'utf8');
- const beforeHostFile = await readFile(join(hostDir, 'host-proof.txt'), 'utf8');
- const beforeHead = await git(hostDir, ['rev-parse', 'HEAD']);
+ try {
+ await writeFile(join(hostDir, 'host-proof.txt'), 'conflicting host edit\n', 'utf8');
+ const beforeHostFile = await readFile(join(hostDir, 'host-proof.txt'), 'utf8');
+ const beforeHead = await git(hostDir, ['rev-parse', 'HEAD']);
- const result = await createGitHostPromotionPort().apply({
- cwd: hostDir,
- worktreeDir,
- baseSha,
- commitSha,
- changedFiles: ['host-proof.txt'],
- });
+ const result = await createGitHostPromotionPort().apply({
+ cwd: hostDir,
+ worktreeDir,
+ baseSha,
+ commitSha,
+ changedFiles: ['host-proof.txt'],
+ });
- expect(result).toMatchObject({ status: 'failed' });
- expect(await readFile(join(hostDir, 'host-proof.txt'), 'utf8')).toBe(beforeHostFile);
- expect(await git(hostDir, ['rev-parse', 'HEAD'])).toBe(beforeHead);
- expect(await git(hostDir, ['diff', '--cached', '--name-only'])).toBe('');
+ expect(result).toMatchObject({ status: 'failed' });
+ expect(await readFile(join(hostDir, 'host-proof.txt'), 'utf8')).toBe(beforeHostFile);
+ expect(await git(hostDir, ['rev-parse', 'HEAD'])).toBe(beforeHead);
+ expect(await git(hostDir, ['diff', '--cached', '--name-only'])).toBe('');
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
});
});
From d4774f6525eaab22b3e26b94c63891cf158cb83c Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 14:21:45 +0200
Subject: [PATCH 13/14] FE-1141: Address run observer bot findings
---
src/executor/__tests__/observer-read.test.ts | 16 +++++++
src/executor/observer-read.ts | 4 +-
src/rpc/methods/__tests__/execute.test.ts | 47 +++++++++++++++-----
src/rpc/methods/execute.ts | 31 ++++++++++++-
src/web/__tests__/app.test.tsx | 27 +++++++++++
src/web/routes/root.tsx | 10 ++---
src/web/routes/runs.tsx | 2 +-
7 files changed, 115 insertions(+), 22 deletions(-)
diff --git a/src/executor/__tests__/observer-read.test.ts b/src/executor/__tests__/observer-read.test.ts
index 50aa566b3..81133d06e 100644
--- a/src/executor/__tests__/observer-read.test.ts
+++ b/src/executor/__tests__/observer-read.test.ts
@@ -222,6 +222,22 @@ describe('readRunDetail', () => {
]);
});
+ it('does not label non-terminal slice test results as passing verification', async () => {
+ const cwd = await fixtureCwd('brunch-observer-nonterminal-verify-');
+ const runDir = await writeRun(cwd, 'run-nonterminal', { status: 'test_result_ingested' });
+ await writeFile(
+ join(runDir, 'reports.jsonl'),
+ '{"event":"slice_started","sliceId":"task-1"}\n{"event":"slice_test_result","sliceId":"task-1","status":"running"}\n',
+ 'utf8',
+ );
+
+ const detail = await readRunDetail(cwd, 'run-nonterminal');
+
+ expect(detail && 'sliceProgress' in detail ? detail.sliceProgress : []).toEqual([
+ { sliceId: 'task-1', progress: 'started' },
+ ]);
+ });
+
it('builds requirement statuses from the populated plan snapshot when available', async () => {
const cwd = await fixtureCwd('brunch-observer-populated-plan-');
const runDir = await writeRun(cwd, 'run-populated', {
diff --git a/src/executor/observer-read.ts b/src/executor/observer-read.ts
index c3f8fa4f1..9102ba418 100644
--- a/src/executor/observer-read.ts
+++ b/src/executor/observer-read.ts
@@ -192,7 +192,9 @@ function eventStage(event: RunReportEvent): string | undefined {
case 'slice_agent_result':
return 'agent';
case 'slice_test_result':
- return event['status'] === 'failed' ? 'verify failed' : 'verify passed';
+ if (event['status'] === 'failed') return 'verify failed';
+ if (event['status'] === 'passed') return 'verify passed';
+ return undefined;
case 'slice_completed':
return 'completed';
default:
diff --git a/src/rpc/methods/__tests__/execute.test.ts b/src/rpc/methods/__tests__/execute.test.ts
index 494e93d9d..e9d8c28fb 100644
--- a/src/rpc/methods/__tests__/execute.test.ts
+++ b/src/rpc/methods/__tests__/execute.test.ts
@@ -508,6 +508,18 @@ describe('execute replanning methods', () => {
});
});
+ it('rejects replanning reads when the requested spec does not own the run', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-spec-mismatch-'));
+ await writeRun(cwd, 'run-1', { specId: '99', planPath: planFilePath(cwd, '99') });
+
+ const response = await method('execute.replanRecommendation').handle(
+ contextForSpec(cwd, executableGraph(11)),
+ request('execute.replanRecommendation', { runId: 'run-1', specId: 42 }),
+ );
+
+ expect(response).toMatchObject({ error: { code: -32602, message: 'Invalid params' } });
+ });
+
it('regenerates a stale early-run plan and publishes run updates', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-regenerate-'));
const updates: ProductUpdate[] = [];
@@ -535,10 +547,10 @@ describe('execute replanning methods', () => {
).toBeUndefined();
});
- it('creates a linked superseding run and publishes old and new run updates', async () => {
+ it('does not start a superseding run from a stale plan', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-new-run-'));
const updates: ProductUpdate[] = [];
- await writePlan(cwd, '42', 11);
+ await writePlan(cwd, '42', 10);
await writeRun(cwd, 'run-old', { planPath: planFilePath(cwd, '42'), status: 'worktree_populated' });
const response = await method('execute.replanStartNewRun').handle(
@@ -548,18 +560,31 @@ describe('execute replanning methods', () => {
expect(response).toMatchObject({
result: {
- status: 'created',
+ status: 'launch_not_ready',
previousRunId: 'run-old',
- runId: 'run-new',
- sideEffects: [{ kind: 'mkdir' }, { kind: 'write_file' }],
+ sideEffects: [],
+ },
+ });
+ expect(updates).toEqual([]);
+ });
+
+ it('does not start a superseding run when current run retry is allowed', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'brunch-execute-replan-new-run-fresh-'));
+ await writePlan(cwd, '42', 11);
+ await writeRun(cwd, 'run-old', { planPath: planFilePath(cwd, '42'), status: 'worktree_created' });
+
+ const response = await method('execute.replanStartNewRun').handle(
+ contextForSpec(cwd, executableGraph(11)),
+ request('execute.replanStartNewRun', { previousRunId: 'run-old', runId: 'run-new', specId: 42 }),
+ );
+
+ expect(response).toMatchObject({
+ result: {
+ status: 'start_new_run_not_allowed',
+ eligibility: { status: 'retry_current_run' },
+ sideEffects: [],
},
});
- expect(updates).toEqual([
- { topic: 'execute.runs' },
- { topic: 'execute.run', runId: 'run-old' },
- { topic: 'execute.runs' },
- { topic: 'execute.run', runId: 'run-new' },
- ]);
});
it('marks a run abandoned and publishes exact run updates', async () => {
diff --git a/src/rpc/methods/execute.ts b/src/rpc/methods/execute.ts
index cb2072c2a..5afa75908 100644
--- a/src/rpc/methods/execute.ts
+++ b/src/rpc/methods/execute.ts
@@ -8,7 +8,7 @@ import { abandonRun } from '../../executor/run-abandon.js';
import { recommendRunReplan } from '../../executor/run-replan-recommendation.js';
import { type RunRetryEligibilityResult } from '../../executor/run-retry-eligibility.js';
import { createSupersedingRun } from '../../executor/run-supersession.js';
-import { assertSafeRunId } from '../../executor/run.js';
+import { assertSafeRunId, readRunMetadata, runMetadataPath } from '../../executor/run.js';
import { executeRunProductUpdates } from '../product-updates.js';
import { createJsonRpcFailure, createJsonRpcSuccess, jsonRpcRequestId } from '../protocol.js';
import type { RpcMethodContext, RpcMethodDefinition } from './registry.js';
@@ -294,6 +294,9 @@ export const executeRpcMethods: readonly RpcMethodDefinition[]
if (!params || !safeRunId(params.runId)) {
return createJsonRpcFailure(requestId, -32602, 'Invalid params');
}
+ if (!(await requestSpecMatchesRun(context.cwd, params.runId, params.specId))) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
const result = await recommendRunReplan({
cwd: context.cwd,
runId: params.runId,
@@ -323,6 +326,9 @@ export const executeRpcMethods: readonly RpcMethodDefinition[]
if (!params || !safeRunId(params.runId)) {
return createJsonRpcFailure(requestId, -32602, 'Invalid params');
}
+ if (!(await requestSpecMatchesRun(context.cwd, params.runId, params.specId))) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
const result = await regeneratePlan(context, params);
if (result.sideEffects.length > 0) {
context.productUpdates?.publish(executeRunProductUpdates(params.runId));
@@ -355,10 +361,26 @@ export const executeRpcMethods: readonly RpcMethodDefinition[]
) {
return createJsonRpcFailure(requestId, -32602, 'Invalid params');
}
+ if (!(await requestSpecMatchesRun(context.cwd, params.previousRunId, params.specId))) {
+ return createJsonRpcFailure(requestId, -32602, 'Invalid params');
+ }
+ const current = await currentProjection(context, params);
+ const recommendation = await recommendRunReplan({
+ cwd: context.cwd,
+ runId: params.previousRunId,
+ current,
+ });
+ if (!recommendation.allowedActions.includes('start_new_run')) {
+ return createJsonRpcSuccess(requestId, {
+ status: 'start_new_run_not_allowed',
+ eligibility: recommendation.eligibility,
+ sideEffects: [],
+ });
+ }
const result = await createSupersedingRun({
cwd: context.cwd,
previousRunId: params.previousRunId,
- current: await currentProjection(context, params),
+ current,
...(params.runId ? { runId: params.runId } : {}),
});
if (result.sideEffects.length > 0) {
@@ -418,6 +440,11 @@ function safeRunId(runId: string): boolean {
}
}
+async function requestSpecMatchesRun(cwd: string, runId: string, specId: number): Promise {
+ const metadata = await readRunMetadata(runMetadataPath(cwd, runId));
+ return metadata === undefined || metadata.specId === String(specId);
+}
+
async function currentProjection(
context: RpcMethodContext,
params: { readonly specId: number; readonly mode?: 'greenfield' | 'brownfield' },
diff --git a/src/web/__tests__/app.test.tsx b/src/web/__tests__/app.test.tsx
index 821434493..1f9a58291 100644
--- a/src/web/__tests__/app.test.tsx
+++ b/src/web/__tests__/app.test.tsx
@@ -211,6 +211,33 @@ describe('Brunch React web app', () => {
expect(secondSpecLink.getAttribute('href')).toBe('/spec/2');
});
+ it('counts runs for the active spec on the workspace home page', async () => {
+ const runtime = createBrunchWebRuntime({
+ rpcClient: rpcClient({
+ selectionState: populatedSelectionState,
+ runs: [
+ {
+ runId: 'run-spec-2',
+ specId: '2',
+ status: 'created',
+ presence: { worktree: false, reports: false, petri: false, promotion: false },
+ },
+ {
+ runId: 'run-spec-1',
+ specId: '1',
+ status: 'created',
+ presence: { worktree: false, reports: false, petri: false, promotion: false },
+ },
+ ],
+ }),
+ });
+
+ render( );
+
+ expect(await screen.findByText('2 executor runs')).toBeTruthy();
+ expect(screen.getByText('1 run')).toBeTruthy();
+ });
+
it('renders the index without requesting session projections', async () => {
const calls: RpcCall[] = [];
const runtime = createBrunchWebRuntime({ rpcClient: rpcClient({ calls }) });
diff --git a/src/web/routes/root.tsx b/src/web/routes/root.tsx
index 3a02df2e8..01369a15f 100644
--- a/src/web/routes/root.tsx
+++ b/src/web/routes/root.tsx
@@ -65,12 +65,8 @@ function WorkspaceStatePage() {
throwOnError: false,
});
const activeSpecRunCount =
- currentSpecId === undefined
- ? 0
- : selection.specs.length <= 1
- ? runs.runs.length
- : runs.runs.filter((run) => runSpecId(run) === currentSpecId).length;
- const latestRuns = [...runs.runs].reverse().slice(0, 3);
+ currentSpecId === undefined ? 0 : runs.runs.filter((run) => runSpecId(run) === currentSpecId).length;
+ const runPreviews = runs.runs.slice(0, 3);
return (
@@ -81,7 +77,7 @@ function WorkspaceStatePage() {
graphOverview={graphOverview}
runCount={activeSpecRunCount}
/>
-
+
);
diff --git a/src/web/routes/runs.tsx b/src/web/routes/runs.tsx
index 322585eb0..b0f635156 100644
--- a/src/web/routes/runs.tsx
+++ b/src/web/routes/runs.tsx
@@ -292,7 +292,7 @@ function StreamPanel({
{events.length === 0 ? (
{emptyText}
) : (
-
+
{`${label}: show ${rows.length} compacted log rows`}
{failures.length === 0 ? null : (
From 0486ec89894f4c21a07c70c23c662033b9a0b7b1 Mon Sep 17 00:00:00 2001
From: Kostandin Angjellari
Date: Wed, 8 Jul 2026 15:46:21 +0200
Subject: [PATCH 14/14] FE-1141: Sync executor run environment memory
---
memory/PLAN.md | 4 +-
memory/SPEC.md | 6 +-
...n-environment--actionable-slice-request.md | 146 ++++++++++++++++++
src/executor/TOPOLOGY.md | 6 +-
4 files changed, 154 insertions(+), 8 deletions(-)
create mode 100644 memory/cards/executor-run-environment--actionable-slice-request.md
diff --git a/memory/PLAN.md b/memory/PLAN.md
index 462f7ed0c..b489405a4 100644
--- a/memory/PLAN.md
+++ b/memory/PLAN.md
@@ -227,9 +227,9 @@ Brunch-next has delivered the original composition spine: the host, sealed Pi pr
- **Linear:** [FE-1166](https://linear.app/hash/issue/FE-1166/greenfield-executor-run-substrate-and-verify-policy)
- **Branch:** `ka/fe-1166-greenfield-executor-harness` (stacks on `ka/fe-1114-executor-replanning` / PR #300)
- **Kind:** structural / executor run environment policy
-- **Status:** active; scoped in `memory/cards/executor-run-environment--substrate-verify.md`.
+- **Status:** active. Substrate/verify policy is built on PR #302; follow-up live failure scope is open in `memory/cards/executor-run-environment--actionable-slice-request.md` (buildable next after FE-1166 tie-off). Current scope cards: `memory/cards/executor-run-environment--substrate-verify.md`; `memory/cards/executor-run-environment--actionable-slice-request.md`.
- **Objective:** Separate run substrate and verify target from source-copy policy so greenfield fixture runs can use an isolated run directory and product-owned verification profile instead of always starting from a host git worktree and hardcoded `npm run verify`.
-- **Traceability:** FE-1114 follow-up live-run evidence; `src/executor/worktree.ts`, `src/executor/test-result.ts`, `src/app/test-runner-port.ts`, `src/.pi/extensions/executor/execute-run-create/index.ts`.
+- **Traceability:** FE-1114 follow-up live-run evidence; `src/executor/worktree.ts`, `src/executor/test-result.ts`, `src/app/test-runner-port.ts`, `src/.pi/extensions/executor/execute-run-create/index.ts`; follow-up worker-request evidence from run `run-mrbyf8u9` recorded in `memory/cards/executor-run-environment--actionable-slice-request.md`.