Skip to content

Commit e1fde26

Browse files
wesmcodex
andauthored
Show panel wall-clock elapsed time (#938)
Panel synthesis rows were displaying only the synthesis worker runtime, which understated how long a panel had actually been running. The queue needs to measure from the first reviewer start through synthesis completion so collapsed panels reflect the user's end-to-end wait. Expose the first panel member start time in the existing panel summary aggregate and keep synthesis timestamps as a fallback for rows without summary timing. This makes the display additive and preserves compatibility with older responses. Generated with Codex Co-authored-by: Codex <codex@openai.com>
1 parent c01c685 commit e1fde26

6 files changed

Lines changed: 166 additions & 30 deletions

File tree

cmd/roborev/tui/queue_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,52 @@ func TestTUIJobCellsContent(t *testing.T) {
599599
cells := m.jobCells(member)
600600
assert.Empty(t, cells[8])
601601
})
602+
603+
t.Run("panel parent elapsed uses panel wall clock when members are cached", func(t *testing.T) {
604+
elapsedIdx := colElapsed - colRef
605+
firstMemberStarted := time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
606+
synthesisStarted := firstMemberStarted.Add(9 * time.Minute)
607+
synthesisFinished := firstMemberStarted.Add(11 * time.Minute)
608+
parent := makeJob(10,
609+
withSynthesis("R", storage.PanelSummary{MembersTotal: 2, MembersTerminal: 2}),
610+
withStartedAt(synthesisStarted),
611+
withFinishedAt(&synthesisFinished),
612+
)
613+
memberA := makeJob(11,
614+
withPanelMember("R", "default", 0),
615+
withStartedAt(firstMemberStarted),
616+
)
617+
memberB := makeJob(12,
618+
withPanelMember("R", "security", 1),
619+
withStartedAt(firstMemberStarted.Add(2*time.Minute)),
620+
)
621+
m.panelMembers = map[string][]storage.ReviewJob{"R": {memberA, memberB}}
622+
623+
cells := m.jobCells(parent)
624+
625+
assert.Equal(t, "11m0s", cells[elapsedIdx])
626+
})
627+
628+
t.Run("panel parent elapsed uses panel summary before members are cached", func(t *testing.T) {
629+
elapsedIdx := colElapsed - colRef
630+
firstMemberStarted := time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
631+
synthesisStarted := firstMemberStarted.Add(9 * time.Minute)
632+
synthesisFinished := firstMemberStarted.Add(11 * time.Minute)
633+
parent := makeJob(10,
634+
withSynthesis("R", storage.PanelSummary{
635+
MembersTotal: 2,
636+
MembersTerminal: 2,
637+
FirstStartedAt: &firstMemberStarted,
638+
}),
639+
withStartedAt(synthesisStarted),
640+
withFinishedAt(&synthesisFinished),
641+
)
642+
m.panelMembers = nil
643+
644+
cells := m.jobCells(parent)
645+
646+
assert.Equal(t, "11m0s", cells[elapsedIdx])
647+
})
602648
}
603649

604650
func TestTUIJobCellsReviewTypeTag(t *testing.T) {
@@ -689,6 +735,35 @@ func TestTUIQueueShowsCostColumnByDefault(t *testing.T) {
689735
"cost value should render in the row")
690736
}
691737

