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:
YeonGyu-Kim
2026-02-21 05:59:30 +09:00
parent 546cefd8f8
commit 8623f58a38
11 changed files with 601 additions and 168 deletions
-4
View File
@@ -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)
}
}
+10 -1
View File
@@ -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)
+210 -1
View File
@@ -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)
})
})
})