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) => ( -
  1. -
    - {event.kind} - {event.sliceId} - #{event.sequence} -
    -

    {event.message}

    -
  2. - ))} -
+
+ {failures.length === 0 ? null : ( +
+

Verify failures

+
    + {failures.map((failure) => ( +
  • + {failure.message} +
  • + ))} +
+
+ )} +
    + {rows.map((event) => ( +
  1. +
    + {event.kind} + {event.sliceId} + #{event.sequence} + {event.count > 1 ? ( + x{event.count} + ) : null} +
    +

    {event.message}

    +
  2. + ))} +
+
+ 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) => ( -
  1. - {event.event} - {typeof event['sliceId'] === 'string' ? ( - {event['sliceId']} - ) : null} -
  2. - ))} -
+
+
    + {sliceProgress.map((slice) => ( +
  1. +
    + {slice.sliceId} + {slice.progress} +
    +
  2. + ))} +
+
+ Raw events +
    + {run.reportsTail.map((event, index) => ( +
  1. + {event.event} + {typeof event['sliceId'] === 'string' ? ( + #{event['sliceId']} + ) : null} +
  2. + ))} +
+
+
)}
); } +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) => (
  1. {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) => ( -
    1. -
      - {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 ( +
    2. +
      + {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(', ')}

      )} -
    -
  2. - ))} +
    + + 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 ( + + ); + })} +
+ {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) => ( +
  1. + {'unreadable' in run ? : } +
  2. + ))} +
+ )} +
+ ); +} + +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 ( ); } + +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 ( + + + ); +} + +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 ( -
)} ); @@ -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.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 ( - ); - })} -
- {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`.