mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user