kilo-loop/test/orchestrator-retry.test.mjs
2026-09-13 22:38:01 +03:00

152 lines
4.6 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { Orchestrator } from '../src/orchestrator.mjs'
const sink = { write: () => true }
function makeProject(stages) {
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-retry-'))
fs.mkdirSync(path.join(project, '.kilocode-loop'), { recursive: true })
fs.writeFileSync(path.join(project, 'goal.json'), JSON.stringify({ title: 'Retry goal', stages }))
return project
}
function makeConfig(project, overrides = {}) {
return {
project,
goal: 'goal.json',
goalText: '',
iterations: 1,
agent: 'code-design',
model: '',
variant: '',
auto: true,
sharedContext: false,
hitl: 'off',
confirm: false,
port: 7998,
host: '127.0.0.1',
runId: 'retry-run',
dryRun: true,
quiet: true,
color: false,
promptExtra: '',
maxIterationMinutes: 0,
contextWarnTokens: 150000,
verify: '',
verifyTimeoutMinutes: 30,
maxCost: 0,
maxTokens: 0,
maxStaleIterations: 0,
checkpoint: false,
guard: true,
redact: true,
review: false,
reviewAgent: '',
autoFreshTokens: 0,
notify: '',
syncGoal: true,
force: false,
retry: true,
maxRetries: 3,
retryBaseDelayMs: 1,
retryMaxDelayMs: 2,
...overrides,
}
}
function okResult(sessionID) {
return {
sessionID,
exitCode: 0,
error: null,
interrupted: false,
usage: { input: 10, output: 5, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 15 },
cost: 0.001,
texts: ['done'],
reasoning: [],
toolCalls: [],
stderr: '',
durationMs: 3,
}
}
function failResult(sessionID, message) {
return { ...okResult(sessionID), exitCode: 1, error: message, cost: 0, durationMs: 2 }
}
test('a transient connection reset is retried and the iteration recovers', async () => {
const project = makeProject([{ id: 's1', title: 'One', verify: 'true' }])
let calls = 0
const seenSessionIDs = []
const runner = async ({ sessionID }) => {
calls += 1
seenSessionIDs.push(sessionID)
if (calls === 1) return failResult('ses_partial', 'Connection reset by server')
return okResult('ses_partial')
}
const final = await new Orchestrator(makeConfig(project, { runner }), { out: sink }).run()
assert.equal(calls, 2)
assert.equal(final.iterations[0].attempts, 2)
assert.equal(final.iterations[0].status, 'completed')
// The retry continued the session created by the failed attempt.
assert.equal(seenSessionIDs[0], undefined)
assert.equal(seenSessionIDs[1], 'ses_partial')
})
test('a non-transient failure is not retried', async () => {
const project = makeProject([{ id: 's1', title: 'One' }])
let calls = 0
const runner = async () => {
calls += 1
return failResult('ses_x', 'AssertionError: expected 1 to equal 2')
}
const final = await new Orchestrator(makeConfig(project, { runner }), { out: sink }).run()
assert.equal(calls, 1)
assert.equal(final.iterations[0].status, 'failed')
})
test('--no-retry disables retries', async () => {
const project = makeProject([{ id: 's1', title: 'One' }])
let calls = 0
const runner = async () => {
calls += 1
return failResult('ses_x', 'Connection reset by server')
}
await new Orchestrator(makeConfig(project, { runner, retry: false }), { out: sink }).run()
assert.equal(calls, 1)
})
test('retries stop after maxRetries and the iteration fails', async () => {
const project = makeProject([{ id: 's1', title: 'One' }])
let calls = 0
const runner = async () => {
calls += 1
return failResult(`ses_${calls}`, 'ECONNRESET')
}
const final = await new Orchestrator(makeConfig(project, { runner, maxRetries: 2 }), { out: sink }).run()
assert.equal(calls, 3) // initial + 2 retries
assert.equal(final.iterations[0].attempts, 3)
assert.equal(final.iterations[0].status, 'failed')
})
test('a guard violation is never retried as a transient error', async () => {
const project = makeProject([{ id: 's1', title: 'One' }])
let calls = 0
const runner = async ({ onEvent }) => {
calls += 1
onEvent?.({ type: 'tool_use', part: { tool: 'bash', state: { input: { command: 'git commit -m x' } } } })
return failResult('ses_x', 'Connection reset by server')
}
// --guard-stop: the hit stops the run immediately (no transient retry); with
// the default the loop would instead continue to the next iteration.
const final = await new Orchestrator(makeConfig(project, { runner, guardStop: true }), { out: sink }).run()
assert.equal(calls, 1)
assert.equal(final.stopReason, 'guard')
})