Skip to content

Commit e26139e

Browse files
FL4TLiN3claude
andauthored
feat: add git-graph style run tree display and improve delegation error reporting (#752)
* feat: add git-graph style run tree display and improve delegation error reporting Add git-graph visualization with colored lanes for the log viewer delegation tree. Resume runs are rendered as separate navigable nodes. Fix missing delegated child runs by using directory-based run discovery. Include error details in delegation failure messages propagated to parent runs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove unused flattenTreeAll function Replaced by buildGraphLines for log viewer tree rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 79b16aa commit e26139e

File tree

12 files changed

+1238
-269
lines changed

12 files changed

+1238
-269
lines changed

.changeset/git-graph-log-viewer.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@perstack/tui-components": patch
3+
"@perstack/log": patch
4+
"@perstack/runtime": patch
5+
---
6+
7+
feat: add git-graph style run tree display with colored lanes and improved delegation error reporting
8+
9+
- Add git-graph style visualization for delegation tree in log viewer with colored lanes
10+
- Render resume runs as separate navigable nodes instead of merging into originals
11+
- Use commit-chain style (single lane per delegation group) for better information density
12+
- Fix missing delegated child runs by using directory-based run discovery
13+
- Include error details in delegation failure messages propagated to parent runs
14+
- Log warnings instead of silently swallowing storeEvent errors in delegation catch block

packages/log/src/data-fetcher.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe("createLogDataFetcher", () => {
8888
retrieveCheckpoint: mock(),
8989
getEventContents: mock(),
9090
getAllRuns: mock(),
91+
getRunIdsByJobId: mock().mockReturnValue([]),
9192
getJobIds: mock().mockReturnValue([]),
9293
getBasePath: mock().mockReturnValue("/tmp/test"),
9394
}

packages/log/src/data-fetcher.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getAllRuns,
88
getCheckpointsByJobId,
99
getEventContents,
10+
getRunIdsByJobId,
1011
retrieveJob,
1112
} from "@perstack/filesystem-storage"
1213

@@ -33,6 +34,7 @@ export interface StorageAdapter {
3334
typeFilter?: Set<string>,
3435
): Promise<RunEvent[]>
3536
getAllRuns(): Promise<RunSetting[]>
37+
getRunIdsByJobId(jobId: string): string[]
3638
getJobIds(): string[]
3739
getBasePath(): string
3840
}
@@ -133,10 +135,11 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher {
133135
"continueToNextStep",
134136
"resolveToolResults",
135137
])
136-
const runs = await this.getRuns(jobId)
138+
// Use getRunIdsByJobId to discover ALL runs (including those without run-setting.json)
139+
const runIds = storage.getRunIdsByJobId(jobId)
137140
const allEvents: RunEvent[] = []
138-
for (const run of runs) {
139-
const events = await storage.getEventContents(jobId, run.runId, undefined, treeEventTypes)
141+
for (const runId of runIds) {
142+
const events = await storage.getEventContents(jobId, runId, undefined, treeEventTypes)
140143
allEvents.push(...events)
141144
}
142145
return allEvents.sort((a, b) => a.timestamp - b.timestamp)
@@ -167,6 +170,7 @@ export function createStorageAdapter(basePath: string): StorageAdapter {
167170
getEventContents: async (jobId, runId, maxStep, typeFilter) =>
168171
getEventContents(jobId, runId, maxStep, typeFilter),
169172
getAllRuns: async () => getAllRuns(),
173+
getRunIdsByJobId: (jobId) => getRunIdsByJobId(jobId),
170174
getJobIds: () => {
171175
const jobsDir = path.join(basePath, "jobs")
172176
if (!existsSync(jobsDir)) return []

packages/runtime/src/orchestration/delegation-executor.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,12 @@ export class DelegationExecutor {
290290

291291
// Persist events via parent's callbacks
292292
if (parentOptions?.storeEvent) {
293-
await parentOptions.storeEvent(startRunEvent).catch(() => {})
294-
await parentOptions.storeEvent(stopRunByErrorEvent).catch(() => {})
293+
await parentOptions.storeEvent(startRunEvent).catch((e) => {
294+
console.warn(`Failed to store startRun event for ${expert.key}: ${e}`)
295+
})
296+
await parentOptions.storeEvent(stopRunByErrorEvent).catch((e) => {
297+
console.warn(`Failed to store stopRunByError event for ${expert.key}: ${e}`)
298+
})
295299
}
296300
if (parentOptions?.eventListener) {
297301
parentOptions.eventListener(startRunEvent)
@@ -310,11 +314,14 @@ export class DelegationExecutor {
310314

311315
// Handle non-completed delegation (stoppedByError, stoppedByCancellation, etc.)
312316
if (resultCheckpoint.status !== "completed") {
317+
const errorDetail = resultCheckpoint.error
318+
? `: ${resultCheckpoint.error.name}: ${resultCheckpoint.error.message}`
319+
: ""
313320
return {
314321
toolCallId,
315322
toolName,
316323
expertKey: expert.key,
317-
text: `Delegation to ${expert.key} ended with status: ${resultCheckpoint.status}`,
324+
text: `Delegation to ${expert.key} ended with status: ${resultCheckpoint.status}${errorDetail}`,
318325
stepNumber: resultCheckpoint.stepNumber,
319326
deltaUsage: resultCheckpoint.usage,
320327
}

packages/tui-components/src/execution/hooks/use-delegation-tree.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -133,40 +133,6 @@ export function getStatusCounts(state: DelegationTreeState): {
133133
return { running, waiting }
134134
}
135135

136-
/** Flatten tree without pruning - shows all nodes including completed/error. For log viewer. */
137-
export function flattenTreeAll(state: DelegationTreeState): FlatTreeNode[] {
138-
const result: FlatTreeNode[] = []
139-
const visited = new Set<string>()
140-
141-
function dfs(nodeId: string, depth: number, isLast: boolean, ancestorIsLast: boolean[]) {
142-
const node = state.nodes.get(nodeId)
143-
if (!node || visited.has(nodeId)) return
144-
145-
visited.add(nodeId)
146-
result.push({ node, depth, isLast, ancestorIsLast: [...ancestorIsLast] })
147-
148-
const children = node.childRunIds
149-
for (let i = 0; i < children.length; i++) {
150-
const childIsLast = i === children.length - 1
151-
dfs(children[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast])
152-
}
153-
}
154-
155-
if (state.rootRunId) {
156-
dfs(state.rootRunId, 0, true, [])
157-
}
158-
159-
// Show orphaned nodes (no parent in tree) at root level
160-
for (const [nodeId, node] of state.nodes) {
161-
if (!visited.has(nodeId)) {
162-
visited.add(nodeId)
163-
result.push({ node, depth: 0, isLast: true, ancestorIsLast: [] })
164-
}
165-
}
166-
167-
return result
168-
}
169-
170136
export function flattenTree(state: DelegationTreeState): FlatTreeNode[] {
171137
if (!state.rootRunId) return []
172138
const root = state.nodes.get(state.rootRunId)

packages/tui-components/src/log-viewer/app.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,17 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA
9595
setLoading(false)
9696
return
9797
}
98-
const { treeState, runQueries, runStats } = await buildRunTree(fetcher, initialJobId)
99-
setScreen({ type: "runList", job, treeState, runQueries, runStats })
98+
const { treeState, runQueries, runStats, delegationGroups, resumeNodes } =
99+
await buildRunTree(fetcher, initialJobId)
100+
setScreen({
101+
type: "runList",
102+
job,
103+
treeState,
104+
runQueries,
105+
runStats,
106+
delegationGroups,
107+
resumeNodes,
108+
})
100109
setLoading(false)
101110
return
102111
}
@@ -130,8 +139,17 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA
130139
async (job: Job) => {
131140
setLoading(true)
132141
try {
133-
const { treeState, runQueries, runStats } = await buildRunTree(fetcher, job.id)
134-
setScreen({ type: "runList", job, treeState, runQueries, runStats })
142+
const { treeState, runQueries, runStats, delegationGroups, resumeNodes } =
143+
await buildRunTree(fetcher, job.id)
144+
setScreen({
145+
type: "runList",
146+
job,
147+
treeState,
148+
runQueries,
149+
runStats,
150+
delegationGroups,
151+
resumeNodes,
152+
})
135153
} catch (err) {
136154
setError(err instanceof Error ? err.message : String(err))
137155
}
@@ -264,6 +282,8 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA
264282
job={screen.job}
265283
treeState={screen.treeState}
266284
runQueries={screen.runQueries}
285+
delegationGroups={screen.delegationGroups}
286+
resumeNodes={screen.resumeNodes}
267287
onSelectRun={(node) => {
268288
const run: RunInfo = {
269289
jobId: screen.job.id,

0 commit comments

Comments
 (0)