738+
func TestTUIQueuePanelParentRendersPanelElapsedTime(t *testing.T) {
739+
firstMemberStarted := time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
740+
synthesisStarted := firstMemberStarted.Add(9 * time.Minute)
741+
synthesisFinished := firstMemberStarted.Add(11 * time.Minute)
742+
parent := makeJob(10,
743+
withRef("syn1234"),
744+
withRepoName("repo"),
745+
withAgent("test"),
746+
withSynthesis("R", storage.PanelSummary{
747+
MembersTotal: 2,
748+
MembersTerminal: 2,
749+
FirstStartedAt: &firstMemberStarted,
750+
}),
751+
withStartedAt(synthesisStarted),
752+
withFinishedAt(&synthesisFinished),
753+
)
754+
755+
m := newModel(localhostEndpoint, withExternalIODisabled())
756+
m.width = 200
757+
m.height = 30
758+
m.jobs = []storage.ReviewJob{parent}
759+
m.selectedIdx = 0
760+
m.selectedJobID = parent.ID
761+
762+
out := stripTestANSI(m.renderQueueView())
763+
assert.Contains(t, out, "11m0s")
764+
assert.NotContains(t, out, "2m0s")
765+
}
766+
692767
func TestTUIQueueHeaderShowsPausedQueueState(t *testing.T) {
693768
assert := assert.New(t)
694769
m := newModel(localhostEndpoint, withExternalIODisabled())

cmd/roborev/tui/render_queue.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -930,14 +930,7 @@ func (m model) jobCells(job storage.ReviewJob) []string {
930930

931931
enqueued := job.EnqueuedAt.Local().Format("Jan 02 15:04")
932932

933-
elapsed := ""
934-
if job.StartedAt != nil {
935-
if job.FinishedAt != nil {
936-
elapsed = job.FinishedAt.Sub(*job.StartedAt).Round(time.Second).String()
937-
} else {
938-
elapsed = time.Since(*job.StartedAt).Round(time.Second).String()
939-
}
940-
}
933+
elapsed := m.jobElapsedCell(job)
941934

942935
status := statusLabel(job)
943936

@@ -968,6 +961,45 @@ func (m model) jobCells(job storage.ReviewJob) []string {
968961
return []string{ref, branch, repo, agentName, enqueued, elapsed, status, verdict, handled, sessionID, requestedModel, requestedProvider, cost}
969962
}
970963

964+
func (m model) jobElapsedCell(job storage.ReviewJob) string {
965+
startedAt, ok := m.jobElapsedStart(job)
966+
if !ok {
967+
return ""
968+
}
969+
if job.FinishedAt != nil {
970+
return job.FinishedAt.Sub(startedAt).Round(time.Second).String()
971+
}
972+
return time.Since(startedAt).Round(time.Second).String()
973+
}
974+
975+
func (m model) jobElapsedStart(job storage.ReviewJob) (time.Time, bool) {
976+
startedAt := time.Time{}
977+
hasStartedAt := false
978+
if job.StartedAt != nil {
979+
startedAt = *job.StartedAt
980+
hasStartedAt = true
981+
}
982+
983+
if !job.IsSynthesisJob() || job.PanelRunUUID == "" {
984+
return startedAt, hasStartedAt
985+
}
986+
987+
if job.PanelSummary != nil && job.PanelSummary.FirstStartedAt != nil {
988+
startedAt = *job.PanelSummary.FirstStartedAt
989+
hasStartedAt = true
990+
}
991+
for _, member := range m.panelMembers[job.PanelRunUUID] {
992+
if member.StartedAt == nil {
993+
continue
994+
}
995+
if !hasStartedAt || member.StartedAt.Before(startedAt) {
996+
startedAt = *member.StartedAt
997+
hasStartedAt = true
998+
}
999+
}
1000+
return startedAt, hasStartedAt
1001+
}
1002+
9711003
// jobCostCell renders the stored priced estimate for a row. For a panel parent,
9721004
// it adds known member costs as a lower-bound aggregate; unpriced members do not
9731005
// suppress costs from members that did report pricing.

internal/storage/jobs.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,16 +1866,17 @@ func (db *DB) GetPanelMemberReviews(panelRunUUID string) ([]BatchReviewResult, e
18661866
// ambiguous — an all-failed panel is finished but has zero done members — so
18671867
// the terminal set is broken out explicitly.
18681868
type PanelSummary struct {
1869-
PanelRunUUID string `json:"panel_run_uuid"`
1870-
MembersTotal int `json:"members_total"`
1871-
MembersTerminal int `json:"members_terminal"`
1872-
MembersSucceeded int `json:"members_succeeded"`
1873-
MembersFailed int `json:"members_failed"`
1874-
MembersCanceled int `json:"members_canceled"`
1875-
MembersSkipped int `json:"members_skipped"`
1876-
MembersWithCost int `json:"members_with_cost,omitempty"`
1877-
MembersCostUSD float64 `json:"members_cost_usd,omitempty"`
1878-
MembersCostComplete bool `json:"members_cost_complete,omitempty"`
1869+
PanelRunUUID string `json:"panel_run_uuid"`
1870+
MembersTotal int `json:"members_total"`
1871+
MembersTerminal int `json:"members_terminal"`
1872+
MembersSucceeded int `json:"members_succeeded"`
1873+
MembersFailed int `json:"members_failed"`
1874+
MembersCanceled int `json:"members_canceled"`
1875+
MembersSkipped int `json:"members_skipped"`
1876+
MembersWithCost int `json:"members_with_cost,omitempty"`
1877+
MembersCostUSD float64 `json:"members_cost_usd,omitempty"`
1878+
MembersCostComplete bool `json:"members_cost_complete,omitempty"`
1879+
FirstStartedAt *time.Time `json:"first_started_at,omitempty"`
18791880
}
18801881

18811882
// GetPanelSummaries computes the member breakdown for each given panel run in
@@ -1900,7 +1901,8 @@ func (db *DB) GetPanelSummaries(runUUIDs []string) (map[string]PanelSummary, err
19001901
COALESCE(SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END), 0),
19011902
COALESCE(SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END), 0),
19021903
COALESCE(SUM(CASE WHEN json_valid(token_usage) AND json_extract(token_usage, '$.has_cost') THEN 1 ELSE 0 END), 0),
1903-
COALESCE(SUM(CASE WHEN json_valid(token_usage) AND json_extract(token_usage, '$.has_cost') THEN json_extract(token_usage, '$.cost_usd') ELSE 0 END), 0)
1904+
COALESCE(SUM(CASE WHEN json_valid(token_usage) AND json_extract(token_usage, '$.has_cost') THEN json_extract(token_usage, '$.cost_usd') ELSE 0 END), 0),
1905+
MIN(started_at)
19041906
FROM review_jobs
19051907
WHERE panel_role = 'member' AND panel_run_uuid IN (%s)
19061908
GROUP BY panel_run_uuid
@@ -1916,13 +1918,18 @@ func (db *DB) GetPanelSummaries(runUUIDs []string) (map[string]PanelSummary, err
19161918
for rows.Next() {
19171919
var s PanelSummary
19181920
var membersWithCost int
1921+
var firstStartedAt sql.NullString
19191922
if err := rows.Scan(&s.PanelRunUUID, &s.MembersTotal, &s.MembersTerminal,
19201923
&s.MembersSucceeded, &s.MembersFailed, &s.MembersCanceled, &s.MembersSkipped,
1921-
&membersWithCost, &s.MembersCostUSD); err != nil {
1924+
&membersWithCost, &s.MembersCostUSD, &firstStartedAt); err != nil {
19221925
return nil, fmt.Errorf("scan panel summary: %w", err)
19231926
}
19241927
s.MembersWithCost = membersWithCost
19251928
s.MembersCostComplete = s.MembersTotal > 0 && membersWithCost == s.MembersTotal
1929+
if firstStartedAt.Valid {
1930+
t := parseSQLiteTime(firstStartedAt.String)
1931+
s.FirstStartedAt = &t
1932+
}
19261933
out[s.PanelRunUUID] = s
19271934
}
19281935
return out, rows.Err()

internal/storage/panels_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package storage
33
import (
44
"context"
55
"database/sql"
6+
"encoding/json"
67
"errors"
78
"testing"
89
"time"
@@ -195,6 +196,14 @@ func setStatus(t *testing.T, db *DB, jobID int64, status JobStatus) {
195196
require.NoError(t, err)
196197
}
197198

199+
func setStartedAt(t *testing.T, db *DB, jobID int64, startedAt time.Time) {
200+
t.Helper()
201+
_, err := db.Exec(
202+
`UPDATE review_jobs SET started_at = ? WHERE id = ?`,
203+
startedAt.UTC().Format(time.RFC3339), jobID)
204+
require.NoError(t, err)
205+
}
206+
198207
func claimBlockedOf(t *testing.T, db *DB, jobID int64) bool {
199208
t.Helper()
200209
var cb int
@@ -431,6 +440,10 @@ func TestGetPanelSummaries(t *testing.T) {
431440

432441
// Run A: 3 members — done, failed, skipped (all terminal; 1 succeeded).
433442
_, a := enqueuePanelRun(t, db, repo.ID, "run-A", 3)
443+
firstAStarted := time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
444+
setStartedAt(t, db, a[0].ID, firstAStarted.Add(2*time.Minute))
445+
setStartedAt(t, db, a[1].ID, firstAStarted)
446+
setStartedAt(t, db, a[2].ID, firstAStarted.Add(time.Minute))
434447
setStatus(t, db, a[0].ID, JobStatusDone)
435448
setStatus(t, db, a[1].ID, JobStatusFailed)
436449
setStatus(t, db, a[2].ID, JobStatusSkipped)
@@ -463,6 +476,11 @@ func TestGetPanelSummaries(t *testing.T) {
463476
assert.Equal(3, sumA.MembersWithCost)
464477
assert.True(sumA.MembersCostComplete)
465478
assert.InDelta(0.40, sumA.MembersCostUSD, 0.000001)
479+
sumAJSON, err := json.Marshal(sumA)
480+
require.NoError(t, err)
481+
var sumAMap map[string]any
482+
require.NoError(t, json.Unmarshal(sumAJSON, &sumAMap))
483+
assert.Equal(firstAStarted.Format(time.RFC3339), sumAMap["first_started_at"])
466484

467485
sumB := got["run-B"]
468486
assert.Equal(2, sumB.MembersTotal)

pkg/client/generated/types.go

Lines changed: 11 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/client/openapi.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,9 @@ components:
10671067
PanelSummary:
10681068
additionalProperties: false
10691069
properties:
1070+
first_started_at:
1071+
format: date-time
1072+
type: string
10701073
members_canceled:
10711074
format: int64
10721075
type: integer

0 commit comments

Comments
 (0)