fix(worktrees): keep a proven-exited session from refusing removal

The structured sweep re-observes after a close that reported `stopped: false`,
but folded a proven `exited` into `unverifiable` — so a close that threw past
its own observation, or one whose death evidence landed a beat later, refused a
delete over a child that is demonstrably gone. That is the defect this sweep
exists to remove, and the PTY gate it mirrors never refuses on a proven exit.

Take the proof, and run the tab retirement the close skipped when it gave up:
a chat tab left behind re-attaches a released session pointing at a workspace
that is about to be deleted.
This commit is contained in:
Merge Sim
2026-09-09 12:38:02 -07:00
parent 2d8e6b3478
commit 6d05fb8a52
2 changed files with 38 additions and 1 deletions
@@ -50,6 +50,8 @@ function installHost(options: {
stuck?: Set<string>
/** Sessions the host drops without death evidence, so the observation is `unverifiable`. */
unverifiable?: Set<string>
/** Sessions whose child dies and is recorded dead, but whose close then fails past that point. */
settledThenThrows?: Set<string>
/** Blocks every close, to exercise the shared sweep budget without fake timers. */
closeGate?: Promise<void>
}): { closed: string[] } {
@@ -74,6 +76,9 @@ function installHost(options: {
record.lease.claimStatus = 'released'
record.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
}
if (options.settledThenThrows?.has(sessionId)) {
throw new Error('the event sink could not be flushed')
}
}
}
// `observeStructuredWorker` reads the record through the same host, so keep them consistent.
@@ -201,6 +206,28 @@ describe('worktree teardown and structured agent sessions', () => {
warn.mockRestore()
})
it('takes the proof when a failed close is re-observed as exited', async () => {
// `closeStructuredAgentSessionChild` reports `stopped: false` for anything that throws past its
// own observation, and for a record whose death evidence lands after it read. The re-read here
// can still PROVE the exit — refusing a delete over a child that is demonstrably gone is the
// defect this whole sweep exists to remove, so the proof has to win over the close's verdict.
const retired: string[] = []
const runtime = {
stopTerminalsForWorktree: async () => ({ stopped: 0 }),
retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => {
retired.push(sessionId)
return true
}
} as never
installHost({ records: [record('s1', WORKTREE)], settledThenThrows: new Set(['s1']) })
await expect(
killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime })
).resolves.toMatchObject({ structuredStopped: 1 })
// Retired here because the close gave up before its own retirement step, and a chat tab left
// behind re-attaches a released session pointing at a workspace that is about to be deleted.
expect(retired).toEqual(['s1'])
})
it('leaves the best-effort reconciliation paths alone', async () => {
// Those callers repair state and delete nothing, so a refusal there would wedge a repair.
installHost({ records: [record('s1', WORKTREE)] })
@@ -29,6 +29,7 @@ import { STILL_LIVE_DETAIL_PREFIX } from '../../shared/worktree/removal'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { observeStructuredWorker } from './structured-worker-authority'
import { closeStructuredAgentSessionChild } from './structured-agent-session-close'
import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement'
import type { OrcaRuntimeService } from './orca-runtime'
export type LiveStructuredSessionInWorkspace = {
@@ -165,7 +166,16 @@ export async function closeStructuredSessionsForWorktree(
// Re-observed rather than reusing the close's own reason string: what the user is asked to
// waive is the state AFTER the attempt, and a close that threw never reached an observation.
const status = observeStructuredWorker({ sessionId: session.sessionId }).status
unstopped.push({ ...session, status: status === 'live' ? 'live' : 'unverifiable' })
if (status === 'exited') {
// The re-read can PROVE the exit a failed close could not — it threw past its own
// observation, or the record's death evidence landed after it read. Refusing on a child
// that is demonstrably gone is the defect this sweep exists to remove, so take the proof
// and run the retirement `closeStructuredAgentSessionChild` skipped when it gave up.
retireSettledStructuredWorkerTab(session.sessionId, runtime)
closed += 1
continue
}
unstopped.push({ ...session, status })
}
return { closed, unstopped }
}