mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
1221 lines
51 KiB
JavaScript
1221 lines
51 KiB
JavaScript
import { accessSync, constants, lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
import { assetstore, algo, events, exec, material as materialNs, personality, schema, signals, util, verify } from '@evomap/evolver-core';
|
|
import { MATERIAL_RUNTIME_SESSION_SNAPSHOT_SCHEMA, materialHasRuntimeSessionSnapshot, materialSourceAvailable, runtimeSessionEvidenceSummariesFromMaterialPayload, runtimeSessionSourcesForMaterial, runtimeSessionSourcesForMaterialDetails, } from './materialSnapshot.js';
|
|
import { runRequiredSandboxedValidation } from './requiredSandboxValidation.js';
|
|
import { signalTokens } from './distillPrimitives.js';
|
|
import { readEvents, showCycle } from './commands.js';
|
|
import { RUNTIME_CAPABILITY_MATRIX, runtimeCapabilities } from './runtimeCapabilities.js';
|
|
import { parseRuntimeSessionSources } from './runtimeSessionSource.js';
|
|
import { LocalMemoryGraph, resolveLocalMemoryUserIdentity } from './localMemoryGraph.js';
|
|
import { makeCurriculumCapabilityGapsProvider } from './curriculumCapabilityGaps.js';
|
|
import { semanticIdfEnabled } from './semanticIdfConfig.js';
|
|
import { selectionFloorFromEnv, selectionGuardFromEnv, selectionPolicyFromEnv, } from './selectionPolicyConfig.js';
|
|
const CYCLE_GROUP = 'cycle';
|
|
const DEFAULT_LIMIT = 5;
|
|
const DEFAULT_WATCH_IDLE_MS = 1000;
|
|
const DEFAULT_WATCH_MAX_IDLE_MS = 30_000;
|
|
const DEFAULT_WATCH_BACKOFF = 2;
|
|
const CYCLE_USAGE = 'usage: evolver cycle capabilities [--json] | evolver cycle show <id> | evolver cycle status [--json] | evolver cycle recover [--limit N] [--json] | evolver cycle watch --repo <path> [--resume] [--idle-ms N] [--max-idle N] [--state-file <path>] [--validation-cmd <cmd>] [--timeout-ms N] [--json] | evolver cycle --repo <path> [--resume] [--limit N] [--target <path>] [--expected-effect <text>] [--runner claude|codex|gemini] [--validation-cmd <cmd>] [--timeout-ms N]\n';
|
|
const WATCH_USAGE = 'usage: evolver cycle watch --repo <path> [--resume] [--limit N] [--idle-ms N] [--max-idle-ms N] [--max-idle N] [--max-iterations N] [--state-file <path>] [--target <path>] [--expected-effect <text>] [--runner claude|codex|gemini] [--validation-cmd <cmd>] [--timeout-ms N] [--json]\n';
|
|
class CycleWatchStateWriteError extends Error {
|
|
constructor() {
|
|
super('cycle watch state file write failed');
|
|
this.name = 'CycleWatchStateWriteError';
|
|
}
|
|
}
|
|
function parseFlags(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (!arg?.startsWith('--'))
|
|
continue;
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith('--')) {
|
|
out[arg.slice(2)] = next;
|
|
i++;
|
|
}
|
|
else {
|
|
out[arg.slice(2)] = '';
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
function parseSwitchFlag(argv, name) {
|
|
const flag = `--${name}`;
|
|
let present = false;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg?.startsWith(`${flag}=`))
|
|
return null;
|
|
if (arg !== flag)
|
|
continue;
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith('--'))
|
|
return null;
|
|
present = true;
|
|
}
|
|
return present;
|
|
}
|
|
function parseRepeatedFlag(argv, name) {
|
|
const values = [];
|
|
const flag = `--${name}`;
|
|
const flagEquals = `${flag}=`;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg?.startsWith(flagEquals)) {
|
|
const value = arg.slice(flagEquals.length);
|
|
if (value.trim() === '')
|
|
return null;
|
|
values.push(value);
|
|
continue;
|
|
}
|
|
if (arg !== flag)
|
|
continue;
|
|
const value = argv[i + 1];
|
|
if (value === undefined || value.startsWith('--') || value.trim() === '')
|
|
return null;
|
|
values.push(value);
|
|
i++;
|
|
}
|
|
return values;
|
|
}
|
|
function makeCycleValidationHook(validationCmds, fallback, runSandboxedValidation = verify.runSandboxedValidation) {
|
|
if (validationCmds.length === 0)
|
|
return fallback;
|
|
return (task) => {
|
|
const fallbackHook = fallback?.(task);
|
|
return async (mutation, decision, cwd, signal) => {
|
|
const fallbackResult = fallbackHook ? await fallbackHook(mutation, decision, cwd, signal) : null;
|
|
if (fallbackResult && !fallbackResult.passed)
|
|
return fallbackResult;
|
|
const result = await runRequiredSandboxedValidation(task.validationCmds ?? validationCmds, cwd, { signal }, runSandboxedValidation);
|
|
return { passed: result.passed, score: result.score };
|
|
};
|
|
};
|
|
}
|
|
function parsePositiveInt(value, fallback) {
|
|
if (value === undefined || value.trim() === '')
|
|
return fallback;
|
|
const n = Number(value);
|
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
}
|
|
function parseRequiredPositiveIntFlag(flags, name) {
|
|
if (!(name in flags))
|
|
return undefined;
|
|
const value = flags[name] ?? '';
|
|
if (value.trim() === '')
|
|
return null;
|
|
const n = Number(value);
|
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
}
|
|
function parseRunner(value) {
|
|
if (value === undefined || value === 'claude')
|
|
return { ok: true, runner: 'claude' };
|
|
if (value.trim() === '')
|
|
return { ok: false, error: 'runner value is required (supported: claude, codex, gemini)' };
|
|
if (value === 'codex' || value === 'gemini')
|
|
return { ok: true, runner: value };
|
|
if (value === 'cursor' || value === 'antigravity' || value === 'kimi' || value === 'kiro' || value === 'opencode') {
|
|
const capability = RUNTIME_CAPABILITY_MATRIX[value].execute;
|
|
return { ok: false, error: `runner '${value}' execute capability is ${capability.status}: ${capability.evidence}` };
|
|
}
|
|
return { ok: false, error: `unknown runner '${value}' (supported: claude, codex, gemini)` };
|
|
}
|
|
function resolveMaterialRunner(material, requestedRunner) {
|
|
const sourceAgent = material.sourceAgent;
|
|
let runner;
|
|
switch (sourceAgent) {
|
|
case 'claude-code':
|
|
runner = 'claude';
|
|
break;
|
|
case 'codex':
|
|
case 'gemini':
|
|
runner = sourceAgent;
|
|
break;
|
|
case 'cursor':
|
|
case 'antigravity':
|
|
case 'kimi': {
|
|
const capability = RUNTIME_CAPABILITY_MATRIX[sourceAgent].execute;
|
|
return {
|
|
ok: false,
|
|
error: `sourceAgent '${sourceAgent}' execute capability is ${capability.status}: ${capability.evidence}`,
|
|
};
|
|
}
|
|
case 'kiro':
|
|
case 'opencode':
|
|
case 'generic-chat':
|
|
return { ok: false, error: `sourceAgent '${sourceAgent}' has no supported cycle runner` };
|
|
default:
|
|
return { ok: false, error: 'runtime_session material has no supported sourceAgent' };
|
|
}
|
|
if (requestedRunner !== undefined && requestedRunner !== runner) {
|
|
return {
|
|
ok: false,
|
|
error: `explicit runner '${requestedRunner}' does not match sourceAgent '${sourceAgent}' runner '${runner}'`,
|
|
};
|
|
}
|
|
return { ok: true, runner };
|
|
}
|
|
function printRuntimeCapabilities() {
|
|
for (const entry of runtimeCapabilities()) {
|
|
const values = ['ingest', 'inject', 'execute', 'verify', 'resume']
|
|
.map((capability) => `${capability}=${entry[capability].status}`)
|
|
.join(' ');
|
|
process.stdout.write(`${entry.runtime}: ${values}\n`);
|
|
}
|
|
}
|
|
function resolveMaterialCycleDeps(deps = {}) {
|
|
const materialStore = deps.materialStore ?? new materialNs.MaterialStore({ path: events.materialStorePath() });
|
|
const consumer = deps.consumer ?? new materialNs.ConsumerGroups({
|
|
store: materialStore,
|
|
path: join(dirname(events.materialStorePath()), 'cycle-consumer.json'),
|
|
});
|
|
const store = deps.store ?? new assetstore.LocalJsonlProvider(events.assetsDir());
|
|
const assetDir = store instanceof assetstore.LocalJsonlProvider ? store.baseDir : events.assetsDir();
|
|
const provenance = deps.provenance ?? new assetstore.ProvenanceStore(assetDir);
|
|
const review = deps.review ?? new assetstore.ReviewLedger(assetDir);
|
|
const ingestor = deps.ingestor ?? new events.Ingestor({ path: events.rootEventsPath() });
|
|
const personalityStore = deps.personality ?? new personality.PersonalityStore();
|
|
const memoryGraphDir = join(events.evomapHome(), 'evolution');
|
|
const memoryGraph = deps.memoryGraph ?? new LocalMemoryGraph({
|
|
dir: memoryGraphDir,
|
|
...resolveLocalMemoryUserIdentity(memoryGraphDir),
|
|
});
|
|
const engine = deps.engine ?? new algo.CycleEngine({
|
|
ingestor,
|
|
selection: algo.makeGeneSelectionPoint(),
|
|
store,
|
|
now: () => Date.now(),
|
|
personality: personalityStore,
|
|
capabilityGaps: makeCurriculumCapabilityGapsProvider(),
|
|
});
|
|
const selectionFloor = deps.selectionFloor ?? selectionFloorFromEnv();
|
|
return {
|
|
materialStore,
|
|
consumer,
|
|
store,
|
|
provenance,
|
|
review,
|
|
ingestor,
|
|
engine,
|
|
personality: personalityStore,
|
|
memoryGraph,
|
|
nativeSessionHome: deps.nativeSessionHome ?? homedir(),
|
|
disableSemanticIdf: deps.disableSemanticIdf ?? !semanticIdfEnabled(),
|
|
selectionPolicy: deps.selectionPolicy ?? selectionPolicyFromEnv(),
|
|
selectionGuard: deps.selectionGuard ?? selectionGuardFromEnv(),
|
|
...(selectionFloor !== undefined ? { selectionFloor } : {}),
|
|
};
|
|
}
|
|
export function cycleIdForMaterial(materialId) {
|
|
return `autoexec-material-${materialId}`;
|
|
}
|
|
function consumedEventExists(rootEvents, materialId) {
|
|
return rootEvents.some((event) => {
|
|
if (event.type !== 'cycle.consumed')
|
|
return false;
|
|
const payload = event.payload;
|
|
return payload?.materialId === materialId;
|
|
});
|
|
}
|
|
function terminalCycleFor(rootEvents, cycleId) {
|
|
for (const event of rootEvents) {
|
|
if (event.type !== 'cycle.solidified' && event.type !== 'cycle.failed' && event.type !== 'cycle.aborted')
|
|
continue;
|
|
const payload = event.payload;
|
|
if (payload?.cycleId !== cycleId)
|
|
continue;
|
|
if (event.type === 'cycle.solidified')
|
|
return { type: event.type, finalStage: 'solidified' };
|
|
if (event.type === 'cycle.failed')
|
|
return { type: event.type, finalStage: 'failed' };
|
|
return { type: event.type, finalStage: 'aborted' };
|
|
}
|
|
return null;
|
|
}
|
|
function consumedPayloadFor(rootEvents, materialId) {
|
|
for (const event of rootEvents) {
|
|
if (event.type !== 'cycle.consumed')
|
|
continue;
|
|
const payload = event.payload;
|
|
if (payload?.['materialId'] === materialId)
|
|
return payload;
|
|
}
|
|
return null;
|
|
}
|
|
function inFlightCycleExists(rootEvents, cycleId) {
|
|
let started = false;
|
|
for (const event of rootEvents) {
|
|
const payload = event.payload;
|
|
if (payload?.cycleId !== cycleId)
|
|
continue;
|
|
if (event.type === 'cycle.solidified' || event.type === 'cycle.failed' || event.type === 'cycle.aborted')
|
|
return false;
|
|
if (event.type === 'cycle.started')
|
|
started = true;
|
|
}
|
|
return started;
|
|
}
|
|
function failureConsumedPayload(payload) {
|
|
return payload['action'] === 'fail'
|
|
|| payload['status'] === 'parse_failed'
|
|
|| payload['status'] === 'no_signals'
|
|
|| payload['status'] === 'refused'
|
|
|| payload['status'] === 'failed';
|
|
}
|
|
function statusForMaterial(material, rootEvents) {
|
|
const consumed = consumedPayloadFor(rootEvents, material.materialId);
|
|
if (consumed)
|
|
return consumed['action'] === 'observe' || consumed['status'] === 'observed' ? 'observed' : 'consumed';
|
|
const cycleId = cycleIdForMaterial(material.materialId);
|
|
if (terminalCycleFor(rootEvents, cycleId))
|
|
return 'terminal_missing_consumed';
|
|
if (inFlightCycleExists(rootEvents, cycleId))
|
|
return 'in_flight';
|
|
return 'pending';
|
|
}
|
|
export function cycleLockPathForMaterial(materialStorePath, materialId) {
|
|
const safeId = materialId.replace(/[^A-Za-z0-9_.-]+/g, '_');
|
|
return `${materialStorePath}.cycle-${safeId}.lock`;
|
|
}
|
|
function tryAcquireCycleLock(lockPath) {
|
|
try {
|
|
util.acquireLock(lockPath, { maxTries: 3, waitMs: 1 });
|
|
return true;
|
|
}
|
|
catch (error) {
|
|
if (error instanceof util.LockTimeoutError)
|
|
return false;
|
|
throw error;
|
|
}
|
|
}
|
|
function fallbackSignalTokens(sigs) {
|
|
const out = new Set();
|
|
for (const sig of sigs) {
|
|
if (sig.strength === 'agent') {
|
|
const token = sig.text.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
|
|
if (token)
|
|
out.add(token);
|
|
}
|
|
else if (sig.strength === 'strong') {
|
|
out.add(sig.kind);
|
|
}
|
|
if (out.size >= 8)
|
|
break;
|
|
}
|
|
return [...out];
|
|
}
|
|
function materialSignals(sources) {
|
|
const sigs = sources.flatMap((source) => signals.extractSignals(source.turns));
|
|
const tokens = signalTokens(sigs);
|
|
return {
|
|
signals: tokens.length > 0 ? tokens : fallbackSignalTokens(sigs),
|
|
sourceCount: sources.length,
|
|
};
|
|
}
|
|
function resolveMaterialResume(material, runner, sources, liveSources) {
|
|
if (material.sourceAgent !== 'claude-code' && material.sourceAgent !== 'cursor') {
|
|
const capability = material.sourceAgent
|
|
? RUNTIME_CAPABILITY_MATRIX[material.sourceAgent].resume
|
|
: undefined;
|
|
return {
|
|
ok: false,
|
|
error: capability
|
|
? `sourceAgent '${material.sourceAgent}' resume capability is ${capability.status}: ${capability.evidence}`
|
|
: 'runtime_session material has no resumable sourceAgent',
|
|
};
|
|
}
|
|
const expectedRunner = material.sourceAgent === 'cursor' ? 'cursor' : 'claude';
|
|
if (runner !== expectedRunner) {
|
|
return { ok: false, error: `resume identity harness does not match runner '${runner}'` };
|
|
}
|
|
if (material.sourceAgent === 'cursor' && basename(material.sourcePath).toLowerCase() === 'state.vscdb') {
|
|
return { ok: false, error: 'Cursor state.vscdb composer identity is ingest-only and cannot be resumed' };
|
|
}
|
|
const hasSnapshotSchema = typeof material.payload === 'object'
|
|
&& material.payload !== null
|
|
&& material.payload['schema'] === MATERIAL_RUNTIME_SESSION_SNAPSHOT_SCHEMA;
|
|
const snapshot = runtimeSessionEvidenceSummariesFromMaterialPayload(material.payload);
|
|
if (hasSnapshotSchema && !snapshot) {
|
|
return { ok: false, error: 'resume identity snapshot metadata is invalid' };
|
|
}
|
|
if (snapshot && (snapshot.sourceCount !== 1 || snapshot.omittedSourceCount !== 0)) {
|
|
return {
|
|
ok: false,
|
|
error: `resume identity is ambiguous for sourceAgent '${material.sourceAgent}': snapshot reports ${snapshot.sourceCount} sessions (${snapshot.omittedSourceCount} omitted)`,
|
|
};
|
|
}
|
|
const snapshotSource = snapshot?.summaries[0];
|
|
if (snapshot && snapshotSource?.agent !== material.sourceAgent) {
|
|
return { ok: false, error: `resume identity harness does not match sourceAgent '${material.sourceAgent}' in snapshot` };
|
|
}
|
|
const snapshotSessionId = snapshotSource?.sessionId?.trim();
|
|
if (snapshot && !snapshotSessionId) {
|
|
return { ok: false, error: `resume identity missing from snapshot for sourceAgent '${material.sourceAgent}'` };
|
|
}
|
|
if (!liveSources && snapshotSource?.resumeIdentityProvenance !== 'canonical_native_transcript') {
|
|
return {
|
|
ok: false,
|
|
error: `resume identity snapshot lacks verified native transcript provenance for sourceAgent '${material.sourceAgent}'`,
|
|
};
|
|
}
|
|
// Signals may safely fall back to the durable snapshot, but resume identity must retain live provenance.
|
|
// Otherwise an existing transcript with a new path-bound identity and no parseable turns can be filtered out,
|
|
// making the stale snapshot identity appear to match itself and resuming the wrong native session.
|
|
const identitySources = liveSources ?? sources;
|
|
if (identitySources.length === 0) {
|
|
return { ok: false, error: `resume identity missing for sourceAgent '${material.sourceAgent}'` };
|
|
}
|
|
if (identitySources.some((source) => source.agent !== material.sourceAgent)) {
|
|
return { ok: false, error: `resume identity harness does not match sourceAgent '${material.sourceAgent}'` };
|
|
}
|
|
if (identitySources.length !== 1) {
|
|
return { ok: false, error: `resume identity is ambiguous for sourceAgent '${material.sourceAgent}': expected one session, found ${identitySources.length}` };
|
|
}
|
|
const sessionId = identitySources[0].sessionId?.trim();
|
|
if (!sessionId) {
|
|
return { ok: false, error: `resume identity missing for sourceAgent '${material.sourceAgent}'` };
|
|
}
|
|
if (snapshotSessionId && sessionId !== snapshotSessionId) {
|
|
return { ok: false, error: `resume identity does not match snapshot for sourceAgent '${material.sourceAgent}'` };
|
|
}
|
|
const resume = { runner, sessionId };
|
|
try {
|
|
exec.validateAgentSessionResume(resume, runner);
|
|
}
|
|
catch (error) {
|
|
const code = error instanceof exec.AgentSessionResumeError ? error.code : 'invalid_session_id';
|
|
return { ok: false, error: `resume identity is invalid for sourceAgent '${material.sourceAgent}' (${code})` };
|
|
}
|
|
return { ok: true, resume };
|
|
}
|
|
async function emitConsumed(ingestor, material, item, extra = {}) {
|
|
await ingestor.ingest({
|
|
type: 'cycle.consumed',
|
|
human: {
|
|
title: `material ${material.materialId} ${item.status}`,
|
|
...(item.reason ? { detail: item.reason } : {}),
|
|
severity: item.action === 'fail' ? 'warn' : 'info',
|
|
},
|
|
payload: {
|
|
materialId: material.materialId,
|
|
sourceKind: material.sourceKind,
|
|
kind: material.kind,
|
|
sourcePath: material.sourcePath,
|
|
action: item.action,
|
|
status: item.status,
|
|
...(item.cycleId ? { cycleId: item.cycleId } : {}),
|
|
...(item.reason ? { reason: item.reason } : {}),
|
|
...extra,
|
|
},
|
|
actor: { kind: 'machine' },
|
|
});
|
|
}
|
|
function ackOne(consumer, material) {
|
|
consumer.ack(CYCLE_GROUP, [material.materialId]);
|
|
}
|
|
async function processMaterial(material, opts, deps) {
|
|
const cycleId = cycleIdForMaterial(material.materialId);
|
|
const lockPath = cycleLockPathForMaterial(deps.materialStore.path, material.materialId);
|
|
if (!tryAcquireCycleLock(lockPath)) {
|
|
return {
|
|
materialId: material.materialId,
|
|
action: 'skip',
|
|
status: 'already_running',
|
|
cycleId,
|
|
reason: 'cycle consumer lock is held',
|
|
};
|
|
}
|
|
try {
|
|
const rootEvents = deps.ingestor.readAll();
|
|
if (consumedEventExists(rootEvents, material.materialId)) {
|
|
const item = { materialId: material.materialId, action: 'skip', status: 'already_consumed', cycleId };
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
const terminal = terminalCycleFor(rootEvents, cycleId);
|
|
if (terminal) {
|
|
const item = { materialId: material.materialId, action: 'skip', status: 'already_terminal', cycleId };
|
|
await emitConsumed(deps.ingestor, material, item, { terminalEvent: terminal.type, finalStage: terminal.finalStage });
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
if (inFlightCycleExists(rootEvents, cycleId)) {
|
|
return {
|
|
materialId: material.materialId,
|
|
action: 'skip',
|
|
status: 'already_running',
|
|
cycleId,
|
|
reason: 'cycle already started without terminal event',
|
|
};
|
|
}
|
|
if (material.sourceKind === 'proxy_trace' || material.kind === 'llm_trace') {
|
|
const item = { materialId: material.materialId, action: 'observe', status: 'observed' };
|
|
await emitConsumed(deps.ingestor, material, item, { reason: 'proxy_trace is observation-only for cycle consumer' });
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
const runner = resolveMaterialRunner(material, opts.runner);
|
|
if (!runner.ok) {
|
|
const item = {
|
|
materialId: material.materialId,
|
|
action: 'fail',
|
|
status: 'refused',
|
|
cycleId,
|
|
reason: runner.error,
|
|
};
|
|
await emitConsumed(deps.ingestor, material, item);
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
let sources;
|
|
let liveResumeSources;
|
|
try {
|
|
const resolvedSources = runtimeSessionSourcesForMaterialDetails(material, undefined, deps.nativeSessionHome);
|
|
const sourceAvailable = materialSourceAvailable(material);
|
|
if (opts.resume && sourceAvailable && resolvedSources.sourceError) {
|
|
throw resolvedSources.sourceError;
|
|
}
|
|
sources = resolvedSources.sources;
|
|
// A successful read is authoritative even if the source disappears before the availability check.
|
|
// Discarding captured provenance here could resume a stale snapshot identity.
|
|
liveResumeSources = opts.resume ? resolvedSources.liveSources : undefined;
|
|
}
|
|
catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
const item = { materialId: material.materialId, action: 'fail', status: 'parse_failed', cycleId, reason };
|
|
await emitConsumed(deps.ingestor, material, item);
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
const resume = opts.resume
|
|
? resolveMaterialResume(material, runner.runner, sources, liveResumeSources)
|
|
: undefined;
|
|
if (resume && !resume.ok) {
|
|
const item = {
|
|
materialId: material.materialId,
|
|
action: 'fail',
|
|
status: 'refused',
|
|
cycleId,
|
|
reason: resume.error,
|
|
};
|
|
await emitConsumed(deps.ingestor, material, item);
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
const extracted = materialSignals(sources);
|
|
if (extracted.sourceCount === 0 || extracted.signals.length === 0) {
|
|
const reason = extracted.sourceCount === 0 ? 'no parseable runtime session source' : 'no cycle-worthy session signals';
|
|
const item = { materialId: material.materialId, action: 'fail', status: 'no_signals', cycleId, reason };
|
|
await emitConsumed(deps.ingestor, material, item, { sourceCount: extracted.sourceCount, signalCount: extracted.signals.length });
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
if ((runner.runner === 'claude' || runner.runner === 'codex') && !opts.agent) {
|
|
const runnerName = runner.runner === 'claude' ? 'Claude' : 'Codex';
|
|
const item = {
|
|
materialId: material.materialId,
|
|
action: 'fail',
|
|
status: 'refused',
|
|
cycleId,
|
|
reason: `execute capability is unsupported: built-in ${runnerName} requires a verified host filesystem sandbox`,
|
|
};
|
|
await emitConsumed(deps.ingestor, material, item);
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
const repo = resolve(opts.repo);
|
|
const task = {
|
|
id: `material-${material.materialId}`,
|
|
repo,
|
|
target: opts.target ?? '.',
|
|
expectedEffect: opts.expectedEffect ?? 'evolve from consumed session material',
|
|
signals: extracted.signals,
|
|
...(opts.validationCmds ? { validationCmds: opts.validationCmds } : {}),
|
|
};
|
|
const safety = {
|
|
allowedRoots: [repo],
|
|
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
...opts.safety,
|
|
runner: runner.runner,
|
|
...(resume?.ok ? { resume: resume.resume } : {}),
|
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
};
|
|
const verdict = await exec.runAutoExecTask({
|
|
engine: deps.engine,
|
|
store: deps.store,
|
|
provenance: deps.provenance,
|
|
...(process.env['EVOLVER_BENCHMARK_ID'] !== undefined ? { benchmark: assetstore.benchmarkContext(process.env['EVOLVER_BENCHMARK_ID']) } : {}),
|
|
review: deps.review,
|
|
personality: deps.personality,
|
|
memoryGraph: deps.memoryGraph,
|
|
...(deps.disableSemanticIdf ? { disableSemanticIdf: true } : {}),
|
|
...(deps.selectionPolicy !== 'engine-health' ? { selectionPolicy: deps.selectionPolicy } : {}),
|
|
selectionGuard: deps.selectionGuard,
|
|
...(deps.selectionFloor !== undefined ? { selectionFloor: deps.selectionFloor } : {}),
|
|
...(opts.validate ? { validate: opts.validate } : {}),
|
|
...(opts.agent ? { agent: opts.agent } : {}),
|
|
...(opts.git ? { git: opts.git } : {}),
|
|
}, task, safety);
|
|
const item = {
|
|
materialId: material.materialId,
|
|
action: 'cycle',
|
|
status: verdict.status,
|
|
cycleId,
|
|
...(verdict.reason ? { reason: verdict.reason } : {}),
|
|
};
|
|
await emitConsumed(deps.ingestor, material, item, {
|
|
finalStage: verdict.finalStage,
|
|
signalCount: extracted.signals.length,
|
|
signals: extracted.signals,
|
|
runner: runner.runner,
|
|
...(resume?.ok && verdict.status === 'solidified' ? { resumedSession: true } : {}),
|
|
});
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
finally {
|
|
util.releaseLock(lockPath);
|
|
}
|
|
}
|
|
export async function runMaterialCycleConsumer(opts, injectedDeps = {}) {
|
|
const deps = resolveMaterialCycleDeps(injectedDeps);
|
|
const limit = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
|
|
const claimed = deps.consumer.claim(CYCLE_GROUP, limit);
|
|
const items = [];
|
|
for (const material of claimed) {
|
|
if (opts.signal?.aborted)
|
|
break;
|
|
items.push(await processMaterial(material, opts, deps));
|
|
}
|
|
return { claimed: claimed.length, processed: items.length, items };
|
|
}
|
|
function defaultSleep(ms, signal) {
|
|
return new Promise((resolveSleep) => {
|
|
if (signal?.aborted) {
|
|
resolveSleep();
|
|
return;
|
|
}
|
|
const timer = setTimeout(finish, ms);
|
|
function finish() {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', finish);
|
|
resolveSleep();
|
|
}
|
|
signal?.addEventListener('abort', finish, { once: true });
|
|
});
|
|
}
|
|
function watchResult(deps, stopped, counters) {
|
|
return {
|
|
ok: true,
|
|
group: 'cycle.watch',
|
|
...counters,
|
|
cursor: deps.consumer.position(CYCLE_GROUP),
|
|
stopped,
|
|
};
|
|
}
|
|
function positiveNumber(value, fallback) {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
}
|
|
function hasCycleWatchProgress(result, cursorBefore, cursorAfter) {
|
|
if (cursorAfter > cursorBefore)
|
|
return true;
|
|
return result.items.some((item) => (item.action === 'cycle'
|
|
|| item.action === 'observe'
|
|
|| item.action === 'fail'
|
|
|| item.status === 'already_terminal'));
|
|
}
|
|
function watchFailedItemCount(items) {
|
|
return items.filter((item) => item.action === 'fail' || item.status === 'refused' || item.status === 'failed').length;
|
|
}
|
|
export async function runMaterialCycleWatch(opts, injectedDeps = {}, hooks = {}) {
|
|
const deps = resolveMaterialCycleDeps(injectedDeps);
|
|
const sleep = injectedDeps.sleep ?? defaultSleep;
|
|
const idleBase = Math.floor(positiveNumber(opts.idleMs, DEFAULT_WATCH_IDLE_MS));
|
|
const idleMax = Math.floor(Math.max(idleBase, positiveNumber(opts.maxIdleMs, DEFAULT_WATCH_MAX_IDLE_MS)));
|
|
const backoff = Math.max(1, positiveNumber(opts.backoffMultiplier, DEFAULT_WATCH_BACKOFF));
|
|
const maxIdle = opts.maxIdle !== undefined && opts.maxIdle > 0 ? Math.floor(opts.maxIdle) : undefined;
|
|
const maxIterations = opts.maxIterations !== undefined && opts.maxIterations > 0 ? Math.floor(opts.maxIterations) : undefined;
|
|
let nextDelayMs = idleBase;
|
|
let iterations = 0;
|
|
let idleIterations = 0;
|
|
let totalClaimed = 0;
|
|
let totalProcessed = 0;
|
|
let totalFailedItems = 0;
|
|
for (;;) {
|
|
if (opts.signal?.aborted) {
|
|
return watchResult(deps, 'cancelled', {
|
|
iterations,
|
|
claimed: totalClaimed,
|
|
processed: totalProcessed,
|
|
idleIterations,
|
|
failedItems: totalFailedItems,
|
|
});
|
|
}
|
|
iterations += 1;
|
|
const cursorBefore = deps.consumer.position(CYCLE_GROUP);
|
|
const result = await runMaterialCycleConsumer(opts, deps);
|
|
const cursorAfter = deps.consumer.position(CYCLE_GROUP);
|
|
const progress = hasCycleWatchProgress(result, cursorBefore, cursorAfter);
|
|
const idle = !progress;
|
|
const iteration = {
|
|
ok: true,
|
|
group: 'cycle.watch',
|
|
iteration: iterations,
|
|
claimed: result.claimed,
|
|
processed: result.processed,
|
|
cursor: cursorAfter,
|
|
idle,
|
|
nextDelayMs: idle ? nextDelayMs : 0,
|
|
items: result.items,
|
|
};
|
|
totalClaimed += result.claimed;
|
|
totalProcessed += result.processed;
|
|
totalFailedItems += watchFailedItemCount(result.items);
|
|
hooks.onIteration?.(iteration);
|
|
if (idle)
|
|
idleIterations += 1;
|
|
else {
|
|
idleIterations = 0;
|
|
nextDelayMs = idleBase;
|
|
}
|
|
const stopped = opts.signal?.aborted
|
|
? 'cancelled'
|
|
: maxIterations !== undefined && iterations >= maxIterations
|
|
? 'max_iterations'
|
|
: maxIdle !== undefined && idleIterations >= maxIdle
|
|
? 'max_idle'
|
|
: null;
|
|
if (stopped) {
|
|
return watchResult(deps, stopped, {
|
|
iterations,
|
|
claimed: totalClaimed,
|
|
processed: totalProcessed,
|
|
idleIterations,
|
|
failedItems: totalFailedItems,
|
|
});
|
|
}
|
|
if (idle) {
|
|
await sleep(nextDelayMs, opts.signal);
|
|
nextDelayMs = Math.min(idleMax, Math.max(idleBase, Math.ceil(nextDelayMs * backoff)));
|
|
}
|
|
}
|
|
}
|
|
function createProcessCancellation() {
|
|
const controller = new AbortController();
|
|
let code = 1;
|
|
const interrupt = () => { code = 130; controller.abort(); };
|
|
const terminate = () => { code = 143; controller.abort(); };
|
|
process.once('SIGINT', interrupt);
|
|
process.once('SIGTERM', terminate);
|
|
return {
|
|
signal: controller.signal,
|
|
exitCode: () => code,
|
|
dispose: () => {
|
|
process.removeListener('SIGINT', interrupt);
|
|
process.removeListener('SIGTERM', terminate);
|
|
},
|
|
};
|
|
}
|
|
function combineSignals(primary, secondary) {
|
|
return secondary ? AbortSignal.any([primary, secondary]) : primary;
|
|
}
|
|
async function recoverMaterialCycleAudit(material, deps) {
|
|
const cycleId = cycleIdForMaterial(material.materialId);
|
|
const lockPath = cycleLockPathForMaterial(deps.materialStore.path, material.materialId);
|
|
if (!tryAcquireCycleLock(lockPath)) {
|
|
return {
|
|
materialId: material.materialId,
|
|
action: 'skip',
|
|
status: 'already_running',
|
|
cycleId,
|
|
reason: 'cycle consumer lock is held',
|
|
};
|
|
}
|
|
try {
|
|
const rootEvents = deps.ingestor.readAll();
|
|
if (consumedEventExists(rootEvents, material.materialId)) {
|
|
const item = { materialId: material.materialId, action: 'skip', status: 'already_consumed', cycleId };
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
const terminal = terminalCycleFor(rootEvents, cycleId);
|
|
if (terminal) {
|
|
const item = { materialId: material.materialId, action: 'skip', status: 'already_terminal', cycleId };
|
|
await emitConsumed(deps.ingestor, material, item, { terminalEvent: terminal.type, finalStage: terminal.finalStage });
|
|
ackOne(deps.consumer, material);
|
|
return item;
|
|
}
|
|
if (inFlightCycleExists(rootEvents, cycleId)) {
|
|
return {
|
|
materialId: material.materialId,
|
|
action: 'skip',
|
|
status: 'already_running',
|
|
cycleId,
|
|
reason: 'cycle already started without terminal event',
|
|
};
|
|
}
|
|
return {
|
|
materialId: material.materialId,
|
|
action: 'skip',
|
|
status: 'pending',
|
|
cycleId,
|
|
reason: 'cycle has not reached a terminal event',
|
|
};
|
|
}
|
|
finally {
|
|
util.releaseLock(lockPath);
|
|
}
|
|
}
|
|
async function recoverMaterialCycleConsumer(opts = {}, injectedDeps = {}) {
|
|
const deps = resolveMaterialCycleDeps(injectedDeps);
|
|
const limit = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
|
|
const claimed = deps.consumer.claim(CYCLE_GROUP, limit);
|
|
const items = [];
|
|
for (const material of claimed) {
|
|
items.push(await recoverMaterialCycleAudit(material, deps));
|
|
}
|
|
const recovered = items.filter((item) => item.status === 'already_terminal').length;
|
|
const alreadyConsumed = items.filter((item) => item.status === 'already_consumed').length;
|
|
return {
|
|
ok: true,
|
|
group: CYCLE_GROUP,
|
|
claimed: claimed.length,
|
|
recovered,
|
|
alreadyConsumed,
|
|
blocked: claimed.length - recovered - alreadyConsumed,
|
|
items,
|
|
};
|
|
}
|
|
function summarizeCycleConsumer(injectedDeps = {}) {
|
|
const deps = resolveMaterialCycleDeps(injectedDeps);
|
|
const rootEvents = deps.ingestor.readAll();
|
|
const materials = deps.materialStore.readAll();
|
|
const cursor = deps.consumer.position(CYCLE_GROUP);
|
|
let pending = 0;
|
|
let consumed = 0;
|
|
let inFlight = 0;
|
|
let failed = 0;
|
|
let observed = 0;
|
|
let recoverable = 0;
|
|
let recoverLimit = null;
|
|
const next = [];
|
|
materials.forEach((material, index) => {
|
|
const status = statusForMaterial(material, rootEvents);
|
|
const consumedPayload = consumedPayloadFor(rootEvents, material.materialId);
|
|
if (status === 'consumed' || status === 'observed')
|
|
consumed += 1;
|
|
if (status === 'observed')
|
|
observed += 1;
|
|
if (consumedPayload && failureConsumedPayload(consumedPayload))
|
|
failed += 1;
|
|
if (status === 'in_flight')
|
|
inFlight += 1;
|
|
if (status === 'pending' || status === 'terminal_missing_consumed')
|
|
pending += 1;
|
|
if (status === 'terminal_missing_consumed') {
|
|
recoverable += 1;
|
|
if (index >= cursor && recoverLimit === null)
|
|
recoverLimit = index - cursor + 1;
|
|
}
|
|
if (index >= cursor && next.length < 5 && status !== 'consumed' && status !== 'observed') {
|
|
const sourceAvailable = materialSourceAvailable(material);
|
|
const hasSnapshot = materialHasRuntimeSessionSnapshot(material);
|
|
next.push({
|
|
materialId: material.materialId,
|
|
sourceKind: material.sourceKind,
|
|
kind: material.kind,
|
|
capturedAt: material.capturedAt,
|
|
status,
|
|
sourceAvailable,
|
|
hasSnapshot,
|
|
recoverable: material.sourceKind === 'runtime_session' && (sourceAvailable || hasSnapshot),
|
|
});
|
|
}
|
|
});
|
|
return {
|
|
ok: true,
|
|
group: CYCLE_GROUP,
|
|
total: materials.length,
|
|
cursor,
|
|
pending,
|
|
consumed,
|
|
inFlight,
|
|
failed,
|
|
observed,
|
|
recoverable,
|
|
recoverLimit,
|
|
next,
|
|
};
|
|
}
|
|
function printResult(result) {
|
|
if (result.claimed === 0) {
|
|
process.stdout.write('cycle: no pending material\n');
|
|
return;
|
|
}
|
|
for (const item of result.items) {
|
|
const cycle = item.cycleId ? ` cycle=${item.cycleId}` : '';
|
|
const reason = item.reason ? ' reason=details-redacted' : '';
|
|
process.stdout.write(`cycle: material=${item.materialId} action=${item.action} status=${item.status}${cycle}${reason}\n`);
|
|
}
|
|
}
|
|
function printStatus(summary) {
|
|
process.stdout.write(`cycle status: group=${summary.group} total=${summary.total} cursor=${summary.cursor} pending=${summary.pending} consumed=${summary.consumed} inFlight=${summary.inFlight} failed=${summary.failed} observed=${summary.observed} recoverable=${summary.recoverable}\n`);
|
|
if (summary.next.length === 0) {
|
|
process.stdout.write(' next: none\n');
|
|
}
|
|
else {
|
|
process.stdout.write(' next:\n');
|
|
for (const item of summary.next) {
|
|
process.stdout.write(` ${item.materialId} sourceKind=${item.sourceKind} kind=${item.kind} capturedAt=${item.capturedAt} status=${item.status}\n`);
|
|
}
|
|
}
|
|
if (summary.recoverLimit !== null) {
|
|
process.stdout.write(` recovery: run \`evolver cycle recover --limit ${summary.recoverLimit}\` to emit missing cycle.consumed without running an agent\n`);
|
|
}
|
|
else if (summary.recoverable > 0) {
|
|
process.stdout.write(' recovery: terminal_missing_consumed exists before the current cursor; inspect cursor/root events before resetting\n');
|
|
}
|
|
if (summary.inFlight > 0) {
|
|
process.stdout.write(' note: in_flight material is left untouched until a terminal cycle event appears\n');
|
|
}
|
|
}
|
|
function printRecoverResult(result) {
|
|
process.stdout.write(`cycle recover: group=${result.group} claimed=${result.claimed} recovered=${result.recovered} alreadyConsumed=${result.alreadyConsumed} blocked=${result.blocked}\n`);
|
|
if (result.claimed === 0) {
|
|
process.stdout.write(' items: none\n');
|
|
return;
|
|
}
|
|
for (const item of result.items) {
|
|
const cycle = item.cycleId ? ` cycle=${item.cycleId}` : '';
|
|
const reason = item.reason ? ' reason=details-redacted' : '';
|
|
process.stdout.write(` material=${item.materialId} action=${item.action} status=${item.status}${cycle}${reason}\n`);
|
|
}
|
|
}
|
|
function printWatchIteration(iteration) {
|
|
process.stdout.write(`cycle watch: iteration=${iteration.iteration} claimed=${iteration.claimed} processed=${iteration.processed} cursor=${iteration.cursor} idle=${iteration.idle} nextDelayMs=${iteration.nextDelayMs}\n`);
|
|
for (const item of iteration.items) {
|
|
const cycle = item.cycleId ? ` cycle=${item.cycleId}` : '';
|
|
const reason = item.reason ? ' reason=details-redacted' : '';
|
|
process.stdout.write(` material=${item.materialId} action=${item.action} status=${item.status}${cycle}${reason}\n`);
|
|
}
|
|
}
|
|
function printWatchResult(result) {
|
|
process.stdout.write(`cycle watch stopped: reason=${result.stopped} iterations=${result.iterations} claimed=${result.claimed} processed=${result.processed} idleIterations=${result.idleIterations} failedItems=${result.failedItems} cursor=${result.cursor}\n`);
|
|
}
|
|
function redactedCycleItem(item) {
|
|
return item.reason ? { ...item, reason: 'details-redacted' } : item;
|
|
}
|
|
function redactedWatchIteration(iteration) {
|
|
return { ...iteration, items: iteration.items.map(redactedCycleItem) };
|
|
}
|
|
function prepareWatchStateFile(path) {
|
|
const trimmed = path?.trim();
|
|
if (!trimmed)
|
|
return null;
|
|
try {
|
|
const stat = lstatSync(trimmed);
|
|
if (!stat.isFile() || stat.isSymbolicLink())
|
|
return null;
|
|
accessSync(trimmed, constants.W_OK);
|
|
}
|
|
catch (error) {
|
|
if (error.code !== 'ENOENT')
|
|
return null;
|
|
}
|
|
try {
|
|
mkdirSync(dirname(trimmed), { recursive: true, mode: 0o700 });
|
|
return probeWatchStateFile(trimmed) ? trimmed : null;
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
function writeWatchStateFile(path, state) {
|
|
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
|
|
writeFileSync(tmp, `${JSON.stringify(state)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
renameSync(tmp, path);
|
|
}
|
|
function probeWatchStateFile(path) {
|
|
const dir = dirname(path);
|
|
const base = basename(path);
|
|
const probe = join(dir, `.${base}.${process.pid}.probe`);
|
|
const renamed = `${probe}.renamed`;
|
|
try {
|
|
writeFileSync(probe, '', { encoding: 'utf8', mode: 0o600 });
|
|
renameSync(probe, renamed);
|
|
unlinkSync(renamed);
|
|
return true;
|
|
}
|
|
catch {
|
|
try {
|
|
unlinkSync(probe);
|
|
}
|
|
catch { /* best effort */ }
|
|
try {
|
|
unlinkSync(renamed);
|
|
}
|
|
catch { /* best effort */ }
|
|
return false;
|
|
}
|
|
}
|
|
function tryWriteWatchStateFile(path, state, writer = writeWatchStateFile) {
|
|
try {
|
|
writer(path, state);
|
|
return true;
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
function hasFailedItem(item) {
|
|
return item.action === 'fail'
|
|
|| item.status === 'refused'
|
|
|| item.status === 'failed'
|
|
|| item.status === 'already_running';
|
|
}
|
|
function materialCycleExitCode(result) {
|
|
return result.items.some(hasFailedItem) ? 1 : 0;
|
|
}
|
|
export async function runCycleCommand(argv, injectedDeps = {}) {
|
|
if (argv[0] === 'capabilities') {
|
|
const flags = parseFlags(argv.slice(1));
|
|
if ('json' in flags)
|
|
process.stdout.write(`${JSON.stringify(runtimeCapabilities())}\n`);
|
|
else
|
|
printRuntimeCapabilities();
|
|
return 0;
|
|
}
|
|
if (argv[0] === 'show') {
|
|
if (!argv[1]) {
|
|
process.stderr.write('usage: evolver cycle show <id>\n');
|
|
return 1;
|
|
}
|
|
for (const t of showCycle(readEvents(), argv[1]).timeline)
|
|
process.stdout.write(`#${t.seq} ${t.type} ${t.title}\n`);
|
|
return 0;
|
|
}
|
|
if (argv[0] === 'status') {
|
|
const flags = parseFlags(argv.slice(1));
|
|
const summary = summarizeCycleConsumer(injectedDeps);
|
|
if ('json' in flags)
|
|
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
else
|
|
printStatus(summary);
|
|
return 0;
|
|
}
|
|
if (argv[0] === 'recover') {
|
|
const flags = parseFlags(argv.slice(1));
|
|
const result = await recoverMaterialCycleConsumer({
|
|
limit: parsePositiveInt(flags['limit'], DEFAULT_LIMIT),
|
|
}, injectedDeps);
|
|
if ('json' in flags)
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
else
|
|
printRecoverResult(result);
|
|
return 0;
|
|
}
|
|
if (argv[0] === 'watch') {
|
|
const watchArgv = argv.slice(1);
|
|
const flags = parseFlags(watchArgv);
|
|
const resume = parseSwitchFlag(watchArgv, 'resume');
|
|
if (resume === null) {
|
|
process.stderr.write(WATCH_USAGE);
|
|
return 1;
|
|
}
|
|
const runner = parseRunner(flags['runner']);
|
|
if (!runner.ok) {
|
|
process.stderr.write(`${runner.error}\n`);
|
|
return 1;
|
|
}
|
|
const validationCmds = parseRepeatedFlag(argv.slice(1), 'validation-cmd');
|
|
const repo = flags['repo'];
|
|
if (!repo) {
|
|
process.stderr.write(WATCH_USAGE);
|
|
return 1;
|
|
}
|
|
if (validationCmds === null) {
|
|
process.stderr.write(WATCH_USAGE);
|
|
return 1;
|
|
}
|
|
const validate = makeCycleValidationHook(validationCmds, injectedDeps.validate, injectedDeps.runSandboxedValidation);
|
|
const maxIdle = parseRequiredPositiveIntFlag(flags, 'max-idle');
|
|
const maxIterations = parseRequiredPositiveIntFlag(flags, 'max-iterations');
|
|
const timeoutMs = parseRequiredPositiveIntFlag(flags, 'timeout-ms');
|
|
if (maxIdle === null || maxIterations === null || timeoutMs === null) {
|
|
process.stderr.write(WATCH_USAGE);
|
|
return 1;
|
|
}
|
|
const stateFile = prepareWatchStateFile(flags['state-file']);
|
|
if ('state-file' in flags && !stateFile) {
|
|
process.stderr.write(WATCH_USAGE);
|
|
return 1;
|
|
}
|
|
const json = 'json' in flags;
|
|
let stateClaimed = 0;
|
|
let stateProcessed = 0;
|
|
let stateIdleIterations = 0;
|
|
let stateFailedItems = 0;
|
|
let lastIteration;
|
|
const writeState = injectedDeps.watchStateWriter ?? writeWatchStateFile;
|
|
const cancellation = createProcessCancellation();
|
|
const signal = combineSignals(cancellation.signal, injectedDeps.safety?.signal);
|
|
let result;
|
|
try {
|
|
result = await runMaterialCycleWatch({
|
|
repo,
|
|
...(resume ? { resume: true } : {}),
|
|
limit: parsePositiveInt(flags['limit'], DEFAULT_LIMIT),
|
|
target: flags['target'] || '.',
|
|
expectedEffect: flags['expected-effect'] || 'evolve from consumed session material',
|
|
...('runner' in flags ? { runner: runner.runner } : {}),
|
|
signal,
|
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
...(validationCmds.length > 0 ? { validationCmds } : {}),
|
|
...(validate ? { validate } : {}),
|
|
safety: { ...injectedDeps.safety, signal },
|
|
...(injectedDeps.agent ? { agent: injectedDeps.agent } : {}),
|
|
...(injectedDeps.git ? { git: injectedDeps.git } : {}),
|
|
idleMs: parsePositiveInt(flags['idle-ms'], DEFAULT_WATCH_IDLE_MS),
|
|
maxIdleMs: parsePositiveInt(flags['max-idle-ms'], DEFAULT_WATCH_MAX_IDLE_MS),
|
|
...(maxIdle !== undefined ? { maxIdle } : {}),
|
|
...(maxIterations !== undefined ? { maxIterations } : {}),
|
|
}, injectedDeps, {
|
|
onIteration: (iteration) => {
|
|
const safeIteration = redactedWatchIteration(iteration);
|
|
if (json)
|
|
process.stdout.write(`${JSON.stringify(safeIteration)}\n`);
|
|
else
|
|
printWatchIteration(iteration);
|
|
stateClaimed += iteration.claimed;
|
|
stateProcessed += iteration.processed;
|
|
stateFailedItems += watchFailedItemCount(iteration.items);
|
|
stateIdleIterations = iteration.idle ? stateIdleIterations + 1 : 0;
|
|
lastIteration = safeIteration;
|
|
if (stateFile && !tryWriteWatchStateFile(stateFile, {
|
|
ok: true,
|
|
group: 'cycle.watch.state',
|
|
updatedAt: new Date().toISOString(),
|
|
running: true,
|
|
iteration: iteration.iteration,
|
|
cursor: iteration.cursor,
|
|
claimed: stateClaimed,
|
|
processed: stateProcessed,
|
|
idleIterations: stateIdleIterations,
|
|
failedItems: stateFailedItems,
|
|
lastIteration: safeIteration,
|
|
}, writeState)) {
|
|
throw new CycleWatchStateWriteError();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
catch (error) {
|
|
if (!(error instanceof CycleWatchStateWriteError))
|
|
throw error;
|
|
if (stateFile) {
|
|
tryWriteWatchStateFile(stateFile, {
|
|
ok: true,
|
|
group: 'cycle.watch.state',
|
|
updatedAt: new Date().toISOString(),
|
|
running: false,
|
|
iteration: lastIteration?.iteration ?? 0,
|
|
cursor: lastIteration?.cursor ?? 0,
|
|
claimed: stateClaimed,
|
|
processed: stateProcessed,
|
|
idleIterations: stateIdleIterations,
|
|
failedItems: stateFailedItems,
|
|
...(lastIteration ? { lastIteration } : {}),
|
|
stopped: 'state_write_failed',
|
|
}, writeState);
|
|
}
|
|
process.stderr.write('cycle watch failed: state_write_failed\n');
|
|
return 1;
|
|
}
|
|
finally {
|
|
cancellation.dispose();
|
|
}
|
|
const finalState = {
|
|
ok: true,
|
|
group: 'cycle.watch.state',
|
|
updatedAt: new Date().toISOString(),
|
|
running: false,
|
|
iteration: result.iterations,
|
|
cursor: result.cursor,
|
|
claimed: result.claimed,
|
|
processed: result.processed,
|
|
idleIterations: result.idleIterations,
|
|
failedItems: result.failedItems,
|
|
...(lastIteration ? { lastIteration } : {}),
|
|
stopped: result.stopped,
|
|
};
|
|
if (stateFile && !tryWriteWatchStateFile(stateFile, finalState, writeState)) {
|
|
tryWriteWatchStateFile(stateFile, {
|
|
...finalState,
|
|
updatedAt: new Date().toISOString(),
|
|
stopped: 'state_write_failed',
|
|
}, writeState);
|
|
process.stderr.write('cycle watch failed: state_write_failed\n');
|
|
return 1;
|
|
}
|
|
if (json)
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
else
|
|
printWatchResult(result);
|
|
return result.stopped === 'cancelled' ? cancellation.exitCode() : 0;
|
|
}
|
|
const flags = parseFlags(argv);
|
|
const resume = parseSwitchFlag(argv, 'resume');
|
|
if (resume === null) {
|
|
process.stderr.write(CYCLE_USAGE);
|
|
return 1;
|
|
}
|
|
const runner = parseRunner(flags['runner']);
|
|
if (!runner.ok) {
|
|
process.stderr.write(`${runner.error}\n`);
|
|
return 1;
|
|
}
|
|
const validationCmds = parseRepeatedFlag(argv, 'validation-cmd');
|
|
const repo = flags['repo'];
|
|
if (!repo) {
|
|
process.stderr.write(CYCLE_USAGE);
|
|
return 1;
|
|
}
|
|
if (validationCmds === null) {
|
|
process.stderr.write(CYCLE_USAGE);
|
|
return 1;
|
|
}
|
|
const timeoutMs = parseRequiredPositiveIntFlag(flags, 'timeout-ms');
|
|
if (timeoutMs === null) {
|
|
process.stderr.write(CYCLE_USAGE);
|
|
return 1;
|
|
}
|
|
const validate = makeCycleValidationHook(validationCmds, injectedDeps.validate, injectedDeps.runSandboxedValidation);
|
|
const cancellation = createProcessCancellation();
|
|
const signal = combineSignals(cancellation.signal, injectedDeps.safety?.signal);
|
|
let result;
|
|
try {
|
|
result = await runMaterialCycleConsumer({
|
|
repo,
|
|
...(resume ? { resume: true } : {}),
|
|
limit: parsePositiveInt(flags['limit'], DEFAULT_LIMIT),
|
|
target: flags['target'] || '.',
|
|
expectedEffect: flags['expected-effect'] || 'evolve from consumed session material',
|
|
...('runner' in flags ? { runner: runner.runner } : {}),
|
|
signal,
|
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
...(validationCmds.length > 0 ? { validationCmds } : {}),
|
|
...(validate ? { validate } : {}),
|
|
safety: { ...injectedDeps.safety, signal },
|
|
...(injectedDeps.agent ? { agent: injectedDeps.agent } : {}),
|
|
...(injectedDeps.git ? { git: injectedDeps.git } : {}),
|
|
}, injectedDeps);
|
|
}
|
|
finally {
|
|
cancellation.dispose();
|
|
}
|
|
printResult(result);
|
|
return signal.aborted ? cancellation.exitCode() : materialCycleExitCode(result);
|
|
} |