From 8efda0bbb26aab33dd98e6378622a777dd48025a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:32:21 -0700 Subject: [PATCH] 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 --- src/main/runtime/orca-runtime.test.ts | 147 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 53 +++++++++- 2 files changed, 196 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b44dfc5c5ed..c640cf52c77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 = { + [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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9cee18d16cf..367f77690ea 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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(), + paneKeys: new Set(), + ptyIds: new Set() + } + 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, runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds: Set, - mirroredWorktreeIdByTabId: ReadonlyMap + mirroredWorktreeIdByTabId: ReadonlyMap, + connectedPtyEvidence: { + tabIds: ReadonlySet + paneKeys: ReadonlySet + ptyIds: ReadonlySet + } ): 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 }