refactor(orchestration): name the disposed close error once instead of in both predicates

isTransient and isMissing both matched disposed, so the two read as
disjoint while one silently shadowed the other. A disposed endpoint is
genuinely both, and only the host observation decides which; pin the
certified-exit outcome that had no coverage.
This commit is contained in:
Jinwoo-H
2026-09-04 15:36:14 -04:00
parent 4d36cba55d
commit 36685faf86
2 changed files with 23 additions and 4 deletions
@@ -1,11 +1,18 @@
function isTransientWorkerTerminalCloseError(reason: string): boolean {
return /disposed|not connected|unavailable/i.test(reason)
return /not connected|unavailable/i.test(reason)
}
/** The close found nothing to close. Against a host-certified exit that is the goal state, not new
* doubt: a retry only aims the same dead handle at the same absent terminal, forever. */
function isMissingWorkerTerminalCloseError(reason: string): boolean {
return /handle_stale|stale handle|disposed|not found|no such terminal/i.test(reason)
return /handle_stale|stale handle|not found|no such terminal/i.test(reason)
}
/** A disposed endpoint is genuinely both: nothing is left to close, and it may return on
* reconnect. Only the host observation can say which, so it is named once here instead of
* being spelled into two predicates that then read as if they were disjoint. */
function isDisposedWorkerTerminalCloseError(reason: string): boolean {
return /disposed/i.test(reason)
}
export function classifyWorkerTerminalCloseError(error: unknown): {
@@ -14,10 +21,11 @@ export function classifyWorkerTerminalCloseError(error: unknown): {
alreadyGone: boolean
} {
const reason = error instanceof Error ? error.message : String(error)
const disposed = isDisposedWorkerTerminalCloseError(reason)
return {
reason,
transient: isTransientWorkerTerminalCloseError(reason),
alreadyGone: isMissingWorkerTerminalCloseError(reason)
transient: disposed || isTransientWorkerTerminalCloseError(reason),
alreadyGone: disposed || isMissingWorkerTerminalCloseError(reason)
}
}
@@ -212,6 +212,17 @@ describe('orchestration worker release recovery', () => {
expect(runtime.closeTerminal).not.toHaveBeenCalled()
})
it('settles a disposed endpoint as released once the host certified the exit', async () => {
setup()
const { dispatchId } = await startSettledWorker()
vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({ status: 'exited' })
vi.mocked(runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed'))
await expect(
call('orchestration.workerRelease', { dispatch: dispatchId })
).resolves.toMatchObject({ state: 'released', processAction: 'closed_exited_terminal' })
})
it('defers instead of settling unknown while inventory is incomplete', async () => {
setup()
const { dispatchId } = await startSettledWorker()