diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index dcd9c308f2a..912b14d51fe 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -26,11 +26,11 @@ the structured-session mapping and nothing else. An audit on 2026-09-09 found six producers and three consumers, and three separate copies of the same row inside the main process alone: -| Main-process copy | Keyed by | Owned by | Persisted | Evicted | -| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- | -| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate | -| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only | -| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | +| Main-process copy | Keyed by | Owned by | Persisted | Evicted | +| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------- | +| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate, worktree removal | +| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only | +| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | The second copy is a duplicate write: the OSC status parsed in main is forwarded to the hook server _and_ retained in the runtime store from the same diff --git a/src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts b/src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts new file mode 100644 index 00000000000..daa99b603c1 --- /dev/null +++ b/src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts @@ -0,0 +1,283 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' +import { LEAF_1 } from './server.test-fixtures' + +const REMOVED = 'repo::/removed' +const KEPT = 'repo::/kept' +const PANE = makePaneKey('tab-foreign', LEAF_1) +const TOKEN = 'foreign-launch' +const HASH = createHash('sha256').update(TOKEN).digest('hex') +const working = { state: 'working', prompt: 'live', agentType: 'codex' } as const + +describe('removed-worktree foreign authority', () => { + let userDataPath: string + beforeEach(() => { + _internals.resetCachesForTests() + userDataPath = mkdtempSync(join(tmpdir(), 'orca-foreign-authority-')) + }) + afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + + it('preserves ordinary tokenless OSC after a tokened new turn revives a pane', () => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + source: 'codex', + hookEventName: 'SessionStart', + payload: working + }, + null + ) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + connectionId: null, + payload: { ...working, state: 'done' } + }) + expect(server.getStatusSnapshot()).toMatchObject([{ worktreeId: KEPT, state: 'done' }]) + server.stop() + }) + + it('keeps foreign hydrated evidence when a removed commitment outlives its row', async () => { + const seed = new AgentHookServer() + await seed.start({ env: 'production', userDataPath }) + seed.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: working + }, + 'user@box' + ) + seed.flushStatusPersistSync() + seed.stop() + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: REMOVED, + launchToken: 'removed-launch', + payload: working + }, + null + ) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + connectionId: 'user@box', + payload: working + }) + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + expect(server.getStatusSnapshot()).toMatchObject([ + { worktreeId: KEPT, connectionId: 'user@box' } + ]) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: HASH, + connectionId: 'user@box', + terminalProvenance: 'restored' + }) + ).toEqual({ paneKey: PANE, source: 'hydrated_commitment' }) + expect(server.getCurrentAuthorityObservations()).toEqual([]) + server.flushStatusPersistSync() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.authorityCommitments[PANE]).toBeUndefined() + } finally { + server.stop() + } + }) + + it.each([ + { owner: REMOVED, connectionId: null }, + { owner: KEPT, connectionId: 'user@box' }, + { owner: REMOVED, connectionId: 'user@box' } + ])( + 'revokes hydrated evidence only for removed $owner on $connectionId', + async ({ owner, connectionId }) => { + const token = connectionId === null ? 'removed-launch' : TOKEN + const hash = createHash('sha256').update(token).digest('hex') + const seed = new AgentHookServer() + await seed.start({ env: 'production', userDataPath }) + seed.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: owner, + launchToken: token, + payload: working + }, + connectionId + ) + seed.flushStatusPersistSync() + seed.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const attest = () => + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: hash, + connectionId, + terminalProvenance: 'restored' + }) + try { + expect(attest()).toEqual({ paneKey: PANE, source: 'hydrated_commitment' }) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: working + }, + 'user@box' + ) + server.clearStatusEntriesForConnection('user@box') + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: REMOVED, + connectionId: null, + payload: working + }) + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + expect(attest()).toEqual( + owner === REMOVED && connectionId === null + ? null + : { paneKey: PANE, source: 'hydrated_commitment' } + ) + server.flushStatusPersistSync() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.authorityCommitments[PANE]).toMatchObject({ + worktreeId: KEPT, + connectionId: 'user@box', + launchTokenHash: HASH + }) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: working + }, + 'user@box' + ) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: HASH, + connectionId: 'user@box', + terminalProvenance: 'current_runtime' + }) + ).toEqual({ paneKey: PANE, source: 'current_hook' }) + } finally { + server.stop() + } + } + ) + + it.each([false, true])( + 'keeps a foreign claim after removed OSC with disconnect=%s', + async (disconnect) => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const remote = (state: 'working' | 'done') => + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: { ...working, state } + }, + 'user@box' + ) + const terminal = (worktreeId: string, connectionId: string | null) => + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId, + connectionId, + payload: working + }) + const attest = () => + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: HASH, + connectionId: 'user@box', + terminalProvenance: 'current_runtime' + }) + try { + remote('working') + if (disconnect) { + server.clearStatusEntriesForConnection('user@box') + } + terminal(REMOVED, null) + if (!disconnect) { + expect(attest()).not.toBeNull() + } + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + if (!disconnect) { + expect(attest()).not.toBeNull() + } + server.flushStatusPersistSync() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.authorityCommitments[PANE]).toMatchObject({ + worktreeId: KEPT, + connectionId: 'user@box', + launchTokenHash: HASH + }) + expect(file.entries[PANE]).toBeUndefined() + + terminal(REMOVED, null) + expect(server.getStatusSnapshot()).toHaveLength(0) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-foreign', worktreeId: REMOVED, payload: working }, + 'user@box' + ) + expect(server.getStatusSnapshot()).toHaveLength(0) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-foreign', worktreeId: KEPT, payload: working }, + 'user@box' + ) + expect(server.getStatusSnapshot()).toMatchObject([ + { worktreeId: KEPT, connectionId: 'user@box' } + ]) + terminal(KEPT, 'user@box') + expect(server.getStatusSnapshot()).toMatchObject([ + { worktreeId: KEPT, connectionId: 'user@box' } + ]) + remote('done') + expect(server.getStatusSnapshot()).toMatchObject([{ worktreeId: KEPT, state: 'done' }]) + expect(attest()).not.toBeNull() + } finally { + server.stop() + } + } + ) +}) diff --git a/src/main/agent-hooks/server-removed-worktree-status.test.ts b/src/main/agent-hooks/server-removed-worktree-status.test.ts new file mode 100644 index 00000000000..d40540326c0 --- /dev/null +++ b/src/main/agent-hooks/server-removed-worktree-status.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' +import { LEAF_1, LEAF_2, LEAF_3, LEAF_4, LEAF_5, recentTs } from './server.test-fixtures' + +const REMOVED = 'repo-1::/workspace/removed' +const KEPT = 'repo-1::/workspace/kept' +const LOCAL_PANE = makePaneKey('tab-local', LEAF_1) +const WSL_PANE = makePaneKey('tab-wsl', LEAF_2) +const SSH_PANE = makePaneKey('tab-ssh', LEAF_3) +const SSH_COMMITMENT_PANE = makePaneKey('tab-ssh-idle', LEAF_4) +const OTHER_PANE = makePaneKey('tab-other', LEAF_5) + +function row(paneKey: string, worktreeId: string, connectionId: string | null) { + const receivedAt = recentTs() + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId, + connectionId, + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'working', prompt: 'stranded', agentType: 'codex' } + } +} + +describe('AgentHookServer removed-worktree retirement', () => { + let userDataPath: string + const lastStatusPath = () => join(userDataPath, 'agent-hooks', 'last-status.json') + + beforeEach(() => { + _internals.resetCachesForTests() + userDataPath = mkdtempSync(join(tmpdir(), 'orca-removed-worktree-')) + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [LOCAL_PANE]: row(LOCAL_PANE, REMOVED, null), + [WSL_PANE]: row(WSL_PANE, REMOVED, 'wsl:Ubuntu'), + [SSH_PANE]: row(SSH_PANE, REMOVED, 'user@box'), + [OTHER_PANE]: row(OTHER_PANE, KEPT, null) + } + }), + 'utf8' + ) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('retires only the removing host rows and commitments, then persists the pruned map', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + // An SSH commitment recorded this session outlives its row across a disconnect clear. + server.ingestRemote( + { + paneKey: SSH_COMMITMENT_PANE, + tabId: 'tab-ssh-idle', + worktreeId: REMOVED, + launchToken: 'idle-launch', + payload: { state: 'working', prompt: 'idle', agentType: 'codex' } + }, + 'idle@box' + ) + server.clearStatusEntriesForConnection('idle@box') + const persisted = () => { + server.flushStatusPersistSync() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + return { + entries: Object.keys(file.entries).sort(), + commitments: Object.keys(file.authorityCommitments ?? {}) + } + } + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'runtime:env-1') + expect(persisted().entries).toHaveLength(4) + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + expect(persisted()).toEqual({ + entries: [OTHER_PANE, SSH_PANE].sort(), + commitments: [SSH_COMMITMENT_PANE] + }) + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'ssh:user%40box') + expect(persisted()).toEqual({ entries: [OTHER_PANE], commitments: [SSH_COMMITMENT_PANE] }) + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'ssh:idle%40box') + expect(persisted().commitments).toEqual([]) + } finally { + server.stop() + } + }) + + it('fences only the retired pane, so its kept tab still reports a new agent', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + const newPane = makePaneKey('tab-local', '66666666-6666-4666-8666-666666666666') + const done = { state: 'done', prompt: 'late', agentType: 'codex' } as const + server.ingestTerminalStatus({ paneKey: LOCAL_PANE, connectionId: null, payload: done }) + server.ingestTerminalStatus({ paneKey: newPane, connectionId: null, payload: done }) + + const panes = server.getStatusSnapshot().map((entry) => entry.paneKey) + expect(panes).toContain(newPane) + expect(panes).not.toContain(LOCAL_PANE) + } finally { + server.stop() + } + }) + + it.each([ + { occupant: 'the removed worktree', sshWorktree: KEPT, localWorktree: REMOVED, host: 'local' }, + { + occupant: 'another owner', + sshWorktree: REMOVED, + localWorktree: KEPT, + host: 'ssh:user%40box' + } + ] as const)( + 'decides a reused pane by its occupant: $occupant', + async ({ sshWorktree, localWorktree, host }) => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const pane = makePaneKey('tab-reused', '77777777-7777-4777-8777-777777777777') + const working = { state: 'working', prompt: 'live', agentType: 'codex' } as const + const reportLocally = (state: 'working' | 'done') => + server.ingestTerminalStatus({ + paneKey: pane, + worktreeId: localWorktree, + connectionId: null, + payload: { ...working, state } + }) + server.ingestRemote( + { + paneKey: pane, + tabId: 'tab-reused', + worktreeId: sshWorktree, + launchToken: 'ssh', + payload: working + }, + 'user@box' + ) + server.clearStatusEntriesForConnection('user@box') + reportLocally('working') + + server.dropStatusEntriesForRemovedWorktree(REMOVED, host) + reportLocally('done') + + server.flushStatusPersistSync() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + if (localWorktree === REMOVED) { + expect(file.authorityCommitments?.[pane]).toMatchObject({ worktreeId: KEPT }) + // The retained foreign launch fence rejects the removed workspace's late repaint. + expect(file.entries[pane]).toBeUndefined() + } else { + expect(file.authorityCommitments?.[pane]).toBeUndefined() + // The occupant keeps reporting, without the removed owner's token hash stamped on its row. + expect(file.entries[pane]).toMatchObject({ worktreeId: KEPT, payload: { state: 'done' } }) + expect(file.entries[pane].launchTokenHash).toBeUndefined() + } + } finally { + server.stop() + } + } + ) +}) diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 15a773a9b22..252c995a95c 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -1,11 +1,19 @@ import type { AgentProcessPresence } from '../../../shared/agent-process-presence' import { admitLegacyAgentStatus, + clearPaneCacheState, deleteLegacyAgentStatus, paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + parseExecutionHostId, + type ExecutionHostScope +} from '../../../shared/execution-host' +import { worktreeIdsEqual } from '../../../shared/worktree/id' +import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { EnrichedAgentHookEventPayload } from './server-types' import { AgentHookServerAuthorityFences } from './server-authority-fences' @@ -171,6 +179,79 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return Boolean(this.getTmuxSelectedStatus(paneKey)) || paneHasStateClaims(this.state, paneKey) } + /** Retire the panes a removed worktree occupied on `host`, and its leftover claim on any other pane. */ + dropStatusEntriesForRemovedWorktree(worktreeId: string, host?: ExecutionHostScope): void { + const parsed = host === ALL_EXECUTION_HOSTS_SCOPE ? null : parseExecutionHostId(host ?? 'local') + // Why: a runtime host keeps its own store, so no row here is its to retire. + if (host !== ALL_EXECUTION_HOSTS_SCOPE && (!parsed || parsed.kind === 'runtime')) { + return + } + const ownedByRemoved = (claim: { connectionId: string | null; worktreeId?: string }): boolean => + Boolean(claim.worktreeId && worktreeIdsEqual(claim.worktreeId, worktreeId)) && + (!parsed || + (parsed.kind === 'ssh' + ? claim.connectionId === parsed.targetId + : // Why: WSL panes are local; their relay only stamps transport provenance. + claim.connectionId === null || isWslHookRelayConnectionId(claim.connectionId))) + // The startup snapshot may outlive its replaced map entry; revoke only the removed owner. + for (const commitment of this.hydratedAuthorityCommitments) { + if (ownedByRemoved(commitment)) { + this.revokedHydratedAuthorityCommitments.add(commitment) + } + } + const paneKeys = new Set() + for (const claim of [ + ...this.state.lastStatusByPaneKey.values(), + ...this.persistedAuthorityCommitmentsByPaneKey.values() + ]) { + if (ownedByRemoved(claim)) { + paneKeys.add(claim.paneKey) + } + } + for (const paneKey of paneKeys) { + const row = this.state.lastStatusByPaneKey.get(paneKey) + const commitment = this.persistedAuthorityCommitmentsByPaneKey.get(paneKey) + if (row && ownedByRemoved(row) && commitment && !ownedByRemoved(commitment)) { + // A tokenless repaint cannot prove a foreign launch exited; retain it behind its token fence. + const observation = this.currentAuthorityObservations.get(paneKey) + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + clearPaneCacheState(this.state, paneKey) + if (observation && !ownedByRemoved(observation)) { + this.currentAuthorityObservations.set(paneKey, observation) + } + this.restartedStatusLaunchTokenHashByPaneKey.set(paneKey, { + hash: commitment.launchTokenHash, + allowRetainedOwner: true + }) + this.observations.forget(paneKey) + this.commitStatusRowMutation(deleted, undefined) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitPaneStatusCleared({ paneKey }) + continue + } + const occupant = row ?? commitment + if (occupant && !ownedByRemoved(occupant)) { + // Another owner has the pane now; only our outlived commitment is left to clear. + if (commitment && ownedByRemoved(commitment)) { + this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) + this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) + this.scheduleStatusPersist() + } + const observation = this.currentAuthorityObservations.get(paneKey) + if (observation && ownedByRemoved(observation)) { + this.currentAuthorityObservations.delete(paneKey) + } + continue + } + // Why a pane fence, not a tab one: a surviving same-id host keeps the shared tab. + this.retirePaneAuthority(paneKey) + if (row) { + this.emitPaneStatusCleared({ paneKey }) + } + } + } + /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { const normalizedConnectionId = connectionId.trim() diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 5fc4456f30f..787f3ed0cef 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -21,45 +21,15 @@ import { olderPeerAgentStatusLegacyMode } from '../../../shared/agent-status-legacy-adapter' import { isValidPiProviderSessionOnly } from './server-status-identity' -import { normalizeRemoteEnvelopeFields } from './server-remote-envelope-normalization' +import { + normalizeRemoteEnvelopeFields, + type RemoteAgentStatusEnvelope +} from './server-remote-envelope-normalization' import { AgentHookServerIngestStructuredChildren } from './server-ingest-structured-children' export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructuredChildren { /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ - ingestRemote( - envelope: { - paneKey: string - tabId?: string - worktreeId?: string - env?: string - version?: string - launchToken?: string - hasExplicitPrompt?: boolean - promptInteractionKey?: string - agentPresence?: unknown - hookEventName?: string - source?: unknown - providerPromptId?: unknown - grokPromptBoundary?: unknown - compactTrigger?: unknown - toolUseId?: string - toolAgentId?: string - teammateName?: string - toolAgentType?: string - providerSession?: unknown - providerSessionOnly?: unknown - isReplay?: boolean - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ - shedFields?: unknown - claudeRunningNonAgentTask?: unknown - /** The producing peer's advertised run-capability set — a property of the peer/connection that built this envelope, not an orthogonal call parameter. Absent (older relay/HTTP paths) defaults to the unadvertised-legacy-peer set. */ - advertisedAgentStatusCapabilities?: readonly string[] - statusUnavailable?: unknown - evidenceAgeMs?: unknown - payload: unknown - }, - connectionId: string | null - ): void { + ingestRemote(envelope: RemoteAgentStatusEnvelope, connectionId: string | null): void { if ( !canAdmitLegacyAgentStatus( 'main-status-update', @@ -194,7 +164,13 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS hookEventName, isReplay: envelope.isReplay === true, hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken + launchToken: envelope.launchToken, + retainedLaunchTokenHash: envelope.launchToken?.trim() + ? undefined + : this.retainedOwnerLaunchTokenHash(paneKey, { + worktreeId, + connectionId: trimmedConnectionId + }) }) if (statusDisposition === 'suppress') { return diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 63355e3592b..004a6462d27 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -47,12 +47,23 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId + const worktreeId = event.worktreeId?.trim() || undefined + const connectionId = + typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 + ? event.connectionId.trim() + : null + const retainedLaunchTokenHash = this.retainedOwnerLaunchTokenHash(paneKey, { + worktreeId, + connectionId + }) // 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 + : this.restartedStatusLaunchTokenHashByPaneKey.get(paneKey)?.allowRetainedOwner + ? { retainedLaunchTokenHash } + : undefined ) if (disposition === 'suppress') { return @@ -60,14 +71,6 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges if (disposition === 'restart') { this.observations.rebind(paneKey) } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined - const connectionId = - typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 - ? event.connectionId.trim() - : null const terminalHandle = typeof event.terminalHandle === 'string' && event.terminalHandle.trim().length > 0 ? event.terminalHandle.trim() diff --git a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts index 3765c8c0a50..0f7f009d1ad 100644 --- a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts +++ b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts @@ -5,6 +5,38 @@ import { } from '../../../shared/agent-hook-listener/listener-limits' import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +export type RemoteAgentStatusEnvelope = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hasExplicitPrompt?: boolean + promptInteractionKey?: string + agentPresence?: unknown + hookEventName?: string + source?: unknown + providerPromptId?: unknown + grokPromptBoundary?: unknown + compactTrigger?: unknown + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown + providerSessionOnly?: unknown + isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown + /** The producing peer's advertised run-capability set — a property of the peer/connection that built this envelope, not an orthogonal call parameter. Absent (older relay/HTTP paths) defaults to the unadvertised-legacy-peer set. */ + advertisedAgentStatusCapabilities?: readonly string[] + statusUnavailable?: unknown + evidenceAgeMs?: unknown + payload: unknown +} + export type RemoteEnvelopeFields = { hookEventName?: string source?: AgentHookSource diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts index cde178e335d..e8540392075 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -79,7 +79,7 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene } protected sameTerminalOwner( - previous: EnrichedAgentHookEventPayload, + previous: Pick, incoming: Pick ): boolean { if ( @@ -112,6 +112,20 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene ) } + protected retainedOwnerLaunchTokenHash( + paneKey: string, + incoming: Pick + ): string | undefined { + const fence = this.restartedStatusLaunchTokenHashByPaneKey.get(paneKey) + const authority = this.persistedAuthorityCommitmentsByPaneKey.get(paneKey) + return fence?.allowRetainedOwner && + authority?.worktreeId && + incoming.worktreeId && + this.sameTerminalOwner(authority, incoming) + ? authority.launchTokenHash + : undefined + } + protected commitStatusRowMutation( before: EnrichedAgentHookEventPayload | null | undefined, after: EnrichedAgentHookEventPayload | null | undefined, diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 172eaeaf1ef..db61f046e24 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -148,7 +148,10 @@ export abstract class AgentHookServerState { protected promptSentHashSalt = randomBytes(16).toString('hex') protected closedAgentStatusTabIds = new Set() protected closedAgentStatusPaneKeys = new Set() - protected restartedStatusLaunchTokenHashByPaneKey = new Map() + protected restartedStatusLaunchTokenHashByPaneKey = new Map< + string, + { hash: string; allowRetainedOwner?: true } + >() protected connectionTimestampWatermarkById = new Map() // Why: survives the row itself. A transport clear deletes the pane's status row on purpose // (absence, not completion), but the *age* of the evidence a later replay restates is not a @@ -191,6 +194,7 @@ export abstract class AgentHookServerState { isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string + retainedLaunchTokenHash?: string } ): 'accept' | 'restart' | 'suppress' protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index ef633d7c2c8..8b709aeb46a 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -50,6 +50,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string + /** Host/workspace provenance matched internally to a retained authority commitment. */ + retainedLaunchTokenHash?: string /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ processNewTurn?: boolean } @@ -89,16 +91,18 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt ) { const startedLaunchToken = event.launchToken?.trim() if (startedLaunchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(startedLaunchToken).digest('hex') - ) + this.restartedStatusLaunchTokenHashByPaneKey.set(ownerPaneKey, { + hash: createHash('sha256').update(startedLaunchToken).digest('hex') + }) return 'accept' } } if (event && event.processNewTurn !== true && tokenFence) { const launchToken = event.launchToken?.trim() - if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + const tokenHash = + event.retainedLaunchTokenHash ?? + (launchToken ? createHash('sha256').update(launchToken).digest('hex') : undefined) + if (tokenHash !== tokenFence.hash) { return 'suppress' } } @@ -144,10 +148,9 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt this.closedAgentStatusPaneKeys.delete(ownerPaneKey) const launchToken = event?.launchToken?.trim() if (launchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(launchToken).digest('hex') - ) + this.restartedStatusLaunchTokenHashByPaneKey.set(ownerPaneKey, { + hash: createHash('sha256').update(launchToken).digest('hex') + }) } else { this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) } diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index fdeea389d61..d23efd2fb2b 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -12,6 +12,8 @@ import { mockSelectedWslProjectRuntime } from './worktrees-test-fixtures' import { pruneMetadataMissingFromAuthoritativeLocalScan } from './worktrees/listing/authoritative-local-worktree-metadata-pruning' import { listDetectedWorktreesForCapturedRepo } from './worktrees/listing/detected-provider-listing' import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { agentHookServer } from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' import { isRegisteredWorktreePath, registerWorktreeRootsForRepo @@ -207,10 +209,33 @@ describe('authoritative local worktree metadata pruning integration', () => { await Promise.resolve() return rows }) + // A worktree deleted outside Orca strands its status row unless the prune retires it. + const stalePane = makePaneKey('tab-stale', '11111111-1111-4111-8111-111111111111') + const livePane = makePaneKey('tab-live', '22222222-2222-4222-8222-222222222222') + const working = { state: 'working', prompt: 'p', agentType: 'codex' } as const + agentHookServer.ingestTerminalStatus({ + paneKey: stalePane, + worktreeId: staleId, + connectionId: null, + payload: working + }) + agentHookServer.ingestTerminalStatus({ + paneKey: livePane, + worktreeId: `${REPO_ID}::/workspace/live`, + connectionId: null, + payload: working + }) await Promise.all([listDetected(), listDetected(), listDetected()]) await listDetected() + try { + expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([livePane]) + } finally { + agentHookServer.dropStatusEntriesByTabPrefix('tab-stale') + agentHookServer.dropStatusEntriesByTabPrefix('tab-live') + } + expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1) expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) const firstPruneCall = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index b1e91daea9d..5827eb65391 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -6,6 +6,8 @@ import { join } from 'node:path' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { RedactableSpan } from '../observability/redactor' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' +import { agentHookServer } from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' import { ORIGINAL_PLATFORM, setPlatform, @@ -158,15 +160,35 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue({}) - - await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) - - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() - expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() - expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { - repoId: 'repo-1' + // Both hosts' agents share one tab; only the removed host's pane may be retired. + const localPane = makePaneKey('tab-shared', '11111111-1111-4111-8111-111111111111') + const sshPane = makePaneKey('tab-shared', '22222222-2222-4222-8222-222222222222') + const payload = { state: 'working', prompt: 'stranded', agentType: 'codex' } as const + agentHookServer.ingestTerminalStatus({ + paneKey: localPane, + tabId: 'tab-shared', + worktreeId, + connectionId: null, + payload }) + agentHookServer.ingestRemote( + { paneKey: sshPane, tabId: 'tab-shared', worktreeId, payload }, + 'conn-1' + ) + + try { + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([sshPane]) + } finally { + agentHookServer.dropStatusEntriesByTabPrefix('tab-shared') + } }) it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { @@ -698,4 +720,47 @@ describe('registerWorktreeHandlers', () => { }) expect(getSshPtyProviderMock).not.toHaveBeenCalled() }) + // A scan the host answered is the only evidence that ever retires an off-host WorktreeMeta row, + // so it must retire that worktree's hook-status rows too, or they stay stranded in last-status.json. + it("retires the scan-proven host rows from the agent status store, and only that host's", async () => { + const worktreeId = 'repo-1::/remote/deleted' + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + ]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue({ + [worktreeId]: makeWorktreeMeta({ hostId: 'ssh:target-a' }) + }) + const scannedPane = makePaneKey('tab-scan', '33333333-3333-4333-8333-333333333333') + const otherHostPane = makePaneKey('tab-scan', '44444444-4444-4444-8444-444444444444') + const payload = { state: 'working', prompt: 'stranded', agentType: 'codex' } as const + agentHookServer.ingestRemote( + { paneKey: scannedPane, tabId: 'tab-scan', worktreeId, payload }, + 'target-a' + ) + agentHookServer.ingestRemote( + { paneKey: otherHostPane, tabId: 'tab-scan', worktreeId, payload }, + 'target-b' + ) + + try { + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: 'ssh:target-a', + worktreeIds: [worktreeId] + }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:target-a') + expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([otherHostPane]) + } finally { + agentHookServer.dropStatusEntriesByTabPrefix('tab-scan') + } + }) }) diff --git a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts index ca5c3019844..3349a986519 100644 --- a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts +++ b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/worktree/id' import type { GitWorktreeInfo } from '../../../../shared/worktree/types' import { isWslUncPath } from '../../../../shared/wsl-paths' +import { agentHookServer } from '../../../agent-hooks/server' import type { Store } from '../../../persistence/loading-store/store' import type { NativeLocalWorktreeMetadataScanExpectation } from '../../../persistence/tracking-repos/missing-local-worktree-metadata-pruning' import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' @@ -141,6 +142,9 @@ export async function pruneMetadataMissingFromAuthoritativeLocalScan({ })) void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + for (const worktreeId of removedIds) { + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, LOCAL_EXECUTION_HOST_ID) + } } return result(removedIds, generationCurrent()) } diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index ac231a4c697..1e4fecfe437 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -14,6 +14,7 @@ import type { DetectedWorktree } from '../../../../shared/worktree/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { agentHookServer } from '../../../agent-hooks/server' import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot' import { findExactRepoOwner, hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' @@ -144,6 +145,10 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { continue } store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + // Why here too: a scan the host answered is positive evidence of removal, and this is the only + // path that ever retires an off-host row — so it owes the status store the same drop the + // in-Orca delete does, or the SSH rows stay stranded in `last-status.json`. + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, parsedHost.id) forgottenWorktreeIds.push(worktreeId) } if (forgottenWorktreeIds.length > 0) { diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts index ecc982076c5..780e443fdc9 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -8,6 +8,7 @@ import type { Repo } from '../../../../shared/repo-types' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../../worktree-removal-repo-owner' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { agentHookServer } from '../../../agent-hooks/server' import { localhostWorktreeLabelProxy } from '../../../localhost-worktree-label-proxy' import { deleteWorktreeHistoryDir } from '../../../terminal-history-deletion' import { pruneWorktreePRRefreshAliases } from '../../../github/pr-refresh-coordinator' @@ -88,6 +89,8 @@ export function removeWorktreeMetadataAndTransientState( } else { store.removeWorktreeMeta(worktreeId) } + // Why outside the same-id gate: retirement is per host and per pane, so a surviving owner keeps its own. + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { advertisedUrlWatcher.forgetWorktree(worktreeId) // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index a71307a8df5..7e75c5024a2 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -257,6 +257,8 @@ async function startOrcadRuntime( checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + dropAgentStatusForRemovedWorktree: (worktreeId, host) => + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, host), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts index 1f399e0464f..12ef6dba7c7 100644 --- a/src/main/runtime/agent-status-store-wiring.test-fixture.ts +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -28,6 +28,7 @@ export function makeAgentStatusStoreWiring(): { reconcileAgentStatusForEndedProcess: ( paneKeys: Parameters[0] ) => void + dropAgentStatusForRemovedWorktree: AgentHookServer['dropStatusEntriesForRemovedWorktree'] } /** Call once the runtime exists; returns the republish teardown. */ attach: (runtime: WiredRuntime) => () => void @@ -44,7 +45,9 @@ export function makeAgentStatusStoreWiring(): { statusStore.getStatusSnapshotForPane(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => { statusStore.reconcileEndedProcessForPaneKeys(paneKeys) - } + }, + dropAgentStatusForRemovedWorktree: (worktreeId, host) => + statusStore.dropStatusEntriesForRemovedWorktree(worktreeId, host) }, attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) } diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99a..6ecdc714935 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -1,5 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithTerminalDrivers } from './orca-runtime-terminal-drivers' +import { ALL_EXECUTION_HOSTS_SCOPE, type ExecutionHostScope } from '../../shared/execution-host' import { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import type { IPtyProvider } from '../providers/types' import type { @@ -98,6 +99,10 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | ((paneKeys: Iterable) => void) | null + protected readonly dropAgentStatusForRemovedWorktreeFn: + | ((worktreeId: string, host?: ExecutionHostScope) => void) + | null + protected readonly canRecoverPersistentLocalPtysFn: () => boolean protected readonly getPairedDeviceNameFn: (pairedDeviceId: string) => string | null @@ -247,6 +252,8 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin if (this.store) { this.removeWorktreeMetadataAndHistory(this.store, worktreeId) } + // Why every host after the local-only hub: this store mints folder ids, so none is shared. + this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, ALL_EXECUTION_HOSTS_SCOPE) } }) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 115a5be8f0d..f66240f5739 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -148,6 +148,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } else { store.removeWorktreeMeta(worktreeId) } + // Why outside the same-id gate: retirement is per host and per pane, so a surviving owner keeps its own. + this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { // A paired PTY can outlive the delete acknowledgement; it must not be // rescued into a newly-created occupant of the same path-derived ID. diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ede9c6edce5..404ddb35da3 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -1,5 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands' +import type { ExecutionHostScope } from '../../shared/execution-host' import type { RuntimeStore } from './runtime-store-contract' import type { StatsCollector } from '../stats/collector' import type { IPtyProvider } from '../providers/types' @@ -86,6 +87,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void + dropAgentStatusForRemovedWorktree?: (worktreeId: string, host?: ExecutionHostScope) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; // a closure defers the lookup past that ordering instead of inverting ownership. @@ -230,6 +232,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { deps?.retireAgentHookCompatibilityAuthority ?? null this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null + this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) // Why: configure the shared AiVault scan cache from a serve-mode-reachable diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index d0cd3608414..957f4b66ca4 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -346,12 +346,16 @@ function createMobileCreateTestNotifier( } } -function createWorktreeRemovalRuntime(runtimeStore: unknown = store): RuntimeService { +function createWorktreeRemovalRuntime( + runtimeStore: unknown = store, + deps: ConstructorParameters[2] = {} +): RuntimeService { const emptyPtyProvider = { listProcesses: vi.fn(async () => []), shutdown: vi.fn(async () => {}) } return new OrcaRuntimeService(runtimeStore as never, undefined, { + ...deps, getLocalProvider: () => emptyPtyProvider as never, getSshProvider: () => emptyPtyProvider as never }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 2d1af59155b..d371ac3f96f 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -36,6 +36,7 @@ import { } from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import { getLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' describe('OrcaRuntimeService', () => { it('creates the first terminal by id when duplicate repo entries expose the same path', async () => { @@ -493,6 +494,48 @@ describe('OrcaRuntimeService', () => { ) }) + it('retires the removed worktree agent status rows from the host store', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = createWorktreeRemovalRuntime(store, statusWiring.deps) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: 'tab-removed:11111111-1111-4111-8111-111111111111', + tabId: 'tab-removed', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { state: 'working', prompt: 'stranded', agentType: 'codex' } + }) + vi.mocked(removeWorktree).mockResolvedValue({}) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('retires a deleted SSH folder workspace agent status rows', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const folderStore = { + ...store, + getFolderWorkspaces: () => [{ id: 'ws-1', folderPath: '/srv/app', connectionId: 'user@box' }], + removeFolderWorkspace: () => true + } + const runtime = createWorktreeRemovalRuntime(folderStore, statusWiring.deps) + statusWiring.statusStore.ingestRemote( + { + paneKey: 'tab-folder:11111111-1111-4111-8111-111111111111', + tabId: 'tab-folder', + worktreeId: 'folder:ws-1', + payload: { state: 'working', prompt: 'stranded', agentType: 'codex' } + }, + 'user@box' + ) + + await runtime.deleteFolderWorkspace('ws-1') + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + it('passes project shared links through the runtime removal preflight and cleanup', async () => { const runtime = createWorktreeRemovalRuntime() vi.mocked(loadHooks).mockReturnValue({ diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 9da3d7212ef..bf9a8ad33a5 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -122,6 +122,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + dropAgentStatusForRemovedWorktree: (worktreeId, host) => + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, host), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: evaluated per call, not captured — the RPC server that owns the device registry is // constructed with this runtime and does not exist yet at this point.