mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
co-authored by
Neil
Neil
parent
6012570996
commit
420447e063
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user