diff --git a/src/main/agent-hooks/server-process-lifetime-status.test.ts b/src/main/agent-hooks/server-process-lifetime-status.test.ts index e0f489299af1..8e8b99685d1e 100644 --- a/src/main/agent-hooks/server-process-lifetime-status.test.ts +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -1,9 +1,10 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' -import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import { buildBody, LEAF_1, PANE, postHookEvent } from './server.test-fixtures' +import { makePaneKey } from '../../shared/stable-pane-id' let dir: string let server: AgentHookServer @@ -15,6 +16,7 @@ beforeEach(async () => { }) afterEach(() => { + vi.restoreAllMocks() server.stop() rmSync(dir, { recursive: true, force: true }) }) @@ -44,6 +46,10 @@ function paneState(): string { return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing' } +function paneReceivedAt(): number | undefined { + return server.getStatusSnapshotForPane(PANE)[0]?.receivedAt +} + async function nextMillisecond(): Promise { await new Promise((resolve) => setTimeout(resolve, 5)) } @@ -109,4 +115,149 @@ describe('process-lifetime status', () => { processLifetime('done', Date.now()) expect(paneState()).toBe('missing') }) + + // Why: a pane holding launch authority retires it at the 133;D that ends the run. + it('keeps the Done of a run whose own command end retired the pane, and fences what follows', async () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + await nextMillisecond() + server.retirePaneAuthority(PANE) + expect(paneState()).toBe('missing') + + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + + // A late post from the retired launch is still suppressed: the Done did not lift the fence. + await openCodeHook('SessionBusy') + expect(paneState()).toBe('done') + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + expect(paneState()).toBe('done') + }) + + it('suppresses a stale hook post after retirement before the run reports its end', async () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + await nextMillisecond() + server.retirePaneAuthority(PANE) + + await openCodeHook('SessionBusy') + expect(paneState()).toBe('missing') + }) + + it('keeps the Done of a retired run out of a closed tab', async () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + await nextMillisecond() + server.retirePaneAuthority(PANE) + server.dropStatusEntriesByTabPrefix('tab-1') + + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('missing') + }) + + it.each([ + ['still working', ['SessionBusy']], + ['already done', ['SessionBusy', 'SessionIdle']] + ])( + 'keeps the process Done of a hook-owned run out of a retired pane (hook %s)', + async (_, hooks) => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + for (const hook of hooks) { + await openCodeHook(hook) + } + server.retirePaneAuthority(PANE) + + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('missing') + } + ) + + it('keeps the Done of a retired run whatever the wall clock reads at retirement', () => { + const commandStartedAt = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(commandStartedAt) + processLifetime('working', commandStartedAt) + clock.mockReturnValue(commandStartedAt - 60_000) + server.retirePaneAuthority(PANE) + + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + // Why: a command end retires each of the pane's keys, so the later retirements find no rows. + it('keeps the Done when the pane retires again before the run reports its end', () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + server.retirePaneAuthority(PANE) + server.retirePaneAuthority(PANE) + + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('settles a retired run once', async () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + server.retirePaneAuthority(PANE) + processLifetime('done', commandStartedAt) + const settledAt = paneReceivedAt() + expect(settledAt).toBeDefined() + + await nextMillisecond() + processLifetime('done', commandStartedAt) + expect(paneReceivedAt()).toBe(settledAt) + }) + + it('does not settle a run that had already ended when the pane retired', () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + processLifetime('done', commandStartedAt) + server.retirePaneAuthority(PANE) + + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('missing') + }) + + it('does not let a run captured before a newer run settle that newer run', () => { + const firstStartedAt = Date.now() + processLifetime('working', firstStartedAt) + server.retirePaneAuthority(PANE) + const secondStartedAt = Date.now() + processLifetime('working', secondStartedAt) + expect(paneState()).toBe('working') + // The newer run's row is dismissed, so its retirement captures nothing of its own. + server.dropStatusEntry(PANE, { preserveResumeIdentity: false }) + server.retirePaneAuthority(PANE) + + processLifetime('done', secondStartedAt) + expect(paneState()).toBe('missing') + }) + + // Why: moving the pane to another tab and back lifts its retirement without a new-turn restart. + it('does not carry a run over from a retirement the pane has since left', async () => { + const firstStartedAt = Date.now() + processLifetime('working', firstStartedAt) + server.retirePaneAuthority(PANE) + const otherTabPane = makePaneKey('tab-2', LEAF_1) + server.transferPaneAuthority(PANE, otherTabPane, 'pty-1') + server.transferPaneAuthority(otherTabPane, PANE, 'pty-1') + await nextMillisecond() + + // The next run is hook-owned (OpenCode 1 style); its row is dismissed before its command end. + const secondStartedAt = Date.now() + processLifetime('working', secondStartedAt) + await openCodeHook('SessionBusy') + expect(paneState()).toBe('working') + server.dropStatusEntry(PANE, { preserveResumeIdentity: false }) + server.retirePaneAuthority(PANE) + + processLifetime('done', secondStartedAt) + expect(paneState()).toBe('missing') + }) }) diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 3bd289d9c551..91d21182193a 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -12,6 +12,10 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth retirePaneAuthority(paneKey: string, retirementId?: string): void { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const previousFence = this.retiredPaneFencesByKey.get(ownerPaneKey) + // Why: before the closed marks are re-added; a pane that left retirement has no run to carry over. + const previousRun = this.closedAgentStatusPaneKeys.has(ownerPaneKey) + ? previousFence?.retiredRun + : undefined const paneKeys = new Set([paneKey, ownerPaneKey]) for (const key of previousFence?.paneKeys ?? []) { if ( @@ -35,18 +39,27 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth aliasChanged = true } } - this.recordRetiredPaneFence( - paneKeys, - retiredAliases, - this.isClosedAgentStatusTabForPaneKey(ownerPaneKey) ? undefined : retirementId - ) - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) const retiredRows = [...paneKeys].flatMap((key) => { const row = this.state.lastStatusByPaneKey.get(key) as | EnrichedAgentHookEventPayload | undefined return row ? [row] : [] }) + const liveRow = retiredRows.find((row) => row.paneKey === ownerPaneKey) ?? retiredRows[0] + this.recordRetiredPaneFence( + paneKeys, + retiredAliases, + this.isClosedAgentStatusTabForPaneKey(ownerPaneKey) ? undefined : retirementId, + liveRow + ? { + state: liveRow.payload.state, + origin: liveRow.observation?.origin, + receivedAt: liveRow.receivedAt + } + : // Why: an earlier retirement of this pane (another of its keys, or a mid-run one) deleted the row. + previousRun + ) + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) const hadStatus = retiredRows.length > 0 for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 63355e3592b2..17241b4eb679 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -7,6 +7,24 @@ import type { EnrichedAgentHookEventPayload } from './server-types' import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' +// Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does, +// and its Done ends that run. +function processLifetimeEvent(event: { + origin?: 'process' + payload: ParsedAgentStatusPayload + yieldsToHookSince?: number +}): { processNewTurn: true } | { endedProcessRunStartedAt: number } | undefined { + if (event.origin !== 'process') { + return undefined + } + if (event.payload.state === 'working') { + return { processNewTurn: true } + } + return event.payload.state === 'done' && event.yieldsToHookSince !== undefined + ? { endedProcessRunStartedAt: event.yieldsToHookSince } + : undefined +} + export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { ingestTerminalStatus(event: { ptyId?: string @@ -47,13 +65,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId - // Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does. - const disposition = this.getAgentStatusDisposition( - paneKey, - event.origin === 'process' && event.payload.state === 'working' - ? { processNewTurn: true } - : undefined - ) + const disposition = this.getAgentStatusDisposition(paneKey, processLifetimeEvent(event)) if (disposition === 'suppress') { return } @@ -96,11 +108,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined - // Why: a hook that reported during this command owns it (OpenCode 1 `run` loads its plugin in-process). const hookOwnsCommand = event.yieldsToHookSince !== undefined && - previous?.observation?.origin === 'hook' && - previous.receivedAt >= event.yieldsToHookSince + previous !== undefined && + this.hookOwnsProcessRun( + previous.observation?.origin, + previous.receivedAt, + event.yieldsToHookSince + ) if ( hookOwnsCommand || (previous?.payload.agentType === 'claude' && diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 8ba89b9663bb..8011e619b7b0 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -39,6 +39,7 @@ import type { ProviderSessionChangeListener, RetiredPaneAlias, RetiredPaneFence, + RetiredPaneRun, ServerAgentStatusListener, ServerStatusLineListener, StatusChangeListener, @@ -186,7 +187,8 @@ export abstract class AgentHookServerState { protected abstract recordRetiredPaneFence( paneKeys: ReadonlySet, aliases: readonly RetiredPaneAlias[], - retirementId?: string + retirementId?: string, + retiredRun?: RetiredPaneRun ): void protected abstract markPaneClosedForAgentStatus(paneKey: string): void protected abstract attachStatusTiming( diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index ef633d7c2c80..d9e9b317197e 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -8,7 +8,8 @@ import { CLOSED_AGENT_STATUS_TAB_IDS_MAX, RETIRED_PANE_FENCES_MAX } from './server-constants' -import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import type { RetiredPaneAlias, RetiredPaneFence, RetiredPaneRun } from './server-types' import { AgentHookServerStatusInference } from './server-status-inference' export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { @@ -52,6 +53,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt launchToken?: string /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ processNewTurn?: boolean + /** A process-lifetime Done: the run that started at this time ended. */ + endedProcessRunStartedAt?: number } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -96,7 +99,12 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt return 'accept' } } - if (event && event.processNewTurn !== true && tokenFence) { + if ( + event && + event.processNewTurn !== true && + event.endedProcessRunStartedAt === undefined && + tokenFence + ) { const launchToken = event.launchToken?.trim() if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { return 'suppress' @@ -104,6 +112,21 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } return 'accept' } + // Why: a run still live when the pane retired (its own 133;D retires it) may settle its row once, + // unless a hook owned it, as on a live pane; the fence stays up so late posts stay suppressed. + if ( + event?.endedProcessRunStartedAt !== undefined && + retirementFence?.retiredRun !== undefined && + retirementFence.retiredRun.state !== 'done' && + !this.hookOwnsProcessRun( + retirementFence.retiredRun.origin, + retirementFence.retiredRun.receivedAt, + event.endedProcessRunStartedAt + ) + ) { + retirementFence.retiredRun = undefined + return 'accept' + } // Why: command completion retires launch authority but leaves its shell pane reusable. // A live new-turn event proves a new agent process owns the retired pane just like a // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). @@ -142,6 +165,10 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + // Why: a new run supersedes the one the retirement captured. + if (retirementFence) { + retirementFence.retiredRun = undefined + } const launchToken = event?.launchToken?.trim() if (launchToken) { this.restartedStatusLaunchTokenHashByPaneKey.set( @@ -156,6 +183,15 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt return 'suppress' } + // Why: a hook that reported during this command owns it (OpenCode 1 `run` loads its plugin in-process). + protected hookOwnsProcessRun( + rowOrigin: AgentStatusObservationOrigin | undefined, + rowReceivedAt: number, + runStartedAt: number + ): boolean { + return rowOrigin === 'hook' && rowReceivedAt >= runStartedAt + } + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric // keys never parse as stable ones — resolve both forms so neither slips the tab check. protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { @@ -176,7 +212,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt protected recordRetiredPaneFence( paneKeys: ReadonlySet, aliases: readonly RetiredPaneAlias[], - retirementId?: string + retirementId?: string, + retiredRun?: RetiredPaneRun ): void { const closed = [...paneKeys].some( (key) => @@ -193,6 +230,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt paneKeys: [...paneKeys], aliases, retirementIdsByPaneKey, + ...(retiredRun ? { retiredRun } : {}), ...(closed ? { closed: true as const } : {}) } for (const key of paneKeys) { diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 79b4e569cdd6..87a11575ab10 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -4,7 +4,10 @@ import type { AgentStatusClearIpcPayload, AgentStatusState } from '../../../shared/agent-status-types' -import type { AgentStatusObservation } from '../../../shared/agent-status-observation' +import type { + AgentStatusObservation, + AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' import type { AgentKind } from '../../../shared/telemetry-events' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' @@ -122,6 +125,15 @@ export type RetiredPaneFence = { aliases: readonly RetiredPaneAlias[] closed?: true retirementIdsByPaneKey: Record + /** What the row this retirement deleted reported; in memory only, consumed when that run ends. */ + retiredRun?: RetiredPaneRun +} + +/** Which run a pane showed when it retired, and who reported it. */ +export type RetiredPaneRun = { + state: AgentStatusState + origin?: AgentStatusObservationOrigin + receivedAt: number } export type LastStatusFile = { diff --git a/src/main/runtime/opencode-run-done-after-launch-authority-retirement.test.ts b/src/main/runtime/opencode-run-done-after-launch-authority-retirement.test.ts new file mode 100644 index 000000000000..7245eb08ca9a --- /dev/null +++ b/src/main/runtime/opencode-run-done-after-launch-authority-retirement.test.ts @@ -0,0 +1,166 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { readCommandLineMock } = vi.hoisted(() => ({ readCommandLineMock: vi.fn() })) + +vi.mock('./local-pty-foreground-command-line', () => ({ + readLocalPtyForegroundCommandLine: readCommandLineMock +})) + +import { OrcaRuntimeService } from './orca-runtime' +import { AgentHookServer } from '../agent-hooks/server' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +const WORKTREE_ID = 'repo::/worktree' +const TAB_ID = 'tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const PTY_ID = 'pty-1' +const INCARNATION_ID = 'incarnation-1' + +let userDataPath: string +let server: AgentHookServer + +function persistedSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TAB_ID, + ptyId: PTY_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + }, + terminalPtyIncarnationsByPaneKey: { [PANE_KEY]: INCARNATION_ID } + } +} + +// The real desktop wiring: runtime status and launch-authority retirement both land in the hook server. +function createRuntime(): OrcaRuntimeService { + const session = persistedSession() + const store = { + getRepos: () => [], + getRepo: () => undefined, + getAllWorktreeMeta: () => ({}), + getSettings: () => ({}), + getWorkspaceSession: () => session + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh reads only the members stubbed above. + const runtime = new OrcaRuntimeService(store as never, undefined, { + onTerminalAgentStatus: (event) => server.ingestTerminalStatus(event), + onTerminalSideEffects: () => {}, + retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey) + }) + runtime.setPtyController({ + spawn: vi.fn(), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'opencode', + listProcesses: async () => [ + { + id: PTY_ID, + cwd: '/worktree', + title: 'zsh', + worktreeId: WORKTREE_ID, + incarnationId: INCARNATION_ID, + terminalHandle: 'term_restored' + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { tabId: TAB_ID, worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: PTY_ID } + ] + }) + return runtime +} + +function paneState(): string { + return server.getStatusSnapshotForPane(PANE_KEY)[0]?.state ?? 'missing' +} + +async function startOpenCodeRun(runtime: OrcaRuntimeService): Promise { + runtime.onPtyData(PTY_ID, '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + expect(paneState()).toBe('working') +} + +beforeEach(async () => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-opencode-run-retired-')) + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + readCommandLineMock.mockReset() + readCommandLineMock.mockResolvedValue('opencode run fix the bug') +}) + +afterEach(() => { + vi.useRealTimers() + server.stop() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +// `orca terminal list` / `worktree ps` / mobile refreshes arm restored launch authority on the pane, so +// the run's 133;D retires it. The run's own Done must still land, as any finished turn does. +describe('OpenCode run Done after its command retires launch authority', () => { + it('keeps the Done of a visible pane after an inventory refresh', async () => { + const runtime = createRuntime() + await runtime.listTerminals() + await startOpenCodeRun(runtime) + + runtime.onPtyData(PTY_ID, 'done\x1b]133;D;0\x07', 101) + + expect(paneState()).toBe('done') + }) + + it('keeps the Done of a hidden pane whose daemon reported the command end', async () => { + const runtime = createRuntime() + await runtime.listTerminals() + await startOpenCodeRun(runtime) + + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + + expect(paneState()).toBe('done') + }) + + it('keeps the Done of a visible pane whatever the wall clock reads at its command end', async () => { + const runtime = createRuntime() + await runtime.listTerminals() + await startOpenCodeRun(runtime) + + vi.setSystemTime(Date.now() - 60_000) + runtime.onPtyData(PTY_ID, 'done\x1b]133;D;0\x07', 101) + + expect(paneState()).toBe('done') + }) +})