diff --git a/src/automation/autonomousRunner.cancel.test.ts b/src/automation/autonomousRunner.cancel.test.ts index 3ac473b..0e7e14b 100644 --- a/src/automation/autonomousRunner.cancel.test.ts +++ b/src/automation/autonomousRunner.cancel.test.ts @@ -62,7 +62,11 @@ describe('AutonomousRunner cancellation state sync', () => { beforeEach(async () => { vi.resetModules(); tempDir = mkdtempSync(join(tmpdir(), 'openswarm-cancel-sync-')); - vi.stubEnv('OPENSWARM_TASK_STATE_FILE', join(tempDir, 'task-state.json')); + vi.stubEnv('OPENSWARM_TASK_STATE_FILE', join(tempDir, 'task-state.json')); // canonical store (taskState/store) + vi.stubEnv('OPENSWARM_RUNNER_TASK_STATE_FILE', join(tempDir, 'runner-task-state.json')); // legacy runnerState + vi.stubEnv('OPENSWARM_RUNNER_REJECTION_STATE_FILE', join(tempDir, 'runner-rejection-state.json')); + vi.stubEnv('OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE', join(tempDir, 'runner-pipeline-history.json')); + vi.stubEnv('OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE', join(tempDir, 'runner-decomposition-state.json')); ({ AutonomousRunner } = await import('./autonomousRunner.js')); runnerExecution = await import('./runnerExecution.js'); taskStateStore = await import('../taskState/store.js'); diff --git a/src/automation/autonomousRunner.infraError.test.ts b/src/automation/autonomousRunner.infraError.test.ts index bce8d84..1921a92 100644 --- a/src/automation/autonomousRunner.infraError.test.ts +++ b/src/automation/autonomousRunner.infraError.test.ts @@ -74,7 +74,11 @@ describe('AutonomousRunner infra_error handling (INT-2010)', () => { beforeEach(async () => { vi.resetModules(); tempDir = mkdtempSync(join(tmpdir(), 'openswarm-infra-')); - vi.stubEnv('OPENSWARM_TASK_STATE_FILE', join(tempDir, 'task-state.json')); + vi.stubEnv('OPENSWARM_TASK_STATE_FILE', join(tempDir, 'task-state.json')); // canonical store (taskState/store) + vi.stubEnv('OPENSWARM_RUNNER_TASK_STATE_FILE', join(tempDir, 'runner-task-state.json')); // legacy runnerState + vi.stubEnv('OPENSWARM_RUNNER_REJECTION_STATE_FILE', join(tempDir, 'runner-rejection-state.json')); + vi.stubEnv('OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE', join(tempDir, 'runner-pipeline-history.json')); + vi.stubEnv('OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE', join(tempDir, 'runner-decomposition-state.json')); ({ AutonomousRunner } = await import('./autonomousRunner.js')); runnerExecution = await import('./runnerExecution.js'); }, 30000); diff --git a/src/automation/runnerState.coverage.test.ts b/src/automation/runnerState.coverage.test.ts index 6dbe206..2e6f817 100644 --- a/src/automation/runnerState.coverage.test.ts +++ b/src/automation/runnerState.coverage.test.ts @@ -13,6 +13,12 @@ async function loadFreshModule() { tempHome = mkdtempSync(join(tmpdir(), 'openswarm-runner-state-')); vi.stubEnv('HOME', tempHome); vi.stubEnv('USERPROFILE', tempHome); + // Clear any RUNNER_* overrides inherited from the shell (e.g. CI) so every path + // derives from tempHome, independent of the ambient environment. (INT-2543) + for (const v of ['OPENSWARM_RUNNER_TASK_STATE_FILE', 'OPENSWARM_RUNNER_REJECTION_STATE_FILE', + 'OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE', 'OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE']) { + vi.stubEnv(v, ''); + } mod = await import('./runnerState.js'); } @@ -206,3 +212,57 @@ it('pickFailureDetail skips locale noSummary fallbacks (INT-2504 follow-up)', () expect(mod.pickFailureDetail(['(no summary)', 'real feedback here'])).toBe('real feedback here'); expect(mod.pickFailureDetail(['(요약 없음)'])).toBeUndefined(); }); + +describe('state-file env overrides (INT-2543)', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('honors the OPENSWARM_RUNNER_* overrides for all four state files (test isolation)', async () => { + vi.resetModules(); + const taskPath = join(tmpdir(), 'ovr-int2543-task.json'); + const rejPath = join(tmpdir(), 'ovr-int2543-rej.json'); + const histPath = join(tmpdir(), 'ovr-int2543-hist.json'); + const decompPath = join(tmpdir(), 'ovr-int2543-decomp.json'); + vi.stubEnv('OPENSWARM_RUNNER_TASK_STATE_FILE', taskPath); + vi.stubEnv('OPENSWARM_RUNNER_REJECTION_STATE_FILE', rejPath); + vi.stubEnv('OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE', histPath); + vi.stubEnv('OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE', decompPath); + const m = await import('./runnerState.js'); + expect(m.TASK_STATE_FILE).toBe(taskPath); + expect(m.REJECTION_STATE_FILE).toBe(rejPath); + expect(m.PIPELINE_HISTORY_FILE).toBe(histPath); + expect(m.DECOMPOSITION_STATE_FILE).toBe(decompPath); + }); + + it('appendPipelineHistory writes ONLY to the overridden pipeline-history file', async () => { + vi.resetModules(); + const histPath = join(tmpdir(), `ovr-int2543-hist-${process.pid}-write.json`); + if (existsSync(histPath)) rmSync(histPath); + vi.stubEnv('OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE', histPath); + vi.stubEnv('HOME', mkdtempSync(join(tmpdir(), 'oh-'))); // guard: any leak lands here, not real ~/.claude + const m = await import('./runnerState.js'); + m.appendPipelineHistory({ taskId: 't1', issueId: 'INT-X', title: 'x', finalStatus: 'approved', success: true, durationMs: 1, at: '2026-01-01T00:00:00Z' } as any); + expect(existsSync(histPath)).toBe(true); // the override path got the write + expect(JSON.parse(readFileSync(histPath, 'utf8')).length).toBe(1); + }); + + it('does NOT reuse the canonical store env var (would clobber taskState/store schema)', async () => { + vi.resetModules(); + // Setting ONLY the canonical store's var must not move the legacy runner path — + // sharing it would let one store overwrite the other's differently-schema'd file. + vi.stubEnv('OPENSWARM_TASK_STATE_FILE', join(tmpdir(), 'canonical-store.json')); + vi.stubEnv('OPENSWARM_RUNNER_TASK_STATE_FILE', ''); // independent of ambient shell env + vi.stubEnv('HOME', '/tmp/fake-home-int2543'); + vi.stubEnv('USERPROFILE', '/tmp/fake-home-int2543'); + const m = await import('./runnerState.js'); + expect(m.TASK_STATE_FILE).toBe('/tmp/fake-home-int2543/.claude/openswarm-task-state.json'); + }); + + it('falls back to the ~/.claude default when the override is unset (backward compatible)', async () => { + vi.resetModules(); + vi.stubEnv('OPENSWARM_RUNNER_TASK_STATE_FILE', ''); // empty → falsy → fallback + vi.stubEnv('HOME', '/tmp/fake-home-int2543'); + vi.stubEnv('USERPROFILE', '/tmp/fake-home-int2543'); + const m = await import('./runnerState.js'); + expect(m.TASK_STATE_FILE).toBe('/tmp/fake-home-int2543/.claude/openswarm-task-state.json'); + }); +}); diff --git a/src/automation/runnerState.ts b/src/automation/runnerState.ts index b174908..3c73d49 100644 --- a/src/automation/runnerState.ts +++ b/src/automation/runnerState.ts @@ -17,10 +17,19 @@ export function isPathEnabled(resolvedPath: string, enabledProjects: Set return false; } -export const TASK_STATE_FILE = join(homedir(), '.claude', 'openswarm-task-state.json'); -export const PIPELINE_HISTORY_FILE = join(homedir(), '.claude', 'openswarm-pipeline-history.json'); -export const REJECTION_STATE_FILE = join(homedir(), '.claude', 'openswarm-rejection-state.json'); -export const DECOMPOSITION_STATE_FILE = join(homedir(), '.claude', 'openswarm-decomposition-state.json'); +// State-file paths honor env overrides so tests (and alternate deployments) can +// redirect them off the real ~/.claude state — an unset override keeps the exact +// legacy path (backward compatible). Without this, every runner integration test +// wrote the LIVE daemon's state files (observed: ISSUE-1 accrued 184 phantom failures), +// which also made those tests flaky. Read at import; tests stub the env then re-import. +// NOTE: these use OPENSWARM_RUNNER_* names, deliberately DISTINCT from the canonical +// task store's OPENSWARM_TASK_STATE_FILE (src/taskState/store.ts) — this legacy runner +// state has a different schema ({completed,failed,retryTimes,…}), so sharing the env +// var would let one store silently overwrite the other's file. (INT-2543) +export const TASK_STATE_FILE = process.env.OPENSWARM_RUNNER_TASK_STATE_FILE || join(homedir(), '.claude', 'openswarm-task-state.json'); +export const PIPELINE_HISTORY_FILE = process.env.OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE || join(homedir(), '.claude', 'openswarm-pipeline-history.json'); +export const REJECTION_STATE_FILE = process.env.OPENSWARM_RUNNER_REJECTION_STATE_FILE || join(homedir(), '.claude', 'openswarm-rejection-state.json'); +export const DECOMPOSITION_STATE_FILE = process.env.OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE || join(homedir(), '.claude', 'openswarm-decomposition-state.json'); export const DAILY_PACE_FILE = join(homedir(), '.openswarm', 'daily-pace.json'); export const PROJECT_SELECTION_FILE = join(homedir(), '.openswarm', 'project-selection.json'); const MAX_PIPELINE_HISTORY = 100;