Files
TORCH/src/runtime/continuation.mjs
T

97 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TorchError } from '../kernel/errors.mjs';
function statePath(control) { return join(control.stateRoot, 'continuation.json'); }
function read(control) {
const policyPath = join(control.stateRoot, 'continuation-policy.json');
const state = { enabled: false, maxTurnsPerDay: 12, areas: [], day: null, attempts: 0, handled: {},
...(existsSync(statePath(control)) ? JSON.parse(readFileSync(statePath(control), 'utf8')) : {}),
...(existsSync(policyPath) ? JSON.parse(readFileSync(policyPath, 'utf8')) : {}) };
if (typeof state.enabled !== 'boolean' || !Number.isInteger(state.maxTurnsPerDay)
|| state.maxTurnsPerDay < 1 || state.maxTurnsPerDay > 48 || !Array.isArray(state.areas)
|| !Number.isInteger(state.attempts) || state.attempts < 0 || !state.handled || typeof state.handled !== 'object') {
throw new TorchError('Continuation policy or attempt ledger is invalid; launch refused.', { code: 'CONTINUATION_POLICY_INVALID' });
}
return state;
}
function save(control, state, policy = false) {
const path = policy ? join(control.stateRoot, 'continuation-policy.json') : statePath(control);
const temporary = `${path}.${randomUUID()}.tmp`;
const value = policy ? { enabled: state.enabled, maxTurnsPerDay: state.maxTurnsPerDay, areas: state.areas }
: { day: state.day, attempts: state.attempts, handled: state.handled };
writeFileSync(temporary, JSON.stringify(value), { flag: 'wx', mode: 0o600 });
renameSync(temporary, path);
}
export function configureContinuation(control, { actorId, enabled, maxTurnsPerDay = 12 } = {}) {
control.assertOwnerActor(actorId);
if (typeof enabled !== 'boolean' || !Number.isInteger(maxTurnsPerDay) || maxTurnsPerDay < 1 || maxTurnsPerDay > 48) {
throw new TorchError('Continuation requires an explicit enable/pause and 1–48 daily turns.', { code: 'CONTINUATION_POLICY_INVALID' });
}
const state = { ...read(control), enabled, maxTurnsPerDay, areas: control.listAgents().map(a => a.areaId) };
save(control, state, true);
control.auditOwnerAction({ actorId, operation: 'runtime.continuation.configure', entityType: 'project', entityId: control.projectId,
details: { enabled, maxTurnsPerDay, areas: state.areas } });
return { enabled, maxTurnsPerDay, areas: state.areas, sessionsStarted: false };
}
export function planContinuation(control, backlog, { at = new Date(), limit = 3 } = {}) {
const state = read(control);
const day = at.toISOString().slice(0, 10);
const attempts = state.day === day ? state.attempts : 0;
if (!Number.isInteger(limit) || limit < 1 || limit > 3) throw new TorchError('Continuation limit must be 1–3.', { code: 'CONTINUATION_POLICY_INVALID' });
const metadata = JSON.parse(readFileSync(join(control.stateRoot, 'project.json'), 'utf8'));
if (!state.enabled || metadata.detachedAt || attempts >= state.maxTurnsPerDay) {
return { enabled: state.enabled, reason: metadata.detachedAt ? 'detached' : !state.enabled ? 'paused' : 'daily-turn-cap', candidates: [], attempts };
}
const tasks = backlog.list();
const candidates = [];
for (const areaId of state.areas) {
const agent = control.getAgent(areaId);
if (!['idle', 'offline', 'waiting'].includes(agent.state)
|| existsSync(join(control.stateRoot, 'sessions', 'turn-guards', areaId))) continue;
const messages = control.readMessages({ recipient: areaId, unacknowledgedOnly: true, limit: 1000 });
const active = tasks.filter(t => t.owner === areaId && ['assigned', 'in_progress'].includes(t.state));
const previous = state.handled[areaId] ?? {};
const freshMessages = messages.filter(m => !previous.messageIds?.includes(m.id));
const freshTasks = active.filter(t => previous.taskRevisions?.[t.id] !== t.revision);
if (!freshMessages.length && !freshTasks.length) continue;
const signal = { messageIds: messages.map(m => m.id), taskRevisions: Object.fromEntries(active.map(t => [t.id, t.revision])) };
const fingerprint = createHash('sha256').update(JSON.stringify(signal)).digest('hex');
candidates.push({ areaId, fingerprint, signal, messageCount: freshMessages.length, taskIds: freshTasks.map(t => t.id) });
}
candidates.sort((a, b) => Number(b.areaId === 'session-manager') - Number(a.areaId === 'session-manager'));
return { enabled: true, day, attempts, remainingTurns: state.maxTurnsPerDay - attempts,
candidates: candidates.slice(0, Math.min(limit, state.maxTurnsPerDay - attempts)), mutationPerformed: false };
}
export async function runContinuation(control, backlog, { actorId, launch, at, clock = () => at ?? new Date(), limit = 3 } = {}) {
control.assertOwnerActor(actorId);
if (typeof launch !== 'function') throw new TorchError('Continuation needs an authorized launcher.', { code: 'RUNTIME_EXECUTION_NOT_AUTHORIZED' });
const lock = join(control.stateRoot, 'continuation.lock');
try { mkdirSync(lock); } catch (error) {
if (error.code === 'EEXIST') return { reason: 'controller-active-or-unreconciled', launched: [] };
throw error;
}
try {
const plan = planContinuation(control, backlog, { at: clock(), limit });
const pending = [];
for (const candidate of plan.candidates) {
// Fresh plan and policy check before each launch; a concurrent pause wins.
const current = planContinuation(control, backlog, { at: clock(), limit: 3 });
if (!current.candidates.some(c => c.areaId === candidate.areaId && c.fingerprint === candidate.fingerprint)) continue;
const state = read(control);
if (state.day !== current.day) { state.day = current.day; state.attempts = 0; }
state.attempts++;
state.handled[candidate.areaId] = candidate.signal;
save(control, state); // Charge/reserve before launch; interrupted attempts are never refunded/retried silently.
let result;
try { result = launch(candidate.areaId); } catch { result = null; }
pending.push(Promise.resolve(result).catch(() => null).then(status => ({ areaId: candidate.areaId, status })));
}
return { launched: await Promise.all(pending), reason: plan.reason ?? null };
} finally { rmdirSync(lock); }
}