mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(runtime): drop stale local agent rows from worktree.ps after tab close
attachAgentRowsToSummaries attached every hydrated hook row by worktreeId with no check that the pane/tab still exists, so agents from closed tabs (last-status.json hydrates for days) kept showing on mobile as current activity. Local rows now require the tab in a session/runtime graph or a connected PTY; remote rows are exempt since their tabs may only exist on the remote host. Fixes #6072
This commit is contained in:
@@ -31073,6 +31073,21 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
]
|
||||
})
|
||||
// Why: local rows only project while their tab exists (#6072); freshness
|
||||
// is what varies here, so keep the tab present in the runtime graph.
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
activeLeafId: '33333333-3333-4333-8333-333333333333',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: []
|
||||
})
|
||||
|
||||
const { worktrees } = await runtime.getWorktreePs()
|
||||
|
||||
@@ -31083,6 +31098,138 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('drops a hydrated done hook row after its local tab is closed', async () => {
|
||||
// Why (#6072): last-status.json hydrates hook rows for days; a closed tab's
|
||||
// agent must not resurface on mobile as current worktree activity.
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {}
|
||||
})
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey: 'closed-tab:66666666-6666-4666-8666-666666666666',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'closed-tab',
|
||||
state: 'done',
|
||||
prompt: 'refactor the parser',
|
||||
agentType: 'claude',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 60_000
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { worktrees } = await runtime.getWorktreePs()
|
||||
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
liveTerminalCount: 0,
|
||||
hasHostSidebarActivity: false,
|
||||
status: 'inactive',
|
||||
agents: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a local hook row while a connected PTY still backs its pane', async () => {
|
||||
// Why: daemon-held terminals stay live across renderer graph gaps even when
|
||||
// no session tab records them; a connected PTY is proof the pane exists.
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {}
|
||||
})
|
||||
const paneKey = 'daemon-tab:77777777-7777-4777-8777-777777777777'
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
tabId: 'daemon-tab',
|
||||
state: 'working',
|
||||
prompt: 'long-running daemon agent',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 100
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime['recordPtyWorktree']('daemon-pty', TEST_WORKTREE_ID, {
|
||||
connected: true,
|
||||
tabId: 'daemon-tab',
|
||||
paneKey
|
||||
})
|
||||
|
||||
const { worktrees } = await runtime.getWorktreePs()
|
||||
const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)
|
||||
|
||||
expect(summary?.agents).toEqual([
|
||||
expect.objectContaining({ paneKey, state: 'working', prompt: 'long-running daemon agent' })
|
||||
])
|
||||
expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' })
|
||||
})
|
||||
|
||||
it('keeps remote hook rows whose tabs are only tracked on the remote host', async () => {
|
||||
// Why: the local session partition for an SSH host can be empty while the
|
||||
// remote host owns the terminals; absence there is not proof of a close.
|
||||
const remoteRepo = {
|
||||
id: 'repo-ssh-6072',
|
||||
path: '/home/me/project',
|
||||
displayName: 'remote-vm',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-6072'
|
||||
}
|
||||
const remoteWorktree = {
|
||||
path: '/home/me/project/.worktrees/feature-agents',
|
||||
head: 'def',
|
||||
branch: 'refs/heads/feature/agents',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const remoteWorktreeId = `${remoteRepo.id}::${remoteWorktree.path}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[remoteWorktreeId]: makeWorktreeMeta({ displayName: 'Remote agents' })
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
getWorkspaceSession: () => getDefaultWorkspaceSession()
|
||||
}
|
||||
registerSshGitProvider('ssh-6072', {
|
||||
listWorktrees: vi.fn().mockResolvedValue([remoteWorktree])
|
||||
} as never)
|
||||
const now = Date.now()
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey: 'remote-tab:88888888-8888-4888-8888-888888888888',
|
||||
worktreeId: remoteWorktreeId,
|
||||
tabId: 'remote-tab',
|
||||
state: 'working',
|
||||
prompt: 'remote agent without local tab records',
|
||||
agentType: 'codex',
|
||||
connectionId: 'ssh-6072',
|
||||
receivedAt: now,
|
||||
stateStartedAt: now - 100
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const { worktrees } = await runtime.getWorktreePs()
|
||||
const summary = worktrees.find((worktree) => worktree.worktreeId === remoteWorktreeId)
|
||||
|
||||
expect(summary?.agents).toEqual([
|
||||
expect.objectContaining({ prompt: 'remote agent without local tab records' })
|
||||
])
|
||||
})
|
||||
|
||||
it('marks the desktop-active worktree as isActive', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal()
|
||||
|
||||
@@ -16461,11 +16461,32 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a connected PTY proves a pane is still live even when its tab has
|
||||
// already left every session record (daemon-held terminals, graph gaps).
|
||||
const connectedPtyEvidence = {
|
||||
tabIds: new Set<string>(),
|
||||
paneKeys: new Set<string>(),
|
||||
ptyIds: new Set<string>()
|
||||
}
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (!pty.connected) {
|
||||
continue
|
||||
}
|
||||
connectedPtyEvidence.ptyIds.add(pty.ptyId)
|
||||
if (pty.tabId) {
|
||||
connectedPtyEvidence.tabIds.add(pty.tabId)
|
||||
}
|
||||
if (pty.paneKey) {
|
||||
connectedPtyEvidence.paneKeys.add(pty.paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
this.attachAgentRowsToSummaries(
|
||||
summaries,
|
||||
runtimeWorktreeSummaryPathIndex,
|
||||
missingRuntimeWorktreeIds,
|
||||
mirroredWorktreeIdByTabId
|
||||
mirroredWorktreeIdByTabId,
|
||||
connectedPtyEvidence
|
||||
)
|
||||
|
||||
const sorted = [...summaries.values()].sort(compareWorktreePs)
|
||||
@@ -16484,7 +16505,12 @@ export class OrcaRuntimeService {
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||||
runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex,
|
||||
missingRuntimeWorktreeIds: Set<string>,
|
||||
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
|
||||
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>,
|
||||
connectedPtyEvidence: {
|
||||
tabIds: ReadonlySet<string>
|
||||
paneKeys: ReadonlySet<string>
|
||||
ptyIds: ReadonlySet<string>
|
||||
}
|
||||
): void {
|
||||
// Why: most agents report via hooks (agent-hooks/server), not OSC, so the
|
||||
// hook snapshot is the primary source — same one the desktop sidebar reads.
|
||||
@@ -16493,8 +16519,10 @@ export class OrcaRuntimeService {
|
||||
string,
|
||||
{
|
||||
paneKey: string
|
||||
ptyId?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
connectionId: string | null
|
||||
state: ParsedAgentStatusPayload['state']
|
||||
agentType: string | null
|
||||
prompt: string
|
||||
@@ -16510,8 +16538,10 @@ export class OrcaRuntimeService {
|
||||
const { payload } = snapshot
|
||||
rowSources.set(snapshot.paneKey, {
|
||||
paneKey: snapshot.paneKey,
|
||||
ptyId: snapshot.ptyId,
|
||||
tabId: snapshot.tabId,
|
||||
worktreeId: snapshot.worktreeId,
|
||||
connectionId: null,
|
||||
state: payload.state,
|
||||
agentType: payload.agentType ?? null,
|
||||
prompt: payload.prompt,
|
||||
@@ -16534,6 +16564,7 @@ export class OrcaRuntimeService {
|
||||
paneKey: entry.paneKey,
|
||||
tabId: entry.tabId,
|
||||
worktreeId: entry.worktreeId,
|
||||
connectionId: entry.connectionId,
|
||||
state: entry.state,
|
||||
agentType: entry.agentType ?? null,
|
||||
prompt: entry.prompt,
|
||||
@@ -16555,8 +16586,22 @@ export class OrcaRuntimeService {
|
||||
// Why: hooks retain launch-time attribution across automatic workspace
|
||||
// renames; the tab's current mirrored owner is authoritative when present.
|
||||
const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId
|
||||
const worktreeId =
|
||||
(tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? src.worktreeId
|
||||
const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined
|
||||
if (
|
||||
tabId !== undefined &&
|
||||
mirroredWorktreeId === undefined &&
|
||||
src.connectionId === null &&
|
||||
!connectedPtyEvidence.tabIds.has(tabId) &&
|
||||
!connectedPtyEvidence.paneKeys.has(src.paneKey) &&
|
||||
(src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId))
|
||||
) {
|
||||
// Why: hook snapshots hydrate from last-status.json for days, so a local
|
||||
// row whose tab left every session/graph and has no connected PTY is
|
||||
// retained history — surfacing it resurrects closed agents on mobile
|
||||
// (#6072). Remote rows are exempt: their tabs may only exist remotely.
|
||||
continue
|
||||
}
|
||||
const worktreeId = mirroredWorktreeId ?? src.worktreeId
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user