fix(agent-status): retire a removed worktree's hook-status rows (#23881)

* 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 <neil@stably.ai>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Kelvin Amoaba
2026-10-05 20:12:38 -07:00
committed by GitHub
co-authored by Neil Neil
parent 6012570996
commit 420447e063
23 changed files with 810 additions and 71 deletions
+5 -5
View File
@@ -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
@@ -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()
}
}
)
})
@@ -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()
}
}
)
})
@@ -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<string>()
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()
@@ -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
@@ -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()
@@ -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
@@ -79,7 +79,7 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene
}
protected sameTerminalOwner(
previous: EnrichedAgentHookEventPayload,
previous: Pick<AgentHookEventPayload, 'connectionId' | 'worktreeId'>,
incoming: Pick<AgentHookEventPayload, 'connectionId' | 'worktreeId'>
): boolean {
if (
@@ -112,6 +112,20 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene
)
}
protected retainedOwnerLaunchTokenHash(
paneKey: string,
incoming: Pick<AgentHookEventPayload, 'connectionId' | 'worktreeId'>
): 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,
+5 -1
View File
@@ -148,7 +148,10 @@ export abstract class AgentHookServerState {
protected promptSentHashSalt = randomBytes(16).toString('hex')
protected closedAgentStatusTabIds = new Set<string>()
protected closedAgentStatusPaneKeys = new Set<string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<
string,
{ hash: string; allowRetainedOwner?: true }
>()
protected connectionTimestampWatermarkById = new Map<string, number>()
// 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
@@ -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)
}
@@ -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
@@ -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')
}
})
})
@@ -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())
}
@@ -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) {
@@ -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.
+2
View File
@@ -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
@@ -28,6 +28,7 @@ export function makeAgentStatusStoreWiring(): {
reconcileAgentStatusForEndedProcess: (
paneKeys: Parameters<AgentHookServer['reconcileEndedProcessForPaneKeys']>[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)
}
@@ -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<string>) => 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)
}
})
@@ -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.
@@ -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<string>) => 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
@@ -346,12 +346,16 @@ function createMobileCreateTestNotifier(
}
}
function createWorktreeRemovalRuntime(runtimeStore: unknown = store): RuntimeService {
function createWorktreeRemovalRuntime(
runtimeStore: unknown = store,
deps: ConstructorParameters<typeof OrcaRuntimeService>[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
})
@@ -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({
@@ -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.