diff --git a/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts b/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts new file mode 100644 index 000000000..0b898a04a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts @@ -0,0 +1,182 @@ +/** + * STA-3107 (blank-pane half): a paired client with SIX remote terminal tabs is + * slept and woken. All six panes share one multiplex connection, so the sleep + * closes every stream at once and all six recoveries race each other. + * + * Invariant: after the reconnect every pane resolves to a live host handle — + * including a pane whose host PTY was parked while the client was away, which + * only `session.tabs.activate` can re-materialize (the STA-3002 defect fixed by + * #11542). No pane may be left with a null PTY id. + * + * The fault is injected at the multiplex transport seam (onClose), never with + * elapsed time; the oracle is the per-pane handle inventory. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson +} from '../../../../shared/terminal-stream-protocol' + +type SubscriptionCallbacks = { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void +} + +const PANE_COUNT = 6 +const HOST_TAB_IDS = Array.from({ length: PANE_COUNT }, (_, index) => `host-tab-${index + 1}`) + +describe('paired client sleep/wake with several remote terminal tabs', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: SubscriptionCallbacks | null = null + /** Host handle currently published for each host tab; null models a parked surface. */ + let hostHandleByTabId = new Map() + + function subscribedHandles(): string[] { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + } + + function surfaceFor(hostTabId: string): Record { + const handle = hostHandleByTabId.get(hostTabId) ?? null + return { + type: 'terminal', + id: `${hostTabId}::pane:1`, + parentTabId: hostTabId, + leafId: 'pane:1', + title: 'Terminal', + isActive: false, + ...(handle ? { status: 'ready', terminal: handle } : { status: 'pending' }) + } + } + + function inventory(): unknown { + return { + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: HOST_TAB_IDS.map(surfaceFor) + } + } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: vi.fn(async () => {}) + })) + vi.clearAllMocks() + subscriptionCallbacks = null + subscriptionSendBinary.mockReset() + hostHandleByTabId = new Map( + HOST_TAB_IDS.map((hostTabId, index) => [hostTabId, `terminal-${index + 1}`]) + ) + + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate') { + const params = request.params as { tabId: string } + // Why: activation is the only call that mints a PTY for a parked surface. + if (!hostHandleByTabId.get(params.tabId)) { + hostHandleByTabId.set(params.tabId, `${params.tabId}-respawned`) + } + return inventory() + } + if (request.method === 'session.tabs.list') { + return inventory() + } + return { ok: true, result: {} } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: SubscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + async function attachAllPanes(): Promise< + { hostTabId: string; transport: { getPtyId: () => string | null; destroy?: () => void } }[] + > { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const panes = HOST_TAB_IDS.map((hostTabId) => { + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: `web-terminal-${hostTabId}`, + leafId: 'pane:1' + }) + transport.attach({ + existingPtyId: `remote:env-1@@${hostHandleByTabId.get(hostTabId)}`, + cols: 80, + rows: 24, + callbacks: {} + }) + return { hostTabId, transport } + }) + await vi.waitFor(() => expect(subscribedHandles()).toHaveLength(PANE_COUNT)) + return panes + } + + it('rebinds every pane after the shared multiplex connection drops', async () => { + const panes = await attachAllPanes() + const before = subscribedHandles().length + + // Laptop closed: the single multiplex socket dies, taking all six streams. + subscriptionCallbacks?.onClose?.() + + await vi.waitFor(() => + expect(subscribedHandles().length).toBeGreaterThanOrEqual(before + PANE_COUNT) + ) + const afterReconnect = subscribedHandles().slice(before) + expect(new Set(afterReconnect).size, `resubscribes: ${JSON.stringify(afterReconnect)}`).toBe( + PANE_COUNT + ) + for (const pane of panes) { + expect(pane.transport.getPtyId(), `pane ${pane.hostTabId} lost its handle`).not.toBeNull() + pane.transport.destroy?.() + } + }) + + it('re-materializes a pane whose host PTY was parked while the client slept', async () => { + const panes = await attachAllPanes() + const before = subscribedHandles().length + // One agent's host PTY went away while the laptop was closed; the host still + // publishes the surface, but unmaterialized. This is the pane the reporter + // sees as blank/black. + const parked = HOST_TAB_IDS[2]! + hostHandleByTabId.set(parked, null) + + subscriptionCallbacks?.onClose?.() + + await vi.waitFor(() => + expect(subscribedHandles().length).toBeGreaterThanOrEqual(before + PANE_COUNT) + ) + const afterReconnect = subscribedHandles().slice(before) + const evidence = `resubscribes: ${JSON.stringify(afterReconnect)}` + expect(afterReconnect, evidence).toContain(`${parked}-respawned`) + for (const pane of panes) { + expect(pane.transport.getPtyId(), `pane ${pane.hostTabId} lost its handle`).not.toBeNull() + pane.transport.destroy?.() + } + }) +}) diff --git a/src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts b/src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts new file mode 100644 index 000000000..a5f77f49b --- /dev/null +++ b/src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts @@ -0,0 +1,371 @@ +/** + * STA-3107: a paired client working against a remote Orca host is slept + * (laptop closed), then woken. Every terminal tab is still open and still has a + * live host PTY, but the sidebar shows fewer agents than there are tabs. + * + * Invariant under test: a mirrored pane that still has a live host PTY and had + * an agent keeps its sidebar row across a sleep/wake reconnect. A quiet agent + * must DECAY (buildWorktreeAgentRows already renders a stale entry as 'idle'), + * exactly like a local pane — it must not be erased. + * + * Causal boundary: buildMirroredAgentStatusPatch's delete loop in + * web-session-tabs-sync.ts. A host snapshot that carries no agentStatus for a + * pane deletes the client's mirrored entry unless the client's own entry is + * still FRESH. Sleeping past AGENT_STATUS_STALE_AFTER_MS makes every + * client-owned entry stale by definition, so the first post-wake snapshot + * erases the row of every pane whose status only the client ever wrote. + * + * Everything is injected at the seam: the real host-snapshot mirror and the + * real sidebar row builder, driven by fake clocks. Elapsed time is never the + * oracle — the row/tab inventory is. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { getDefaultSettings } from '../../../shared/constants' +import type { AppState } from '../store/types' +import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane, + resetRendererOwnedAgentStatusPanesForTests +} from '../components/terminal-pane/renderer-owned-agent-status-registry' +import { + applyFreshWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests +} from './web-session-tabs-sync' +import { buildWorktreeAgentRows } from '../components/sidebar/worktree-agent-rows' +import { + selectLiveAgentStatusEntriesForWorktree, + selectRetainedAgentEntriesForWorktree +} from '../components/sidebar/worktree-agent-row-selectors' +import { + selectLivePtyIdsForWorktree, + selectRuntimePaneTitlesForWorktree +} from '../components/sidebar/worktree-card-status-inputs' + +// Why: web-session-tabs-sync imports the app-level store singleton; this +// harness drives a createTestStore instance instead, like its sibling suites. +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}) + } +})) + +const WT = 'repo1::/path/wt1' +const ENV = 'web-env-1' +const HOST_EPOCH = 'host-epoch-1' +const T0 = 1_700_000_000_000 +/** The laptop was closed well past the agent-status freshness boundary. */ +const LONG_SLEEP_MS = AGENT_STATUS_STALE_AFTER_MS * 6 +/** A blip the client rides out without its own status going stale. */ +const BRIEF_DROP_MS = 5_000 + +type TestStore = ReturnType + +type PaneSpec = { + hostTabId: string + leafId: string + agentType: 'claude' | 'omp' + /** Which writer publishes this pane's agent status. */ + statusSource: 'host-hook' | 'client-bytes' + /** Host-resolved surface title. */ + title: string +} + +function leafUuid(n: number): string { + return `1111111${n}-1111-4111-8111-111111111111` +} + +/** + * Six OMP/Claude panes matching the report. Host-hook panes model an agent the + * host tracks itself; client-bytes panes model the OSC-only panes whose status + * only this renderer ever parses (the host publishes no agentStatus for them). + */ +const PANES: PaneSpec[] = [ + { + hostTabId: 'host-tab-1', + leafId: leafUuid(1), + agentType: 'omp', + statusSource: 'host-hook', + title: 'OMP' + }, + { + hostTabId: 'host-tab-2', + leafId: leafUuid(2), + agentType: 'omp', + statusSource: 'host-hook', + title: 'OMP' + }, + { + hostTabId: 'host-tab-3', + leafId: leafUuid(3), + agentType: 'omp', + statusSource: 'client-bytes', + title: 'Terminal' + }, + { + hostTabId: 'host-tab-4', + leafId: leafUuid(4), + agentType: 'omp', + statusSource: 'client-bytes', + title: 'Terminal' + }, + { + hostTabId: 'host-tab-5', + leafId: leafUuid(5), + agentType: 'claude', + statusSource: 'host-hook', + title: 'Claude Code' + }, + { + hostTabId: 'host-tab-6', + leafId: leafUuid(6), + agentType: 'claude', + statusSource: 'client-bytes', + title: 'Terminal' + } +] + +const CLIENT_OWNED_PANES = PANES.filter((pane) => pane.statusSource === 'client-bytes') + +function mirrorTabId(pane: PaneSpec): string { + return toWebTerminalSurfaceTabId(pane.hostTabId) +} + +function mirrorPaneKey(pane: PaneSpec): string { + return makePaneKey(mirrorTabId(pane), pane.leafId) +} + +/** `hostNow` stamps host-side status: the remote machine keeps working while the laptop is closed. */ +function makeHostSnapshot(args: { + snapshotVersion: number + hostNow: number +}): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: HOST_EPOCH, + snapshotVersion: args.snapshotVersion, + activeGroupId: 'host-group-1', + activeTabId: `${PANES[0]!.hostTabId}::${PANES[0]!.leafId}`, + activeTabType: 'terminal', + tabs: PANES.map((pane, index) => ({ + type: 'terminal' as const, + id: `${pane.hostTabId}::${pane.leafId}`, + title: pane.title, + parentTabId: pane.hostTabId, + leafId: pane.leafId, + isActive: index === 0, + launchAgent: pane.agentType, + status: 'ready' as const, + terminal: `terminal-${index + 1}`, + ...(pane.statusSource === 'host-hook' + ? { + agentStatus: { + state: 'working' as const, + prompt: `work on ${pane.hostTabId}`, + updatedAt: args.hostNow, + stateStartedAt: args.hostNow - 60_000, + agentType: pane.agentType, + paneKey: makePaneKey(pane.hostTabId, pane.leafId), + tabId: pane.hostTabId, + worktreeId: WT, + stateHistory: [] + } + } + : {}) + })) + } +} + +/** Mirrors applyWebSessionTabsStorePatch: build the patch from live state, then set it. */ +function applyHostSnapshot( + store: TestStore, + snapshot: RuntimeMobileSessionTabsResult, + now: number +): void { + vi.setSystemTime(now) + const state = store.getState() + const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now) + expect(patch, 'host snapshot must pass the freshness gate').not.toBe(state) + store.setState(patch as Partial) +} + +/** + * Byte-identical replay of what pty-connection does for a remote pane + * (shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null): claim the + * pane at transport creation, then prove the claim on each byte-derived write. + * Returns the pane's release, which pty-connection holds for dispose(). + */ +function replayClientByteStatus(store: TestStore, pane: PaneSpec, clientNow: number): () => void { + vi.setSystemTime(clientNow) + const paneKey = mirrorPaneKey(pane) + const release = registerRendererOwnedAgentStatusPane(paneKey, ENV) + markRendererOwnedAgentStatusWrite(paneKey) + store + .getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: `work on ${pane.hostTabId}`, agentType: pane.agentType }, + pane.agentType, + undefined, + { tabId: mirrorTabId(pane), worktreeId: WT } + ) + return release +} + +function seedPairedClientStore(): TestStore { + const store = createTestStore() + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + activeWorktreeId: WT + } as Partial) + return store +} + +type SidebarObservation = { + tabIds: string[] + rowPaneKeys: string[] + tabsWithLivePty: string[] +} + +/** Byte-identical to useWorktreeAgentRows' inputs, minus React. */ +function observeSidebar(store: TestStore, now: number): SidebarObservation { + const state = store.getState() + const tabs = state.tabsByWorktree[WT] ?? [] + const rows = buildWorktreeAgentRows({ + tabs, + entries: selectLiveAgentStatusEntriesForWorktree(state, WT), + retained: selectRetainedAgentEntriesForWorktree(state, WT), + runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, WT), + ptyIdsByTabId: selectLivePtyIdsForWorktree(state, WT), + terminalLayoutsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]]) + ), + now + }) + return { + tabIds: tabs.map((tab) => tab.id), + rowPaneKeys: rows.filter((row) => row.rowSource !== 'subagent').map((row) => row.paneKey), + tabsWithLivePty: Object.keys(selectLivePtyIdsForWorktree(state, WT)) + } +} + +/** Attaches, runs one turn on every pane, then drops the transport for `awayMs` + * and lets the host republish its live state on reconnect. */ +function runSleepWakeReconnect(store: TestStore, awayMs: number): SidebarObservation { + applyHostSnapshot(store, makeHostSnapshot({ snapshotVersion: 1, hostNow: T0 - 1_000 }), T0) + for (const pane of CLIENT_OWNED_PANES) { + replayClientByteStatus(store, pane, T0) + } + const attached = observeSidebar(store, T0) + expect(attached.rowPaneKeys, 'precondition: every pane has a row while attached').toHaveLength( + PANES.length + ) + + const wakeAt = T0 + awayMs + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostNow: wakeAt - 1_000 }), + wakeAt + ) + return observeSidebar(store, wakeAt) +} + +describe('STA-3107: sidebar agent rows survive a paired-client sleep/wake reconnect', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(T0) + resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + resetRendererOwnedAgentStatusPanesForTests() + }) + + it('control: a brief transport drop keeps every pane in the sidebar', () => { + const store = seedPairedClientStore() + const reconnected = runSleepWakeReconnect(store, BRIEF_DROP_MS) + + expect(reconnected.tabIds).toHaveLength(PANES.length) + expect(reconnected.rowPaneKeys.sort()).toEqual(PANES.map(mirrorPaneKey).sort()) + }) + + it('keeps a sidebar row for every still-live pane after a long sleep', () => { + const store = seedPairedClientStore() + const reconnected = runSleepWakeReconnect(store, LONG_SLEEP_MS) + const evidence = `after wake:\n${JSON.stringify(reconnected, null, 2)}` + + // Two independent signals prove the panes are alive, so a missing row is a + // sidebar defect and not an honest report of a dead pane: the tab is still + // in the tab bar, and the host still publishes a live PTY for it. + expect(reconnected.tabIds, evidence).toHaveLength(PANES.length) + expect(reconnected.tabsWithLivePty, evidence).toHaveLength(PANES.length) + expect(reconnected.rowPaneKeys.sort(), evidence).toEqual(PANES.map(mirrorPaneKey).sort()) + }) + + it('the erased rows are exactly the panes whose status only the client wrote', () => { + const store = seedPairedClientStore() + const reconnected = runSleepWakeReconnect(store, LONG_SLEEP_MS) + const missing = PANES.map(mirrorPaneKey).filter( + (paneKey) => !reconnected.rowPaneKeys.includes(paneKey) + ) + + // Pins the causal boundary: host-authoritative panes are republished with a + // fresh host timestamp and are never at risk; only client-owned panes are. + expect(missing).toEqual([]) + }) + + it('still cedes a pane this renderer never wrote status for', () => { + const store = seedPairedClientStore() + applyHostSnapshot(store, makeHostSnapshot({ snapshotVersion: 1, hostNow: T0 - 1_000 }), T0) + // A remote pane claims ownership at transport creation but has produced no + // byte-derived status, so the host stays authoritative for it. + const unwritten = CLIENT_OWNED_PANES[0]! + registerRendererOwnedAgentStatusPane(mirrorPaneKey(unwritten), ENV) + store + .getState() + .setAgentStatus( + mirrorPaneKey(unwritten), + { state: 'working', prompt: 'host-sourced', agentType: unwritten.agentType }, + unwritten.agentType, + undefined, + { tabId: mirrorTabId(unwritten), worktreeId: WT } + ) + const wakeAt = T0 + LONG_SLEEP_MS + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostNow: wakeAt - 1_000 }), + wakeAt + ) + + expect(store.getState().agentStatusByPaneKey[mirrorPaneKey(unwritten)]).toBeUndefined() + }) + + it('releases authority on pane teardown so the host can retire the row', () => { + const store = seedPairedClientStore() + applyHostSnapshot(store, makeHostSnapshot({ snapshotVersion: 1, hostNow: T0 - 1_000 }), T0) + const releases = CLIENT_OWNED_PANES.map((pane) => replayClientByteStatus(store, pane, T0)) + for (const release of releases) { + release() + } + + const wakeAt = T0 + LONG_SLEEP_MS + applyHostSnapshot( + store, + makeHostSnapshot({ snapshotVersion: 2, hostNow: wakeAt - 1_000 }), + wakeAt + ) + + for (const pane of CLIENT_OWNED_PANES) { + expect(store.getState().agentStatusByPaneKey[mirrorPaneKey(pane)]).toBeUndefined() + } + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 574b4d5cc..412911617 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -46,6 +46,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { deriveGeneratedTabTitle } from '../../../shared/agent-tab-title' @@ -458,12 +459,21 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined() }) - it('lets the stale boundary release a client "working" the agent never closed', () => { + // Contract change (STA-3107): the stale boundary DECAYS a client-owned + // pane, it does not delete it. This test previously asserted deletion, + // whose premise — that going stale hands the pane back to the host — does + // not hold when the host publishes no status for the pane: there is no host + // value to hand back to, so deletion erased the pane from the sidebar + // instead. A paired client asleep past the boundary lost a row for every + // pane it owned on the first snapshot after wake. Teardown (the test above) + // is the real handback signal; staleness is a display state that every + // consumer already renders as idle, exactly like a local pane. + it('decays rather than deletes a client "working" the agent never closed', () => { const store = seedPairedClientStore() replayClientOscWorking(store, T0) expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working') - // The agent died OSC-silent: no further client write, no host status. + // The agent went OSC-silent: no further client write, no host status. const afterStale = T0 + AGENT_STATUS_STALE_AFTER_MS + 1 applyHostSnapshot( store, @@ -471,7 +481,12 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status afterStale ) - expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined() + const entry = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY] + expect(entry).toBeDefined() + expect( + isExplicitAgentStatusFresh(entry!, afterStale, AGENT_STATUS_STALE_AFTER_MS), + 'the retained entry must read as stale so consumers render it idle' + ).toBe(false) }) it('lets a host permission block pierce the fence (hook-HTTP-only on the host)', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 9e878ada5..3a597180e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -715,18 +715,23 @@ function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean } /** True while this renderer's own byte-derived status owns the pane: it claimed - * the pane at transport creation, wrote status from bytes, and that write has - * not gone stale (an OSC-silent dead agent hands the pane back to the host). */ + * the pane at transport creation and wrote status from bytes. The claim is + * released on pane teardown, which is how the host takes the pane back. */ +function isClientOwnedAgentStatus( + paneKey: string, + existing: AgentStatusEntry | undefined +): existing is AgentStatusEntry { + return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey) +} + +/** Owned AND still fresh — the arbitration rule for a pane the host also has an + * opinion about: an OSC-silent dead agent hands that contest back to the host. */ function isFencedClientAgentStatus( paneKey: string, existing: AgentStatusEntry | undefined, now: number ): existing is AgentStatusEntry { - return ( - existing !== undefined && - isClientAuthoritativeAgentStatusPane(paneKey) && - isAgentStatusFresh(existing, now) - ) + return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) } /** Generates a state patch for mirrored agent statuses, merging host entries with client overrides. */ @@ -816,7 +821,11 @@ function buildMirroredAgentStatusPatch( // Why: the host surface carrying no status is not proof the agent stopped — // hook-only hosts publish nothing for OSC-driven panes. Keep a live entry // this renderer owns; it decays through the normal freshness boundary. - if (isFencedClientAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey], now)) { + // Ownership, not freshness, is the gate here: with no competing host value + // there is nothing to arbitrate, and a client asleep past the stale + // boundary would otherwise erase every pane it owns on the first snapshot + // after wake (STA-3107) instead of decaying it like a local pane. + if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) { continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) {