test scheduled state rollback boundary

This commit is contained in:
2026-07-28 01:58:52 +00:00
parent d29b78228b
commit a531332668
2 changed files with 80 additions and 10 deletions
+18 -10
View File
@@ -15,6 +15,13 @@ type CapturedParticipant = {
snapshot: unknown;
};
type EngineStateSavepointData = {
owner: symbol;
participants: readonly CapturedParticipant[];
};
const savepointData = new WeakMap<EngineStateSavepoint, EngineStateSavepointData>();
export interface EngineStateInspection {
revision: number;
transactionActive: boolean;
@@ -23,13 +30,10 @@ export interface EngineStateInspection {
export class EngineStateSavepoint {
readonly revision: number;
readonly participants: readonly CapturedParticipant[];
readonly owner: symbol;
constructor(owner: symbol, revision: number, participants: CapturedParticipant[]) {
this.owner = owner;
this.revision = revision;
this.participants = participants;
savepointData.set(this, { owner, participants });
}
}
@@ -57,9 +61,9 @@ export class EngineStateManager {
throw new Error(`Engine state participant is already registered: ${name}`);
}
this.participants.set(name, {
capture: participant.capture,
capture: () => participant.capture(),
restore: (snapshot) => participant.restore(snapshot as T),
inspect: participant.inspect,
inspect: participant.inspect ? () => participant.inspect?.() : undefined,
});
}
@@ -127,23 +131,27 @@ export class EngineStateManager {
}
private assertOwnedSavepoint(savepoint: EngineStateSavepoint): void {
if (savepoint.owner !== this.owner) {
if (savepointData.get(savepoint)?.owner !== this.owner) {
throw new Error('Engine state savepoint belongs to a different manager.');
}
}
private restoreParticipants(savepoint: EngineStateSavepoint): void {
this.assertOwnedSavepoint(savepoint);
const capturedParticipants = savepointData.get(savepoint)?.participants;
if (!capturedParticipants) {
throw new Error('Engine state savepoint data is unavailable.');
}
const currentNames = Array.from(this.participants.keys());
const capturedNames = savepoint.participants.map(({ name }) => name);
const capturedNames = capturedParticipants.map(({ name }) => name);
if (
currentNames.length !== capturedNames.length ||
currentNames.some((name, index) => name !== capturedNames[index])
) {
throw new Error('Engine state participant set changed after the savepoint was captured.');
}
for (let index = savepoint.participants.length - 1; index >= 0; index -= 1) {
const captured = savepoint.participants[index];
for (let index = capturedParticipants.length - 1; index >= 0; index -= 1) {
const captured = capturedParticipants[index];
if (!captured) {
continue;
}
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
InMemoryControlQueue,
EngineStateManager,
ManualClock,
TurnDaemonLifecycle,
getNextTickTime,
@@ -13,6 +14,67 @@ import {
const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000);
describe('TurnDaemonLifecycle', () => {
it('restores engine state when a scheduled calculation throws', async () => {
const now = new Date('2026-01-01T00:10:00.000Z');
const queue = new InMemoryControlQueue();
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
capture: () => structuredClone(engineState),
restore: (snapshot) => {
engineState = snapshot;
},
});
let resolveError: (() => void) | undefined;
const errorObserved = new Promise<void>((resolve) => {
resolveError = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T00:05:00.000Z'),
stateStore: {
loadLastTurnTime: async () => new Date('2026-01-01T00:00:00.000Z'),
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
},
processor: {
run: async () => {
engineState.value = 'partial';
throw new Error('scheduled calculation failed');
},
},
stateManager,
hooks: {
onRunError: async () => {
resolveError?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const loop = lifecycle.start();
await errorObserved;
expect(engineState).toEqual({ value: 'before' });
expect(stateManager.getRevision()).toBe(0);
expect(lifecycle.getStatus()).toMatchObject({
state: 'paused',
paused: true,
lastError: 'scheduled calculation failed',
});
await lifecycle.stop('done');
await loop;
});
it('runs scheduled turn based on queue front and checkpoint context', async () => {
const turnTermMinutes = 10;
const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0);