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:
parent
546cefd8f8
commit
8623f58a38
@ -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 { TmuxConfig } from '../../config/schema'
|
||||||
import type { WindowState, PaneAction } from './types'
|
import type { WindowState, PaneAction } from './types'
|
||||||
import type { ActionResult, ExecuteContext } from './action-executor'
|
import type { ActionResult, ExecuteContext } from './action-executor'
|
||||||
import type { TmuxUtilDeps } from './manager'
|
import type { TmuxUtilDeps } from './manager'
|
||||||
|
import * as sharedModule from '../../shared'
|
||||||
|
|
||||||
type ExecuteActionsResult = {
|
type ExecuteActionsResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
@ -656,6 +657,135 @@ describe('TmuxSessionManager', () => {
|
|||||||
expect((manager as any).deferredQueue).toEqual([])
|
expect((manager as any).deferredQueue).toEqual([])
|
||||||
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
|
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', () => {
|
describe('onSessionDeleted', () => {
|
||||||
|
|||||||
@ -345,7 +345,8 @@ export class TmuxSessionManager {
|
|||||||
try {
|
try {
|
||||||
const state = await queryWindowState(sourcePaneId)
|
const state = await queryWindowState(sourcePaneId)
|
||||||
if (!state) {
|
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
|
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) {
|
if (result.success && result.spawnedPaneId) {
|
||||||
const sessionReady = await this.waitForSessionReady(sessionId)
|
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||||
|
|
||||||
@ -445,12 +442,10 @@ export class TmuxSessionManager {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (closeActionSucceeded) {
|
log("[tmux-session-manager] re-queueing deferred session after spawn failure", {
|
||||||
log("[tmux-session-manager] re-queueing deferred session after close+spawn failure", {
|
sessionId,
|
||||||
sessionId,
|
})
|
||||||
})
|
this.enqueueDeferredSession(sessionId, title)
|
||||||
this.enqueueDeferredSession(sessionId, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.spawnedPaneId) {
|
if (result.spawnedPaneId) {
|
||||||
await executeAction(
|
await executeAction(
|
||||||
|
|||||||
@ -143,10 +143,6 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
||||||
} finally {
|
} finally {
|
||||||
const state = sessionStates.get(sessionID)
|
|
||||||
if (state?.pendingFallbackModel === newModel) {
|
|
||||||
state.pendingFallbackModel = undefined
|
|
||||||
}
|
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
|
|
||||||
helpers.clearSessionFallbackTimeout(sessionID)
|
helpers.clearSessionFallbackTimeout(sessionID)
|
||||||
|
|
||||||
if (sessionRetryInFlight.has(sessionID)) {
|
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||||
await helpers.abortSessionRequest(sessionID, "session.stop")
|
await helpers.abortSessionRequest(sessionID, "session.stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +92,15 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
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)
|
sessionAwaitingFallbackResult.delete(sessionID)
|
||||||
helpers.clearSessionFallbackTimeout(sessionID)
|
helpers.clearSessionFallbackTimeout(sessionID)
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
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 type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
|
||||||
import * as sharedModule from "../../shared"
|
import * as sharedModule from "../../shared"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
@ -2083,4 +2083,213 @@ describe("runtime-fallback", () => {
|
|||||||
expect(maxLog).toBeDefined()
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { resetMessageCursor } from "../shared"
|
|||||||
import { lspManager } from "../tools"
|
import { lspManager } from "../tools"
|
||||||
import { shouldRetryError } from "../shared/model-error-classifier"
|
import { shouldRetryError } from "../shared/model-error-classifier"
|
||||||
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
|
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||||
|
import { log } from "../shared/logger"
|
||||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
|
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
|
||||||
|
|
||||||
import type { CreatedHooks } from "../create-hooks"
|
import type { CreatedHooks } from "../create-hooks"
|
||||||
@ -250,57 +251,61 @@ export function createEventHandler(args: {
|
|||||||
// Model fallback: in practice, API/model failures often surface as assistant message errors.
|
// Model fallback: in practice, API/model failures often surface as assistant message errors.
|
||||||
// session.error events are not guaranteed for all providers, so we also observe message.updated.
|
// session.error events are not guaranteed for all providers, so we also observe message.updated.
|
||||||
if (sessionID && role === "assistant") {
|
if (sessionID && role === "assistant") {
|
||||||
const assistantMessageID = info?.id as string | undefined
|
try {
|
||||||
const assistantError = info?.error
|
const assistantMessageID = info?.id as string | undefined
|
||||||
if (assistantMessageID && assistantError) {
|
const assistantError = info?.error
|
||||||
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
|
if (assistantMessageID && assistantError) {
|
||||||
if (lastHandled === assistantMessageID) {
|
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
|
||||||
return
|
if (lastHandled === assistantMessageID) {
|
||||||
}
|
return
|
||||||
|
|
||||||
const errorName = extractErrorName(assistantError)
|
|
||||||
const errorMessage = extractErrorMessage(assistantError)
|
|
||||||
const errorInfo = { name: errorName, message: errorMessage }
|
|
||||||
|
|
||||||
if (shouldRetryError(errorInfo)) {
|
|
||||||
// Prefer the agent/model/provider from the assistant message payload.
|
|
||||||
let agentName = agent ?? getSessionAgent(sessionID)
|
|
||||||
if (!agentName && sessionID === getMainSessionID()) {
|
|
||||||
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
|
||||||
agentName = "sisyphus"
|
|
||||||
} else if (errorMessage.includes("gpt-5")) {
|
|
||||||
agentName = "hephaestus"
|
|
||||||
} else {
|
|
||||||
agentName = "sisyphus"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentName) {
|
const errorName = extractErrorName(assistantError)
|
||||||
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
|
const errorMessage = extractErrorMessage(assistantError)
|
||||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
|
const errorInfo = { name: errorName, message: errorMessage }
|
||||||
const currentModel = normalizeFallbackModelID(rawModel)
|
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(
|
if (shouldRetryError(errorInfo)) {
|
||||||
sessionID,
|
// Prefer the agent/model/provider from the assistant message payload.
|
||||||
agentName,
|
let agentName = agent ?? getSessionAgent(sessionID)
|
||||||
currentProvider,
|
if (!agentName && sessionID === getMainSessionID()) {
|
||||||
currentModel,
|
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
||||||
)
|
agentName = "sisyphus"
|
||||||
|
} else if (errorMessage.includes("gpt-5")) {
|
||||||
|
agentName = "hephaestus"
|
||||||
|
} else {
|
||||||
|
agentName = "sisyphus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
if (agentName) {
|
||||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
|
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
|
||||||
|
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
|
||||||
|
const currentModel = normalizeFallbackModelID(rawModel)
|
||||||
|
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
const setFallback = setPendingModelFallback(
|
||||||
await ctx.client.session
|
sessionID,
|
||||||
.prompt({
|
agentName,
|
||||||
path: { id: sessionID },
|
currentProvider,
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
currentModel,
|
||||||
query: { directory: ctx.directory },
|
)
|
||||||
})
|
|
||||||
.catch(() => {})
|
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||||
|
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
|
||||||
|
|
||||||
|
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
|
await ctx.client.session
|
||||||
|
.prompt({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("[event] model-fallback error in message.updated:", { sessionID, error: err })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -312,31 +317,111 @@ export function createEventHandler(args: {
|
|||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
if (sessionID && status?.type === "retry") {
|
if (sessionID && status?.type === "retry") {
|
||||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
try {
|
||||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
|
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||||
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
|
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
|
||||||
return
|
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
|
||||||
}
|
return
|
||||||
lastHandledRetryStatusKey.set(sessionID, retryKey)
|
}
|
||||||
|
lastHandledRetryStatusKey.set(sessionID, retryKey)
|
||||||
|
|
||||||
const errorInfo = { name: undefined, message: retryMessage }
|
const errorInfo = { name: undefined as string | undefined, message: retryMessage }
|
||||||
if (shouldRetryError(errorInfo)) {
|
if (shouldRetryError(errorInfo)) {
|
||||||
|
let agentName = getSessionAgent(sessionID)
|
||||||
|
if (!agentName && sessionID === getMainSessionID()) {
|
||||||
|
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
|
||||||
|
agentName = "sisyphus"
|
||||||
|
} else if (retryMessage.includes("gpt-5")) {
|
||||||
|
agentName = "hephaestus"
|
||||||
|
} else {
|
||||||
|
agentName = "sisyphus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentName) {
|
||||||
|
const parsed = extractProviderModelFromErrorMessage(retryMessage)
|
||||||
|
const lastKnown = lastKnownModelBySession.get(sessionID)
|
||||||
|
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
|
||||||
|
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
|
||||||
|
currentModel = normalizeFallbackModelID(currentModel)
|
||||||
|
|
||||||
|
const setFallback = setPendingModelFallback(
|
||||||
|
sessionID,
|
||||||
|
agentName,
|
||||||
|
currentProvider,
|
||||||
|
currentModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||||
|
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
|
await ctx.client.session
|
||||||
|
.prompt({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("[event] model-fallback error in session.status:", { sessionID, error: err })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
try {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
const error = props?.error
|
||||||
|
|
||||||
|
const errorName = extractErrorName(error)
|
||||||
|
const errorMessage = extractErrorMessage(error)
|
||||||
|
const errorInfo = { name: errorName, message: errorMessage }
|
||||||
|
|
||||||
|
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
|
||||||
|
if (hooks.sessionRecovery?.isRecoverableError(error)) {
|
||||||
|
const messageInfo = {
|
||||||
|
id: props?.messageID as string | undefined,
|
||||||
|
role: "assistant" as const,
|
||||||
|
sessionID,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
|
||||||
|
|
||||||
|
if (
|
||||||
|
recovered &&
|
||||||
|
sessionID &&
|
||||||
|
sessionID === getMainSessionID() &&
|
||||||
|
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||||
|
) {
|
||||||
|
await ctx.client.session
|
||||||
|
.prompt({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
|
query: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
||||||
|
else if (sessionID && shouldRetryError(errorInfo)) {
|
||||||
let agentName = getSessionAgent(sessionID)
|
let agentName = getSessionAgent(sessionID)
|
||||||
|
|
||||||
if (!agentName && sessionID === getMainSessionID()) {
|
if (!agentName && sessionID === getMainSessionID()) {
|
||||||
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
|
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
||||||
agentName = "sisyphus"
|
agentName = "sisyphus"
|
||||||
} else if (retryMessage.includes("gpt-5")) {
|
} else if (errorMessage.includes("gpt-5")) {
|
||||||
agentName = "hephaestus"
|
agentName = "hephaestus"
|
||||||
} else {
|
} else {
|
||||||
agentName = "sisyphus"
|
agentName = "sisyphus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentName) {
|
if (agentName) {
|
||||||
const parsed = extractProviderModelFromErrorMessage(retryMessage)
|
const parsed = extractProviderModelFromErrorMessage(errorMessage)
|
||||||
const lastKnown = lastKnownModelBySession.get(sessionID)
|
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
|
||||||
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
|
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
|
||||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
|
|
||||||
currentModel = normalizeFallbackModelID(currentModel)
|
currentModel = normalizeFallbackModelID(currentModel)
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(
|
const setFallback = setPendingModelFallback(
|
||||||
@ -345,9 +430,10 @@ export function createEventHandler(args: {
|
|||||||
currentProvider,
|
currentProvider,
|
||||||
currentModel,
|
currentModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
|
|
||||||
await ctx.client.session
|
await ctx.client.session
|
||||||
.prompt({
|
.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@ -358,87 +444,9 @@ export function createEventHandler(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (err) {
|
||||||
}
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
log("[event] model-fallback error in session.error:", { sessionID, error: err })
|
||||||
if (event.type === "session.error") {
|
|
||||||
const sessionID = props?.sessionID as string | undefined
|
|
||||||
const error = props?.error
|
|
||||||
|
|
||||||
const errorName = extractErrorName(error)
|
|
||||||
const errorMessage = extractErrorMessage(error)
|
|
||||||
const errorInfo = { name: errorName, message: errorMessage }
|
|
||||||
|
|
||||||
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
|
|
||||||
if (hooks.sessionRecovery?.isRecoverableError(error)) {
|
|
||||||
const messageInfo = {
|
|
||||||
id: props?.messageID as string | undefined,
|
|
||||||
role: "assistant" as const,
|
|
||||||
sessionID,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
|
|
||||||
|
|
||||||
if (
|
|
||||||
recovered &&
|
|
||||||
sessionID &&
|
|
||||||
sessionID === getMainSessionID() &&
|
|
||||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
|
||||||
) {
|
|
||||||
await ctx.client.session
|
|
||||||
.prompt({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
|
||||||
else if (sessionID && shouldRetryError(errorInfo)) {
|
|
||||||
// Get the current agent for this session, or default to "sisyphus" for main sessions
|
|
||||||
let agentName = getSessionAgent(sessionID)
|
|
||||||
|
|
||||||
// For main sessions, if no agent is set, try to infer from the error or default to sisyphus
|
|
||||||
if (!agentName && sessionID === getMainSessionID()) {
|
|
||||||
// Try to infer agent from model in error message
|
|
||||||
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
|
||||||
agentName = "sisyphus"
|
|
||||||
} else if (errorMessage.includes("gpt-5")) {
|
|
||||||
agentName = "hephaestus"
|
|
||||||
} else {
|
|
||||||
// Default to sisyphus for main session errors
|
|
||||||
agentName = "sisyphus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentName) {
|
|
||||||
const parsed = extractProviderModelFromErrorMessage(errorMessage)
|
|
||||||
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
|
|
||||||
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
|
|
||||||
currentModel = normalizeFallbackModelID(currentModel)
|
|
||||||
|
|
||||||
// Try to set pending model fallback
|
|
||||||
const setFallback = setPendingModelFallback(
|
|
||||||
sessionID,
|
|
||||||
agentName,
|
|
||||||
currentProvider,
|
|
||||||
currentModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
|
||||||
// Abort the current session and prompt with "continue" to trigger the fallback
|
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
|
||||||
|
|
||||||
await ctx.client.session
|
|
||||||
.prompt({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -177,4 +177,26 @@ describe("scheduleDeferredModelOverride", () => {
|
|||||||
expect.stringContaining("DB not found"),
|
expect.stringContaining("DB not found"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should not crash when DB file exists but is corrupted", async () => {
|
||||||
|
//#given
|
||||||
|
const { chmodSync, writeFileSync } = await import("node:fs")
|
||||||
|
const corruptedDbPath = join(tempDir, "opencode", "opencode.db")
|
||||||
|
writeFileSync(corruptedDbPath, "this is not a valid sqlite database file")
|
||||||
|
chmodSync(corruptedDbPath, 0o000)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const { scheduleDeferredModelOverride } = await import("./ultrawork-db-model-override")
|
||||||
|
scheduleDeferredModelOverride(
|
||||||
|
"msg_corrupt",
|
||||||
|
{ providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
)
|
||||||
|
await flushMicrotasks(5)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to open DB"),
|
||||||
|
expect.objectContaining({ messageId: "msg_corrupt" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -120,7 +120,17 @@ export function scheduleDeferredModelOverride(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Database(dbPath)
|
let db: InstanceType<typeof Database>
|
||||||
|
try {
|
||||||
|
db = new Database(dbPath)
|
||||||
|
} catch (error) {
|
||||||
|
log("[ultrawork-db-override] Failed to open DB, skipping deferred override", {
|
||||||
|
messageId,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
retryViaMicrotask(db, messageId, targetModel, variant, 0)
|
retryViaMicrotask(db, messageId, targetModel, variant, 0)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
|
|||||||
|
|
||||||
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
||||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/
|
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/
|
||||||
|
export const HASHLINE_LEGACY_REF_PATTERN = /^([0-9]+):([0-9a-fA-F]{2,})$/
|
||||||
|
|||||||
@ -52,3 +52,46 @@ describe("validateLineRef", () => {
|
|||||||
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/current hash/)
|
expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/current hash/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("legacy LINE:HEX backward compatibility", () => {
|
||||||
|
it("parses legacy LINE:HEX ref", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "42:ab"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseLineRef(ref)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual({ line: 42, hash: "ab" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses legacy LINE:HEX ref with uppercase hex", () => {
|
||||||
|
//#given
|
||||||
|
const ref = "10:FF"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseLineRef(ref)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual({ line: 10, hash: "FF" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("legacy ref fails validation with hash mismatch, not parse error", () => {
|
||||||
|
//#given
|
||||||
|
const lines = ["function hello() {"]
|
||||||
|
|
||||||
|
//#when / #then
|
||||||
|
expect(() => validateLineRef(lines, "1:ab")).toThrow(/Hash mismatch|current hash/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("extracts legacy ref from content with markers", () => {
|
||||||
|
//#given
|
||||||
|
const ref = ">>> 42:ab|const x = 1"
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = parseLineRef(ref)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toEqual({ line: 42, hash: "ab" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -1,18 +1,21 @@
|
|||||||
import { computeLineHash } from "./hash-computation"
|
import { computeLineHash } from "./hash-computation"
|
||||||
import { HASHLINE_REF_PATTERN } from "./constants"
|
import { HASHLINE_REF_PATTERN, HASHLINE_LEGACY_REF_PATTERN } from "./constants"
|
||||||
|
|
||||||
export interface LineRef {
|
export interface LineRef {
|
||||||
line: number
|
line: number
|
||||||
hash: string
|
hash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2}|[0-9]+:[0-9a-fA-F]{2,})/
|
||||||
|
|
||||||
function normalizeLineRef(ref: string): string {
|
function normalizeLineRef(ref: string): string {
|
||||||
const trimmed = ref.trim()
|
const trimmed = ref.trim()
|
||||||
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
if (HASHLINE_LEGACY_REF_PATTERN.test(trimmed)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
||||||
if (extracted) {
|
if (extracted) {
|
||||||
@ -25,15 +28,22 @@ function normalizeLineRef(ref: string): string {
|
|||||||
export function parseLineRef(ref: string): LineRef {
|
export function parseLineRef(ref: string): LineRef {
|
||||||
const normalized = normalizeLineRef(ref)
|
const normalized = normalizeLineRef(ref)
|
||||||
const match = normalized.match(HASHLINE_REF_PATTERN)
|
const match = normalized.match(HASHLINE_REF_PATTERN)
|
||||||
if (!match) {
|
if (match) {
|
||||||
throw new Error(
|
return {
|
||||||
`Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")`
|
line: Number.parseInt(match[1], 10),
|
||||||
)
|
hash: match[2],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
const legacyMatch = normalized.match(HASHLINE_LEGACY_REF_PATTERN)
|
||||||
line: Number.parseInt(match[1], 10),
|
if (legacyMatch) {
|
||||||
hash: match[2],
|
return {
|
||||||
|
line: Number.parseInt(legacyMatch[1], 10),
|
||||||
|
hash: legacyMatch[2],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateLineRef(lines: string[], ref: string): void {
|
export function validateLineRef(lines: string[], ref: string): void {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user