From d88279a2dc9921c0a3bdaf1bcd65dde34f22471d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:01:09 -0700 Subject: [PATCH 1/3] fix(agent-status): keep an opencode run's Done when its command end retires the pane A pane that holds launch authority (including the restored receipt any terminal list / worktree ps / mobile refresh mints) retires it at the 133;D that ends `opencode run`. Retirement closed the pane for status and the host's own process Done for that run was then suppressed, so the row went Working -> removed with no Done. The status store now accepts a process-lifetime Done on a retired pane when the run started before the retirement, without lifting the fence: late hook and OSC posts stay suppressed, and a closed tab still wins. --- .../server-process-lifetime-status.test.ts | 45 +++++ .../server/server-ingest-terminal.ts | 13 +- .../server/server-status-disposition.ts | 20 ++- src/main/agent-hooks/server/server-types.ts | 2 + ...-after-launch-authority-retirement.test.ts | 155 ++++++++++++++++++ 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/opencode-run-done-after-launch-authority-retirement.test.ts 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..c9ed3c54ad83 100644 --- a/src/main/agent-hooks/server-process-lifetime-status.test.ts +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -109,4 +109,49 @@ 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') + }) }) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 63355e3592b2..31e1a96bc8ee 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -47,12 +47,17 @@ 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. + // Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does; + // its Done is the host's own observation of that run's end. const disposition = this.getAgentStatusDisposition( paneKey, - event.origin === 'process' && event.payload.state === 'working' - ? { processNewTurn: true } - : undefined + event.origin !== 'process' + ? undefined + : event.payload.state === 'working' + ? { processNewTurn: true } + : event.payload.state === 'done' && event.yieldsToHookSince !== undefined + ? { endedProcessRunStartedAt: event.yieldsToHookSince } + : undefined ) if (disposition === 'suppress') { return diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index ef633d7c2c80..2e1f87ab4bb2 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -52,6 +52,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 host saw the run that started at this time end. */ + endedProcessRunStartedAt?: number } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -96,7 +98,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 +111,16 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } return 'accept' } + // Why: the end of a run that was already running when the pane retired is the host's own + // observation of the retired command (its 133;D retires it), not a late post from after it. + // Accept without lifting the fence, so stale posts stay suppressed. + if ( + event?.endedProcessRunStartedAt !== undefined && + retirementFence !== undefined && + retirementFence.retiredAt > event.endedProcessRunStartedAt + ) { + 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). @@ -193,6 +210,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt paneKeys: [...paneKeys], aliases, retirementIdsByPaneKey, + retiredAt: Date.now(), ...(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..dcd660d7cc3a 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -122,6 +122,8 @@ export type RetiredPaneFence = { aliases: readonly RetiredPaneAlias[] closed?: true retirementIdsByPaneKey: Record + /** Status observed for a command that was already running at this time is about the retired command, not after it. */ + retiredAt: 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..25bb5350d2e1 --- /dev/null +++ b/src/main/runtime/opencode-run-done-after-launch-authority-retirement.test.ts @@ -0,0 +1,155 @@ +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') + }) +}) From 2b16b5f4edd18da88df1aba3ffcbb43da58cca6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:35:40 -0700 Subject: [PATCH 2/3] fix(agent-status): settle a retired pane's run by the row it showed, not by the clock The previous commit admitted a process-lifetime Done on a retired pane when the run started before the retirement, comparing two wall-clock reads. A clock stepped backward during the run lost the Done again, and because retirement deletes the row the hook-yield rule reads, a process Done for a command a hook owned (OpenCode 1 `run` with its in-process plugin) was written where a live pane would yield. Retirement now records on its fence which run the pane showed (agent, state, who reported it, when), carried over when a later retirement of the same command end finds the rows already gone. A process Done on the retired pane is accepted once, only when that run was still live and no hook reported during it (the same rule a live pane applies). The fence is not lifted, a new run invalidates the record, and a closed tab still wins. --- .../server-process-lifetime-status.test.ts | 85 ++++++++++++++++++- .../server/server-authority-fences.ts | 22 +++-- .../server/server-ingest-terminal.ts | 40 +++++---- src/main/agent-hooks/server/server-state.ts | 4 +- .../server/server-status-disposition.ts | 38 +++++++-- src/main/agent-hooks/server/server-types.ts | 20 ++++- ...-after-launch-authority-retirement.test.ts | 11 +++ 7 files changed, 184 insertions(+), 36 deletions(-) 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 c9ed3c54ad83..6f513372ed1a 100644 --- a/src/main/agent-hooks/server-process-lifetime-status.test.ts +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -1,7 +1,7 @@ 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' @@ -15,6 +15,7 @@ beforeEach(async () => { }) afterEach(() => { + vi.restoreAllMocks() server.stop() rmSync(dir, { recursive: true, force: true }) }) @@ -44,6 +45,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)) } @@ -154,4 +159,82 @@ describe('process-lifetime status', () => { 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 when the clock steps back between the run start and its 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') + }) }) diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 3bd289d9c551..6127b870bf33 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -35,18 +35,28 @@ 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 + ? { + agentType: liveRow.payload.agentType, + state: liveRow.payload.state, + origin: liveRow.observation?.origin, + receivedAt: liveRow.receivedAt + } + : // Why: a command end retires each of the pane's keys; the later calls find the rows gone. + previousFence?.retiredRun + ) + 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 31e1a96bc8ee..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,18 +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; - // its Done is the host's own observation of that run's end. - const disposition = this.getAgentStatusDisposition( - paneKey, - event.origin !== 'process' - ? undefined - : event.payload.state === 'working' - ? { processNewTurn: true } - : event.payload.state === 'done' && event.yieldsToHookSince !== undefined - ? { endedProcessRunStartedAt: event.yieldsToHookSince } - : undefined - ) + const disposition = this.getAgentStatusDisposition(paneKey, processLifetimeEvent(event)) if (disposition === 'suppress') { return } @@ -101,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 2e1f87ab4bb2..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,7 +53,7 @@ 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 host saw the run that started at this time end. */ + /** A process-lifetime Done: the run that started at this time ended. */ endedProcessRunStartedAt?: number } ): 'accept' | 'restart' | 'suppress' { @@ -111,14 +112,19 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } return 'accept' } - // Why: the end of a run that was already running when the pane retired is the host's own - // observation of the retired command (its 133;D retires it), not a late post from after it. - // Accept without lifting the fence, so stale posts stay suppressed. + // 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 !== undefined && - retirementFence.retiredAt > event.endedProcessRunStartedAt + 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. @@ -159,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( @@ -173,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 { @@ -193,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) => @@ -210,7 +230,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt paneKeys: [...paneKeys], aliases, retirementIdsByPaneKey, - retiredAt: Date.now(), + ...(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 dcd660d7cc3a..f5ee93807b0b 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -2,9 +2,13 @@ import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusli import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusClearIpcPayload, - AgentStatusState + AgentStatusState, + AgentType } 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,8 +126,16 @@ export type RetiredPaneFence = { aliases: readonly RetiredPaneAlias[] closed?: true retirementIdsByPaneKey: Record - /** Status observed for a command that was already running at this time is about the retired command, not after it. */ - retiredAt: number + /** 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 = { + agentType?: AgentType + 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 index 25bb5350d2e1..c8d2f48eebfd 100644 --- 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 @@ -152,4 +152,15 @@ describe('OpenCode run Done after its command retires launch authority', () => { expect(paneState()).toBe('done') }) + + it('keeps the Done when the wall clock steps back during the run', 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') + }) }) From e49dfb5a2f518a786325956217bf4a71c962fdc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:52:42 -0700 Subject: [PATCH 3/3] fix(agent-status): carry a retired run over only while the pane stays retired A retirement that finds no rows inherited the previous fence's recorded run even after the pane had left retirement without a new-turn restart (moved to another tab and back). A later hook-owned run whose row was dismissed could then have its process Done admitted with an empty prompt, where main shows nothing. The carry-over now applies only while the pane is still retired under that fence, the same condition the key merge uses. Also drops the unused agent type from the recorded run and renames the clock tests to describe behaviour. --- .../server-process-lifetime-status.test.ts | 27 +++++++++++++++++-- .../server/server-authority-fences.ts | 9 ++++--- src/main/agent-hooks/server/server-types.ts | 4 +-- ...-after-launch-authority-retirement.test.ts | 2 +- 4 files changed, 33 insertions(+), 9 deletions(-) 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 6f513372ed1a..8e8b99685d1e 100644 --- a/src/main/agent-hooks/server-process-lifetime-status.test.ts +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' 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 @@ -178,7 +179,7 @@ describe('process-lifetime status', () => { } ) - it('keeps the Done when the clock steps back between the run start and its retirement', () => { + 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) @@ -237,4 +238,26 @@ describe('process-lifetime status', () => { 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 6127b870bf33..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 ( @@ -48,13 +52,12 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth this.isClosedAgentStatusTabForPaneKey(ownerPaneKey) ? undefined : retirementId, liveRow ? { - agentType: liveRow.payload.agentType, state: liveRow.payload.state, origin: liveRow.observation?.origin, receivedAt: liveRow.receivedAt } - : // Why: a command end retires each of the pane's keys; the later calls find the rows gone. - previousFence?.retiredRun + : // 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 diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index f5ee93807b0b..87a11575ab10 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -2,8 +2,7 @@ import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusli import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusClearIpcPayload, - AgentStatusState, - AgentType + AgentStatusState } from '../../../shared/agent-status-types' import type { AgentStatusObservation, @@ -132,7 +131,6 @@ export type RetiredPaneFence = { /** Which run a pane showed when it retired, and who reported it. */ export type RetiredPaneRun = { - agentType?: AgentType state: AgentStatusState origin?: AgentStatusObservationOrigin receivedAt: number 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 index c8d2f48eebfd..7245eb08ca9a 100644 --- 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 @@ -153,7 +153,7 @@ describe('OpenCode run Done after its command retires launch authority', () => { expect(paneState()).toBe('done') }) - it('keeps the Done when the wall clock steps back during the run', async () => { + 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)