mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Keep sidebar agent rows stable during status updates (#6961)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: this suite covers one row-building seam
|
||||
shared by retention, stale decay, and orchestration lineage regressions. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
@@ -9,6 +7,7 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/typ
|
||||
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
|
||||
import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { comparePaneKeysOrdinal } from './worktree-agent-row-order'
|
||||
import { buildWorktreeAgentRows } from './worktree-agent-rows'
|
||||
|
||||
const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111')
|
||||
@@ -293,6 +292,111 @@ describe('buildWorktreeAgentRows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps simultaneous worktree-attributed agents in a deterministic order', () => {
|
||||
const first = makeEntry(PANE_KEY_1, 1000, {
|
||||
state: 'working',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
prompt: 'omp worker'
|
||||
})
|
||||
const second = makeEntry(PANE_KEY_2, 1000, {
|
||||
state: 'working',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-2',
|
||||
prompt: 'omp worker'
|
||||
})
|
||||
const third = makeEntry(PANE_KEY_3, 1000, {
|
||||
state: 'working',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-3',
|
||||
prompt: 'omp worker'
|
||||
})
|
||||
|
||||
const build = (entries: AgentStatusEntry[]) =>
|
||||
buildWorktreeAgentRows({
|
||||
tabs: [],
|
||||
entries,
|
||||
retained: [],
|
||||
now: 2000
|
||||
}).map((row) => row.paneKey)
|
||||
|
||||
// Why: OMP can send frequent same-state updates for several panes. When
|
||||
// those workers are worktree-attributed before their tabs arrive, row order
|
||||
// must not inherit a noisy status-map iteration order.
|
||||
expect(build([third, first, second])).toEqual([PANE_KEY_1, PANE_KEY_2, PANE_KEY_3])
|
||||
expect(build([{ ...second, updatedAt: 1500 }, third, first])).toEqual([
|
||||
PANE_KEY_1,
|
||||
PANE_KEY_2,
|
||||
PANE_KEY_3
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps missing-tab row order stable after real state transitions', () => {
|
||||
const first = makeEntry(PANE_KEY_1, 2000, {
|
||||
state: 'done',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
stateHistory: [{ state: 'working', prompt: 'omp worker', startedAt: 1000 }]
|
||||
})
|
||||
const second = makeEntry(PANE_KEY_2, 1500, {
|
||||
state: 'blocked',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-2',
|
||||
stateHistory: [{ state: 'working', prompt: 'omp worker', startedAt: 1000 }]
|
||||
})
|
||||
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [],
|
||||
entries: [second, first],
|
||||
retained: [],
|
||||
now: 2500
|
||||
})
|
||||
|
||||
// Why: both rows originally started together. A later working -> terminal
|
||||
// transition must not make fallback tab createdAt outrank paneKey order.
|
||||
expect(rows.map((row) => [row.paneKey, row.startedAt, row.tab.createdAt])).toEqual([
|
||||
[PANE_KEY_1, 1000, 1000],
|
||||
[PANE_KEY_2, 1000, 1000]
|
||||
])
|
||||
})
|
||||
|
||||
it('anchors missing-tab row order to the oldest state history entry', () => {
|
||||
const first = makeEntry(PANE_KEY_1, 2400, {
|
||||
state: 'done',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
stateHistory: [
|
||||
{ state: 'working', prompt: 'omp worker', startedAt: 1000 },
|
||||
{ state: 'blocked', prompt: 'omp worker', startedAt: 1800 }
|
||||
]
|
||||
})
|
||||
const second = makeEntry(PANE_KEY_2, 1600, {
|
||||
state: 'waiting',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-2',
|
||||
stateHistory: [
|
||||
{ state: 'working', prompt: 'omp worker', startedAt: 1000 },
|
||||
{ state: 'blocked', prompt: 'omp worker', startedAt: 1200 }
|
||||
]
|
||||
})
|
||||
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [],
|
||||
entries: [second, first],
|
||||
retained: [],
|
||||
now: 2500
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.paneKey, row.startedAt, row.tab.createdAt])).toEqual([
|
||||
[PANE_KEY_1, 1000, 1000],
|
||||
[PANE_KEY_2, 1000, 1000]
|
||||
])
|
||||
})
|
||||
|
||||
it('uses ordinal pane-key comparison for final row ordering ties', () => {
|
||||
expect(comparePaneKeysOrdinal('tab-\u00e4', 'tab-z')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('uses runtime orchestration metadata for hook-reported live rows', () => {
|
||||
const parent = makeEntry(PANE_KEY_1, 1000, {
|
||||
prompt: 'parent',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
|
||||
export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number {
|
||||
return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
|
||||
}
|
||||
|
||||
export function tabFromWorktreeAttributedStatusEntry(
|
||||
entry: AgentStatusEntry,
|
||||
effectiveStartedAt: number
|
||||
): TerminalTab | null {
|
||||
const parsed = parsePaneKey(entry.paneKey)
|
||||
if (!parsed || !entry.worktreeId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: parsed.tabId,
|
||||
ptyId: null,
|
||||
worktreeId: entry.worktreeId,
|
||||
title: entry.terminalTitle ?? 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: Number.MAX_SAFE_INTEGER,
|
||||
// Why: missing-tab rows must keep their original clock through real state
|
||||
// transitions; current stateStartedAt is a status timestamp, not tab order.
|
||||
createdAt: effectiveStartedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
|
||||
|
||||
function comparableNumber(value: number | undefined, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
export function comparePaneKeysOrdinal(a: string, b: string): number {
|
||||
if (a < b) {
|
||||
return -1
|
||||
}
|
||||
if (a > b) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function compareWorktreeAgentRows(a: DashboardAgentRow, b: DashboardAgentRow): number {
|
||||
return (
|
||||
comparableNumber(a.startedAt) - comparableNumber(b.startedAt) ||
|
||||
comparableNumber(a.tab.sortOrder) - comparableNumber(b.tab.sortOrder) ||
|
||||
comparableNumber(a.tab.createdAt) - comparableNumber(b.tab.createdAt) ||
|
||||
comparePaneKeysOrdinal(a.paneKey, b.paneKey)
|
||||
)
|
||||
}
|
||||
@@ -23,23 +23,11 @@ import {
|
||||
resolveAgentTypeFromTerminalTitle
|
||||
} from './worktree-title-derived-agent-rows'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
|
||||
function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null {
|
||||
const parsed = parsePaneKey(entry.paneKey)
|
||||
if (!parsed || !entry.worktreeId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: parsed.tabId,
|
||||
ptyId: null,
|
||||
worktreeId: entry.worktreeId,
|
||||
title: entry.terminalTitle ?? 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: Number.MAX_SAFE_INTEGER,
|
||||
createdAt: entry.stateStartedAt
|
||||
}
|
||||
}
|
||||
import { compareWorktreeAgentRows } from './worktree-agent-row-order'
|
||||
import {
|
||||
effectiveWorktreeAgentRowStartedAt,
|
||||
tabFromWorktreeAttributedStatusEntry
|
||||
} from './worktree-agent-row-fallback-tab'
|
||||
|
||||
/**
|
||||
* Resolves the sidebar row agent type, prioritizing launch agent configuration
|
||||
@@ -250,6 +238,7 @@ export function buildWorktreeAgentRows(args: {
|
||||
(rowEntry.state === 'working' ||
|
||||
rowEntry.state === 'blocked' ||
|
||||
rowEntry.state === 'waiting')
|
||||
const startedAt = effectiveWorktreeAgentRowStartedAt(rowEntry)
|
||||
rows.push({
|
||||
paneKey: rowEntry.paneKey,
|
||||
entry: rowEntry,
|
||||
@@ -257,7 +246,7 @@ export function buildWorktreeAgentRows(args: {
|
||||
agentType: resolveRowAgentType(rowEntry, tab),
|
||||
rowSource: 'live',
|
||||
state: shouldDecay ? 'idle' : rowEntry.state,
|
||||
startedAt: rowEntry.stateHistory[0]?.startedAt ?? rowEntry.stateStartedAt
|
||||
startedAt
|
||||
})
|
||||
seenPaneKeys.add(rowEntry.paneKey)
|
||||
}
|
||||
@@ -282,7 +271,8 @@ export function buildWorktreeAgentRows(args: {
|
||||
continue
|
||||
}
|
||||
const rowEntry = entryWithRuntimeOrchestration(entry, args.runtimeAgentOrchestrationByPaneKey)
|
||||
const tab = tabFromAttributedStatusEntry(rowEntry)
|
||||
const startedAt = effectiveWorktreeAgentRowStartedAt(rowEntry)
|
||||
const tab = tabFromWorktreeAttributedStatusEntry(rowEntry, startedAt)
|
||||
if (!tab) {
|
||||
continue
|
||||
}
|
||||
@@ -297,7 +287,7 @@ export function buildWorktreeAgentRows(args: {
|
||||
agentType: resolveRowAgentType(rowEntry, tab),
|
||||
rowSource: 'live',
|
||||
state: shouldDecay ? 'idle' : rowEntry.state,
|
||||
startedAt: rowEntry.stateHistory[0]?.startedAt ?? rowEntry.stateStartedAt
|
||||
startedAt
|
||||
})
|
||||
seenPaneKeys.add(rowEntry.paneKey)
|
||||
}
|
||||
@@ -330,6 +320,8 @@ export function buildWorktreeAgentRows(args: {
|
||||
})
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.startedAt - b.startedAt)
|
||||
// Why: hook pings can rebuild the live entry list in a different iteration
|
||||
// order. Equal-start agents still need a deterministic sidebar order.
|
||||
rows.sort(compareWorktreeAgentRows)
|
||||
return rows
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user