mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(sidebar): keep agent rows ordered by their session start once history wraps
Sidebar agent rows are ordered by the session's first reported state, read as `stateHistory[0].startedAt`. `stateHistory` is a rolling window capped at AGENT_STATE_HISTORY_MAX that trims oldest-first, so past ~20 transitions that row is a recent turn: the row's identity clock silently becomes an activity clock and busy agents re-sort themselves on every turn. Latch the session origin in `firstStateStartedAt` when the entry is built and read it through `agentEntrySessionStartedAt`, keeping the history lookup as a fallback for entries that predate the field.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
|
||||
export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number {
|
||||
return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
|
||||
return agentEntrySessionStartedAt(entry)
|
||||
}
|
||||
|
||||
export function tabFromWorktreeAttributedStatusEntry(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_STATE_HISTORY_MAX,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { buildWorktreeAgentRows } from './worktree-agent-rows'
|
||||
|
||||
const BUSY_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const QUIET_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const BUSY_PANE_KEY = makePaneKey('tab-busy', BUSY_LEAF_ID)
|
||||
const QUIET_PANE_KEY = makePaneKey('tab-quiet', QUIET_LEAF_ID)
|
||||
|
||||
const NOW = 900_000
|
||||
|
||||
function makeTab(id: string, sortOrder: number): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
worktreeId: 'wt-1',
|
||||
ptyId: null,
|
||||
title: 'Claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** A history window that has already wrapped: the session's real first states were trimmed away. */
|
||||
function wrappedHistory(oldestRetainedAt: number): AgentStateHistoryEntry[] {
|
||||
return Array.from({ length: AGENT_STATE_HISTORY_MAX }, (_, index) => ({
|
||||
state: index % 2 === 0 ? ('working' as const) : ('done' as const),
|
||||
prompt: `turn ${index}`,
|
||||
startedAt: oldestRetainedAt + index * 1000
|
||||
}))
|
||||
}
|
||||
|
||||
function makeEntry(paneKey: string, overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
|
||||
return {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'keep going',
|
||||
agentType: 'claude',
|
||||
stateStartedAt: NOW - 1000,
|
||||
updatedAt: NOW - 1000,
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree agent row order across new activity', () => {
|
||||
// Why: the busy agent started FIRST, so it must stay first. Its stateHistory has wrapped the
|
||||
// AGENT_STATE_HISTORY_MAX cap, so stateHistory[0] no longer marks the session start — reading
|
||||
// it directly would rank the row by its most recent turns and jump it past the quiet agent.
|
||||
it('keeps the older session ahead once its history window has wrapped', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-busy', 0), makeTab('tab-quiet', 1)],
|
||||
entries: [
|
||||
makeEntry(BUSY_PANE_KEY, {
|
||||
firstStateStartedAt: 1000,
|
||||
stateHistory: wrappedHistory(500_000)
|
||||
}),
|
||||
makeEntry(QUIET_PANE_KEY, { firstStateStartedAt: 2000, stateStartedAt: 2000 })
|
||||
],
|
||||
retained: [],
|
||||
now: NOW
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.paneKey)).toEqual([BUSY_PANE_KEY, QUIET_PANE_KEY])
|
||||
expect(rows[0].startedAt).toBe(1000)
|
||||
})
|
||||
|
||||
it('does not re-sort the busy row when its next turn slides the history window', () => {
|
||||
const buildRows = (oldestRetainedAt: number): string[] =>
|
||||
buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-busy', 0), makeTab('tab-quiet', 1)],
|
||||
entries: [
|
||||
makeEntry(BUSY_PANE_KEY, {
|
||||
firstStateStartedAt: 1000,
|
||||
stateHistory: wrappedHistory(oldestRetainedAt)
|
||||
}),
|
||||
makeEntry(QUIET_PANE_KEY, { firstStateStartedAt: 2000, stateStartedAt: 2000 })
|
||||
],
|
||||
retained: [],
|
||||
now: NOW
|
||||
}).map((row) => row.paneKey)
|
||||
|
||||
const before = buildRows(500_000)
|
||||
// One more turn: the oldest retained history row is dropped, so stateHistory[0] advances.
|
||||
const after = buildRows(502_000)
|
||||
|
||||
expect(after).toEqual(before)
|
||||
expect(after).toEqual([BUSY_PANE_KEY, QUIET_PANE_KEY])
|
||||
})
|
||||
|
||||
// Why: entries rehydrated from disk (or synthesized by main) carry no latched origin; they must
|
||||
// still order by their oldest known state rather than collapsing to the current status clock.
|
||||
it('falls back to the oldest retained history row when no origin was latched', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-busy', 0), makeTab('tab-quiet', 1)],
|
||||
entries: [
|
||||
makeEntry(BUSY_PANE_KEY, { stateHistory: wrappedHistory(1000) }),
|
||||
makeEntry(QUIET_PANE_KEY, { stateStartedAt: 2000 })
|
||||
],
|
||||
retained: [],
|
||||
now: NOW
|
||||
})
|
||||
|
||||
expect(rows.map((row) => row.paneKey)).toEqual([BUSY_PANE_KEY, QUIET_PANE_KEY])
|
||||
expect(rows[0].startedAt).toBe(1000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_STATE_HISTORY_MAX } from '../../../../shared/agent-status-types'
|
||||
import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
const PANE_KEY = 'tab-1:leaf-1'
|
||||
const SESSION_START = 10_000
|
||||
|
||||
function runTurns(store: ReturnType<typeof createTestStore>, turns: number): void {
|
||||
for (let turn = 0; turn < turns; turn++) {
|
||||
const at = SESSION_START + (turn + 1) * 1000
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{ state: 'working', prompt: `turn ${turn}`, agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: at, stateStartedAt: at }
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{ state: 'done', prompt: `turn ${turn}`, agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: at + 500, stateStartedAt: at + 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent status session-start latch', () => {
|
||||
it('latches the first reported state as the session start', () => {
|
||||
const store = createTestStore()
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{ state: 'working', prompt: 'first prompt', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: SESSION_START, stateStartedAt: SESSION_START }
|
||||
)
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.firstStateStartedAt).toBe(SESSION_START)
|
||||
})
|
||||
|
||||
// Why: stateHistory is a rolling window that trims oldest-first, so past the cap its first row
|
||||
// is a recent turn. Without a latched origin the sidebar's row clock would follow activity.
|
||||
it('holds the session start once stateHistory has wrapped its cap', () => {
|
||||
const store = createTestStore()
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{ state: 'working', prompt: 'first prompt', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: SESSION_START, stateStartedAt: SESSION_START }
|
||||
)
|
||||
// Two transitions per turn, so this comfortably overruns AGENT_STATE_HISTORY_MAX.
|
||||
runTurns(store, AGENT_STATE_HISTORY_MAX)
|
||||
|
||||
const entry = store.getState().agentStatusByPaneKey[PANE_KEY]
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.stateHistory).toHaveLength(AGENT_STATE_HISTORY_MAX)
|
||||
// The window really did slide: the oldest retained row is no longer the session start.
|
||||
expect(entry!.stateHistory[0]!.startedAt).toBeGreaterThan(SESSION_START)
|
||||
expect(entry!.firstStateStartedAt).toBe(SESSION_START)
|
||||
expect(agentEntrySessionStartedAt(entry!)).toBe(SESSION_START)
|
||||
})
|
||||
|
||||
it('starts a fresh session clock after the pane status is dropped', () => {
|
||||
const store = createTestStore()
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{ state: 'working', prompt: 'first prompt', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: SESSION_START, stateStartedAt: SESSION_START }
|
||||
)
|
||||
store.getState().dropAgentStatus(PANE_KEY)
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{ state: 'working', prompt: 'new session', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: 900_000, stateStartedAt: 900_000 }
|
||||
)
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.firstStateStartedAt).toBe(900_000)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../../../../shared/agent-status-identity'
|
||||
import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary'
|
||||
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
|
||||
import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time'
|
||||
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
@@ -550,7 +551,7 @@ function retainedAgentEntryFromLive(
|
||||
worktreeId,
|
||||
tab,
|
||||
agentType,
|
||||
startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
|
||||
startedAt: agentEntrySessionStartedAt(entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2133,6 +2134,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
tabId: statusTabId,
|
||||
terminalTitle: effectiveTitle,
|
||||
stateHistory: history,
|
||||
// Why: `history` trims oldest-first at the cap, so latch the session's first state
|
||||
// once — otherwise the sidebar's row clock advances with every turn past the cap and
|
||||
// busy agents re-sort themselves. Falls back for entries that predate the field.
|
||||
firstStateStartedAt:
|
||||
existing?.firstStateStartedAt ??
|
||||
existing?.stateHistory[0]?.startedAt ??
|
||||
existing?.stateStartedAt ??
|
||||
stateStartedAt,
|
||||
toolName: payload.toolName,
|
||||
toolInput: payload.toolInput,
|
||||
// Why: full untruncated AskUserQuestion JSON so mobile/web can render the live prompt
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AgentStatusEntry } from './agent-status-types'
|
||||
|
||||
/** The subset of a hook entry a session start is derived from. */
|
||||
export type AgentSessionStartSource = Pick<
|
||||
AgentStatusEntry,
|
||||
'firstStateStartedAt' | 'stateStartedAt' | 'stateHistory'
|
||||
>
|
||||
|
||||
function finiteOrNull(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* When this pane's agent session first reported a state — the stable identity clock the
|
||||
* sidebar orders agent rows by.
|
||||
*
|
||||
* Why not `stateHistory[0].startedAt` alone: `stateHistory` is a rolling window capped at
|
||||
* `AGENT_STATE_HISTORY_MAX` that trims oldest-first, so once a session passes the cap its
|
||||
* first *retained* row advances on every transition. Reading it directly turns a stable
|
||||
* identity clock into an activity clock, and the row re-sorts on each new turn.
|
||||
* `firstStateStartedAt` is latched at session start and never moves; history remains the
|
||||
* fallback for entries that predate it (rehydrated from disk, derived rows).
|
||||
*/
|
||||
export function agentEntrySessionStartedAt(entry: AgentSessionStartSource): number {
|
||||
return (
|
||||
finiteOrNull(entry.firstStateStartedAt) ??
|
||||
finiteOrNull(entry.stateHistory[0]?.startedAt) ??
|
||||
entry.stateStartedAt
|
||||
)
|
||||
}
|
||||
@@ -117,6 +117,11 @@ export type AgentStatusEntry = {
|
||||
terminalTitle?: string
|
||||
/** Rolling log of previous states, capped at AGENT_STATE_HISTORY_MAX. */
|
||||
stateHistory: AgentStateHistoryEntry[]
|
||||
/** Timestamp (ms) of this session's FIRST reported state, latched once and never moved.
|
||||
* Why: `stateHistory` trims oldest-first at the cap, so `stateHistory[0]` stops being the
|
||||
* session origin after ~20 transitions; row order keyed off it would follow activity.
|
||||
* Read it via `agentEntrySessionStartedAt`, which still falls back to history. */
|
||||
firstStateStartedAt?: number
|
||||
/** Name of the tool the agent is currently using (e.g. "Edit", "Bash"). */
|
||||
toolName?: string
|
||||
/** Short preview of the tool input (e.g. file path, command). */
|
||||
|
||||
Reference in New Issue
Block a user