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:
@@ -143,10 +143,6 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
} catch (retryError) {
|
||||
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
||||
} finally {
|
||||
const state = sessionStates.get(sessionID)
|
||||
if (state?.pendingFallbackModel === newModel) {
|
||||
state.pendingFallbackModel = undefined
|
||||
}
|
||||
sessionRetryInFlight.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
helpers.clearSessionFallbackTimeout(sessionID)
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID)) {
|
||||
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||
await helpers.abortSessionRequest(sessionID, "session.stop")
|
||||
}
|
||||
|
||||
@@ -92,6 +92,15 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] session.error skipped — retry in flight`, {
|
||||
sessionID,
|
||||
retryInFlight: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionAwaitingFallbackResult.delete(sessionID)
|
||||
helpers.clearSessionFallbackTimeout(sessionID)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createRuntimeFallbackHook, type RuntimeFallbackHook } from "./index"
|
||||
import { createRuntimeFallbackHook } from "./index"
|
||||
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
import * as sharedModule from "../../shared"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
@@ -2083,4 +2083,213 @@ describe("runtime-fallback", () => {
|
||||
expect(maxLog).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("race condition guards", () => {
|
||||
test("session.error is skipped while retry request is in flight", async () => {
|
||||
const never = new Promise<never>(() => {})
|
||||
|
||||
//#given
|
||||
const hook = createRuntimeFallbackHook(
|
||||
createMockPluginInput({
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
|
||||
}),
|
||||
promptAsync: async () => never,
|
||||
},
|
||||
}),
|
||||
{
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: {
|
||||
categories: {
|
||||
test: {
|
||||
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
const sessionID = "test-race-retry-in-flight"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#when - first error starts retry (promptAsync hangs, keeping retryInFlight set)
|
||||
const firstErrorPromise = hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
//#when - second error fires while first retry is in flight
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Second rate limit" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped"))
|
||||
expect(skipLog).toBeDefined()
|
||||
expect(skipLog?.data).toMatchObject({ retryInFlight: true })
|
||||
|
||||
const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
|
||||
expect(fallbackLogs).toHaveLength(1)
|
||||
|
||||
void firstErrorPromise
|
||||
})
|
||||
|
||||
test("consecutive session.errors advance chain normally when retry completes between them", async () => {
|
||||
//#given
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: {
|
||||
categories: {
|
||||
test: {
|
||||
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const sessionID = "test-race-chain-advance"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#when - two errors fire sequentially (retry completes immediately between them)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit again" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#then - both should advance the chain (no skip)
|
||||
const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
|
||||
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("session.stop aborts when sessionAwaitingFallbackResult is set", async () => {
|
||||
const abortCalls: Array<{ path?: { id?: string } }> = []
|
||||
|
||||
//#given
|
||||
const hook = createRuntimeFallbackHook(
|
||||
createMockPluginInput({
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
|
||||
}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async (args: unknown) => {
|
||||
abortCalls.push(args as { path?: { id?: string } })
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: {
|
||||
categories: {
|
||||
test: {
|
||||
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
const sessionID = "test-race-stop-awaiting"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.stop",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("pendingFallbackModel advances chain on subsequent error even when persisted", async () => {
|
||||
//#given
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: {
|
||||
categories: {
|
||||
test: {
|
||||
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const sessionID = "test-race-pending-persists"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
|
||||
},
|
||||
})
|
||||
|
||||
const autoRetryLog = logCalls.find((call) => call.msg.includes("No user message found for auto-retry"))
|
||||
expect(autoRetryLog).toBeDefined()
|
||||
|
||||
//#when - second error fires after retry completed (retryInFlight cleared)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit again" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#then - chain advances normally (not skipped), consistent with consecutive errors test
|
||||
const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
|
||||
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user