fix(remote-runtime): stop a slept paired client from erasing its own agent rows (#12664)
After a laptop sleep against a remote machine, the sidebar agent count came back lower than the number of open terminal tabs — rows vanished for panes whose tab and host process were both still alive. The client mirror deletes a mirrored pane's agent status whenever the host snapshot carries none for it, unless the client's own byte-derived entry is still fresh. But for a remote pane the client is the ONLY writer of that status, and a laptop closed past the 30-minute staleness boundary makes every such entry stale by definition — so the first snapshot after wake erased the sidebar row of every pane the client owned. Freshness was the wrong gate. It exists to arbitrate between two competing writers, but on the delete branch the host published nothing, so there was nothing to arbitrate and "my status is old" quietly became "delete this pane". The branch now gates on ownership: a pane this renderer claimed and wrote keeps its entry and decays to idle through the normal staleness boundary, exactly like a local pane. Teardown releases the claim, which is how the host takes the pane back. That reverses a contract #12641 pinned, so its test was updated in place with the reasoning inline rather than deleted — the old premise was that going stale hands the pane back to the host, which does not hold when the host has no value to hand back. The assertion is now stricter: the entry must be retained AND read as stale so consumers render it idle. This is the sidebar-count half of STA-3107. The blank-terminal half was fixed by #11542 and is proven so: reverting that fix in a six-pane harness makes exactly one of six panes fail to resubscribe while its siblings recover, matching the report. A remaining gap is documented in the PR: a pane the client never wrote status for stays host-authoritative and can still lose its row. Separating "the host has no opinion" from "the host proved there is no agent" needs the origin marker tracked as STA-3455.
This commit is contained in:
parent
950985645d
commit
249645832f
|
|
@ -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<ArrayBufferLike>) => 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<string, string | null>()
|
||||
|
||||
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<string, unknown> {
|
||||
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?.()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof createTestStore>
|
||||
|
||||
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<AppState>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AppState>)
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue