From 7f88fa07e8d71cf6b3bde97e56f6998832309fc7 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Tue, 23 Jun 2026 19:28:26 -0700 Subject: [PATCH] Fix stale mobile worktree agent rows --- src/main/runtime/orca-runtime.test.ts | 92 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 56 ++++++++-------- src/renderer/src/i18n/locales/zh.json | 3 +- 3 files changed, 121 insertions(+), 30 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 46fb3088e08..322bc4ab2ad 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14800,6 +14800,62 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not report stale persisted tabs or hydrated done hook rows as live mobile activity', async () => { + const leafId = '44444444-4444-4444-8444-444444444444' + const paneKey = `host-tab:${leafId}` + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeTabId: 'host-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'host-tab' }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'persisted-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex Done', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [leafId]: 'persisted-pty' }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'host-tab', + state: 'done', + prompt: 'old prompt', + agentType: 'codex', + lastAssistantMessage: 'old done', + connectionId: null, + receivedAt: 1000, + stateStartedAt: 900 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + + expect(await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).toMatchObject({ terminals: [] }) + expect(summary).toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + liveTerminalCount: 0, + hasAttachedPty: false, + status: 'inactive', + agents: [] + }) + }) + it('falls back to the path-keyed GitHub cache entry', async () => { const runtimeStore = { ...store, @@ -14903,6 +14959,42 @@ describe('OrcaRuntimeService', () => { ]) }) + it('keeps agent rows for connected PTYs without a mounted renderer leaf', async () => { + const runtime = new OrcaRuntimeService(store) + const leafId = '55555555-5555-4555-8555-555555555555' + const paneKey = `headless-tab:${leafId}` + runtime['recordPtyWorktree']('headless-pty', TEST_WORKTREE_ID, { + connected: true, + tabId: 'headless-tab', + paneKey + }) + + runtime.onPtyData( + 'headless-pty', + '\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex","lastAssistantMessage":"on it"}\x07', + 321 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ + liveTerminalCount: 1, + hasAttachedPty: true, + hasHostSidebarActivity: true, + status: 'active' + }) + expect(summary?.agents).toEqual([ + expect.objectContaining({ + paneKey, + state: 'working', + agentType: 'codex', + prompt: 'ship it', + lastAssistantMessage: 'on it' + }) + ]) + }) + it('attaches inline agent rows from hook-reported status (not just OSC)', async () => { // Why: agent status normally arrives via hooks, not OSC terminal output; // worktree.ps reads the hook snapshot so mobile surfaces those agents too. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fef93b96e19..b0961ae9e3d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -8599,6 +8599,8 @@ export class OrcaRuntimeService { } const countedPtyIds = new Set() + const liveAgentPaneKeys = new Set() + const liveAgentPtyIds = new Set() for (const leaf of this.leaves.values()) { const summary = this.getSummaryForRuntimeWorktreeId( summaries, @@ -8608,8 +8610,10 @@ export class OrcaRuntimeService { if (!summary) { continue } + liveAgentPaneKeys.add(this.makeRuntimePaneKey(leaf)) if (leaf.ptyId) { countedPtyIds.add(leaf.ptyId) + liveAgentPtyIds.add(leaf.ptyId) } if (leaf.ptyId && leaf.connected) { summary.hasHostSidebarActivity = true @@ -8642,9 +8646,14 @@ export class OrcaRuntimeService { if (!summary) { continue } + if (pty.paneKey) { + liveAgentPaneKeys.add(pty.paneKey) + } + liveAgentPtyIds.add(pty.ptyId) const previousLastOutputAt = summary.lastOutputAt summary.liveTerminalCount += 1 summary.hasAttachedPty = true + summary.hasHostSidebarActivity = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) summary.status = mergeWorktreeStatus(summary.status, 'active') if ( @@ -8655,30 +8664,9 @@ export class OrcaRuntimeService { } } + // Why: tabsByWorktree is restore state, not proof of a live terminal. Mobile + // activity must come from runtime leaves or connected PTY records only. const session = this.store?.getWorkspaceSession?.() - for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { - if (tabs.length === 0) { - continue - } - const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId) - if (!summary) { - continue - } - // Why: desktop can show terminal tabs that are not mounted as renderer - // leaves and are not currently visible in the PTY provider list. Mobile - // still needs those worktrees to show as terminal-bearing entries. - summary.liveTerminalCount = Math.max(summary.liveTerminalCount, tabs.length) - summary.hasAttachedPty = summary.hasAttachedPty || tabs.some((tab) => tab.ptyId !== null) - if (tabs.some((tab) => tab.ptyId !== null && this.ptysById.get(tab.ptyId)?.connected)) { - summary.hasHostSidebarActivity = true - } - for (const tab of tabs) { - summary.status = mergeWorktreeStatus( - summary.status, - getSavedTabWorktreeStatus(tab.title, tab.ptyId !== null) - ) - } - } // Why: surface the desktop's focused worktree so mobile can scroll it into // view and highlight it. Resolve through getSummaryForRuntimeWorktreeId so @@ -8694,7 +8682,10 @@ export class OrcaRuntimeService { } } - this.attachAgentRowsToSummaries(summaries) + this.attachAgentRowsToSummaries(summaries, { + livePaneKeys: liveAgentPaneKeys, + livePtyIds: liveAgentPtyIds + }) const sorted = [...summaries.values()].sort(compareWorktreePs) return { @@ -8708,7 +8699,10 @@ export class OrcaRuntimeService { // agent list, mirroring the desktop sidebar. Lineage parent is resolved from // the orchestration db (paneKey-keyed), not the OSC payload, since spawn // hierarchy is pane-level state tracked separately from terminal output. - private attachAgentRowsToSummaries(summaries: Map): void { + private attachAgentRowsToSummaries( + summaries: Map, + liveEvidence: { livePaneKeys: ReadonlySet; livePtyIds: 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. // OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey. @@ -8716,6 +8710,7 @@ export class OrcaRuntimeService { string, { paneKey: string + ptyId?: string worktreeId?: string state: ParsedAgentStatusPayload['state'] agentType: string | null @@ -8732,6 +8727,7 @@ export class OrcaRuntimeService { const { payload } = snapshot rowSources.set(snapshot.paneKey, { paneKey: snapshot.paneKey, + ptyId: snapshot.ptyId, worktreeId: snapshot.worktreeId, state: payload.state, agentType: payload.agentType ?? null, @@ -8769,6 +8765,12 @@ export class OrcaRuntimeService { if (!worktreeId || !summaries.has(worktreeId)) { continue } + if ( + !liveEvidence.livePaneKeys.has(src.paneKey) && + (!src.ptyId || !liveEvidence.livePtyIds.has(src.ptyId)) + ) { + continue + } const taskTitle = orchestrationByPaneKey?.[src.paneKey]?.taskTitle ?? null const displayName = orchestrationByPaneKey?.[src.paneKey]?.displayName ?? null const row: RuntimeWorktreeAgentRow = { @@ -22400,10 +22402,6 @@ function getLatestAgentCandidateTitleInfo( return latest } -function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { - return getDetectedWorktreeStatus(detectAgentStatusFromTitle(title), hasPty) -} - function getDetectedWorktreeStatus( detected: AgentStatus | null, hasPty: boolean diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b67d1db3bf5..dd2564ec309 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1102,7 +1102,8 @@ "importHostSetup": "导入", "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", - "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。" + "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。"