86 lines
5.0 KiB
JavaScript
86 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// tools/secretary-worker.mjs
|
|
// Фон-воркер секретаря: ЕДИНСТВЕННЫЙ писатель протокола темы. Берёт замок темы (занят → выход),
|
|
// разбирает спаны из очереди по порядку (skip уже обработанных), пишет protocol.json/.md + оглавление,
|
|
// двигает курсор. Вне харнесс-таймаута: тяжёлые модели и полный вход — без 900с-стены.
|
|
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
import { join, basename } from 'node:path';
|
|
import { writeFileAtomic } from './secretary-layer1.mjs';
|
|
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
|
|
import { assembleSpan } from './secretary-span.mjs';
|
|
import { distillSpan } from './secretary-distill.mjs';
|
|
import { renderDoc } from './secretary-render-fluffy.mjs';
|
|
import { writeIndexSafely } from './secretary-index.mjs';
|
|
import { acquireThemeLock } from './secretary-lock.mjs';
|
|
import { dequeueSpan, readCursor, writeCursor } from './secretary-queue.mjs';
|
|
import { callAnthropicAPI } from './router-classifier.mjs';
|
|
|
|
function readProto(workDir) {
|
|
try { return JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8')); } catch { return EMPTY_PROTOCOL(); }
|
|
}
|
|
|
|
/** Сердце воркера. Все эффекты через deps — тестируется без процесса и без LLM.
|
|
* deps: { secdir, distill(proto, spanEx, span, ctx), render(proto, opts), acquire(workDir), now() }. */
|
|
export async function runWorker(workDir, deps) {
|
|
const {
|
|
secdir,
|
|
acquire = (wd) => acquireThemeLock(wd, { graceful: true }),
|
|
distill = (proto, spanEx, span, ctx) => distillSpan(proto, spanEx, span, ctx),
|
|
render = (proto, opts) => renderDoc(proto, opts),
|
|
now = () => Date.now(),
|
|
callModel = null,
|
|
} = deps || {};
|
|
|
|
const release = await acquire(workDir);
|
|
if (!release) return; // другой воркер уже крутится — он добьёт очередь
|
|
try {
|
|
const work = basename(workDir);
|
|
let job;
|
|
while ((job = dequeueSpan(workDir)) !== null) {
|
|
const span = job.span;
|
|
if (span.index <= readCursor(workDir)) continue; // уже обработан (идемпотентный повтор постановки)
|
|
try {
|
|
const rawFile = join(secdir, 'raw', `${job.session}.log`);
|
|
const rawText = existsSync(rawFile) ? readFileSync(rawFile, 'utf-8') : '';
|
|
const spanEx = assembleSpan(rawText, span);
|
|
let proto = readProto(workDir);
|
|
proto = await distill(proto, spanEx, { ...span }, { callModel, session: job.session });
|
|
mkdirSync(workDir, { recursive: true });
|
|
writeFileAtomic(join(workDir, 'protocol.json'), JSON.stringify(proto, null, 2));
|
|
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
|
|
writeFileAtomic(join(workDir, 'protocol.md'), render(proto, { work, date: stamp }));
|
|
await writeIndexSafely(join(secdir, 'содержание.md'), {
|
|
slug: work, title: work,
|
|
goal: (proto.subject && proto.subject.trim()) ? proto.subject.trim() : '(дело)',
|
|
status: proto.status || 'открыто', date: stamp,
|
|
});
|
|
} catch { /* мягкая деградация: спан ядовит (окно модели/парс) — не зацикливаемся, идём дальше */ }
|
|
writeCursor(workDir, span.index); // курсор двигаем всегда (даже при деградации) — at-least-once без петли
|
|
}
|
|
} finally { await release(); }
|
|
}
|
|
|
|
/** Модель для вызова: per-call (req.model из distillSpan = resolveModel(role)) > SECRETARY_LLM_MODEL > undefined. */
|
|
export function pickModel(req, env = process.env) {
|
|
return (req && typeof req === 'object' && req.model) || env.SECRETARY_LLM_MODEL || undefined;
|
|
}
|
|
|
|
// Реальный callModel: 900с/вызов, 2 повтора. Тяжёлые модели — в фоне, стены нет.
|
|
// NB: callAnthropicAPI хардкодит max_tokens=65536 (knob не пробрасывается) — отдельной опции вывода нет.
|
|
// Модель — per-call (req.model = resolveModel(role) из distillSpan), иначе 5 слотов decision#4 мертвы.
|
|
function realCallModel() {
|
|
const apiKey = process.env.SECRETARY_LLM_KEY;
|
|
if (!apiKey) return null;
|
|
const baseUrl = process.env.SECRETARY_LLM_BASE_URL || undefined;
|
|
const perAttemptTimeoutMs = Number(process.env.SECRETARY_LLM_TIMEOUT_MS) || 900_000;
|
|
const maxRetries = Number(process.env.SECRETARY_LLM_RETRIES) || 2;
|
|
return (req) => callAnthropicAPI(req, { apiKey, baseUrl, model: pickModel(req), perAttemptTimeoutMs, maxRetries });
|
|
}
|
|
|
|
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-worker.mjs');
|
|
if (isCli) {
|
|
const workDir = process.argv[2];
|
|
const secdir = join(workDir, '..');
|
|
runWorker(workDir, { secdir, callModel: realCallModel() }).then(() => process.exit(0)).catch(() => process.exit(1));
|
|
}
|