Files
stack/packages/processing-control/src/coordinator.mjs
T
rocko 9a7251403a processing: inspectable per-run stop seam for lifecycle fire
Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
2026-10-21 21:04:37 -05:00

75 lines
3.4 KiB
JavaScript

import { initializeControl, processingStatus, transition, ProcessingError } from './state.mjs';
import { readStops, sweepRuntimes } from './runtimes.mjs';
import { withProcessing, currentProcessing } from './effects.mjs';
// The control bus stays reachable. Only its processing, supervised workloads and background effects stop.
export function createProcessing({ dataRoot, graceMs = 5000, killWaitMs = 2000, containerStop = null,
onTransition = () => {}, onStop = () => {}, beforeStop = () => {}, onResume = () => {}, log = () => {} }) {
try { initializeControl(dataRoot); } catch (e) {
if (!e.code?.startsWith('processing-')) throw e;
}
let active = null, timer = null, closed = false;
let last = { quiescent: true, remaining: 0, stopped: 0 };
let settledGeneration = null;
const read = () => {
const state = processingStatus(dataRoot);
const counts = state.invalid || (state.halted && state.generation !== settledGeneration)
? { quiescent: false, remaining: null, stopped: 0 } : last;
return { ...state, ...counts, stopping: active !== null };
};
function reconcile() {
if (active) return active;
const state = processingStatus(dataRoot);
if (state.invalid) return Promise.resolve({ quiescent: false, remaining: null, stopped: 0 });
active = Promise.resolve().then(async () => {
await onTransition(state);
if (state.halted) {
const result = await sweepRuntimes(dataRoot, state.generation, { graceMs, killWaitMs, containerStop, beforeStop });
for (const stop of readStops(dataRoot, state.generation)) await onStop(stop);
const { results, ...summary } = result;
last = summary;
settledGeneration = state.generation;
} else last = { quiescent: true, remaining: 0, stopped: 0 };
return last;
}).finally(() => { active = null; });
return active;
}
return {
read,
work: fn => withProcessing(dataRoot, fn),
guard: () => currentProcessing()?.guard(),
signal: () => currentProcessing()?.signal,
stopReceipts: generation => readStops(dataRoot, generation),
// Publication is synchronous: no timer/model/HTTP wait can delay the admission fence.
halt(actor) {
const published = transition(dataRoot, { ...actor, halted: true });
return reconcile().then(summary => ({ ...published, ...summary }));
},
async resume(actor) {
if (active) throw new ProcessingError('processing-stop-pending');
const state = processingStatus(dataRoot);
if (state.invalid) throw new ProcessingError(state.code);
if (state.generation !== actor.generation) throw new ProcessingError('processing-generation-changed');
const summary = await reconcile();
if (!summary.quiescent) throw new ProcessingError('processing-stop-pending');
await onResume(state.generation);
const published = transition(dataRoot, { ...actor, halted: false });
await onTransition(published);
last = { quiescent: true, remaining: 0, stopped: 0 };
return { ...published, ...last };
},
reconcile,
start() {
if (timer || closed) return;
const tick = () => {
if (!active && processingStatus(dataRoot).halted) reconcile().catch(() => log('processing-reconcile-failed'));
};
tick(); timer = setInterval(tick, 250); timer.unref();
},
async close() {
closed = true; clearInterval(timer); timer = null;
await active;
},
};
}