fix: resolve 5 deployment blockers (runtime-fallback race, hashline legacy, tmux spawn, db open)
- runtime-fallback: guard session.error with sessionRetryInFlight to prevent double-advance during active retry; expand session.stop abort to include sessionAwaitingFallbackResult; remove premature pendingFallbackModel clearing from auto-retry finally block - hashline-edit: add HASHLINE_LEGACY_REF_PATTERN for backward-compatible LINE:HEX dual-parse in parseLineRef and normalizeLineRef - tmux-subagent: defer session on null queryWindowState; unconditionally re-queue deferred session on spawn failure (not just close+spawn) - ultrawork-db: wrap new Database(dbPath) in try/catch to handle corrupted DB - event: add try/catch guards around model-fallback logic in message.updated, session.status, and session.error handlers
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||
import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test'
|
||||
import type { TmuxConfig } from '../../config/schema'
|
||||
import type { WindowState, PaneAction } from './types'
|
||||
import type { ActionResult, ExecuteContext } from './action-executor'
|
||||
import type { TmuxUtilDeps } from './manager'
|
||||
import * as sharedModule from '../../shared'
|
||||
|
||||
type ExecuteActionsResult = {
|
||||
success: boolean
|
||||
@@ -656,6 +657,135 @@ describe('TmuxSessionManager', () => {
|
||||
expect((manager as any).deferredQueue).toEqual([])
|
||||
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
describe('spawn failure recovery', () => {
|
||||
test('#given queryWindowState returns null #when onSessionCreated fires #then session is enqueued in deferred queue', async () => {
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
mockQueryWindowState.mockImplementation(async () => null)
|
||||
const logSpy = spyOn(sharedModule, 'log').mockImplementation(() => {})
|
||||
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
const config: TmuxConfig = {
|
||||
enabled: true,
|
||||
layout: 'main-vertical',
|
||||
main_pane_size: 60,
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
// when
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_null_state', 'ses_parent', 'Null State Task')
|
||||
)
|
||||
|
||||
// then
|
||||
expect(
|
||||
logSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes('failed to query window state, deferring session')
|
||||
)
|
||||
).toBe(true)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_null_state'])
|
||||
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('#given spawn fails without close action #when onSessionCreated fires #then session is enqueued in deferred queue', async () => {
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
mockExecuteActions.mockImplementation(async (actions) => ({
|
||||
success: false,
|
||||
spawnedPaneId: undefined,
|
||||
results: actions.map((action) => ({
|
||||
action,
|
||||
result: { success: false, error: 'spawn failed' },
|
||||
})),
|
||||
}))
|
||||
const logSpy = spyOn(sharedModule, 'log').mockImplementation(() => {})
|
||||
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
const config: TmuxConfig = {
|
||||
enabled: true,
|
||||
layout: 'main-vertical',
|
||||
main_pane_size: 60,
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
// when
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_fail_no_close', 'ses_parent', 'Spawn Fail No Close')
|
||||
)
|
||||
|
||||
// then
|
||||
expect(
|
||||
logSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes('re-queueing deferred session after spawn failure')
|
||||
)
|
||||
).toBe(true)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_fail_no_close'])
|
||||
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('#given spawn fails with close action that succeeded #when onSessionCreated fires #then session is still enqueued in deferred queue', async () => {
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
mockExecuteActions.mockImplementation(async () => ({
|
||||
success: false,
|
||||
spawnedPaneId: undefined,
|
||||
results: [
|
||||
{
|
||||
action: { type: 'close', paneId: '%1', sessionId: 'ses_old' },
|
||||
result: { success: true },
|
||||
},
|
||||
{
|
||||
action: {
|
||||
type: 'spawn',
|
||||
sessionId: 'ses_fail_with_close',
|
||||
description: 'Spawn Fail With Close',
|
||||
targetPaneId: '%0',
|
||||
splitDirection: '-h',
|
||||
},
|
||||
result: { success: false, error: 'spawn failed after close' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
const logSpy = spyOn(sharedModule, 'log').mockImplementation(() => {})
|
||||
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
const config: TmuxConfig = {
|
||||
enabled: true,
|
||||
layout: 'main-vertical',
|
||||
main_pane_size: 60,
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
// when
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_fail_with_close', 'ses_parent', 'Spawn Fail With Close')
|
||||
)
|
||||
|
||||
// then
|
||||
expect(
|
||||
logSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes('re-queueing deferred session after spawn failure')
|
||||
)
|
||||
).toBe(true)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_fail_with_close'])
|
||||
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSessionDeleted', () => {
|
||||
|
||||
@@ -345,7 +345,8 @@ export class TmuxSessionManager {
|
||||
try {
|
||||
const state = await queryWindowState(sourcePaneId)
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] failed to query window state")
|
||||
log("[tmux-session-manager] failed to query window state, deferring session")
|
||||
this.enqueueDeferredSession(sessionId, title)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -407,10 +408,6 @@ export class TmuxSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
const closeActionSucceeded = result.results.some(
|
||||
({ action, result: actionResult }) => action.type === "close" && actionResult.success,
|
||||
)
|
||||
|
||||
if (result.success && result.spawnedPaneId) {
|
||||
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||
|
||||
@@ -445,12 +442,10 @@ export class TmuxSessionManager {
|
||||
})),
|
||||
})
|
||||
|
||||
if (closeActionSucceeded) {
|
||||
log("[tmux-session-manager] re-queueing deferred session after close+spawn failure", {
|
||||
sessionId,
|
||||
})
|
||||
this.enqueueDeferredSession(sessionId, title)
|
||||
}
|
||||
log("[tmux-session-manager] re-queueing deferred session after spawn failure", {
|
||||
sessionId,
|
||||
})
|
||||
this.enqueueDeferredSession(sessionId, title)
|
||||
|
||||
if (result.spawnedPaneId) {
|
||||
await executeAction(
|
||||
|
||||
Reference in New Issue
Block a user