diff --git a/.gitignore b/.gitignore index 5143cc0..6996951 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,8 @@ gitleaks-report.json .stryker-tmp/ survivors.txt +# Pipeline state (ephemeral per-session) +.workflow/ + # kata runtime data (local project state) .kata/ diff --git a/src/acceptance/setup.ts b/src/acceptance/setup.ts index 6d92f90..df700bc 100644 --- a/src/acceptance/setup.ts +++ b/src/acceptance/setup.ts @@ -1,4 +1,5 @@ import '@domain/rules/cycle-rules.steps.js'; import '@domain/services/cycle-manager.steps.js'; +import '@features/cycle-management/bridge-run-syncer.steps.js'; import '@features/cycle-management/cycle-activation-name-resolver.steps.js'; import '@infra/execution/session-bridge.steps.js'; diff --git a/src/features/cycle-management/bridge-run-syncer.feature b/src/features/cycle-management/bridge-run-syncer.feature new file mode 100644 index 0000000..dd68d42 --- /dev/null +++ b/src/features/cycle-management/bridge-run-syncer.feature @@ -0,0 +1,126 @@ +Feature: Bet outcome reconciliation during cooldown + During cooldown, bet outcomes are reconciled with bridge-run metadata + so that the cycle always reflects the true completion state of each bet, + even when the caller provides no explicit outcomes. + + Background: + Given a cycle with bets that have been launched as runs + + # ── Outcome reconciliation ────────────────────────────────── + + Scenario: pending bets are auto-resolved from completed bridge-run metadata + Given bet "scope-parser" is pending with a bridge-run that completed + When outcomes are reconciled for the cycle + Then bet "scope-parser" outcome is recorded as "complete" + + Scenario: pending bets with a failed bridge-run are marked partial + Given bet "scope-parser" is pending with a bridge-run that failed + When outcomes are reconciled for the cycle + Then bet "scope-parser" outcome is recorded as "partial" + + Scenario: multiple bets in different states are reconciled independently + Given bet "scope-parser" is pending with a bridge-run that completed + And bet "ui-refactor" is pending with a bridge-run that failed + And bet "docs-update" already has outcome "complete" + When outcomes are reconciled for the cycle + Then bet "scope-parser" outcome is recorded as "complete" + And bet "ui-refactor" outcome is recorded as "partial" + And bet "docs-update" is not re-synced + + Scenario: bets already resolved are not re-synced + Given bet "scope-parser" already has outcome "complete" + And a bridge-run exists for bet "scope-parser" + When outcomes are reconciled for the cycle + Then no outcomes are recorded + + Scenario: bets without a run ID are skipped during reconciliation + Given bet "scope-parser" is pending but has no run ID + When outcomes are reconciled for the cycle + Then no outcomes are recorded + + Scenario: missing bridge-run file does not abort reconciliation + Given bet "scope-parser" is pending with a run ID + But no bridge-run file exists for that run + When outcomes are reconciled for the cycle + Then no outcomes are recorded + And cooldown continues normally + + Scenario: corrupt bridge-run file is silently skipped + Given bet "scope-parser" is pending with a run ID + And the bridge-run file for that run contains invalid JSON + When outcomes are reconciled for the cycle + Then no outcomes are recorded + And cooldown continues normally + + # ── Incomplete run detection ──────────────────────────────── + + Scenario: in-progress bridge-run is reported as incomplete + Given bet "scope-parser" has a bridge-run with status "in-progress" + When cooldown checks for incomplete runs + Then bet "scope-parser" run is reported as incomplete with status "running" + + Scenario: pending run file is reported as incomplete + Given bet "scope-parser" has a run file with status "pending" + And no bridge-run file exists for that run + When cooldown checks for incomplete runs + Then bet "scope-parser" run is reported as incomplete with status "pending" + + Scenario: failed bridge-run is not reported as incomplete + Given bet "scope-parser" has a bridge-run with status "failed" + When cooldown checks for incomplete runs + Then no incomplete runs are reported + + Scenario: completed bridge-run is not reported as incomplete + Given bet "scope-parser" has a bridge-run with status "complete" + When cooldown checks for incomplete runs + Then no incomplete runs are reported + + Scenario: bridge-run status takes precedence over run file status + Given bet "scope-parser" has a bridge-run with status "complete" + And the same bet has a run file with status "running" + When cooldown checks for incomplete runs + Then no incomplete runs are reported + + Scenario: bets without a run ID are excluded from incomplete check + Given bet "scope-parser" has no run ID + When cooldown checks for incomplete runs + Then no incomplete runs are reported + + # ── Reconciliation safety ─────────────────────────────────── + + Scenario: reconciliation is safely skipped when run metadata is unavailable + Given no run metadata directories are configured + When outcomes are reconciled for the cycle + And cooldown checks for incomplete runs + Then no outcomes are recorded + And no incomplete runs are reported + + # ── Bridge-run ID lookup ──────────────────────────────────── + + Scenario: bridge-run IDs are loaded by scanning metadata files + Given bridge-run metadata files exist linking bets to runs for this cycle + When bridge-run IDs are loaded by bet + Then a mapping from bet ID to run ID is returned + + Scenario: bridge-run files for other cycles are excluded from lookup + Given bridge-run metadata files exist for a different cycle + When bridge-run IDs are loaded by bet + Then the mapping is empty + + Scenario: unreadable bridge-runs directory returns empty map + Given the bridge-runs directory does not exist on disk + When bridge-run IDs are loaded by bet + Then the mapping is empty + + # ── Outcome recording ─────────────────────────────────────── + + Scenario: outcomes are applied to the cycle via the cycle manager + Given bet outcomes to record for the cycle + When bet outcomes are recorded + Then the cycle manager receives the outcome updates + + Scenario: unmatched bet IDs produce a warning but do not fail + Given bet outcomes referencing a bet ID that does not exist in the cycle + When bet outcomes are recorded + Then a warning is logged for the unmatched bet IDs + And cooldown continues normally diff --git a/src/features/cycle-management/bridge-run-syncer.steps.ts b/src/features/cycle-management/bridge-run-syncer.steps.ts new file mode 100644 index 0000000..1ed617c --- /dev/null +++ b/src/features/cycle-management/bridge-run-syncer.steps.ts @@ -0,0 +1,471 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { Given, Then, When, QuickPickleWorld } from 'quickpickle'; +import { expect, vi } from 'vitest'; +import type { BetOutcome } from '@domain/types/bet.js'; +import type { Cycle } from '@domain/types/cycle.js'; +import { logger } from '@shared/lib/logger.js'; +import type { BetOutcomeRecord, IncompleteRunInfo } from './cooldown-session.js'; +import { BridgeRunSyncer, type BridgeRunSyncerDeps } from './bridge-run-syncer.js'; + +// ── World ──────────────────────────────────────────────────── + +interface BridgeRunSyncerWorld extends QuickPickleWorld { + tmpDir: string; + bridgeRunsDir?: string; + runsDir?: string; + cycle: Cycle; + syncer?: BridgeRunSyncer; + syncedOutcomes?: BetOutcomeRecord[]; + incompleteRuns?: IncompleteRunInfo[]; + bridgeRunIdMap?: Map; + outcomesToRecord?: BetOutcomeRecord[]; + updateBetOutcomesSpy: ReturnType; + loggerWarnSpy: ReturnType; + lastError?: Error; +} + +// ── Helpers ────────────────────────────────────────────────── + +function makeBet(overrides: { + id?: string; + description?: string; + outcome?: BetOutcome; + runId?: string; +}): Cycle['bets'][number] { + return { + id: overrides.id ?? randomUUID(), + description: overrides.description ?? 'Test bet', + appetite: 30, + outcome: overrides.outcome ?? 'pending', + issueRefs: [], + ...(overrides.runId ? { runId: overrides.runId } : {}), + }; +} + +function makeCycle(bets: Cycle['bets'][number][]): Cycle { + return { + id: randomUUID(), + budget: {}, + bets, + pipelineMappings: [], + state: 'active', + cooldownReserve: 10, + createdAt: '2026-03-22T10:00:00.000Z', + updatedAt: '2026-03-22T10:00:00.000Z', + }; +} + +function buildSyncer(world: BridgeRunSyncerWorld): BridgeRunSyncer { + const deps: BridgeRunSyncerDeps = { + bridgeRunsDir: world.bridgeRunsDir, + runsDir: world.runsDir, + cycleManager: { + get: () => world.cycle, + updateBetOutcomes: world.updateBetOutcomesSpy, + } as unknown as BridgeRunSyncerDeps['cycleManager'], + }; + return new BridgeRunSyncer(deps); +} + +function writeBridgeRunFile( + bridgeRunsDir: string, + runId: string, + meta: Record, +): void { + writeFileSync(join(bridgeRunsDir, `${runId}.json`), JSON.stringify(meta)); +} + +function writeRunFile( + runsDir: string, + runId: string, + status: string, +): void { + const runDir = join(runsDir, runId); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, 'run.json'), + JSON.stringify({ + id: runId, + cycleId: randomUUID(), + betId: randomUUID(), + betPrompt: 'Test bet prompt', + stageSequence: ['build'], + currentStage: null, + status, + startedAt: '2026-03-22T10:00:00.000Z', + }), + ); +} + +// ── Background ─────────────────────────────────────────────── + +Given( + 'a cycle with bets that have been launched as runs', + (world: BridgeRunSyncerWorld) => { + world.tmpDir = mkdtempSync(join(tmpdir(), 'brs-')); + world.bridgeRunsDir = join(world.tmpDir, 'bridge-runs'); + world.runsDir = join(world.tmpDir, 'runs'); + mkdirSync(world.bridgeRunsDir, { recursive: true }); + mkdirSync(world.runsDir, { recursive: true }); + world.cycle = makeCycle([]); + world.updateBetOutcomesSpy = vi.fn().mockReturnValue({ unmatchedBetIds: [] }); + world.loggerWarnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + }, +); + +// ── Given: bet setup ───────────────────────────────────────── + +Given( + 'bet {string} is pending with a bridge-run that completed', + (world: BridgeRunSyncerWorld, betName: string) => { + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending', runId })); + writeBridgeRunFile(world.bridgeRunsDir!, runId, { status: 'complete' }); + }, +); + +Given( + 'bet {string} is pending with a bridge-run that failed', + (world: BridgeRunSyncerWorld, betName: string) => { + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending', runId })); + writeBridgeRunFile(world.bridgeRunsDir!, runId, { status: 'failed' }); + }, +); + +Given( + 'bet {string} already has outcome {string}', + (world: BridgeRunSyncerWorld, betName: string, outcome: string) => { + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ description: betName, outcome: outcome as BetOutcome, runId })); + }, +); + +Given( + 'a bridge-run exists for bet {string}', + (world: BridgeRunSyncerWorld, betName: string) => { + const bet = world.cycle.bets.find((b) => b.description === betName); + if (bet?.runId) { + writeBridgeRunFile(world.bridgeRunsDir!, bet.runId, { status: 'complete' }); + } + }, +); + +Given( + 'bet {string} is pending but has no run ID', + (world: BridgeRunSyncerWorld, betName: string) => { + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending' })); + }, +); + +Given( + 'bet {string} is pending with a run ID', + (world: BridgeRunSyncerWorld, betName: string) => { + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending', runId })); + }, +); + +Given( + 'no bridge-run file exists for that run', + (_world: BridgeRunSyncerWorld) => { + // No-op: the bridge-run file was never written + }, +); + +Given( + 'the bridge-run file for that run contains invalid JSON', + (world: BridgeRunSyncerWorld) => { + const lastBet = world.cycle.bets[world.cycle.bets.length - 1]!; + if (lastBet?.runId) { + writeFileSync(join(world.bridgeRunsDir!, `${lastBet.runId}.json`), '<<>>'); + } + }, +); + +Given( + 'the bridge-runs directory is not configured', + (world: BridgeRunSyncerWorld) => { + world.bridgeRunsDir = undefined; + }, +); + +Given( + 'no run metadata directories are configured', + (world: BridgeRunSyncerWorld) => { + world.bridgeRunsDir = undefined; + world.runsDir = undefined; + }, +); + +Given( + 'neither the bridge-runs directory nor the runs directory is configured', + (world: BridgeRunSyncerWorld) => { + world.bridgeRunsDir = undefined; + world.runsDir = undefined; + }, +); + +// ── Given: incomplete run detection ────────────────────────── + +Given( + 'bet {string} has a bridge-run with status {string}', + (world: BridgeRunSyncerWorld, betName: string, status: string) => { + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending', runId })); + writeBridgeRunFile(world.bridgeRunsDir!, runId, { status }); + }, +); + +Given( + 'bet {string} has a run file with status {string}', + (world: BridgeRunSyncerWorld, betName: string, status: string) => { + const existingBet = world.cycle.bets.find((b) => b.description === betName); + if (existingBet?.runId) { + writeRunFile(world.runsDir!, existingBet.runId, status); + } else { + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending', runId })); + writeRunFile(world.runsDir!, runId, status); + } + }, +); + +Given( + 'the same bet has a run file with status {string}', + (world: BridgeRunSyncerWorld, status: string) => { + const lastBet = world.cycle.bets[world.cycle.bets.length - 1]!; + if (lastBet?.runId) { + writeRunFile(world.runsDir!, lastBet.runId, status); + } + }, +); + +Given( + 'bet {string} has no run ID', + (world: BridgeRunSyncerWorld, betName: string) => { + world.cycle.bets.push(makeBet({ description: betName, outcome: 'pending' })); + }, +); + +// ── Given: bridge-run ID lookup ────────────────────────────── + +Given( + 'bridge-run metadata files exist linking bets to runs for this cycle', + (world: BridgeRunSyncerWorld) => { + const betId = randomUUID(); + const runId = randomUUID(); + world.cycle.bets.push(makeBet({ id: betId, outcome: 'pending', runId })); + writeBridgeRunFile(world.bridgeRunsDir!, runId, { + cycleId: world.cycle.id, + betId, + runId, + status: 'complete', + }); + }, +); + +Given( + 'bridge-run metadata files exist for a different cycle', + (world: BridgeRunSyncerWorld) => { + writeBridgeRunFile(world.bridgeRunsDir!, randomUUID(), { + cycleId: randomUUID(), + betId: randomUUID(), + runId: randomUUID(), + status: 'complete', + }); + }, +); + +Given( + 'the bridge-runs directory does not exist on disk', + (world: BridgeRunSyncerWorld) => { + world.bridgeRunsDir = join(world.tmpDir, 'nonexistent-bridge-runs'); + // Directory intentionally not created + }, +); + +// ── Given: outcome recording ───────────────────────────────── + +Given( + 'bet outcomes to record for the cycle', + (world: BridgeRunSyncerWorld) => { + world.outcomesToRecord = [{ betId: randomUUID(), outcome: 'complete' }]; + }, +); + +Given( + 'bet outcomes referencing a bet ID that does not exist in the cycle', + (world: BridgeRunSyncerWorld) => { + const fakeBetId = randomUUID(); + world.outcomesToRecord = [{ betId: fakeBetId, outcome: 'complete' }]; + world.updateBetOutcomesSpy.mockReturnValue({ unmatchedBetIds: [fakeBetId] }); + }, +); + +// ── When ───────────────────────────────────────────────────── + +When( + 'outcomes are reconciled for the cycle', + (world: BridgeRunSyncerWorld) => { + world.syncer = buildSyncer(world); + try { + world.syncedOutcomes = world.syncer.syncOutcomes(world.cycle.id); + } catch (err) { + world.lastError = err as Error; + } + }, +); + +When( + 'cooldown checks for incomplete runs', + (world: BridgeRunSyncerWorld) => { + world.syncer = buildSyncer(world); + try { + world.incompleteRuns = world.syncer.checkIncomplete(world.cycle.id); + } catch (err) { + world.lastError = err as Error; + } + }, +); + +When( + 'bridge-run IDs are loaded by bet', + (world: BridgeRunSyncerWorld) => { + world.syncer = buildSyncer(world); + try { + world.bridgeRunIdMap = world.syncer.loadBridgeRunIdsByBetId(world.cycle.id); + } catch (err) { + world.lastError = err as Error; + } + }, +); + +When( + 'bet outcomes are recorded', + (world: BridgeRunSyncerWorld) => { + world.syncer = buildSyncer(world); + try { + world.syncer.recordBetOutcomes(world.cycle.id, world.outcomesToRecord ?? []); + } catch (err) { + world.lastError = err as Error; + } + }, +); + +// ── Then: outcome assertions ───────────────────────────────── + +Then( + 'bet {string} outcome is recorded as {string}', + (world: BridgeRunSyncerWorld, betName: string, expectedOutcome: string) => { + expect(world.lastError).toBeUndefined(); + const bet = world.cycle.bets.find((b) => b.description === betName); + expect(bet).toBeDefined(); + const calls = world.updateBetOutcomesSpy.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const outcomes: BetOutcomeRecord[] = calls[0]![1] as BetOutcomeRecord[]; + const match = outcomes.find((o) => o.betId === bet!.id); + expect(match).toBeDefined(); + expect(match!.outcome).toBe(expectedOutcome); + }, +); + +Then( + 'bet {string} is not re-synced', + (world: BridgeRunSyncerWorld, betName: string) => { + const bet = world.cycle.bets.find((b) => b.description === betName); + expect(bet).toBeDefined(); + if (world.updateBetOutcomesSpy.mock.calls.length === 0) return; + const outcomes = world.updateBetOutcomesSpy.mock.calls[0]![1] as BetOutcomeRecord[]; + const match = outcomes.find((o) => o.betId === bet!.id); + expect(match).toBeUndefined(); + }, +); + +Then( + 'no outcomes are recorded', + (world: BridgeRunSyncerWorld) => { + expect(world.lastError).toBeUndefined(); + if (world.syncedOutcomes !== undefined) { + expect(world.syncedOutcomes).toHaveLength(0); + } + expect(world.updateBetOutcomesSpy).not.toHaveBeenCalled(); + }, +); + +// ── Then: incomplete run assertions ────────────────────────── + +Then( + 'bet {string} run is reported as incomplete with status {string}', + (world: BridgeRunSyncerWorld, betName: string, expectedStatus: string) => { + expect(world.lastError).toBeUndefined(); + const bet = world.cycle.bets.find((b) => b.description === betName); + expect(bet).toBeDefined(); + const match = world.incompleteRuns?.find((r) => r.betId === bet!.id); + expect(match).toBeDefined(); + expect(match!.status).toBe(expectedStatus); + }, +); + +Then( + 'no incomplete runs are reported', + (world: BridgeRunSyncerWorld) => { + expect(world.lastError).toBeUndefined(); + expect(world.incompleteRuns).toBeDefined(); + expect(world.incompleteRuns).toHaveLength(0); + }, +); + +// ── Then: bridge-run ID lookup assertions ──────────────────── + +Then( + 'a mapping from bet ID to run ID is returned', + (world: BridgeRunSyncerWorld) => { + expect(world.lastError).toBeUndefined(); + expect(world.bridgeRunIdMap).toBeDefined(); + expect(world.bridgeRunIdMap!.size).toBeGreaterThan(0); + }, +); + +Then( + 'the mapping is empty', + (world: BridgeRunSyncerWorld) => { + expect(world.lastError).toBeUndefined(); + expect(world.bridgeRunIdMap).toBeDefined(); + expect(world.bridgeRunIdMap!.size).toBe(0); + }, +); + +// ── Then: outcome recording assertions ─────────────────────── + +Then( + 'the cycle manager receives the outcome updates', + (world: BridgeRunSyncerWorld) => { + expect(world.lastError).toBeUndefined(); + expect(world.updateBetOutcomesSpy).toHaveBeenCalledWith( + world.cycle.id, + world.outcomesToRecord, + ); + }, +); + +Then( + 'a warning is logged for the unmatched bet IDs', + (world: BridgeRunSyncerWorld) => { + expect(world.loggerWarnSpy).toHaveBeenCalled(); + const warnMessage = world.loggerWarnSpy.mock.calls[0]![0] as string; + expect(warnMessage).toContain('nonexistent bet IDs'); + }, +); + +// ── Then: safety assertions ────────────────────────────────── + +Then( + 'cooldown continues normally', + (world: BridgeRunSyncerWorld) => { + expect(world.lastError).toBeUndefined(); + }, +); diff --git a/src/features/cycle-management/bridge-run-syncer.test.ts b/src/features/cycle-management/bridge-run-syncer.test.ts new file mode 100644 index 0000000..468ee42 --- /dev/null +++ b/src/features/cycle-management/bridge-run-syncer.test.ts @@ -0,0 +1,441 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { vi } from 'vitest'; +import type { Cycle } from '@domain/types/cycle.js'; +import { BridgeRunSyncer, type BridgeRunSyncerDeps } from './bridge-run-syncer.js'; +import type { BetOutcomeRecord } from './cooldown-session.js'; + +// ── Helpers ────────────────────────────────────────────────── + +function makeBet(overrides: Partial = {}): Cycle['bets'][number] { + return { + id: overrides.id ?? randomUUID(), + description: overrides.description ?? 'test bet', + appetite: 30, + outcome: overrides.outcome ?? 'pending', + issueRefs: [], + ...overrides, + }; +} + +function makeCycle(bets: Cycle['bets'][number][] = []): Cycle { + return { + id: randomUUID(), + budget: {}, + bets, + pipelineMappings: [], + state: 'active', + cooldownReserve: 10, + createdAt: '2026-03-22T10:00:00.000Z', + updatedAt: '2026-03-22T10:00:00.000Z', + }; +} + +function makeDeps(overrides: Partial = {}): BridgeRunSyncerDeps & { + updateBetOutcomesSpy: ReturnType; + getCycleSpy: ReturnType; +} { + const updateBetOutcomesSpy = vi.fn().mockReturnValue({ unmatchedBetIds: [] }); + const getCycleSpy = vi.fn(); + return { + bridgeRunsDir: overrides.bridgeRunsDir, + runsDir: overrides.runsDir, + cycleManager: { + get: getCycleSpy, + updateBetOutcomes: updateBetOutcomesSpy, + } as unknown as BridgeRunSyncerDeps['cycleManager'], + updateBetOutcomesSpy, + getCycleSpy, + ...overrides, + }; +} + +function writeBridgeRun(dir: string, runId: string, meta: Record): void { + writeFileSync(join(dir, `${runId}.json`), JSON.stringify(meta)); +} + +function writeValidRunFile(dir: string, runId: string, status: string): void { + const runDir = join(dir, runId); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, 'run.json'), + JSON.stringify({ + id: runId, + cycleId: randomUUID(), + betId: randomUUID(), + betPrompt: 'test', + stageSequence: ['build'], + currentStage: null, + status, + startedAt: '2026-03-22T10:00:00.000Z', + }), + ); +} + +// ── syncOutcomes ───────────────────────────────────────────── + +describe('BridgeRunSyncer', () => { + let tmpDir: string; + let bridgeRunsDir: string; + let runsDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'brs-test-')); + bridgeRunsDir = join(tmpDir, 'bridge-runs'); + runsDir = join(tmpDir, 'runs'); + mkdirSync(bridgeRunsDir, { recursive: true }); + mkdirSync(runsDir, { recursive: true }); + }); + + describe('syncOutcomes', () => { + it('returns empty array when bridgeRunsDir is undefined', () => { + const deps = makeDeps(); + const syncer = new BridgeRunSyncer(deps); + expect(syncer.syncOutcomes('any-id')).toEqual([]); + expect(deps.getCycleSpy).not.toHaveBeenCalled(); + }); + + it('syncs completed bridge-run as "complete" outcome', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'complete' }); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ betId: bet.id, outcome: 'complete' }); + expect(deps.updateBetOutcomesSpy).toHaveBeenCalledOnce(); + }); + + it('syncs failed bridge-run as "partial" outcome', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'failed' }); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(1); + expect(result[0].outcome).toBe('partial'); + }); + + it('skips bets that already have a non-pending outcome', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'complete', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'complete' }); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(0); + expect(deps.updateBetOutcomesSpy).not.toHaveBeenCalled(); + }); + + it('skips bets without a runId', () => { + const bet = makeBet({ outcome: 'pending' }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(0); + }); + + it('silently skips missing bridge-run files', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + // No bridge-run file written + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(0); + }); + + it('silently skips corrupt bridge-run files', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeFileSync(join(bridgeRunsDir, `${runId}.json`), '<<>>'); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(0); + }); + + it('skips bridge-runs with in-progress status (not terminal)', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'in-progress' }); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(0); + }); + + it('handles mixed bets: some syncable, some not', () => { + const runId1 = randomUUID(); + const runId2 = randomUUID(); + const bet1 = makeBet({ outcome: 'pending', runId: runId1 }); + const bet2 = makeBet({ outcome: 'complete', runId: runId2 }); + const bet3 = makeBet({ outcome: 'pending' }); // no runId + const cycle = makeCycle([bet1, bet2, bet3]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId1, { status: 'complete' }); + writeBridgeRun(bridgeRunsDir, runId2, { status: 'complete' }); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.syncOutcomes(cycle.id); + + expect(result).toHaveLength(1); + expect(result[0].betId).toBe(bet1.id); + }); + }); + + // ── checkIncomplete ────────────────────────────────────────── + + describe('checkIncomplete', () => { + it('returns empty array when neither dir is configured', () => { + const deps = makeDeps(); + const syncer = new BridgeRunSyncer(deps); + expect(syncer.checkIncomplete('any-id')).toEqual([]); + }); + + it('reports in-progress bridge-run as running', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'in-progress' }); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.checkIncomplete(cycle.id); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ runId, betId: bet.id, status: 'running' }); + }); + + it('reports pending run file as pending', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ runsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeValidRunFile(runsDir, runId, 'pending'); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.checkIncomplete(cycle.id); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('pending'); + }); + + it('does not report failed bridge-runs as incomplete', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'failed' }); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.checkIncomplete(cycle.id)).toHaveLength(0); + }); + + it('does not report completed bridge-runs', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'complete' }); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.checkIncomplete(cycle.id)).toHaveLength(0); + }); + + it('bridge-run status takes precedence over run file status', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir, runsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + writeBridgeRun(bridgeRunsDir, runId, { status: 'complete' }); + writeValidRunFile(runsDir, runId, 'running'); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.checkIncomplete(cycle.id)).toHaveLength(0); + }); + + it('falls back to run file when no bridge-run exists', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir, runsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + // No bridge-run file written + writeValidRunFile(runsDir, runId, 'running'); + + const syncer = new BridgeRunSyncer(deps); + const result = syncer.checkIncomplete(cycle.id); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('running'); + }); + + it('skips bets without a runId', () => { + const bet = makeBet({ outcome: 'pending' }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ bridgeRunsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.checkIncomplete(cycle.id)).toHaveLength(0); + }); + + it('silently skips unreadable run files', () => { + const runId = randomUUID(); + const bet = makeBet({ outcome: 'pending', runId }); + const cycle = makeCycle([bet]); + const deps = makeDeps({ runsDir }); + deps.getCycleSpy.mockReturnValue(cycle); + // No run file written — readRun will throw + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.checkIncomplete(cycle.id)).toHaveLength(0); + }); + }); + + // ── loadBridgeRunIdsByBetId ────────────────────────────────── + + describe('loadBridgeRunIdsByBetId', () => { + it('returns empty map when bridgeRunsDir is undefined', () => { + const deps = makeDeps(); + const syncer = new BridgeRunSyncer(deps); + expect(syncer.loadBridgeRunIdsByBetId('any-id').size).toBe(0); + }); + + it('returns empty map when directory does not exist', () => { + const deps = makeDeps({ bridgeRunsDir: join(tmpDir, 'nonexistent') }); + const syncer = new BridgeRunSyncer(deps); + expect(syncer.loadBridgeRunIdsByBetId('any-id').size).toBe(0); + }); + + it('maps betId to runId from matching cycle metadata', () => { + const betId = randomUUID(); + const runId = randomUUID(); + const cycleId = randomUUID(); + const deps = makeDeps({ bridgeRunsDir }); + + writeBridgeRun(bridgeRunsDir, runId, { cycleId, betId, runId, status: 'complete' }); + + const syncer = new BridgeRunSyncer(deps); + const map = syncer.loadBridgeRunIdsByBetId(cycleId); + + expect(map.size).toBe(1); + expect(map.get(betId)).toBe(runId); + }); + + it('excludes metadata from different cycles', () => { + const deps = makeDeps({ bridgeRunsDir }); + + writeBridgeRun(bridgeRunsDir, randomUUID(), { + cycleId: randomUUID(), + betId: randomUUID(), + runId: randomUUID(), + }); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.loadBridgeRunIdsByBetId(randomUUID()).size).toBe(0); + }); + + it('skips files without betId or runId', () => { + const cycleId = randomUUID(); + const deps = makeDeps({ bridgeRunsDir }); + + writeBridgeRun(bridgeRunsDir, randomUUID(), { cycleId, status: 'complete' }); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.loadBridgeRunIdsByBetId(cycleId).size).toBe(0); + }); + + it('skips corrupt JSON files gracefully', () => { + const deps = makeDeps({ bridgeRunsDir }); + writeFileSync(join(bridgeRunsDir, 'bad.json'), '<<>>'); + + const syncer = new BridgeRunSyncer(deps); + expect(syncer.loadBridgeRunIdsByBetId(randomUUID()).size).toBe(0); + }); + }); + + // ── recordBetOutcomes ──────────────────────────────────────── + + describe('recordBetOutcomes', () => { + it('delegates to cycleManager.updateBetOutcomes', () => { + const deps = makeDeps({ bridgeRunsDir }); + const syncer = new BridgeRunSyncer(deps); + const outcomes: BetOutcomeRecord[] = [{ betId: randomUUID(), outcome: 'complete' }]; + + syncer.recordBetOutcomes('cycle-1', outcomes); + + expect(deps.updateBetOutcomesSpy).toHaveBeenCalledWith('cycle-1', outcomes); + }); + + it('logs a warning for unmatched bet IDs but does not throw', () => { + const fakeBetId = randomUUID(); + const deps = makeDeps({ bridgeRunsDir }); + deps.updateBetOutcomesSpy.mockReturnValue({ unmatchedBetIds: [fakeBetId] }); + + const syncer = new BridgeRunSyncer(deps); + expect(() => syncer.recordBetOutcomes('cycle-1', [{ betId: fakeBetId, outcome: 'complete' }])).not.toThrow(); + }); + + it('does not warn when all bet IDs match', () => { + const deps = makeDeps({ bridgeRunsDir }); + deps.updateBetOutcomesSpy.mockReturnValue({ unmatchedBetIds: [] }); + + const syncer = new BridgeRunSyncer(deps); + syncer.recordBetOutcomes('cycle-1', [{ betId: randomUUID(), outcome: 'complete' }]); + + // No warning — unmatchedBetIds is empty + expect(deps.updateBetOutcomesSpy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/features/cycle-management/bridge-run-syncer.ts b/src/features/cycle-management/bridge-run-syncer.ts new file mode 100644 index 0000000..57920b0 --- /dev/null +++ b/src/features/cycle-management/bridge-run-syncer.ts @@ -0,0 +1,196 @@ +import { join } from 'node:path'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import type { CycleManager } from '@domain/services/cycle-manager.js'; +import { logger } from '@shared/lib/logger.js'; +import { JsonStoreError } from '@infra/persistence/json-store.js'; +import { readRun } from '@infra/persistence/run-store.js'; +import type { BetOutcomeRecord, IncompleteRunInfo } from './cooldown-session.js'; +import { + collectBridgeRunIds, + isJsonFile, + isSyncableBet, + mapBridgeRunStatusToIncompleteStatus, + mapBridgeRunStatusToSyncedOutcome, +} from './cooldown-session.helpers.js'; + +/** + * Dependencies injected into BridgeRunSyncer for testability. + */ +export interface BridgeRunSyncerDeps { + bridgeRunsDir?: string; + runsDir?: string; + cycleManager: CycleManager; +} + +/** + * Syncs bet outcomes from bridge-run metadata and checks for incomplete runs. + * + * Extracted from CooldownSession to isolate bridge-run filesystem scanning + * from the cooldown orchestration logic. + */ +export class BridgeRunSyncer { + constructor(private readonly deps: BridgeRunSyncerDeps) {} + + /** + * Auto-derive bet outcomes from bridge-run metadata for any bets still marked 'pending'. + * + * bridge-run/.json is updated by `execute complete` / `kiai complete`, unlike run.json which is + * written once as "running" and never updated. This ensures cooldown always reflects + * actual run completion even if the caller passed empty betOutcomes (fixes #216). + * + * Non-critical: bridge-run file read errors (ENOENT, corrupt JSON) are swallowed + * so a missing/corrupt bridge-run file does not abort the cooldown. + * CycleNotFoundError from cycleManager.get() still propagates. + */ + syncOutcomes(cycleId: string): BetOutcomeRecord[] { + const bridgeRunsDir = this.deps.bridgeRunsDir; + if (!bridgeRunsDir) return []; + + const cycle = this.deps.cycleManager.get(cycleId); + const toSync: BetOutcomeRecord[] = []; + + for (const bet of cycle.bets) { + // Stryker disable next-line ConditionalExpression: guard redundant — readBridgeRunOutcome returns undefined for missing runId + if (!isSyncableBet(bet)) continue; + + const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId!); + if (outcome) { + toSync.push({ betId: bet.id, outcome }); + } + } + + if (toSync.length > 0) { + this.recordBetOutcomes(cycleId, toSync); + } + + return toSync; + } + + /** + * Check whether any bets in the cycle have runs that are still in-progress. + * Returns an array of IncompleteRunInfo for every run with status 'pending' or 'running'. + * Returns an empty array when runsDir is not configured or all runs are complete/failed. + * Read errors for individual run files are swallowed (the run is skipped silently). + */ + checkIncomplete(cycleId: string): IncompleteRunInfo[] { + // Stryker disable next-line ConditionalExpression: guard redundant — loop skips bets without runId + if (!this.deps.runsDir && !this.deps.bridgeRunsDir) return []; + + const cycle = this.deps.cycleManager.get(cycleId); + const incomplete: IncompleteRunInfo[] = []; + + for (const bet of cycle.bets) { + if (!bet.runId) continue; + const status = this.resolveIncompleteRunStatus(bet.id, bet.runId); + if (status) { + incomplete.push({ runId: bet.runId, betId: bet.id, status }); + } + } + + return incomplete; + } + + /** + * Build a betId → runId map by scanning bridge-run files for the given cycle. + * + * This is the fallback lookup used by synthesis input writing when bet.runId is + * not set on the cycle record (e.g., staged-workflow cycles launched before + * backfillRunIdInCycle was introduced — fixes #335). + * + * Returns an empty Map when bridgeRunsDir is missing or unreadable. + */ + loadBridgeRunIdsByBetId(cycleId: string): Map { + if (!this.deps.bridgeRunsDir) return new Map(); + const files = this.listJsonFiles(this.deps.bridgeRunsDir); + const metas = files + .map((file) => this.readBridgeRunMeta(join(this.deps.bridgeRunsDir!, file))) + .filter((meta): meta is NonNullable => meta !== undefined); + return collectBridgeRunIds(metas, cycleId); + } + + /** + * Apply bet outcomes to the cycle via CycleManager. + * Logs a warning for any unmatched bet IDs. + */ + recordBetOutcomes(cycleId: string, outcomes: BetOutcomeRecord[]): void { + if (outcomes.length === 0) return; + const { unmatchedBetIds } = this.deps.cycleManager.updateBetOutcomes(cycleId, outcomes); + if (unmatchedBetIds.length > 0) { + // Stryker disable next-line StringLiteral: presentation text — join separator in warning message + logger.warn(`Bet outcome(s) for cycle "${cycleId}" referenced nonexistent bet IDs: ${unmatchedBetIds.join(', ')}`); + } + } + + // ── Private helpers ─────────────────────────────────────────────────── + + private readBridgeRunOutcome( + bridgeRunsDir: string, + runId: string, + ): BetOutcomeRecord['outcome'] | undefined { + const bridgeRunPath = join(bridgeRunsDir, `${runId}.json`); + const status = this.readBridgeRunMeta(bridgeRunPath)?.status; + return mapBridgeRunStatusToSyncedOutcome(status); + } + + private listJsonFiles(dir: string): string[] { + try { + // Stryker disable next-line MethodExpression: filter redundant — readBridgeRunMeta catches non-json parse errors + return readdirSync(dir).filter(isJsonFile); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn(`Unexpected error listing bridge-run directory ${dir}: ${err instanceof Error ? err.message : String(err)}`); + } + return []; + } + } + + private readBridgeRunMeta(filePath: string): { cycleId?: string; betId?: string; runId?: string; status?: string } | undefined { + try { + return JSON.parse(readFileSync(filePath, 'utf-8')) as { cycleId?: string; betId?: string; runId?: string; status?: string }; + // Stryker disable next-line all: equivalent mutant — catch returns undefined for expected errors + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SyntaxError)) { + logger.warn(`Unexpected error reading bridge-run file ${filePath}: ${err instanceof Error ? err.message : String(err)}`); + } + return undefined; + } + } + + private resolveIncompleteRunStatus( + betId: string, + runId: string, + ): IncompleteRunInfo['status'] | undefined { + const bridgeStatus = this.readIncompleteBridgeRunStatus(runId); + if (bridgeStatus !== undefined) return bridgeStatus ?? undefined; + return this.readIncompleteRunFileStatus(betId, runId); + } + + private readIncompleteBridgeRunStatus(runId: string): IncompleteRunInfo['status'] | null | undefined { + if (!this.deps.bridgeRunsDir) return undefined; + + const bridgeRunPath = join(this.deps.bridgeRunsDir, `${runId}.json`); + if (!existsSync(bridgeRunPath)) return undefined; + const status = this.readBridgeRunMeta(bridgeRunPath)?.status; + const incompleteStatus = mapBridgeRunStatusToIncompleteStatus(status); + return incompleteStatus ?? null; + } + + private readIncompleteRunFileStatus( + _betId: string, + runId: string, + ): IncompleteRunInfo['status'] | undefined { + if (!this.deps.runsDir) return undefined; + + try { + const run = readRun(this.deps.runsDir, runId); + return run.status === 'pending' || run.status === 'running' ? run.status : undefined; + // Stryker disable next-line all: equivalent mutant — catch returns undefined for expected errors + } catch (err) { + // readRun wraps all file/parse errors as JsonStoreError — only warn for truly unexpected errors + if (!(err instanceof JsonStoreError)) { + logger.warn(`Unexpected error reading run file for ${runId}: ${err instanceof Error ? err.message : String(err)}`); + } + return undefined; + } + } +} diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index 9b27e2c..a266676 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -45,16 +45,12 @@ import { clampConfidenceWithDelta, filterExecutionHistoryForCycle, listCompletedBetDescriptions, - mapBridgeRunStatusToIncompleteStatus, - mapBridgeRunStatusToSyncedOutcome, resolveAppliedProposalIds, selectEffectiveBetOutcomes, shouldWarnOnIncompleteRuns, - isJsonFile, isSynthesisPendingFile, - isSyncableBet, - collectBridgeRunIds, } from './cooldown-session.helpers.js'; +import { BridgeRunSyncer } from './bridge-run-syncer.js'; /** * Dependencies injected into CooldownSession for testability. @@ -272,6 +268,7 @@ export class CooldownSession { private readonly hierarchicalPromoter: Pick; private readonly frictionAnalyzer: Pick | null; private readonly _nextKeikoProposalGenerator: Pick | null; + private readonly bridgeRunSyncer: BridgeRunSyncer; constructor(deps: CooldownSessionDeps) { this.deps = deps; @@ -281,6 +278,11 @@ export class CooldownSession { this.hierarchicalPromoter = this.resolveHierarchicalPromoter(deps); this.frictionAnalyzer = this.resolveFrictionAnalyzer(deps); this._nextKeikoProposalGenerator = this.resolveNextKeikoProposalGenerator(deps); + this.bridgeRunSyncer = new BridgeRunSyncer({ + bridgeRunsDir: deps.bridgeRunsDir, + runsDir: deps.runsDir, + cycleManager: deps.cycleManager, + }); } private resolveProposalGenerator(deps: CooldownSessionDeps): Pick { @@ -355,7 +357,7 @@ export class CooldownSession { learningsCaptured: number; effectiveBetOutcomes: BetOutcomeRecord[]; } { - const syncedOutcomes = this.autoSyncBetOutcomesFromBridgeRuns(cycleId); + const syncedOutcomes = this.bridgeRunSyncer.syncOutcomes(cycleId); this.recordExplicitBetOutcomes(cycleId, betOutcomes); const cycle = this.deps.cycleManager.get(cycleId); const report = this.buildCooldownReport(cycleId); @@ -395,7 +397,7 @@ export class CooldownSession { private recordExplicitBetOutcomes(cycleId: string, betOutcomes: BetOutcomeRecord[]): void { if (betOutcomes.length === 0) return; - this.recordBetOutcomes(cycleId, betOutcomes); + this.bridgeRunSyncer.recordBetOutcomes(cycleId, betOutcomes); } private runCooldownFollowUps(cycle: Cycle): void { @@ -570,7 +572,7 @@ export class CooldownSession { * @param force When true, skips the incomplete-run guard and proceeds even if runs are in-progress. */ async run(cycleId: string, betOutcomes: BetOutcomeRecord[] = [], { force = false, humanPerspective }: { force?: boolean; humanPerspective?: string } = {}): Promise { - const incompleteRuns = this.checkIncompleteRuns(cycleId); + const incompleteRuns = this.bridgeRunSyncer.checkIncomplete(cycleId); this.warnOnIncompleteRuns(incompleteRuns, force); const previousState = this.beginCooldown(cycleId); @@ -626,7 +628,7 @@ export class CooldownSession { * @param force When true, skips the incomplete-run guard and proceeds even if runs are in-progress. */ async prepare(cycleId: string, betOutcomes: BetOutcomeRecord[] = [], depth?: import('@domain/types/synthesis.js').SynthesisDepth, { force = false }: { force?: boolean } = {}): Promise { - const incompleteRuns = this.checkIncompleteRuns(cycleId); + const incompleteRuns = this.bridgeRunSyncer.checkIncomplete(cycleId); this.warnOnIncompleteRuns(incompleteRuns, force); const previousState = this.beginCooldown(cycleId); @@ -802,9 +804,7 @@ export class CooldownSession { // Stryker disable next-line ConditionalExpression: guard redundant with catch in readObservationsForRun if (!this.deps.runsDir) return observations; - const bridgeRunIdByBetId = this.deps.bridgeRunsDir - ? this.loadBridgeRunIdsByBetId(cycleId, this.deps.bridgeRunsDir) - : new Map(); + const bridgeRunIdByBetId = this.bridgeRunSyncer.loadBridgeRunIdsByBetId(cycleId); for (const bet of cycle.bets) { const runId = bet.runId ?? bridgeRunIdByBetId.get(bet.id); @@ -874,93 +874,11 @@ export class CooldownSession { } /** - * Build a betId → runId map by scanning bridge-run files for the given cycle. - * - * This is the fallback lookup used by writeSynthesisInput() when bet.runId is - * not set on the cycle record (e.g., staged-workflow cycles launched before - * backfillRunIdInCycle was introduced in SessionExecutionBridge — fixes #335). - * - * Returns an empty Map when bridgeRunsDir is missing or unreadable. - */ - private loadBridgeRunIdsByBetId(cycleId: string, bridgeRunsDir: string): Map { - const files = this.listJsonFiles(bridgeRunsDir); - const metas = files - .map((file) => this.readBridgeRunMeta(join(bridgeRunsDir, file))) - .filter((meta): meta is NonNullable => meta !== undefined); - return collectBridgeRunIds(metas, cycleId); - } - - private listJsonFiles(dir: string): string[] { - try { - // Stryker disable next-line MethodExpression: filter redundant — readBridgeRunMeta catches non-json parse errors - return readdirSync(dir).filter(isJsonFile); - } catch { - return []; - } - } - - private readBridgeRunMeta(filePath: string): { cycleId?: string; betId?: string; runId?: string; status?: string } | undefined { - try { - return JSON.parse(readFileSync(filePath, 'utf-8')) as { cycleId?: string; betId?: string; runId?: string; status?: string }; - // Stryker disable next-line all: equivalent mutant — empty catch implicitly returns undefined - } catch { - return undefined; - } - } - - /** - * Auto-derive bet outcomes from bridge-run metadata for any bets still marked 'pending'. - * - * bridge-run/.json is updated by `execute complete` / `kiai complete`, unlike run.json which is - * written once as "running" and never updated. This ensures cooldown always reflects - * actual run completion even if the caller passed empty betOutcomes (fixes #216). - * - * Non-critical: any errors are swallowed so a missing/corrupt bridge-run file - * does not abort the cooldown. - */ - private autoSyncBetOutcomesFromBridgeRuns(cycleId: string): BetOutcomeRecord[] { - const bridgeRunsDir = this.deps.bridgeRunsDir; - if (!bridgeRunsDir) return []; - - const cycle = this.deps.cycleManager.get(cycleId); - const toSync: BetOutcomeRecord[] = []; - - for (const bet of cycle.bets) { - // Stryker disable next-line ConditionalExpression: guard redundant — readBridgeRunOutcome returns undefined for missing runId - if (!isSyncableBet(bet)) continue; - - const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId!); - if (outcome) { - toSync.push({ betId: bet.id, outcome }); - } - } - - if (toSync.length > 0) { - this.recordBetOutcomes(cycleId, toSync); - } - - return toSync; - } - - private readBridgeRunOutcome( - bridgeRunsDir: string, - runId: string, - ): BetOutcomeRecord['outcome'] | undefined { - const bridgeRunPath = join(bridgeRunsDir, `${runId}.json`); - const status = this.readBridgeRunMeta(bridgeRunPath)?.status; - return mapBridgeRunStatusToSyncedOutcome(status); - } - - /** - * Apply bet outcomes to the cycle via CycleManager. - * Logs a warning for any unmatched bet IDs. + * Delegate bet outcome recording to the bridge-run syncer. + * This is a thin delegation wrapper — the canonical implementation lives on BridgeRunSyncer. */ recordBetOutcomes(cycleId: string, outcomes: BetOutcomeRecord[]): void { - const { unmatchedBetIds } = this.deps.cycleManager.updateBetOutcomes(cycleId, outcomes); - if (unmatchedBetIds.length > 0) { - // Stryker disable next-line StringLiteral: presentation text — join separator in warning message - logger.warn(`Bet outcome(s) for cycle "${cycleId}" referenced nonexistent bet IDs: ${unmatchedBetIds.join(', ')}`); - } + this.bridgeRunSyncer.recordBetOutcomes(cycleId, outcomes); } /** @@ -1055,61 +973,10 @@ export class CooldownSession { } /** - * Check whether any bets in the cycle have runs that are still in-progress. - * Returns an array of IncompleteRunInfo for every run with status 'pending' or 'running'. - * Returns an empty array when runsDir is not configured or all runs are complete/failed. - * Read errors for individual run files are swallowed (the run is skipped silently). + * Delegate incomplete run checks to the bridge-run syncer. */ checkIncompleteRuns(cycleId: string): IncompleteRunInfo[] { - // Stryker disable next-line ConditionalExpression: guard redundant — loop skips bets without runId - if (!this.deps.runsDir && !this.deps.bridgeRunsDir) return []; - - const cycle = this.deps.cycleManager.get(cycleId); - const incomplete: IncompleteRunInfo[] = []; - - for (const bet of cycle.bets) { - if (!bet.runId) continue; - const status = this.resolveIncompleteRunStatus(bet.id, bet.runId); - if (status) { - incomplete.push({ runId: bet.runId, betId: bet.id, status }); - } - } - - return incomplete; - } - - private resolveIncompleteRunStatus( - betId: string, - runId: string, - ): IncompleteRunInfo['status'] | undefined { - const bridgeStatus = this.readIncompleteBridgeRunStatus(runId); - if (bridgeStatus !== undefined) return bridgeStatus ?? undefined; - return this.readIncompleteRunFileStatus(betId, runId); - } - - private readIncompleteBridgeRunStatus(runId: string): IncompleteRunInfo['status'] | null | undefined { - if (!this.deps.bridgeRunsDir) return undefined; - - const bridgeRunPath = join(this.deps.bridgeRunsDir, `${runId}.json`); - if (!existsSync(bridgeRunPath)) return undefined; - const status = this.readBridgeRunMeta(bridgeRunPath)?.status; - const incompleteStatus = mapBridgeRunStatusToIncompleteStatus(status); - return incompleteStatus ?? null; - } - - private readIncompleteRunFileStatus( - _betId: string, - runId: string, - ): IncompleteRunInfo['status'] | undefined { - if (!this.deps.runsDir) return undefined; - - try { - const run = readRun(this.deps.runsDir, runId); - return run.status === 'pending' || run.status === 'running' ? run.status : undefined; - // Stryker disable next-line all: equivalent mutant — empty catch implicitly returns undefined - } catch { - return undefined; - } + return this.bridgeRunSyncer.checkIncomplete(cycleId); } /** diff --git a/stryker.config.mjs b/stryker.config.mjs index 4f2b85f..4ab5b04 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -14,6 +14,7 @@ export default { 'src/features/execute/workflow-runner.ts', 'src/infrastructure/execution/session-bridge.ts', 'src/cli/commands/execute.ts', + 'src/features/cycle-management/bridge-run-syncer.ts', 'src/features/cycle-management/cooldown-session.ts', 'src/features/kata-agent/kata-agent-confidence-calculator.ts', 'src/features/kata-agent/kata-agent-observability-aggregator.ts',