refactor(planning): give planning workers one lifetime owner
Planning, counterfactual analysis and checkpoint explanation now share one internal worker-job owner for creation, handler cleanup, termination and supersession, and one request/response message contract between the coordinator and the worker. Job-specific progress, checkpoint recovery and completion rules stay explicit. Refs #277 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fc2b33587
commit
b69b3c3690
4 files changed
+344
-183
No files matched your search
@@ -0,0 +1,172 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createPlanningCalculation,
|
||||||
|
type LeavePlacement,
|
||||||
|
type PlanningCalculationContext,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
|
||||||
|
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
|
||||||
|
const plan = {
|
||||||
|
kind: 'Plan',
|
||||||
|
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
|
||||||
|
completedStages: [],
|
||||||
|
};
|
||||||
|
const state = { marker: 'state' };
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
class FakeWorker {
|
||||||
|
static all: FakeWorker[] = [];
|
||||||
|
static constructThrows = false;
|
||||||
|
static postThrows = false;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
terminate = vi.fn();
|
||||||
|
postMessage = vi.fn(() => {
|
||||||
|
if (this.throwOnPost) throw new Error('post failed');
|
||||||
|
});
|
||||||
|
private throwOnPost = FakeWorker.postThrows;
|
||||||
|
constructor() {
|
||||||
|
if (FakeWorker.constructThrows) throw new Error('no worker');
|
||||||
|
FakeWorker.all.push(this);
|
||||||
|
}
|
||||||
|
emit(data: unknown) {
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Started {
|
||||||
|
worker?: FakeWorker;
|
||||||
|
succeeded: Promise<boolean>;
|
||||||
|
deliver: () => void;
|
||||||
|
late: () => void;
|
||||||
|
}
|
||||||
|
type Flags = { construct?: boolean; post?: boolean };
|
||||||
|
const arm = (flags: Flags) => {
|
||||||
|
FakeWorker.constructThrows = !!flags.construct;
|
||||||
|
FakeWorker.postThrows = !!flags.post;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
|
||||||
|
plan: async (flags) => {
|
||||||
|
arm(flags);
|
||||||
|
const progress = vi.fn();
|
||||||
|
const calculation = createPlanningCalculation({ ...request, progress });
|
||||||
|
const worker = FakeWorker.all[0];
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
|
||||||
|
deliver: () => {
|
||||||
|
worker?.emit({ type: 'result', outcome: plan });
|
||||||
|
worker?.emit({ type: 'artifacts', artifacts: {} });
|
||||||
|
},
|
||||||
|
late: () => {
|
||||||
|
worker?.emit({ type: 'progress', stage: 'late' });
|
||||||
|
expect(progress).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
counterfactual: async (flags) => {
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
|
||||||
|
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
|
||||||
|
await calculation.timings;
|
||||||
|
arm(flags);
|
||||||
|
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
|
||||||
|
plan.placements[0],
|
||||||
|
);
|
||||||
|
const succeeded = analysis.then(
|
||||||
|
(result) => result.kind !== 'CounterfactualStale',
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
const worker = FakeWorker.all[1];
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
succeeded,
|
||||||
|
deliver: () =>
|
||||||
|
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
|
||||||
|
late: () => worker?.emit({ type: 'error', message: 'late' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'explain-checkpoint': async (flags) => {
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
const first = FakeWorker.all[0];
|
||||||
|
first.emit({ type: 'checkpoint', plan, state });
|
||||||
|
arm(flags);
|
||||||
|
first.emit({ type: 'error', message: 'planner crashed' });
|
||||||
|
const worker = FakeWorker.all[1];
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
|
||||||
|
deliver: () =>
|
||||||
|
worker?.emit({
|
||||||
|
type: 'explanation',
|
||||||
|
explanation: { kind: 'IncompleteExplanation' },
|
||||||
|
}),
|
||||||
|
late: () => worker?.emit({ type: 'error', message: 'late' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('worker job lifetime', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeWorker.all = [];
|
||||||
|
arm({});
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('Worker', FakeWorker);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
arm({});
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(Object.keys(jobs))('%s job', (name) => {
|
||||||
|
const start = jobs[name];
|
||||||
|
const released = (worker: FakeWorker) => {
|
||||||
|
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||||
|
expect(worker.onmessage).toBeNull();
|
||||||
|
expect(worker.onerror).toBeNull();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('releases the worker once on success and ignores late messages', async () => {
|
||||||
|
const job = await start({});
|
||||||
|
job.deliver();
|
||||||
|
expect(await job.succeeded).toBe(true);
|
||||||
|
released(job.worker!);
|
||||||
|
job.late();
|
||||||
|
job.deliver();
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails without a worker when construction throws', async () => {
|
||||||
|
const job = await start({ construct: true });
|
||||||
|
expect(job.worker).toBeUndefined();
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the worker once when postMessage throws', async () => {
|
||||||
|
const job = await start({ post: true });
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the worker once on a runtime error', async () => {
|
||||||
|
const job = await start({});
|
||||||
|
job.worker!.onerror!();
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
job.deliver();
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the worker once when a newer calculation supersedes it', async () => {
|
||||||
|
const job = await start({});
|
||||||
|
createPlanningCalculation(request);
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
job.deliver();
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
StaleCounterfactualAnalysis,
|
StaleCounterfactualAnalysis,
|
||||||
} from './leave-planner.core';
|
} from './leave-planner.core';
|
||||||
import { planningDateInParis } from './planning-date';
|
import { planningDateInParis } from './planning-date';
|
||||||
|
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
IfThenPlanningRule,
|
IfThenPlanningRule,
|
||||||
@@ -278,20 +279,6 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlannerWorkerResponse =
|
|
||||||
| { type: 'heartbeat' }
|
|
||||||
| { type: 'progress'; stage: string }
|
|
||||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
|
|
||||||
| { type: 'result'; outcome: PlanningOutcome }
|
|
||||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
|
||||||
| {
|
|
||||||
type: 'explanation';
|
|
||||||
explanation?: PlanningExplanation;
|
|
||||||
explanationError?: PlanningReason;
|
|
||||||
finalState?: PlanningStateSnapshot;
|
|
||||||
}
|
|
||||||
| { type: 'error'; code?: string; message: string };
|
|
||||||
|
|
||||||
interface PlanningRun {
|
interface PlanningRun {
|
||||||
outcome: Promise<PlanningOutcome>;
|
outcome: Promise<PlanningOutcome>;
|
||||||
explanation: Promise<PlanningExplanation | undefined>;
|
explanation: Promise<PlanningExplanation | undefined>;
|
||||||
@@ -320,6 +307,47 @@ function interruptedOutcome(
|
|||||||
const latestWorkerCancels = new Set<() => void>();
|
const latestWorkerCancels = new Set<() => void>();
|
||||||
let latestCounterfactualCancel: (() => void) | undefined;
|
let latestCounterfactualCancel: (() => void) | undefined;
|
||||||
|
|
||||||
|
interface WorkerJob {
|
||||||
|
post(message: WorkerRequest, onFailure: (error: unknown) => void): void;
|
||||||
|
release(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single owner of a worker's lifetime: creation, handlers, cancellation registration and an idempotent release.
|
||||||
|
function startWorkerJob(
|
||||||
|
supersede: () => void,
|
||||||
|
onMessage: (message: WorkerResponse) => void,
|
||||||
|
onError: () => void,
|
||||||
|
): WorkerJob {
|
||||||
|
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
});
|
||||||
|
let released = false;
|
||||||
|
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||||
|
if (!released && event.data) onMessage(event.data);
|
||||||
|
};
|
||||||
|
worker.onerror = () => {
|
||||||
|
if (!released) onError();
|
||||||
|
};
|
||||||
|
latestWorkerCancels.add(supersede);
|
||||||
|
return {
|
||||||
|
post(message, onFailure) {
|
||||||
|
try {
|
||||||
|
worker.postMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
release() {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
worker.onmessage = null;
|
||||||
|
worker.onerror = null;
|
||||||
|
worker.terminate();
|
||||||
|
latestWorkerCancels.delete(supersede);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cancelLatestWorkers(): void {
|
function cancelLatestWorkers(): void {
|
||||||
for (const cancel of [...latestWorkerCancels]) cancel();
|
for (const cancel of [...latestWorkerCancels]) cancel();
|
||||||
latestWorkerCancels.clear();
|
latestWorkerCancels.clear();
|
||||||
@@ -349,9 +377,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
});
|
});
|
||||||
let cancel: (() => void) | undefined;
|
let cancel: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
const job = startWorkerJob(
|
||||||
type: 'module',
|
() => supersede(),
|
||||||
});
|
(message) => handleMessage(message),
|
||||||
|
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
|
||||||
|
);
|
||||||
let outcomeSettled = false;
|
let outcomeSettled = false;
|
||||||
let cancellationRequested = false;
|
let cancellationRequested = false;
|
||||||
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
||||||
@@ -364,9 +394,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
let receivedFinalState: PlanningStateSnapshot | undefined;
|
let receivedFinalState: PlanningStateSnapshot | undefined;
|
||||||
let livenessWatchdog: ReturnType<typeof setTimeout>;
|
let livenessWatchdog: ReturnType<typeof setTimeout>;
|
||||||
const terminate = () => {
|
const terminate = () => {
|
||||||
worker.onmessage = null;
|
job.release();
|
||||||
worker.onerror = null;
|
|
||||||
worker.terminate();
|
|
||||||
clearTimeout(livenessWatchdog);
|
clearTimeout(livenessWatchdog);
|
||||||
};
|
};
|
||||||
const finishOutcome = (result: PlanningOutcome) => {
|
const finishOutcome = (result: PlanningOutcome) => {
|
||||||
@@ -434,7 +462,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
resolveArtifacts(complete);
|
resolveArtifacts(complete);
|
||||||
resolveTimings(complete.timings ?? {});
|
resolveTimings(complete.timings ?? {});
|
||||||
terminate();
|
terminate();
|
||||||
latestWorkerCancels.delete(supersede);
|
|
||||||
};
|
};
|
||||||
const supersede = () => {
|
const supersede = () => {
|
||||||
if (artifactsSettled) return;
|
if (artifactsSettled) return;
|
||||||
@@ -456,7 +483,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
finalState: receivedFinalState ?? checkpointState,
|
finalState: receivedFinalState ?? checkpointState,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
latestWorkerCancels.add(supersede);
|
|
||||||
const fail = (code: string, message: string) => {
|
const fail = (code: string, message: string) => {
|
||||||
const reason = { code, message };
|
const reason = { code, message };
|
||||||
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
||||||
@@ -508,15 +534,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
cancel = () => {
|
cancel = () => {
|
||||||
if (outcomeSettled || cancellationRequested) return;
|
if (outcomeSettled || cancellationRequested) return;
|
||||||
cancellationRequested = true;
|
cancellationRequested = true;
|
||||||
try {
|
job.post({ type: 'cancel' }, () =>
|
||||||
worker.postMessage({ type: 'cancel' });
|
fail('worker-error', 'The cancellation request could not reach the worker.'),
|
||||||
} catch {
|
);
|
||||||
fail('worker-error', 'The cancellation request could not reach the worker.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
const handleMessage = (message: WorkerResponse) => {
|
||||||
const message = event.data;
|
|
||||||
if (!message) return;
|
|
||||||
if (message.type === 'heartbeat') {
|
if (message.type === 'heartbeat') {
|
||||||
scheduleLivenessWatchdog();
|
scheduleLivenessWatchdog();
|
||||||
return;
|
return;
|
||||||
@@ -552,13 +574,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
|
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
|
||||||
else fail('worker-error', 'Planning worker returned an unexpected response.');
|
else fail('worker-error', 'Planning worker returned an unexpected response.');
|
||||||
};
|
};
|
||||||
worker.onerror = () =>
|
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
|
||||||
fail('worker-error', 'Planning worker failed before returning explanation data.');
|
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
|
||||||
try {
|
);
|
||||||
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
|
|
||||||
} catch {
|
|
||||||
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
const result = interruptedOutcome(
|
const result = interruptedOutcome(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -677,62 +695,43 @@ function counterfactualInWorker(
|
|||||||
): Promise<CounterfactualAnalysis> {
|
): Promise<CounterfactualAnalysis> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
latestCounterfactualCancel?.();
|
latestCounterfactualCancel?.();
|
||||||
let worker: Worker;
|
let job: WorkerJob;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
job.release();
|
||||||
|
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
let settled = false;
|
||||||
|
const cancel = () =>
|
||||||
|
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
|
||||||
try {
|
try {
|
||||||
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
job = startWorkerJob(
|
||||||
type: 'module',
|
cancel,
|
||||||
});
|
(message) => {
|
||||||
|
if (message.type === 'counterfactual-result' && message.analysis)
|
||||||
|
finish(() => resolve(message.analysis));
|
||||||
|
else if (message.type === 'error')
|
||||||
|
finish(() =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
message.message ??
|
||||||
|
`Counterfactual analysis failed (${message.code ?? 'worker-error'}).`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => finish(() => reject(new Error('Counterfactual worker failed.'))),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let settled = false;
|
|
||||||
const finish = (callback: () => void) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
worker.onmessage = null;
|
|
||||||
worker.onerror = null;
|
|
||||||
worker.terminate();
|
|
||||||
if (cancel) latestWorkerCancels.delete(cancel);
|
|
||||||
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
const cancel = () =>
|
|
||||||
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
|
|
||||||
latestCounterfactualCancel = cancel;
|
latestCounterfactualCancel = cancel;
|
||||||
latestWorkerCancels.add(cancel);
|
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
|
||||||
worker.onmessage = (
|
finish(() => reject(error)),
|
||||||
event: MessageEvent<{
|
);
|
||||||
type: string;
|
|
||||||
analysis?: CounterfactualAnalysis;
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
|
|
||||||
finish(() => resolve(event.data.analysis!));
|
|
||||||
else if (event.data?.type === 'error')
|
|
||||||
finish(() =>
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
event.data.message ??
|
|
||||||
`Counterfactual analysis failed (${event.data.code ?? 'worker-error'}).`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
|
|
||||||
try {
|
|
||||||
worker.postMessage({
|
|
||||||
type: 'counterfactual',
|
|
||||||
request: snapshot,
|
|
||||||
plan,
|
|
||||||
placement,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
finish(() => reject(error));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,53 +742,39 @@ function explainCheckpointInWorker(
|
|||||||
interruption: PlanningReason,
|
interruption: PlanningReason,
|
||||||
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
|
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let worker: Worker;
|
let job: WorkerJob;
|
||||||
try {
|
|
||||||
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
||||||
type: 'module',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (callback: () => void) => {
|
const finish = (callback: () => void) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
worker.onmessage = null;
|
job.release();
|
||||||
worker.onerror = null;
|
|
||||||
worker.terminate();
|
|
||||||
if (cancel) latestWorkerCancels.delete(cancel);
|
|
||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
const cancel = () =>
|
|
||||||
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
|
|
||||||
latestWorkerCancels.add(cancel);
|
|
||||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
|
||||||
const response = event.data;
|
|
||||||
if (response?.type === 'explanation')
|
|
||||||
finish(() =>
|
|
||||||
resolve({
|
|
||||||
explanation: response.explanation,
|
|
||||||
explanationError: response.explanationError,
|
|
||||||
finalState: response.finalState,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
|
|
||||||
};
|
|
||||||
worker.onerror = () =>
|
|
||||||
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
|
|
||||||
try {
|
try {
|
||||||
worker.postMessage({
|
job = startWorkerJob(
|
||||||
type: 'explain-checkpoint',
|
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
|
||||||
request: snapshot,
|
(response) => {
|
||||||
plan,
|
if (response.type === 'explanation')
|
||||||
state,
|
finish(() =>
|
||||||
interruption,
|
resolve({
|
||||||
});
|
explanation: response.explanation,
|
||||||
|
explanationError: response.explanationError,
|
||||||
|
finalState: response.finalState,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
else if (response.type === 'error')
|
||||||
|
finish(() => reject(new Error(response.message)));
|
||||||
|
},
|
||||||
|
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
finish(() => reject(error));
|
reject(error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
job.post(
|
||||||
|
{ type: 'explain-checkpoint', request: snapshot, plan, state, interruption },
|
||||||
|
(error) => finish(() => reject(error)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
CounterfactualAnalysis,
|
||||||
|
LeavePlacement,
|
||||||
|
PlanOutcome,
|
||||||
|
PlanningExplanation,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningReason,
|
||||||
|
PlanningSnapshot,
|
||||||
|
PlanningStateSnapshot,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
import type { PlanningArtifacts } from './leave-planner.core';
|
||||||
|
|
||||||
|
// Message contract shared by the planning coordinator and its worker; types only.
|
||||||
|
export type WorkerPlanningRequest = {
|
||||||
|
calculationDate?: string;
|
||||||
|
declaredDays?: unknown;
|
||||||
|
horizon?: { start?: unknown; end?: unknown };
|
||||||
|
balances?: unknown;
|
||||||
|
rules?: unknown;
|
||||||
|
identities?: unknown;
|
||||||
|
labels?: unknown;
|
||||||
|
deadlineMs?: unknown;
|
||||||
|
progress?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerRequest =
|
||||||
|
| {
|
||||||
|
type: 'plan';
|
||||||
|
request: WorkerPlanningRequest;
|
||||||
|
}
|
||||||
|
| { type: 'cancel' }
|
||||||
|
| {
|
||||||
|
type: 'counterfactual';
|
||||||
|
request: PlanningSnapshot;
|
||||||
|
plan: PlanOutcome;
|
||||||
|
placement: LeavePlacement;
|
||||||
|
state?: PlanningStateSnapshot;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'explain-checkpoint';
|
||||||
|
request: PlanningSnapshot;
|
||||||
|
plan: PlanOutcome;
|
||||||
|
state: PlanningStateSnapshot;
|
||||||
|
interruption: PlanningReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerResponse =
|
||||||
|
| { type: 'heartbeat' }
|
||||||
|
| { type: 'progress'; stage: string }
|
||||||
|
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
|
||||||
|
| { type: 'result'; outcome: PlanningOutcome }
|
||||||
|
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
||||||
|
| {
|
||||||
|
type: 'explanation';
|
||||||
|
explanation?: PlanningExplanation;
|
||||||
|
explanationError?: PlanningReason;
|
||||||
|
finalState?: PlanningStateSnapshot;
|
||||||
|
}
|
||||||
|
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
|
||||||
|
| { type: 'error'; code?: string; message: string };
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
import type {
|
import type { PlanningOutcome } from './leave-planner.service';
|
||||||
CounterfactualAnalysis,
|
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
|
||||||
LeavePlacement,
|
|
||||||
PlanOutcome,
|
|
||||||
PlanningExplanation,
|
|
||||||
PlanningOutcome,
|
|
||||||
PlanningReason,
|
|
||||||
PlanningSnapshot,
|
|
||||||
PlanningStateSnapshot,
|
|
||||||
} from './leave-planner.service';
|
|
||||||
import {
|
import {
|
||||||
analyzeCounterfactualInternal,
|
analyzeCounterfactualInternal,
|
||||||
calculatePlanningAsyncInternal,
|
calculatePlanningAsyncInternal,
|
||||||
@@ -17,54 +9,6 @@ import {
|
|||||||
type PlanningArtifacts,
|
type PlanningArtifacts,
|
||||||
} from './leave-planner.core';
|
} from './leave-planner.core';
|
||||||
|
|
||||||
type WorkerPlanningRequest = {
|
|
||||||
calculationDate?: string;
|
|
||||||
declaredDays?: unknown;
|
|
||||||
horizon?: { start?: unknown; end?: unknown };
|
|
||||||
balances?: unknown;
|
|
||||||
rules?: unknown;
|
|
||||||
identities?: unknown;
|
|
||||||
labels?: unknown;
|
|
||||||
deadlineMs?: unknown;
|
|
||||||
progress?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkerRequest =
|
|
||||||
| {
|
|
||||||
type: 'plan';
|
|
||||||
request: WorkerPlanningRequest;
|
|
||||||
}
|
|
||||||
| { type: 'cancel' }
|
|
||||||
| {
|
|
||||||
type: 'counterfactual';
|
|
||||||
request: PlanningSnapshot;
|
|
||||||
plan: PlanOutcome;
|
|
||||||
placement: LeavePlacement;
|
|
||||||
state?: PlanningStateSnapshot;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'explain-checkpoint';
|
|
||||||
request: PlanningSnapshot;
|
|
||||||
plan: PlanOutcome;
|
|
||||||
state: PlanningStateSnapshot;
|
|
||||||
interruption: PlanningReason;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkerResponse =
|
|
||||||
| { type: 'heartbeat' }
|
|
||||||
| { type: 'progress'; stage: string }
|
|
||||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
|
|
||||||
| { type: 'result'; outcome: PlanningOutcome }
|
|
||||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
|
||||||
| {
|
|
||||||
type: 'explanation';
|
|
||||||
explanation?: PlanningExplanation;
|
|
||||||
explanationError?: PlanningReason;
|
|
||||||
finalState?: PlanningStateSnapshot;
|
|
||||||
}
|
|
||||||
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
|
|
||||||
| { type: 'error'; code?: string; message: string };
|
|
||||||
|
|
||||||
const worker = self as DedicatedWorkerGlobalScope;
|
const worker = self as DedicatedWorkerGlobalScope;
|
||||||
let activeCalculation: { cancelled: boolean } | undefined;
|
let activeCalculation: { cancelled: boolean } | undefined;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user