blob: a42a98280f0c903b1f8b21742e51a110a7af735a [file]
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { SessionEvent } from '@maka/core';
import {
AutomationManager,
AutomationScheduler,
GoalManager,
type GoalTurnOutcome,
} from '@maka/runtime';
import { CliGoalContinuation } from '../cli-goal-continuation.js';
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
async function waitFor(condition: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 1_000;
while (!condition()) {
if (Date.now() >= deadline) assert.fail(message);
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
describe('CLI Goal continuation host', () => {
test('routes a scheduled heartbeat through the shared turn lifecycle and Goal FIFO', async () => {
const sessionId = 'session-1';
let now = 1_000;
let goalId = 0;
let evaluations = 0;
let admissions = 0;
const goalManager = new GoalManager({
generateId: () => `goal-${++goalId}`,
now: () => now,
});
goalManager.create(sessionId, 'ship');
const lifecycle = new CliGoalContinuation({
goalManager,
evaluator: {
evaluate: async () => {
evaluations++;
return JSON.stringify({
met: false,
impossible: false,
progress: true,
waiting: false,
reason: `evidence-${evaluations}`,
});
},
},
getRecentContext: async () => 'recent context',
});
const ownedCompletion = deferred<GoalTurnOutcome>();
lifecycle.bindHost({
admitTurn: () => {
admissions++;
return {
kind: 'prepared',
turnId: 'owned-turn',
start: () => ownedCompletion.promise,
};
},
});
const automationManager = new AutomationManager({
generateId: () => 'automation-1',
now: () => now,
random: () => 0,
});
const automation = automationManager.create({
kind: 'heartbeat',
name: 'check',
prompt: 'check status',
sessionId,
schedule: { type: 'interval', seconds: 10 },
});
assert.ok(!('error' in automation));
now += 11_000;
const timers: Array<() => void> = [];
const streamStarted = deferred<void>();
const releaseStream = deferred<void>();
const scheduler = new AutomationScheduler({
automationManager,
canFire: async () => true,
injectTurn: async () => {
const outcome = await lifecycle.runAutomationTurn({
sessionId,
turnId: 'heartbeat-turn',
start: async function* (): AsyncIterable<SessionEvent> {
streamStarted.resolve();
await releaseStream.promise;
yield {
type: 'complete',
id: 'heartbeat-complete',
turnId: 'heartbeat-turn',
ts: now,
stopReason: 'end_turn',
};
},
});
return { runId: 'heartbeat-turn', ok: outcome.kind === 'completed' };
},
setTimeout: (callback) => {
timers.push(callback);
return callback;
},
clearTimeout: () => {},
now: () => now,
});
scheduler.start();
timers.shift()?.();
await streamStarted.promise;
assert.ok(lifecycle.activities.whenIdle(sessionId));
releaseStream.resolve();
await waitFor(() => evaluations === 1, 'heartbeat completion did not enter the Goal FIFO');
await waitFor(() => admissions === 1, 'Goal admission did not resume after heartbeat drain');
await waitFor(
() => automationManager.get(automation.id)?.lastRunId === 'heartbeat-turn',
'scheduler did not observe the drained heartbeat result',
);
assert.equal(goalManager.get(sessionId)?.iterations, 1);
scheduler.dispose();
lifecycle.dispose();
goalManager.dispose();
});
});