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') })