feat(background-task): track retry attempts across sessions

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Choi Kijin / 최 기진 / チョイ キジン
2026-04-28 15:27:35 +09:00
parent cc3cca7cb7
commit a4968a3d1d
8 changed files with 1147 additions and 56 deletions
@@ -259,6 +259,57 @@ describe("tryFallbackRetry", () => {
expect(queue![0].task).toBe(args.task)
expect(args.processKey).toHaveBeenCalledWith(key)
})
test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => {
const args = createDefaultArgs({
status: "running",
sessionID: "session-attempt-1",
startedAt: new Date("2026-04-27T00:00:00.000Z"),
attempts: [
{
attemptID: "attempt-1",
attemptNumber: 1,
sessionID: "session-attempt-1",
providerID: "provider-a",
modelID: "original-model",
status: "running",
startedAt: new Date("2026-04-27T00:00:00.000Z"),
},
],
currentAttemptID: "attempt-1",
})
await tryFallbackRetry(args)
expect(args.task.attempts).toHaveLength(2)
expect(args.task.attempts?.[0]).toMatchObject({
attemptID: "attempt-1",
sessionID: "session-attempt-1",
status: "error",
error: "model overloaded",
})
expect(args.task.attempts?.[0]?.completedAt).toBeInstanceOf(Date)
const nextAttempt = args.task.attempts?.[1]
expect(nextAttempt).toBeDefined()
expect(nextAttempt?.attemptNumber).toBe(2)
expect(nextAttempt?.providerID).toBe("provider-a")
expect(nextAttempt?.modelID).toBe("fallback-model-1")
expect(nextAttempt?.status).toBe("pending")
expect(args.task.currentAttemptID).toBe(nextAttempt?.attemptID)
expect(args.task.status).toBe("pending")
expect(args.task.model).toEqual({
providerID: "provider-a",
modelID: "fallback-model-1",
variant: undefined,
})
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
const queue = args.queuesByKey.get(key)
expect(queue).toBeDefined()
expect((queue?.[0] as QueueItem & { attemptID?: string })?.attemptID).toBe(nextAttempt?.attemptID)
})
})
describe("#given non-retryable error", () => {
@@ -343,6 +394,25 @@ describe("tryFallbackRetry", () => {
})
})
describe("#given first fallback is a no-op for the current model", () => {
test("skips the no-op fallback and advances to the next distinct model", async () => {
const args = createDefaultArgs({
model: { providerID: "provider-a", modelID: "fallback-model-1" },
fallbackChain: [
{ model: "fallback-model-1", providers: ["provider-a"], variant: undefined },
{ model: "fallback-model-2", providers: ["provider-b"], variant: undefined },
],
})
const result = await tryFallbackRetry(args)
expect(result).toBe(true)
expect(args.task.model?.providerID).toBe("provider-b")
expect(args.task.model?.modelID).toBe("fallback-model-2")
expect(args.task.attemptCount).toBe(2)
})
})
describe("#given disconnected fallback providers with connected preferred provider", () => {
test("keeps fallback entry and selects connected preferred provider", async () => {
;(readProviderModelsCache as any).mockReturnValueOnce({ connected: ["provider-a"] })