150 lines
4.4 KiB
JavaScript
150 lines
4.4 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', 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')
|
|
}
|
|
const final = await new Orchestrator(makeConfig(project, { runner }), { out: sink }).run()
|
|
assert.equal(calls, 1)
|
|
assert.equal(final.stopReason, 'guard')
|
|
})
|