diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index f33d7889b31..4c4145bab2e 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { IPtyProvider } from '../providers/types' +import { ABANDONED_SWEEP_GRACE_MS } from './forced-sweep-settlement' const hostRef: { current: unknown } = { current: null } @@ -512,74 +513,107 @@ describe('worktree teardown and structured agent sessions', () => { }) it('refuses in agent-session wording when the close outlives the sweep budget', async () => { - // A structured close that runs out of time used to reject with the PTY timeout sentinel, which - // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to - // clear the wedge hit the same rejection again (#11960). - installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 5 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 1 agent session (claude)') - expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) - expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + // A structured close that runs out of time used to reject with the PTY timeout sentinel, which + // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to + // clear the wedge hit the same rejection again (#11960). + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(5) + const error = await outcome + expect(error).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) + expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + } finally { + vi.useRealTimers() + } }) it('never wedges Force Delete on a close that will not settle', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) - await expect( - killAllProcessesForWorktree( + try { + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const outcome = killAllProcessesForWorktree( WORKTREE, destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 5 }) ) - ).resolves.toMatchObject({ runtimeStopped: 0 }) - const message = structuredSessionWarning(warn) - expect(message).toContain('could not confirm these closed: 1 agent session (claude)') - // The pin: a close that ran out of time was never watched stay attached. This warn is the only - // record a forced removal leaves, and the removal.ts split exists precisely so "we could not - // confirm" is never reported as "we saw it running" — including here. - expect(message).not.toContain('still attached') - warn.mockRestore() + void outcome.catch(() => undefined) + await vi.advanceTimersByTimeAsync(5 + ABANDONED_SWEEP_GRACE_MS) + await expect(outcome).resolves.toMatchObject({ runtimeStopped: 0 }) + const message = structuredSessionWarning(warn) + expect(message).toContain('could not confirm these closed: 1 agent session (claude)') + // The pin: a close that ran out of time was never watched stay attached. This warn is the only + // record a forced removal leaves, and the removal.ts split exists precisely so "we could not + // confirm" is never reported as "we saw it running" — including here. + expect(message).not.toContain('still attached') + } finally { + warn.mockRestore() + vi.useRealTimers() + } }) it('names only the sessions still open when the budget expires mid-close', async () => { - // The close loop is serial, so a deadline can land part-way through it. A fallback assembled - // at the deadline could only name the whole list — so a removal that had already closed the - // first chat still told the user both were still there, which is the exact thing this sweep - // exists to stop doing: never report state nobody observed. - installHost({ - records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], - closeGates: { s2: new Promise(() => {}) } - }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 40 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 1 agent session (codex)') - expect(error).not.toContain('claude') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + // The close loop is serial, so a deadline can land part-way through it. A fallback assembled + // at the deadline could only name the whole list — so a removal that had already closed the + // first chat still told the user both were still there, which is the exact thing this sweep + // exists to stop doing: never report state nobody observed. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: new Promise(() => {}) } + }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 40 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(40) + const error = await outcome + expect(error).toContain('could not confirm these closed: 1 agent session (codex)') + expect(error).not.toContain('claude') + } finally { + vi.useRealTimers() + } }) it('counts the closes that landed before the budget expired', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) // The other half of the same fallback: it reported zero closes, so the removal log said // `structured=0` for a chat it had just ended. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const slowClose = new Promise((resolve) => { setTimeout(resolve, 300) }) - installHost({ - records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], - closeGates: { s2: slowClose } - }) - const result = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) - ) - expect(result.structuredStopped).toBe(1) - expect(structuredSessionWarning(warn)).toContain( - 'could not confirm these closed: 1 agent session (codex)' - ) - warn.mockRestore() + try { + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: slowClose } + }) + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) + ) + void outcome.catch(() => undefined) + await vi.advanceTimersByTimeAsync(300) + const result = await outcome + expect(result.structuredStopped).toBe(1) + expect(structuredSessionWarning(warn)).toContain( + 'could not confirm these closed: 1 agent session (codex)' + ) + } finally { + try { + await vi.advanceTimersByTimeAsync(300) + await slowClose + } finally { + warn.mockRestore() + vi.useRealTimers() + } + } }) it('stops issuing new closes once the budget is spent', async () => {