From 420447e0636db705190d9ecd24ee895946bb4b7c Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:12:38 +0000 Subject: [PATCH] fix(agent-status): retire a removed worktree's hook-status rows (#23881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-status): retire a removed worktree's hook-status rows Rows whose terminal was never reattached had no teardown path, so they outlived the worktree in last-status.json for up to 7 days. Fixes #23068 * fix(agent-status): skip panes another owner has since reclaimed * fix(agent-status): clear only the removed owner's claims on a shared pane * fix(agent-status): retire hook-status rows a host scan proved removed `worktrees:forgetRemovedForExecutionHost` is the only path that ever retires an off-host WorktreeMeta row: gcStaleWorktreeMeta skips any row whose repo or hostId is not local. It already prunes the cleanup and space-analysis snapshots for a worktree a remote scan proved gone, but left that worktree's hook-status rows behind in `last-status.json` — the same stranding this branch fixes for the in-Orca delete, reached through the other trigger. A scan the host answered is positive evidence of removal rather than loss of contact, so it is the host evidence `ssh-execution-boundary.md` requires, and it publishes no verdict. The drop is scoped to the scanned host's id, so a same-id worktree on another connection keeps its rows, and a runtime host falls through the method's own early return because a paired server owns its own store. Also names the shared-pane condition in `dropStatusEntriesForRemovedWorktree`, which was the one place the two-owner logic was hard to read. * fix(agent-status): stop a removed worktree's row returning on a shared pane The mixed-owner branch deleted the removed worktree's row but left the pane unfenced, so the stale row came straight back. `getAgentStatusDisposition` returns `accept` for an unfenced pane, and the removed worktree's agent can still post a late turn on a pane it shares with another owner — I reproduced the `working` row being rewritten to memory and to `last-status.json`, which is the symptom #23068 is about. A launch-token fence cannot close this: `ingestTerminalStatus` calls the disposition gate with no event, so the token check never runs and an OSC report carries no token to check. The pane fence the sole-owner path already uses does suppress it, and it lifts on PTY reattach or a new agent's turn. Fencing the pane would otherwise discard the surviving owner's claims, which is what the branch existed to protect, so both of its records are captured first and restored after: its persisted authority commitment (its resume identity) and its current authority observation. The observation also fixes a second defect on this path — `deleteStatusEntry` drops it whatever `preserveAuthority` says, so the surviving owner silently lost `current_runtime` attestation until its next hook event. Both are covered by tests that fail against the previous commit. * fix(agent-status): decide a reused pane by its occupant, and retire rows for local scan-proved removals A pane has one terminal, so its newest row names who occupies it. A saved commitment naming a different owner is what an earlier occupant left behind, and serialization already drops it while the row disagrees. Removal now retires a pane the removed worktree occupies, and on a pane another owner occupies it clears only the removed worktree's outlived commitment. This replaces the capture-and-restore handling of two-owner panes. The local authoritative-scan prune is the local twin of the SSH scan forget: it drops a worktree's metadata once the scan proves it gone, and now retires its status rows too. * fix(agent-status): preserve foreign authority during worktree removal * fix(agent-status): preserve terminal connection validation * fix(agent-status): keep ordinary OSC admission unchanged * refactor(agent-status): colocate the remote envelope type * fix(agent-status): revoke removed startup authority evidence * fix(agent-status): retain foreign startup claims during removal --------- Co-authored-by: Neil Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- docs/reference/agent-status-store.md | 10 +- ...removed-worktree-foreign-authority.test.ts | 283 ++++++++++++++++++ .../server-removed-worktree-status.test.ts | 175 +++++++++++ src/main/agent-hooks/server/server-cleanup.ts | 81 +++++ .../server/server-ingest-remote.ts | 48 +-- .../server/server-ingest-terminal.ts | 21 +- .../server-remote-envelope-normalization.ts | 32 ++ .../server/server-row-ownership.ts | 16 +- src/main/agent-hooks/server/server-state.ts | 6 +- .../server/server-status-disposition.ts | 21 +- ...thoritative-local-metadata-pruning.test.ts | 25 ++ .../ipc/worktrees-removal-recovery.test.ts | 81 ++++- ...itative-local-worktree-metadata-pruning.ts | 4 + .../listing/register-host-catalog-handlers.ts | 5 + .../removal/worktree-removal-ownership.ts | 3 + src/main/orcad/orcad-entry.ts | 2 + .../agent-status-store-wiring.test-fixture.ts | 5 +- .../orca-runtime-preserved-branch-cleanup.ts | 7 + ...runtime-resolve-worktree-removal-target.ts | 2 + src/main/runtime/orca-runtime-state-fields.ts | 3 + ...rca-runtime-test-scenario-builders.spec.ts | 6 +- ...orktree-removal-and-reconciliation.spec.ts | 43 +++ .../startup/main-process-runtime-service.ts | 2 + 23 files changed, 810 insertions(+), 71 deletions(-) create mode 100644 src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts create mode 100644 src/main/agent-hooks/server-removed-worktree-status.test.ts 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.