Files
2026-09-16 19:33:47 +08:00

1959 lines
99 KiB
JavaScript
Raw Permalink 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.
// `evolver autoexec` — the deployable form of the autonomous-exec daemon. A resident loop that drains a
// directory task queue through the productized kernel (exec.runAutoExecTask) with all safety controls, guarded
// by single-flight so a slow pass never stacks. DENY-BY-DEFAULT: allowedRoots starts empty (refuses every
// repo) until the operator explicitly allowlists one — autonomous edits to a real repo never happen by accident.
import { homedir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { events, assetstore, algo, exec, ops, observers, hooks, material as materialNs, util, verify, hub as hubNs, daemon as daemonNs, personality, signals as signalNs } from '@evomap/evolver-core';
import { runRequiredSandboxedValidation } from './requiredSandboxValidation.js';
import { startResidentLoop } from './daemonLoop.js';
import { connectPublicHub, createSolidifyPermitCheck, getMinReuseScore, ReuseCache, reuseBeforeSolve, resolveConfiguredHubUrl, resolveHubUrl, searchHubMetadata, } from '@evomap/evolver-adapter-public';
import { loadEnvFileFromEnv, proxyClientFromEnv } from '@evomap/evolver-mcp';
import { resolveValueDigestObserver } from './valueDigest.js';
import { resolveReflectionObserver } from './reflectionObserver.js';
import { resolveCursorRewriteObserver } from './cursorRewrite.js';
import { resolveMemoryEventMirrorObserver } from './memoryEventMirror.js';
import { resolveDistillObserver } from './distillObserver.js';
import { resolveAutoDistillLlm } from './autoDistillLlm.js';
import { resolveAutoDistillAntiGene as resolveAntiGeneDistill } from './autoDistillAntiGene.js';
import { runTranscriptDistillTick, transcriptDistillMode } from './autoDistillTranscript.js';
import { runSessionIngestTick, scanSessionDirs } from './sessionIngest.js';
import { LocalMemoryGraph, resolveLocalMemoryUserIdentity } from './localMemoryGraph.js';
import { resolveAtpAutoDeliver } from './atpAutoDeliver.js';
import { memoryEventsFromCycleLog, resolveTaskReceiver } from './taskReceiver.js';
import { createAtpClientFromEnv, getAtpConsent, resolveAtpHome, resolveAtpSenderId } from './atp.js';
import { AtpAutoBuyer } from './atpAutoBuyer.js';
import { resolveExplicitNodeCredentials, resolveIdentityHome } from './identityHome.js';
import { runAutobuyPrompt } from './atpAutobuyPrompt.js';
import * as solomode from './solo/mode.js';
import * as gitGuard from './solo/gitGuard.js';
import * as breaker from './solo/breaker.js';
import { initializeWorkflowStartupRecovery } from './workflowRuntime.js';
import { readAutoExecConfig } from './autoexecConfig.js';
import { makeCurriculumCapabilityGapsProvider } from './curriculumCapabilityGaps.js';
import { resolveLearningTrace } from './learningTrace.js';
import { semanticIdfEnabled } from './semanticIdfConfig.js';
import { selectionFloorFromEnv, selectionGuardFromEnv, selectionPolicyFromEnv, } from './selectionPolicyConfig.js';
export { readAutoExecConfig } from './autoexecConfig.js';
export { semanticIdfEnabled } from './semanticIdfConfig.js';
export { selectionFloorFromEnv, selectionGuardFromEnv, selectionPolicyFromEnv, } from './selectionPolicyConfig.js';
export function withAutoExecSelectionPolicy(deps, env = process.env) {
const policy = selectionPolicyFromEnv(env);
return policy === 'engine-health' ? deps : { ...deps, selectionPolicy: policy };
}
export function withAutoExecSelectionConfig(deps, env = process.env) {
const withPolicy = withAutoExecSelectionPolicy(deps, env);
const selectionGuard = selectionGuardFromEnv(env);
const selectionFloor = selectionFloorFromEnv(env);
return {
...withPolicy,
selectionGuard,
...(selectionFloor !== undefined ? { selectionFloor } : {}),
};
}
const ENV_FILE_UNAVAILABLE_DIAGNOSTIC = '[evolver-autoexec] env_file_unavailable\n';
/** Create the queue layout under <home>/autoexec/{tasks,inflight,done,refused}. */
export function ensureAutoExecDirs(base) {
const dirs = {
base,
tasks: join(base, 'tasks'),
inflight: join(base, 'inflight'),
done: join(base, 'done'),
refused: join(base, 'refused'),
};
for (const dir of [dirs.tasks, dirs.inflight, receiptDir(dirs), dirs.done, dirs.refused])
mkdirSync(dir, { recursive: true });
return dirs;
}
function clippedValidationText(value) {
return value.length <= 160 ? value : `${value.slice(0, 157)}...`;
}
function validationCommandDisplay(cmd) {
const script = verify.validationScriptPath(cmd);
if (script)
return `node ${script}`;
const executable = String(cmd ?? '').trim().split(/\s+/)[0] ?? '';
return executable || '<empty>';
}
export function summarizeSandboxedValidation(result) {
const parts = [];
if (result.skipped.length > 0) {
const skipped = result.skipped.map((item) => clippedValidationText(`node ${item.script} (${item.reason})`));
parts.push(`skipped ${result.skipped.length} validation command(s): ${skipped.join('; ')}`);
}
const failed = result.results.filter((item) => !item.allowed || !item.passed);
if (failed.length > 0) {
const failures = failed.map((item) => {
const status = item.allowed ? `exit ${String(item.exitCode)}` : 'denied';
return clippedValidationText(`${validationCommandDisplay(item.cmd)} (${status})`);
});
parts.push(`failed ${failed.length} validation command(s): ${failures.join('; ')}`);
}
return parts.length > 0 ? parts.join(' | ') : null;
}
function createAutoExecPersonalityStore() {
return new personality.PersonalityStore();
}
const CLAIMED_TASK_SUFFIX = '.claimed.json';
const STARTED_TASK_SUFFIX = '.started.json';
const AMBIGUOUS_RECOVERY_REASON = 'autoexec_crash_recovery_ambiguous: execution may have started before shutdown; refusing automatic retry, explicitly requeue after inspecting prior side effects';
function receiptDir(dirs) {
return join(dirs.base, 'receipts');
}
const RECEIPT_TEMP_SUFFIX = /^(.*\.started\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
function receiptArtifactClaimName(name) {
const tempMatch = RECEIPT_TEMP_SUFFIX.exec(name);
const claimName = tempMatch?.[1] ?? name;
const claim = parseAutoExecClaim(claimName);
if (!claim || claim.name !== claimName)
return null;
if (tempMatch)
return claim.phase === 'started' ? claimName : null;
return claim.phase === 'started' || claim.phase === 'legacy' ? claimName : null;
}
function cleanupReceiptArtifacts(dirs, shouldRemove) {
const dir = receiptDir(dirs);
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
if (!entry.isFile())
continue;
const claimName = receiptArtifactClaimName(entry.name);
if (!claimName || !shouldRemove(claimName))
continue;
try {
unlinkSync(join(dir, entry.name));
}
catch {
// Cleanup is retried by the next pass after the claim has been archived.
}
}
}
function cleanupCommittedClaimReceipt(dirs, claimName) {
const path = join(receiptDir(dirs), claimName);
try {
if (lstatSync(path).isFile())
unlinkSync(path);
}
catch {
// Orphan cleanup retries this after the claim has been archived.
}
}
function cleanupOrphanReceiptArtifacts(dirs) {
let activeClaims;
try {
activeClaims = new Set(readdirSync(dirs.inflight, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name));
}
catch {
return;
}
cleanupReceiptArtifacts(dirs, (claimName) => !activeClaims.has(claimName));
}
function parseAutoExecClaim(name) {
const separator = name.indexOf('--');
if (separator < 0 || separator + 2 >= name.length)
return null;
const encodedName = name.slice(separator + 2);
if (encodedName.endsWith(CLAIMED_TASK_SUFFIX)) {
return {
name,
originalName: `${encodedName.slice(0, -CLAIMED_TASK_SUFFIX.length)}.json`,
phase: 'claimed',
};
}
if (encodedName.endsWith(STARTED_TASK_SUFFIX)) {
return {
name,
originalName: `${encodedName.slice(0, -STARTED_TASK_SUFFIX.length)}.json`,
phase: 'started',
};
}
return encodedName.endsWith('.json') ? { name, originalName: encodedName, phase: 'legacy' } : null;
}
/**
* Whether a claim for this queue id is already checked out to the exec pass. The name has
* to match on both ends — `--hub-1` is a prefix of `--hub-10`, and treating one task as
* another's execution either strands it until its commitment lapses or runs it twice.
*/
export function hasInflightClaim(dirs, queueTaskId) {
// `.json` is the pre-suffix layout a running daemon may still have on disk after an
// upgrade; parseAutoExecClaim reads those as legacy claims, so this has to see them too.
const suffixes = [CLAIMED_TASK_SUFFIX, STARTED_TASK_SUFFIX, '.json'];
return readdirSync(dirs.inflight)
.some((name) => suffixes.some((suffix) => name.endsWith(`--${queueTaskId}${suffix}`)));
}
function taskClaimName(originalName) {
return `${randomUUID()}--${originalName.slice(0, -'.json'.length)}${CLAIMED_TASK_SUFFIX}`;
}
function archiveTaskPath(dest, originalName, claimName) {
const defaultArchive = join(dest, `task-${originalName}`);
return existsSync(defaultArchive) ? join(dest, `task-${claimName}`) : defaultArchive;
}
function isAutoExecVerdict(value, taskId) {
if (!value || typeof value !== 'object')
return false;
const candidate = value;
return candidate.taskId === taskId
&& (candidate.status === 'refused'
|| candidate.status === 'skipped'
|| candidate.status === 'solidified'
|| candidate.status === 'failed'
|| candidate.status === 'innovated'
|| candidate.status === 'unsafe_to_replay');
}
function persistedClaimReceipt(dirs, claim, taskId) {
try {
const path = join(receiptDir(dirs), claim.name);
if (!lstatSync(path).isFile())
return null;
const value = JSON.parse(readFileSync(path, 'utf8'));
if (!value || typeof value !== 'object')
return null;
const receipt = value;
if (receipt.claimName !== claim.name
|| (receipt.destination !== 'done' && receipt.destination !== 'refused')
|| !isAutoExecVerdict(receipt.verdict, taskId))
return null;
const expectedDestination = receipt.verdict.status === 'refused' || receipt.verdict.status === 'unsafe_to_replay' ? 'refused' : 'done';
if (receipt.destination !== expectedDestination)
return null;
return receipt;
}
catch (error) {
if (error.code !== 'ENOENT'
&& !(error instanceof SyntaxError))
throw error;
return null;
}
}
function persistClaimReceipt(dirs, receipt) {
const receipts = receiptDir(dirs);
const receiptPath = join(receipts, receipt.claimName);
const tempPath = join(receipts, `${receipt.claimName}.${randomUUID()}.tmp`);
try {
writeFileSync(tempPath, JSON.stringify(receipt, null, 2), { encoding: 'utf8', flag: 'wx' });
renameSync(tempPath, receiptPath);
}
finally {
try {
if (lstatSync(tempPath).isFile())
unlinkSync(tempPath);
}
catch {
// The atomic rename normally removes the temp path.
}
}
}
function recoverAmbiguousClaim(dirs, claim) {
const taskPath = join(dirs.inflight, claim.name);
let taskId = `recovery-${claim.name.slice(0, 64)}`;
try {
taskId = exec.normalizeAutoExecTask(JSON.parse(readFileSync(taskPath, 'utf8'))).id;
}
catch {
// The task may have been only partially published before the prior process stopped.
}
const receipt = persistedClaimReceipt(dirs, claim, taskId);
if (receipt) {
const dest = receipt.destination === 'refused' ? dirs.refused : dirs.done;
const canonicalVerdict = join(dest, claim.originalName);
writeFileSync(canonicalVerdict, JSON.stringify(receipt.verdict, null, 2));
renameSync(taskPath, archiveTaskPath(dest, claim.originalName, claim.name));
cleanupCommittedClaimReceipt(dirs, claim.name);
return receipt.verdict;
}
const verdict = {
taskId,
status: 'refused',
reason: AMBIGUOUS_RECOVERY_REASON,
};
writeFileSync(join(dirs.refused, `recovery-${claim.name}`), JSON.stringify(verdict, null, 2));
renameSync(taskPath, archiveTaskPath(dirs.refused, claim.originalName, claim.name));
cleanupCommittedClaimReceipt(dirs, claim.name);
return verdict;
}
/**
* One queue pass: atomically claim regular tasks/*.json into inflight/, then route each verdict to done/
* (success or failure) or refused/ (deny-by-default). A claim is atomically marked started before runOne;
* after a crash, started and legacy claims are ambiguous and fail closed instead of repeating side effects.
* Unreadable or incomplete claimed tasks stay pending because the drop-directory has no atomic publish marker.
*/
export async function autoExecPass(dirs, runOne, options = {}) {
const executePendingTasks = options.executePendingTasks ?? true;
const existingClaims = readdirSync(dirs.inflight, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => parseAutoExecClaim(entry.name))
.filter((claim) => claim !== null);
cleanupOrphanReceiptArtifacts(dirs);
const pendingClaims = existingClaims.filter((claim) => claim.phase === 'claimed');
const out = [];
for (const claim of existingClaims.filter((item) => item.phase !== 'claimed')) {
try {
out.push(recoverAmbiguousClaim(dirs, claim));
}
catch (error) {
const errorCode = error.code;
out.push({
taskId: `recovery-${claim.name.slice(0, claim.name.indexOf('--'))}`,
status: 'failed',
reason: `autoexec_crash_recovery_failed:${errorCode && /^[A-Z0-9_]+$/.test(errorCode) ? errorCode : 'unknown'}`,
});
}
}
cleanupOrphanReceiptArtifacts(dirs);
if (!executePendingTasks)
return out;
const files = readdirSync(dirs.tasks, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name);
for (const f of files) {
const claimName = taskClaimName(f);
try {
renameSync(join(dirs.tasks, f), join(dirs.inflight, claimName));
const claim = parseAutoExecClaim(claimName);
if (claim)
pendingClaims.push(claim);
}
catch (error) {
if (error.code !== 'ENOENT')
throw error;
}
}
for (const claim of pendingClaims) {
const taskPath = join(dirs.inflight, claim.name);
let task;
try {
task = exec.normalizeAutoExecTask(JSON.parse(readFileSync(taskPath, 'utf8')));
}
catch {
// A producer may still hold this path open and finish writing it after this pass.
// Leaving it in place prevents a transient partial read from acknowledging and losing the task.
continue;
}
const startedName = claim.name.slice(0, -CLAIMED_TASK_SUFFIX.length) + STARTED_TASK_SUFFIX;
const startedPath = join(dirs.inflight, startedName);
renameSync(taskPath, startedPath);
const v = await runOne(task).catch((e) => ({ taskId: task.id, status: 'failed', reason: e instanceof Error ? e.message : String(e) }));
const destination = v.status === 'refused' || v.status === 'unsafe_to_replay' ? 'refused' : 'done';
const dest = destination === 'refused' ? dirs.refused : dirs.done;
persistClaimReceipt(dirs, { claimName: startedName, destination, verdict: v });
writeFileSync(join(dest, claim.originalName), JSON.stringify(v, null, 2));
renameSync(startedPath, archiveTaskPath(dest, claim.originalName, startedName));
cleanupCommittedClaimReceipt(dirs, startedName);
out.push(v);
}
cleanupOrphanReceiptArtifacts(dirs);
return out;
}
/**
* Which repo Hub tasks land in. A Hub task does not say which one it belongs to, so a
* multi-repo allowlist cannot be spread across — one receiver serves one repo. Silently
* taking the first is a guess the operator never made, so EVOLVER_TASK_RECEIVER_REPO lets
* them say; it must still be allowlisted, or the receiver has no repo and stays off.
*/
export function taskReceiverRepo(env, allowedRoots) {
const chosen = env['EVOLVER_TASK_RECEIVER_REPO']?.trim();
if (!chosen)
return allowedRoots[0];
return allowedRoots.includes(chosen) ? chosen : undefined;
}
/** Runners cleared to take work off the queue, rather than only keeping the daemon alive. */
export function canExecuteQueue(runner) {
return runner === 'gemini' || runner === 'llm';
}
/** Keep unsupported built-in runners away from the execution queue without stopping the resident daemon. */
export function runnerBoundAutoExecPass(runner, dirs, runOne) {
return () => autoExecPass(dirs, runOne, { executePendingTasks: canExecuteQueue(runner) });
}
const ATP_CAPABILITY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,47}$/;
function atpCapabilityGaps(signals) {
return signalNs.capabilityGapsFromSignals(signals)
.filter((capability) => ATP_CAPABILITY_PATTERN.test(capability))
.slice(0, 8);
}
function hasAtpCapabilityGap(signals) {
return signals.includes('capability_gap') || atpCapabilityGaps(signals).length > 0;
}
function normalizedCapabilityGapSignals(signals) {
if (!hasAtpCapabilityGap(signals))
return signals;
const ordinary = signals.filter((signal) => signal !== 'capability_gap'
&& !signal.startsWith('capability_gap:')
&& !signal.startsWith('cap:'));
return [...new Set([...ordinary, ...atpCapabilityGapRequest(signals).signals])];
}
/** Build the deliberately small, public-safe ATP request allowed to leave autoexec after a verified Hub miss. */
export function atpCapabilityGapRequest(signals) {
const capabilities = atpCapabilityGaps(signals);
if (capabilities.length === 0)
capabilities.push('code_evolution');
const subject = capabilities[0].replace(/[_-]+/g, ' ');
return {
kind: 'capability_gap',
capabilities,
question: `I need help with ${subject}. Please provide one concrete, actionable solution and state any assumptions.`,
routingMode: 'fastest',
verifyMode: 'auto',
signals: ['capability_gap', ...capabilities.map((capability) => `cap:${capability}`)],
};
}
/** Schedule economic work outside the reuse/task critical path and contain all asynchronous failures. */
export function scheduleAtpAutoBuyForVerifiedMiss(miss, buyer) {
void buyer.consider(atpCapabilityGapRequest(miss.signals)).catch(() => undefined);
}
/** Resolve an autonomous buyer only after an explicit env/ack consent check; the buyer checks consent again. */
export function resolveAtpAutoBuyer(env = process.env, createClient = createAtpClientFromEnv) {
if (!getAtpConsent(env).enabled)
return undefined;
try {
return new AtpAutoBuyer({ client: createClient(env), env });
}
catch {
return undefined;
}
}
export function makeHubReuseSeam(cap, cache, ingestor, onAssetReused, onVerifiedSearchMiss, audit = {}) {
return async (signals, ctx) => {
const searchSignals = normalizedCapabilityGapSignals(signals);
// Pending value.reuse_hit ingests (#112). The onReuseHit callback is synchronous, so it cannot await the
// ingest itself; it stashes the (error-swallowing) promise here and the seam awaits them before returning,
// making the emission deterministically durable without letting an ingest error ever break reuse.
const pending = [];
const r = await reuseBeforeSolve(cap, cache, searchSignals, {
...(ctx?.cycleId ? { cycleId: ctx.cycleId } : {}),
...(audit.env ? { env: audit.env } : {}),
// Two consumers hang off a reuse HIT:
// - value-ledger emission (#112): a `value.reuse_hit` root_event so the ledger derives a real
// source=reuse entry (refs → assetId + cycleId) — live wiring, not a fixture;
// - used-asset tracking: the assetId feeds the fetch->outcome attribution claim the outcome
// report sends back to the hub (used_asset_ids). Fires on cache hits too — a cached payload
// is still a USE of that asset.
onReuseHit: (hit) => {
try {
onAssetReused?.(hit.assetId);
}
catch { /* tracking must never break reuse */ }
if (ingestor)
pending.push(emitReuseHit(ingestor, hit));
},
});
if (pending.length > 0)
await Promise.all(pending); // emitReuseHit never rejects (it swallows ingest errors)
const runId = ctx?.cycleId ?? null;
const taskId = runId?.startsWith('autoexec-') ? runId.slice('autoexec-'.length) : undefined;
if (r.action === 'fetch') {
const rawAsset = r.asset;
const assetId = r.candidate?.assetId ?? stringField(rawAsset, 'asset_id');
const assetType = stringField(rawAsset, 'type');
const sourceNodeId = stringField(rawAsset, 'source_node_id');
const chainId = stringField(rawAsset, 'chain_id');
const indexedTokenCost = assetId ? assetTokenCostBestEffort(audit.assetLog, assetId) : undefined;
const savings = hubNs.reuseSavingsForAsset(rawAsset, r.mode, indexedTokenCost);
const hit = {
runId,
...(assetId ? { assetId } : {}),
...(assetType ? { assetType } : {}),
...(sourceNodeId ? { sourceNodeId } : {}),
...(chainId ? { chainId } : {}),
...(r.score !== undefined ? { score: r.score } : {}),
mode: r.mode,
signals: [...searchSignals],
tokensSaved: savings.tokens_saved,
tokensSavedBasis: savings.tokens_saved_basis,
};
scheduleAssetCallBestEffort(audit.assetLog, {
run_id: runId,
action: 'hub_search_hit',
...(hit.assetId ? { asset_id: hit.assetId } : {}),
...(hit.assetType ? { asset_type: hit.assetType } : {}),
...(hit.sourceNodeId ? { source_node_id: hit.sourceNodeId } : {}),
...(hit.chainId ? { chain_id: hit.chainId } : {}),
...(hit.score !== undefined ? { score: hit.score } : {}),
mode: hit.mode,
signals: hit.signals,
...(taskId ? { extra: { task_id: taskId } } : {}),
});
try {
audit.onSearchHit?.(hit);
}
catch { /* audit state must never change reuse */ }
}
else if (r.reason !== 'no_signals') {
scheduleAssetCallBestEffort(audit.assetLog, {
run_id: runId,
action: 'hub_search_miss',
mode: r.mode,
signals: [...searchSignals],
...(r.reason ? { reason: r.reason } : {}),
...(taskId ? { extra: { task_id: taskId } } : {}),
});
}
if (r.action === 'solve-fresh'
&& (r.reason === 'no_results' || r.reason === 'below_threshold')
&& hasAtpCapabilityGap(searchSignals)) {
try {
onVerifiedSearchMiss?.({
reason: r.reason,
signals: [...searchSignals],
...(ctx?.cycleId ? { cycleId: ctx.cycleId } : {}),
});
}
catch { /* an optional economic side effect must never change reuse behavior */ }
}
return r.action === 'fetch' && r.candidate ? [r.candidate] : [];
};
}
/**
* Free search-only seam used by ATP auto-buy when reuse injection is disabled. It proves a capability miss with
* the same dual-leg search and score threshold as reuse, but never performs the paid fetch or contributes a Hub
* candidate.
*/
export function makeHubSearchMissProbe(cap, cache, onVerifiedSearchMiss, enabled, env = process.env, assetLog) {
return async (signals, ctx) => {
if (!enabled() || !hasAtpCapabilityGap(signals))
return [];
// ATP consent authorizes a bounded capability query, not disclosure of local error signatures/transcripts.
const signalList = atpCapabilityGapRequest(signals).signals;
if (signalList.length === 0)
return [];
const search = await searchHubMetadata(cap, cache, signalList, { env });
if (!search.complete) {
scheduleAssetCallBestEffort(assetLog, {
run_id: ctx?.cycleId ?? null,
action: 'hub_search_miss',
mode: 'direct',
signals: search.signals,
reason: 'search_error',
});
return [];
}
const { metadata, searchCached } = search;
let reason;
if (metadata.length === 0) {
reason = 'no_results';
}
else {
const ranked = hubNs.scoreSearchResults(signalList, metadata);
if (hubNs.decideReuse(ranked, { threshold: getMinReuseScore(env) }).action === 'solve-fresh') {
reason = 'below_threshold';
}
}
if (!reason)
return [];
scheduleAssetCallBestEffort(assetLog, {
run_id: ctx?.cycleId ?? null,
action: 'hub_search_miss',
mode: 'direct',
signals: signalList,
reason,
extra: { search_cached: searchCached },
});
try {
onVerifiedSearchMiss({
reason,
signals: signalList,
...(ctx?.cycleId ? { cycleId: ctx.cycleId } : {}),
});
}
catch { /* ATP scheduling must never change the solve path */ }
return [];
};
}
function stringField(record, key) {
const value = record?.[key];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function assetTokenCostBestEffort(log, assetId) {
try {
return log?.assetCostIndex?.()[assetId];
}
catch {
return undefined;
}
}
function appendAssetCallBestEffort(log, entry) {
try {
log?.append(entry);
}
catch { /* local audit failure must never break reuse or task completion */ }
}
function scheduleAssetCallBestEffort(log, entry) {
if (!log)
return;
setImmediate(() => { appendAssetCallBestEffort(log, entry); });
}
/** Append a `value.reuse_hit` root_event for a reuse hit (#112). Never throws (reuse stays an optimization). */
async function emitReuseHit(ingestor, hit) {
try {
const payload = {
assetId: hit.assetId, cycleId: hit.cycleId, signalFingerprint: hit.signalFingerprint, fetchTokens: hit.fetchTokens,
};
await ingestor.ingest({
type: ops.VALUE_REUSE_HIT_EVENT,
human: { title: `reuse hit: ${hit.assetId}`, detail: `cycle ${hit.cycleId}` },
payload: payload,
});
}
catch { /* emission must never break the reuse path */ }
}
/** Build the same event for byte-budget admission and durable emission. */
export function injectEvent(info) {
const payload = {
geneIds: info.sourceQualifications ? info.sourceQualifications.map((item) => item.assetId) : info.geneIds,
...(info.sourceQualifications ? assetstore.compactQualificationReceipts(info.sourceQualifications) : {}),
...(info.contentSchema ? { contentSchema: info.contentSchema } : {}),
...(info.content ? { content: info.content } : {}),
...(info.omittedByBudget !== undefined ? { omittedByBudget: info.omittedByBudget } : {}),
...(info.cycleId ? { cycleId: info.cycleId } : {}),
...(info.sessionId ? { sessionId: info.sessionId } : {}),
...(info.outcome ? { outcome: info.outcome } : {}),
};
return {
type: ops.VALUE_INJECT_EVENT,
human: { title: `injected ${info.geneIds.length} gene(s)`, ...(info.cycleId ? { detail: `cycle ${info.cycleId}` } : {}) },
payload: payload,
};
}
export async function emitInject(ingestor, info) {
try {
if (info.geneIds.length === 0)
return;
await ingestor.ingest(injectEvent(info));
}
catch { /* emission must never break the injection path */ }
}
/**
* Build the SessionStart inject emission seam (#123) — the public-repo composition point that connects core's
* sink-agnostic `onInject` callback to the real `value.inject` root_event sink. Core (composeSessionStartWithRecap)
* never imports the Ingestor; THIS is where the ingestor is wired in, mirroring makeHubReuseSeam for reuse. The
* returned callback is synchronous (the seam core calls is sync) and fire-and-forget: it stashes the
* error-swallowing emit promise so the caller can await durability without ever letting an ingest error surface.
* Returns undefined when there is no ingestor (no sink → no emission, exactly today's behavior).
*/
export function makeInjectEmitter(ingestor) {
if (!ingestor)
return undefined;
const pending = [];
return {
onInject: (info) => { pending.push(emitInject(ingestor, info)); },
flush: async () => { if (pending.length > 0)
await Promise.all(pending); }, // emitInject never rejects
};
}
const DEFAULT_QUESTION_SUBMIT_TIMEOUT_MS = 3_000;
const QUESTION_SUBMIT_TIMEOUT_ENV = 'EVOLVER_QUESTION_SUBMIT_TIMEOUT_MS';
const SOLIDIFY_VERIFY_ENV = 'EVOLVER_SOLIDIFY_VERIFY';
const QUESTION_CONTEXT_SENSITIVE_RE = /secret|token|api[_-]?key|password|passwd|credential|bearer|authorization|cookie|session[_-]?id|private[_-]?key|oauth|refresh[_-]?token/i;
const DISTILL_TICK_EXPLORATION_CONTEXT = 'runtime session material ingestion';
const DISTILL_TICK_EXPLORATION_SIGNALS = ['stable_success_plateau', 'runtime_session_material'];
/**
* Map an autoexec verdict onto the hub outcome vocabulary (success | failed).
* refused/skipped ran no cycle — there is nothing to attribute, so no report.
*/
export function verdictToOutcomeStatus(status) {
if (status === 'solidified')
return 'success';
if (status === 'failed')
return 'failed';
return null;
}
export function questionGeneratorStatePath(home = events.evomapHome()) {
return join(home, 'evolution', 'question_generator_state.json');
}
function readQuestionState(path) {
try {
const raw = JSON.parse(readFileSync(path, 'utf8'));
return {
lastAskedAt: typeof raw.lastAskedAt === 'string' ? raw.lastAskedAt : null,
lastUrgentAt: typeof raw.lastUrgentAt === 'string' ? raw.lastUrgentAt : null,
lastExploreAt: typeof raw.lastExploreAt === 'string' ? raw.lastExploreAt : null,
recentQuestions: Array.isArray(raw.recentQuestions) ? raw.recentQuestions.map(String).slice(-30) : [],
};
}
catch {
return { lastAskedAt: null, lastUrgentAt: null, lastExploreAt: null, recentQuestions: [] };
}
}
function writeQuestionState(path, state) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({
lastAskedAt: state.lastAskedAt ?? null,
lastUrgentAt: state.lastUrgentAt ?? null,
lastExploreAt: state.lastExploreAt ?? null,
recentQuestions: [...(state.recentQuestions ?? [])].slice(-30),
}, null, 2)}\n`);
}
function laterQuestionTimestamp(left, right) {
if (!left)
return right ?? null;
if (!right)
return left;
const leftMs = Date.parse(left);
const rightMs = Date.parse(right);
if (Number.isFinite(leftMs) && Number.isFinite(rightMs))
return rightMs >= leftMs ? right : left;
if (Number.isFinite(leftMs))
return left;
if (Number.isFinite(rightMs))
return right;
return right >= left ? right : left;
}
function mergeQuestionStates(current, incoming) {
const recency = (state) => Math.max(...[state.lastAskedAt, state.lastUrgentAt, state.lastExploreAt]
.map((value) => value ? Date.parse(value) : Number.NEGATIVE_INFINITY)
.filter(Number.isFinite), Number.NEGATIVE_INFINITY);
const [older, newer] = recency(current) <= recency(incoming)
? [current, incoming]
: [incoming, current];
const recentQuestions = [];
for (const question of [...(older.recentQuestions ?? []), ...(newer.recentQuestions ?? [])]) {
const existing = recentQuestions.indexOf(question);
if (existing >= 0)
recentQuestions.splice(existing, 1);
recentQuestions.push(question);
}
return {
lastAskedAt: laterQuestionTimestamp(current.lastAskedAt, incoming.lastAskedAt),
lastUrgentAt: laterQuestionTimestamp(current.lastUrgentAt, incoming.lastUrgentAt),
lastExploreAt: laterQuestionTimestamp(current.lastExploreAt, incoming.lastExploreAt),
recentQuestions: recentQuestions.slice(-30),
};
}
const MAX_PENDING_QUESTION_STATES = 30;
function rememberPendingQuestionState(pendingStates, state) {
pendingStates.add(state);
while (pendingStates.size > MAX_PENDING_QUESTION_STATES) {
const oldest = pendingStates.values().next().value;
if (!oldest)
break;
pendingStates.delete(oldest);
}
}
function questionSubmitTimeoutMs(options) {
const raw = options.timeoutMs ?? Number(options.env?.[QUESTION_SUBMIT_TIMEOUT_ENV]);
if (Number.isFinite(raw) && raw > 0)
return Math.round(raw);
return DEFAULT_QUESTION_SUBMIT_TIMEOUT_MS;
}
function observeQuestionSubmit(submitPromise) {
return submitPromise
.then((receipts) => ({ status: 'submitted', receipts }))
.catch(() => ({ status: 'failed', receipts: [] }));
}
function hasAcceptedQuestionReceipt(receipts) {
if (receipts.length === 0)
return true;
return receipts.some((r) => !r.error);
}
function writeQuestionStateIfAccepted(path, state, receipts) {
if (!hasAcceptedQuestionReceipt(receipts))
return false;
writeQuestionState(path, mergeQuestionStates(readQuestionState(path), state));
return true;
}
function writeLateQuestionStateIfAccepted(submitResult, statePath, state, onSettled) {
void submitResult
.then((submitted) => {
if (submitted.status !== 'submitted')
return;
try {
writeQuestionStateIfAccepted(statePath, state, submitted.receipts);
}
catch {
// The caller already received a timeout; late cooldown persistence is best-effort.
}
})
.finally(onSettled);
}
async function submitQuestionsWithTimeout(submitResult, timeoutMs) {
let timer;
try {
return await Promise.race([
submitResult,
new Promise((resolve) => {
timer = setTimeout(() => resolve({ status: 'timeout', receipts: [] }), timeoutMs);
}),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
async function submitProactiveQuestions(cap, task, options, pendingStates) {
if (options.enabled === false || !cap.questions)
return { status: 'disabled', questionCount: 0 };
try {
const statePath = options.statePath ?? questionGeneratorStatePath();
const context = task.publicQuestionContext?.trim();
if (!context)
return { status: 'skipped', questionCount: 0 };
if (QUESTION_CONTEXT_SENSITIVE_RE.test(context))
return { status: 'skipped', questionCount: 0 };
const effectiveState = [...pendingStates].reduce((state, pending) => mergeQuestionStates(state, pending), readQuestionState(statePath));
const result = hubNs.generateQuestions({
signals: task.signals,
sessionTranscript: context,
state: effectiveState,
now: options.now?.() ?? Date.now(),
env: options.env ?? process.env,
});
if (!result.changed || result.questions.length === 0)
return { status: 'skipped', questionCount: 0 };
const submitResult = observeQuestionSubmit(cap.questions.submit(result.questions));
const submitted = await submitQuestionsWithTimeout(submitResult, questionSubmitTimeoutMs(options));
if (submitted.status === 'timeout') {
rememberPendingQuestionState(pendingStates, result.state);
writeLateQuestionStateIfAccepted(submitResult, statePath, result.state, () => pendingStates.delete(result.state));
}
if (submitted.status !== 'submitted')
return { status: submitted.status, questionCount: result.questions.length };
if (!writeQuestionStateIfAccepted(statePath, result.state, submitted.receipts))
return { status: 'not_accepted', questionCount: result.questions.length };
return { status: 'submitted', questionCount: result.questions.length };
}
catch {
// Proactive questions are optional ecosystem hints; hub failures must not affect task completion.
return { status: 'failed', questionCount: 0 };
}
}
export function makeHubQuestionLink(cap, options = {}) {
let queue = Promise.resolve();
const pendingStates = new Set();
return {
submitForTask: (task) => {
const run = queue.then(() => submitProactiveQuestions(cap, task, options, pendingStates));
queue = run.then(() => undefined, () => undefined);
return run;
},
};
}
export function shouldSubmitProactiveQuestionsForTask(task) {
return typeof task.publicQuestionContext === 'string' && task.publicQuestionContext.trim().length > 0;
}
export function autoexecLockFailureMessage(error) {
if (error instanceof util.UnsafeLockPathError) {
return `evolver autoexec: unsafe single-instance lock (${error.reason}) - inspect the Evolver state directory before retrying\n`;
}
if (error instanceof util.LockTimeoutError) {
return 'evolver autoexec: another daemon holds the single-instance lock - refusing to start a second instance\n';
}
return 'evolver autoexec: failed to acquire the single-instance lock - refusing to start\n';
}
export function autoexecLockReleaseFailureMessage(error) {
const reason = error instanceof util.UnsafeLockPathError
? error.reason
: typeof error === 'object' && error !== null && typeof error.reason === 'string'
? error.reason
: null;
if (reason) {
return `evolver autoexec: could not safely release the single-instance lock (${reason}); inspect the Evolver state directory if the next start is blocked\n`;
}
return 'evolver autoexec: could not release the single-instance lock; inspect the Evolver state directory if the next start is blocked\n';
}
export function releaseAutoexecLock(lockPath, deps = {}) {
try {
const result = (deps.releaseLock ?? util.releaseLock)(lockPath);
if (result && !result.released) {
(deps.stderr ?? ((text) => { process.stderr.write(text); }))(autoexecLockReleaseFailureMessage(result));
return false;
}
return true;
}
catch (error) {
(deps.stderr ?? ((text) => { process.stderr.write(text); }))(autoexecLockReleaseFailureMessage(error));
return false;
}
}
/** Keep a detached daemon alive when its output consumer closes, without hiding other stream failures. */
export function installAutoexecBrokenPipeGuards(deps = {}) {
const stdout = deps.stdout ?? process.stdout;
const stderr = deps.stderr ?? process.stderr;
const onError = (error) => {
if (error?.code === 'EPIPE')
return;
throw error;
};
stdout.on('error', onError);
stderr.on('error', onError);
let installed = true;
return () => {
if (!installed)
return;
installed = false;
removeAutoexecStreamErrorListener(stdout, onError);
removeAutoexecStreamErrorListener(stderr, onError);
};
}
function removeAutoexecStreamErrorListener(stream, listener) {
if (stream.off) {
stream.off('error', listener);
return;
}
stream.removeListener?.('error', listener);
}
/**
* Own autoexec's process-signal lifetime. V2 intentionally has no hot-reload seam: SIGHUP is informational and
* keeps the current generation alive; SIGINT/SIGTERM drain the resident loop before cleanup and exit.
*/
export function waitForAutoexecShutdown(deps) {
const signals = deps.signals ?? process;
const write = deps.write ?? ((message) => { process.stdout.write(message); });
return new Promise((resolve, reject) => {
let stopping = false;
let listenersInstalled = true;
const removeListeners = () => {
if (!listenersInstalled)
return;
listenersInstalled = false;
for (const [signal, listener] of listeners)
removeAutoexecSignalListener(signals, signal, listener);
};
const finish = (failure) => {
removeListeners();
try {
deps.cleanup();
}
catch (cleanupError) {
reject(cleanupError);
return;
}
if (failure)
reject(failure.error);
else
resolve(0);
};
const shutdown = (signal) => {
if (stopping)
return;
stopping = true;
write(`\nevolver autoexec: ${signal} -> graceful stop (finishing in-flight, releasing lock)\n`);
void Promise.resolve()
.then(() => deps.stop())
.then(() => { finish(); }, (error) => { finish({ error }); });
};
const onSighup = () => {
if (stopping)
return;
write('\nevolver autoexec: SIGHUP received; hot reload is not supported, continuing with current configuration (restart the daemon to apply changes)\n');
};
const listeners = [
['SIGINT', () => { shutdown('SIGINT'); }],
['SIGTERM', () => { shutdown('SIGTERM'); }],
['SIGHUP', onSighup],
];
for (const [signal, listener] of listeners)
signals.on(signal, listener);
});
}
function removeAutoexecSignalListener(source, signal, listener) {
if (source.off) {
source.off(signal, listener);
return;
}
source.removeListener?.(signal, listener);
}
export function urgentQuestionRuntimeWiringStatus() {
return hubNs.URGENT_QUESTION_RUNTIME_WIRING_STATUS;
}
function safeDistillContextToken(value) {
const token = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
if (!/^[a-z][a-z0-9_-]{3,40}$/.test(token))
return null;
if (QUESTION_CONTEXT_SENSITIVE_RE.test(token))
return null;
return token;
}
function safeDistillSignalToken(prefix, value) {
const token = safeDistillContextToken(value)?.replace(/-/g, '_');
if (!token)
return null;
return `${prefix}_${token}`.slice(0, 40);
}
function distillTickPublicQuestionContext(source) {
const safeRecorded = Math.max(1, Math.floor(source.recorded));
const anchors = [
...(source.sourceAgents ?? []).map((agent) => safeDistillContextToken(agent)),
...(source.signalKinds ?? []).map((kind) => safeDistillContextToken(kind)),
...(source.signalStrengths ?? []).map((strength) => safeDistillContextToken(strength)),
].filter((token) => Boolean(token)).slice(0, 6);
const anchorText = anchors.length > 0 ? anchors.join(' ') : 'healthy runtime session material';
return `${DISTILL_TICK_EXPLORATION_CONTEXT} recorded ${safeRecorded} material items ${anchorText} ${anchorText}`;
}
function distillTickPublicSignals(source) {
const metadataSignals = [
...(source.sourceAgents ?? []).map((agent) => safeDistillSignalToken('agent', agent)),
...(source.signalKinds ?? []).map((kind) => safeDistillSignalToken('signal', kind)),
...(source.signalStrengths ?? []).map((strength) => safeDistillSignalToken('strength', strength)),
].filter((token) => Boolean(token));
return [...DISTILL_TICK_EXPLORATION_SIGNALS, ...metadataSignals].slice(0, 8);
}
export async function submitDistillTickExplorationQuestion(questions, source) {
if (!questions)
return { status: 'disabled', questionCount: 0 };
if (!source || !('recorded' in source))
return { status: 'skipped', questionCount: 0 };
const recorded = Math.floor(source.recorded);
if (!Number.isFinite(recorded) || recorded <= 0)
return { status: 'skipped', questionCount: 0 };
return await questions.submitForTask({
id: `autoexec-session-material-ingest-${recorded}`,
repo: 'autoexec-runtime',
target: 'runtime session material ingestion',
expectedEffect: 'runtime session material ingestion',
publicQuestionContext: distillTickPublicQuestionContext(source),
signals: distillTickPublicSignals(source),
});
}
export function scheduleAutoExecHubSideEffects(task, verdict, links) {
if (links.questions && verdictToOutcomeStatus(verdict.status) !== null && shouldSubmitProactiveQuestionsForTask(task)) {
void links.questions.submitForTask(task).catch(() => undefined);
}
if (links.outcome)
void links.outcome.reportOutcome(task, verdict).catch(() => undefined);
}
/**
* Compose the reuse seam and the outcome reporter. Reuse HITs still emit value-ledger observability, but the
* Hub outcome `used_asset_ids` claim comes only from the finished verdict's selected/executed asset. A fetched
* candidate can be withheld by trust/review gates or lose selection, so fetch-time attribution would overclaim.
* Reporting never throws and never blocks the verdict.
*/
export function makeHubLink(cap, ingestor, reportEnabled = true, onVerifiedSearchMiss, assetLog, env) {
const maxPendingAuditRuns = 256;
const searchHitsByRun = new Map();
const seam = makeHubReuseSeam(cap, new ReuseCache(), ingestor, undefined, onVerifiedSearchMiss, {
assetLog,
...(env ? { env } : {}),
onSearchHit: (hit) => {
if (!hit.runId || !hit.assetId)
return;
if (!searchHitsByRun.has(hit.runId) && searchHitsByRun.size >= maxPendingAuditRuns) {
const oldestRun = searchHitsByRun.keys().next().value;
if (oldestRun !== undefined)
searchHitsByRun.delete(oldestRun);
}
// One reuse-before-solve invocation can fetch at most one winner. Replace any stale state left by an
// unexpectedly aborted earlier run that reused the same task id.
searchHitsByRun.set(hit.runId, new Map([[hit.assetId, hit]]));
},
});
return {
seam,
reportOutcome: async (task, verdict) => {
const status = verdictToOutcomeStatus(verdict.status);
const runId = `autoexec-${task.id}`;
const searchHits = searchHitsByRun.get(runId);
searchHitsByRun.delete(runId);
const usedAssetIds = Array.isArray(verdict.usedAssetIds)
? [...new Set(verdict.usedAssetIds.filter((id) => typeof id === 'string' && id.length > 0))]
: [];
// Drain attribution synchronously (before a duplicate task id can start), then yield before the production
// sink's synchronous append and all Hub I/O. The detached side effect cannot delay routing to done/.
await new Promise((resolve) => { setImmediate(resolve); });
if (status !== null) {
for (const assetId of usedAssetIds) {
const hit = searchHits?.get(assetId);
const mode = hit?.mode;
appendAssetCallBestEffort(assetLog, {
run_id: runId,
action: mode === 'reference' ? 'asset_reference' : 'asset_reuse',
asset_id: assetId,
...(hit?.assetType ? { asset_type: hit.assetType } : {}),
...(hit?.sourceNodeId ? { source_node_id: hit.sourceNodeId } : {}),
...(hit?.chainId ? { chain_id: hit.chainId } : {}),
...(hit?.score !== undefined ? { score: hit.score } : {}),
...(mode ? { mode } : {}),
...(hit ? { tokens_saved: hit.tokensSaved, tokens_saved_basis: hit.tokensSavedBasis } : {}),
signals: hit?.signals ?? task.signals,
...(verdict.reason ? { reason: verdict.reason } : {}),
extra: { task_id: task.id, verdict_status: verdict.status, outcome_status: status },
});
}
}
if (!reportEnabled || status === null)
return;
try {
await cap.recordOutcome({
signals: task.signals,
status,
...(verdict.outcome?.score !== undefined ? { score: verdict.outcome.score } : {}),
...(usedAssetIds.length > 0 ? { usedAssetIds } : {}),
});
}
catch { /* recordOutcome never throws by contract; belt-and-braces — reporting must never break the pass */ }
if (usedAssetIds.length > 0 && cap.recordReuseResult) {
await Promise.all(usedAssetIds.map(async (assetId) => {
try {
const receipt = await cap.recordReuseResult?.({
assetId,
outcome: status === 'success' ? 'success' : 'failed',
taskId: task.id,
...(verdict.reason ? { reason: verdict.reason } : {}),
});
appendAssetCallBestEffort(assetLog, {
run_id: runId,
action: receipt?.recorded ? 'hub_review_submitted' : 'hub_review_rejected',
asset_id: assetId,
signals: task.signals,
...(safeAssetCallReason(receipt?.reason) ? { reason: safeAssetCallReason(receipt?.reason) } : {}),
extra: { task_id: task.id, outcome_status: status, ...(receipt?.id ? { receipt_id: receipt.id } : {}) },
});
}
catch {
appendAssetCallBestEffort(assetLog, {
run_id: runId,
action: 'hub_review_failed',
asset_id: assetId,
signals: task.signals,
reason: 'record_reuse_result_failed',
extra: { task_id: task.id, outcome_status: status },
});
}
}));
}
},
};
}
function safeAssetCallReason(value) {
return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,100}$/.test(value) ? value : undefined;
}
function resolvePublicHub(env = process.env, connectHub = connectPublicHub) {
if (hubMode(env) !== 'public')
return undefined;
const dir = resolveIdentityHome(env);
if (!existsSync(join(dir, 'token.json')))
return undefined;
try {
const hubUrl = resolveHubUrl(env);
const resolvedSenderId = resolveAtpSenderId(env);
if (!resolvedSenderId)
return undefined;
const { hub } = connectHub({ hubUrl, authMode: 'oauth', evomapDir: dir, senderId: () => resolvedSenderId });
return hub;
}
catch {
return undefined;
}
}
function hubMode(env) {
const mode = String(env['EVOMAP_HUB_MODE'] ?? 'public').trim().toLowerCase();
return mode === 'public' || mode === 'private' ? mode : undefined;
}
/**
* Resolve public/private reuse wiring. Reuse remains default-off. When a public ATP miss handler is supplied,
* a consent-gated free search-only seam may still be composed so auto-buy can prove a miss independently of
* reuse injection; that seam never fetches or injects Hub assets.
*/
export function resolveHubLink(env = process.env, ingestor, connectHub = connectPublicHub, onVerifiedSearchMiss, assetLog) {
const envFile = loadEnvFileFromEnv(env);
if (envFile.error) {
process.stderr.write(ENV_FILE_UNAVAILABLE_DIAGNOSTIC);
return undefined;
}
const reuseEnabled = env['EVOLVER_REUSE_BEFORE_SOLVE'] === '1';
if (!reuseEnabled && !onVerifiedSearchMiss)
return undefined;
const resolvedAssetLog = assetLog ?? new hubNs.AssetCallLog(events.assetCallLogPath(env));
if (hubMode(env) === 'private') {
if (!reuseEnabled)
return undefined;
const proxy = proxyClientFromEnv(env);
return proxy ? makeHubLink(makeProxyHubCapability(proxy), ingestor, env['EVOLVER_OUTCOME_REPORT'] !== '0', undefined, resolvedAssetLog, env) : undefined;
}
const hub = resolvePublicHub(env, connectHub);
if (!hub)
return undefined;
if (!reuseEnabled && onVerifiedSearchMiss) {
return {
seam: makeHubSearchMissProbe(hub, new ReuseCache(), onVerifiedSearchMiss, () => {
try {
return getAtpConsent(env).enabled;
}
catch {
return false;
}
}, env, resolvedAssetLog),
reportOutcome: async () => undefined,
};
}
// Pass the ingestor so a reuse hit emits a value.reuse_hit root_event (#112) — the live source the value
// ledger derives source=reuse entries from.
return makeHubLink(hub, ingestor, env['EVOLVER_OUTCOME_REPORT'] !== '0', onVerifiedSearchMiss, resolvedAssetLog, env);
}
export function resolveHubQuestionLink(env = process.env, connectHub = connectPublicHub) {
if (env['EVOLVER_OUTCOME_REPORT'] === '0')
return undefined;
if (hubMode(env) !== 'public')
return undefined;
const hub = resolvePublicHub(env, connectHub);
if (!hub)
return undefined;
if (!hub.questions)
return undefined;
return makeHubQuestionLink(hub, { env });
}
export function resolveMemoryEventMirror(env = process.env, connectHub = connectPublicHub) {
if (hubMode(env) === 'private') {
return { enabled: false, reason: 'no_hub', observer: null };
}
const disabled = resolveMemoryEventMirrorObserver(env, null);
if (disabled.reason === 'disabled')
return disabled;
return resolveMemoryEventMirrorObserver(env, resolvePublicHub(env, connectHub));
}
function solidifyVerifyFlag(env) {
const raw = env[SOLIDIFY_VERIFY_ENV]?.trim().toLowerCase();
if (raw === '0' || raw === 'false' || raw === 'off')
return 'off';
if (raw === '1' || raw === 'true' || raw === 'on')
return 'on';
return 'auto';
}
export function offlinePermitDir(env = process.env) {
return join(resolveAtpHome(env), 'evolution', 'offline-permit');
}
export function makeOfflineSolidifyPermitGate(permits) {
return () => {
const result = permits.consumeOfflinePermit();
if (result.ok)
return { ok: true, reason: 'offline_permit' };
return {
ok: false,
reason: `hub_solidify_offline_denied:${result.error}`,
...(result.detail ? { detail: result.detail } : {}),
};
};
}
export function resolveSolidifyPermitGate(env = process.env, connectHub = connectPublicHub, opts = {}) {
const flag = solidifyVerifyFlag(env);
if (flag === 'off')
return undefined;
const mode = hubMode(env);
if (mode !== 'public') {
return flag === 'on'
? () => ({
ok: false,
reason: mode === 'private'
? 'hub_solidify_verify_unavailable:private_hub_not_supported'
: 'hub_solidify_verify_unavailable:invalid_hub_mode',
})
: undefined;
}
const hubUrl = resolveConfiguredHubUrl(env) ?? (flag === 'on' ? resolveHubUrl(env) : undefined);
if (!hubUrl) {
if (flag === 'on')
return () => ({ ok: false, reason: 'hub_solidify_verify_unavailable:no_hub_url' });
return undefined;
}
const explicitCredentials = resolveExplicitNodeCredentials(env);
const { nodeSecret } = explicitCredentials;
const senderId = nodeSecret
? explicitCredentials.senderId ?? resolveAtpSenderId(env)
: resolveAtpSenderId(env);
if (!senderId) {
if (flag === 'on')
return () => ({ ok: false, reason: 'hub_solidify_verify_unavailable:no_sender_id' });
return undefined;
}
const authMode = nodeSecret ? 'legacy' : 'oauth';
const dir = resolveIdentityHome(env);
if (authMode === 'oauth' && !existsSync(join(dir, 'token.json'))) {
if (flag === 'on')
return () => ({ ok: false, reason: 'hub_solidify_verify_unavailable:no_credentials' });
return undefined;
}
try {
const { auth } = connectHub({
hubUrl,
authMode,
evomapDir: dir,
senderId: () => senderId,
...(nodeSecret ? { nodeSecret } : {}),
});
return createSolidifyPermitCheck({
hubUrl,
auth,
senderId: () => senderId,
dir: offlinePermitDir(env),
nodeSecret,
...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}),
...(opts.now ? { now: opts.now } : {}),
...(opts.store ? { store: opts.store } : {}),
});
}
catch {
if (flag === 'on')
return () => ({ ok: false, reason: 'hub_solidify_verify_unavailable:no_credentials' });
return undefined;
}
}
export function makeProxyHubCapability(proxy) {
const auth = {
kind: 'enterprise_sso',
login: async () => ({ id: 'proxy', kind: 'enterprise_sso', token: '' }),
authenticate: async () => ({ headers: {} }),
rotate: async () => ({ id: 'proxy', kind: 'enterprise_sso', token: '' }),
revoke: async () => { },
};
return {
auth,
search: async (query) => resultAssets(await proxy.search({
...proxySearchArgs(query),
expectedHubMode: 'private',
})),
fetch: async (query) => resultAssets(await proxy.search({
...proxySearchArgs(query),
expectedHubMode: 'private',
})),
fetchAssetById: async (assetId) => {
const asset = firstAsset(await proxy.fetchAsset({ assetId, expectedHubMode: 'private' }));
return assetMatchesId(asset, assetId) ? asset : null;
},
publish: async () => ({ receiptId: 'proxy-disabled', status: 'rejected', terminal: true, reason: 'proxy_autoexec_publish_disabled' }),
recordOutcome: async () => ({ recorded: false, reason: 'proxy_outcome_not_configured' }),
recordReuseResult: async (report) => reuseReceipt(await proxy.recordReuseResult({
assetId: report.assetId,
outcome: report.outcome,
...(report.taskId ? { taskId: report.taskId } : {}),
...(report.traceId ? { traceId: report.traceId } : {}),
...(report.timeSavedSeconds !== undefined ? { timeSavedSeconds: report.timeSavedSeconds } : {}),
...(report.reason ? { reason: report.reason } : {}),
expectedHubMode: 'private',
})),
task: {
claim: async () => ({ claimId: 'proxy-disabled' }),
complete: async () => ({ status: 'completed' }),
subscribe: async function* () { },
},
mailbox: {
poll: async () => ({ events: [] }),
ack: async () => { },
push: async () => { },
status: async () => ({ pending: 0 }),
},
};
}
function proxySearchArgs(query) {
return {
...(query.text ? { text: query.text } : {}),
...(query.signalsAny ? { signalsAny: query.signalsAny } : {}),
...(query.kind ? { kind: query.kind } : {}),
...(query.category ? { category: query.category } : {}),
...(query.gene ? { gene: query.gene } : {}),
...(query.limit !== undefined ? { limit: query.limit } : {}),
};
}
function resultAssets(value) {
const r = record(value);
const payload = record(r['payload']);
const rows = Array.isArray(r['results']) ? r['results']
: Array.isArray(r['assets']) ? r['assets']
: Array.isArray(payload['results']) ? payload['results']
: Array.isArray(payload['assets']) ? payload['assets']
: [];
return rows.filter((row) => Boolean(row && typeof row === 'object' && !Array.isArray(row)));
}
function firstAsset(value) {
return resultAssets(value)[0] ?? null;
}
function assetMatchesId(asset, assetId) {
return Boolean(asset && asset.asset_id === assetId);
}
function reuseReceipt(value) {
const r = record(value);
const payload = record(r['payload']);
const source = Object.keys(payload).length > 0 ? payload : r;
return {
recorded: source['recorded'] !== false && source['ok'] !== false,
...(typeof source['reason'] === 'string' ? { reason: source['reason'] } : {}),
...(typeof source['id'] === 'string' ? { id: source['id'] } : {}),
};
}
function record(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
/** Session-log dirs the auto-distill producer scans: EVOLVER_SESSION_DIRS (comma-sep) overrides; else the
* standard agent homes. The scanner skips non-existent dirs, so listing cross-platform homes is always safe. */
export function defaultSessionDirs(env = process.env) {
const override = env['EVOLVER_SESSION_DIRS'];
if (override && override.trim())
return override.split(',').map((s) => s.trim()).filter(Boolean);
const h = homedir();
const dirs = [
join(h, '.claude'),
join(h, '.cursor'),
join(h, '.codex'),
join(h, '.gemini', 'tmp'),
join(h, '.gemini', 'antigravity'),
join(h, '.gemini', 'antigravity-ide'),
join(h, '.kimi'),
join(h, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage'),
join(h, 'AppData', 'Roaming', 'Cursor', 'User', 'globalStorage'),
join(h, '.config', 'Cursor', 'User', 'globalStorage'),
];
const appData = env['APPDATA'];
if (appData)
dirs.push(join(appData, 'Cursor', 'User', 'globalStorage'));
return Array.from(new Set(dirs));
}
/**
* Whether the cross-runtime reuse SIGNAL is folded into selection (#268/#274 soft re-order). **Default ON**: the
* re-rank is a SMALL, bounded, clamped nudge (±REUSE_WEIGHT) that the controlled A/B (reuseSignalAb.test.ts)
* proves flips only NEAR-TIES and can never override a gene's health/signal-match — so cross-AI reuse evidence
* shapes selection by default, not only when an operator opts in. `EVOLVER_REUSE_SIGNAL=0` is the kill switch.
*/
export function reuseSignalEnabled(env = process.env) {
return env['EVOLVER_REUSE_SIGNAL'] !== '0';
}
/**
* Whether the gene PROBATION loop is on (#306, phase 2). **Default OFF** — explicit opt-in (EVOLVER_GENE_PROBATION=1),
* because it lets unproven auto-distilled genes be TRIED with their strategy embedded so the cross-AI reuse loop
* self-closes (autoexec then also runs the evidence-based auto-promote tick). Unlike the bounded reuse soft re-order,
* this drives the autonomous agent with an unreviewed strategy — contained by the proven exec hard gates + worktree
* isolation (#309), but a real increase in what the loop attempts, so it stays opt-in until validated in production.
*/
export function geneProbationEnabled(env = process.env) {
return env['EVOLVER_GENE_PROBATION'] === '1';
}
/**
* Wire the auto-distill producer+consumer for the resident daemon (#106 slice2): returns the distillObserver to
* register on the bus + a `tick()` that scans the session dirs and records material onto the SAME bus Ingestor
* (→ `material.batch_ready` → the observer auto-drafts a quarantined gene). Off via `EVOLVER_AUTO_DISTILL=0`.
* All stores/paths are injectable for tests; production defaults to the live ~/.evomap substrate + agent homes.
*/
export function resolveDistillProducer(env, opts) {
const sessionDirs = opts.sessionDirs ?? defaultSessionDirs(env);
if (env['EVOLVER_AUTO_DISTILL'] === '0') {
return { enabled: false, reason: 'off', observer: null, tick: async () => ({ recorded: 0, sourceAgents: [], signalKinds: [], signalStrengths: [] }), sessionDirs };
}
const store = opts.store ?? new assetstore.LocalJsonlProvider(events.assetsDir());
const review = opts.review ?? new assetstore.ReviewLedger(events.assetsDir());
const materialStore = opts.materialStore ?? new materialNs.MaterialStore({ path: events.materialStorePath() });
const watermarkStore = opts.watermarkStore ?? new materialNs.WatermarkStore(events.materialWatermarkPath());
const consumer = opts.consumer ?? new materialNs.ConsumerGroups({ store: materialStore, path: join(dirname(events.materialStorePath()), 'distill-consumer.json') });
const observer = resolveDistillObserver({ consumer, store, review, ingestor: opts.ingestor, ...(opts.maxPerTick !== undefined ? { maxPerTick: opts.maxPerTick } : {}) });
const tick = () => runSessionIngestTick(sessionDirs, { materialStore, watermarkStore, ingestor: opts.ingestor });
return { enabled: true, observer, tick, sessionDirs };
}
export function shouldRunIdleDistill(intensity) {
return intensity === 'aggressive' || intensity === 'deep';
}
export async function runIdleLlmDistillForBeat(beat, guardedLlmDistill, write = (message) => { process.stdout.write(message); }) {
if (!guardedLlmDistill || !shouldRunIdleDistill(beat.intensity))
return;
const d = await guardedLlmDistill();
if (d && !('skipped' in d) && d.ok)
write(` auto-distill-llm: gene=${String(d.gene['id'] ?? d.gene.asset_id)} stored=${d.stored}\n`);
}
export async function runIdleAntiGeneDistillForBeat(beat, guardedAntiGeneDistill, write = (message) => { process.stdout.write(message); }) {
if (!guardedAntiGeneDistill || !shouldRunIdleDistill(beat.intensity))
return;
const d = await guardedAntiGeneDistill();
if (!d || 'skipped' in d)
return;
if (d.ok)
write(` auto-distill-anti-gene: antiGene=${d.antiGene.id} stored=${d.stored}\n`);
else if (d.mode === 'shadow' && d.reason === 'shadow_logged')
write(' auto-distill-anti-gene: shadowed=1\n');
}
const EMPTY_TRANSCRIPT_TICK = { scanned: 0, distilled: 0, shadowed: 0, skipped: 0, transient: 0 };
/**
* Wire the LLM-over-transcript producer for the resident daemon (#319 slice 2). Default OFF
* (EVOLVER_AUTO_DISTILL_TRANSCRIPT unset). When on, the tick scans the session dirs and LLM-distills prose-rich,
* weak/zero-signal sessions (per-session dedup/cooldown + per-tick cap inside runTranscriptDistillTick). Runs in
* the idle slot only (LLM cost), separate from the every-beat structural ingest.
*/
export function resolveAutoDistillTranscript(env, opts) {
const mode = transcriptDistillMode(env);
if (mode === 'off')
return { enabled: false, mode, tick: async () => EMPTY_TRANSCRIPT_TICK };
const sessionDirs = opts.sessionDirs ?? defaultSessionDirs(env);
return {
enabled: true, mode,
tick: () => runTranscriptDistillTick({
files: scanSessionDirs(sessionDirs), store: opts.store, env, cwd: process.cwd(),
...(opts.review ? { review: opts.review } : {}), ...(opts.ingestor ? { ingestor: opts.ingestor } : {}),
}),
};
}
export function resolveAutoDistillAntiGene(env, opts) {
return resolveAntiGeneDistill(env, {
store: opts.store,
cwd: opts.cwd ?? process.cwd(),
...(opts.review ? { review: opts.review } : {}),
...(opts.ingestor ? { ingestor: opts.ingestor } : {}),
...(opts.statePath ? { statePath: opts.statePath } : {}),
...(opts.now ? { now: opts.now } : {}),
...(opts.runner ? { runner: opts.runner } : {}),
});
}
export async function runIdleTranscriptDistillForBeat(beat, guarded, write = (message) => { process.stdout.write(message); }) {
if (!guarded || !shouldRunIdleDistill(beat.intensity))
return;
const d = await guarded();
// singleFlight's busy sentinel is { skipped: true } — our result ALSO has a numeric `skipped`, so discriminate on
// `distilled` (present only on the real result), not on `skipped`.
if (!d || !('distilled' in d))
return;
// Shadow mode never distills (distilled=0), so surface `shadowed` too — that count IS the probation signal: how
// many sessions enforce mode would have distilled. Without it, a shadow daemon looks idle when it is in fact working.
if (d.distilled > 0 || d.shadowed > 0)
write(` auto-distill-transcript: distilled=${d.distilled} shadowed=${d.shadowed} scanned=${d.scanned} transient=${d.transient}\n`);
}
/**
* `evolver autoexec [home]`: resident daemon. Builds the safe deps from EVOLVER_HOME, then single-flight-guards
* autoExecPass on an interval. Real agent execution only happens for repos the operator has allowlisted in
* <home>/autoexec/config.json (allowedRoots) — empty by default = nothing runs.
*/
export async function runAutoExec(argv) {
// Load EVOLVER_ENV_FILE into process.env FIRST (PORT v1 #10 fix): the first-run prompt below and ALL ATP
// home/consent resolution (EVOLVER_ATP_AUTOBUY / EVOLVER_HOME / EVOMAP_DIR / EVOMAP_HOME) read process.env, so an
// env-file-only setting must be merged before the prompt runs — otherwise the prompt can fire when it should skip,
// or read/write the ack under the wrong home (the env-file home is invisible until then). resolveHubLink still
// loads the file too (idempotent re-read) for its standalone callers/tests, so this is the single authoritative
// early load, not a replacement. This MUST stay above runAutobuyPrompt() (which now runs after the lock below).
const envFile = loadEnvFileFromEnv(process.env);
if (envFile.error) {
process.stderr.write(ENV_FILE_UNAVAILABLE_DIAGNOSTIC);
return 1;
}
const home = argv.find((a) => !a.startsWith('-')) ?? join(events.evomapHome(), 'autoexec');
const dirs = ensureAutoExecDirs(home);
const cfg = readAutoExecConfig(home);
if (!canExecuteQueue(cfg.runner)) {
process.stderr.write('evolver autoexec: execute queue is disabled for the configured built-in runner; non-execution daemon duties remain active. Set "runner":"llm" (no agent CLI needed) or "runner":"gemini" after reviewing its experimental capability boundary\n');
}
// Solo mode (--solo): the "constrained wild" profile. Hard-cut network + ATP
// at the SOURCE — in-process, before any resolve* below reads its env gate — so
// both the startup wiring and any in-cycle path see them disabled. This is the
// "no escape valve" cut (a user cannot re-enable hub/ATP under --solo). The
// per-cycle git snapshot/rollback and the failure circuit breaker are wired at
// the resident-loop tick further down. When !solo, everything below is unchanged.
const solo = solomode.isSoloRun(argv, process.env);
const soloRepoRoot = cfg.allowedRoots[0] ?? home;
if (solo) {
solomode.applySoloLockdown(process.env);
for (const line of solomode.soloBanner(soloRepoRoot))
process.stdout.write(line + '\n');
}
const store = new assetstore.LocalJsonlProvider(events.assetsDir());
const memoryGraphDir = join(events.evomapHome(), 'evolution');
const memoryUser = resolveLocalMemoryUserIdentity(memoryGraphDir);
const memoryGraph = new LocalMemoryGraph({ dir: memoryGraphDir, ...memoryUser });
// Observer bus (#113): the event total-bus, finally given its FIRST built-in observer. The value-digest
// observer is hung off the bus and the bus is wired as the Ingestor's sink, so every emitted event fans out to
// it; its weekly cadence + measured-value gate keep it quiet. Off via EVOLVER_VALUE_DIGEST=0. Fault-isolated:
// a broken sink quarantines the observer without ever touching the autoexec write path.
const bus = new observers.ObserverBus();
const digest = resolveValueDigestObserver(process.env);
if (digest.observer)
bus.register(digest.observer);
// Cursor rewrite observer (#124): cursor has no SessionStart hook, so its injected gene memory
// (.cursor/rules/evolver.mdc) is kept fresh by REWRITE-ON-CHANGE. This observer fires (debounced) on the same
// gene-set-change events the CycleEngine already emits through this Ingestor — so a real cycle solidifying a
// gene re-renders the cursor rules file. Opt-in: only registered when the user installed cursor injection at
// the project root (the first allowlisted repo); off via EVOLVER_CURSOR_REWRITE=0. Fault-isolated by the bus.
const provenance = new assetstore.ProvenanceStore(events.assetsDir());
const review = new assetstore.ReviewLedger(events.assetsDir());
const cursorRoot = cfg.allowedRoots[0];
// The cursor rewrite reads the SAME review ledger the exec pool uses, so an auto-distilled draft fired onto this
// bus (gene.distilled) is withheld from cursor's rules until approved (A2a) — not just by process isolation.
const cursorRewrite = cursorRoot
? resolveCursorRewriteObserver(process.env, { projectRoot: cursorRoot, store, review })
: { enabled: false, reason: 'not-installed', observer: null };
if (cursorRewrite.observer)
bus.register(cursorRewrite.observer);
const ingestor = new events.Ingestor({ path: events.rootEventsPath(), sink: bus });
const reflection = resolveReflectionObserver(process.env, { ingestor });
if (reflection.observer)
bus.register(reflection.observer);
const memoryEventMirror = resolveMemoryEventMirror(process.env);
if (memoryEventMirror.observer)
bus.register(memoryEventMirror.observer);
const personalityStore = createAutoExecPersonalityStore();
const engine = new algo.CycleEngine({
ingestor,
selection: algo.makeGeneSelectionPoint(),
store,
now: () => Date.now(),
personality: personalityStore,
capabilityGaps: makeCurriculumCapabilityGapsProvider(process.env),
});
// Reuse-before-solve (#110) + outcome report-back: wire the adapter's reuseBeforeSolve as the hub-reuse
// seam when enabled + credentialed (default OFF → undefined → zero hub calls, exactly today's behavior).
// The seam injects hub candidates into the same selection pool as local genes, trust-first; the link's
// reporter closes the loop by sending the cycle outcome + used-asset claim back to the hub.
let atpAutoBuyer;
const onVerifiedHubSearchMiss = (miss) => {
// Resolve lazily: the first-run consent prompt happens after the daemon lock is acquired below, while Hub
// composition happens here. A later verified miss therefore observes the newly recorded consent correctly.
atpAutoBuyer ??= resolveAtpAutoBuyer(process.env);
if (atpAutoBuyer)
scheduleAtpAutoBuyForVerifiedMiss(miss, atpAutoBuyer);
};
const hubLink = resolveHubLink(process.env, ingestor, connectPublicHub, onVerifiedHubSearchMiss);
const hubQuestionLink = resolveHubQuestionLink(process.env);
const solidifyPermit = resolveSolidifyPermitGate(process.env);
const strategyName = process.env['EVOLVE_STRATEGY'];
const probationOn = geneProbationEnabled();
const semanticIdfOn = semanticIdfEnabled();
const selectionPolicy = selectionPolicyFromEnv();
const selectionGuard = selectionGuardFromEnv();
const selectionFloor = selectionFloorFromEnv();
// File-only and best-effort: tracing must never fail or slow a task.
const learningTrace = resolveLearningTrace(process.env);
const executionBindingAuthority = {
claimLease: () => false,
acceptanceSpec: () => false,
budget: () => false,
consent: () => false,
};
const deps = withAutoExecSelectionConfig({
engine, store, provenance, review, personality: personalityStore, memoryGraph,
...(process.env['EVOLVER_BENCHMARK_ID'] !== undefined ? { benchmark: assetstore.benchmarkContext(process.env['EVOLVER_BENCHMARK_ID']) } : {}),
executionBinding: {
journal: new exec.ExecutionBindingJournal(ingestor),
authority: executionBindingAuthority,
now: () => Date.now(),
maxRuntimeMs: cfg.timeoutMs,
},
...(learningTrace.config ? { learningTrace: learningTrace.config } : {}),
// Probation (#306, gated, default OFF via EVOLVER_GENE_PROBATION): try unproven auto-distilled genes (with their
// strategy embedded) so the cross-AI loop self-closes — contained by the proven exec gates + worktree isolation.
...(probationOn ? { includeProbation: true } : {}),
...(!semanticIdfOn ? { disableSemanticIdf: true } : {}),
...(hubLink ? { hubReuse: hubLink.seam } : {}),
...(solidifyPermit ? { solidifyPermit } : {}),
...(strategyName !== undefined ? { strategyName } : {}),
// Validate through the hardened sandbox verifier so a validation command can't exfiltrate or phone home to game
// the result. Validation is bounded by the sandbox's own per-command cap (NOT the agent exec timeout, a
// different unit) so a long suite is not silently SIGKILL'd against the wrong budget.
validate: (task) => async (_m, _d, cwd, signal) => {
const cmds = task.validationCmds ?? [];
const bindingRuntimeMs = task.execution_binding?.resource_grant.max_runtime_ms;
const safetyRuntimeMs = cfg.timeoutMs;
const validationTimeoutMs = bindingRuntimeMs === undefined
? safetyRuntimeMs
: Math.min(bindingRuntimeMs, safetyRuntimeMs);
const r = await runRequiredSandboxedValidation(cmds, cwd, { timeoutMs: validationTimeoutMs, ...(signal ? { signal } : {}) });
const validationSummary = summarizeSandboxedValidation(r);
if (validationSummary)
process.stdout.write(` validation: ${validationSummary}\n`);
return {
passed: r.passed,
score: r.score,
validator: {
id: 'required_sandboxed_validation',
version: 'sandboxed_validation.v1',
status: 'ran',
plan_digest: exec.computeValidationPlanDigest(cmds),
passed: r.passed,
score: r.score,
results: r.results,
skipped: r.skipped,
isolated: r.isolated,
},
};
},
}, process.env);
const safety = { allowedRoots: cfg.allowedRoots, timeoutMs: cfg.timeoutMs, runner: cfg.runner };
// #268/#274 soft re-order — DEFAULT ON (kill switch: EVOLVER_REUSE_SIGNAL=0). Fold the latest cross-runtime reuse
// outcomes + observed recall into selection as a SMALL, bounded, clamped nudge (±REUSE_WEIGHT); reuseSignalAb.test
// proves it flips only near-ties and never overrides health/signal-match, so cross-AI reuse evidence shapes
// selection without an opt-in. On → one readEvents per pass (the whole root_events log): negligible against a
// multi-minute agent run, and the daemon does one pass per task — revisit only if the log grows large (rotation
// is still deferred). The recall rail stays empty until EVOLVER_AUTO_RECALL=1 separately enables value.recall.
const reuseSignalOn = reuseSignalEnabled();
const runOne = async (task) => {
// One read of root_events (per pass) feeds BOTH rails: reported reuse outcomes (#268) and observed recall (#274
// slice 3, folded at a lower weight). value.recall rides the SAME flag — it is the same soft re-order actuator.
const evts = reuseSignalOn ? events.readEvents(events.rootEventsPath()) : null;
const taskDeps = evts
? { ...deps, reuseOutcomes: ops.summarizeReuseOutcomes(evts), recallEvents: evts.filter((e) => e.type === ops.VALUE_RECALL_EVENT) }
: deps;
const v = await exec.runAutoExecTask(taskDeps, task, safety);
// Hub writes are best-effort side effects. Schedule them after the verdict exists, but never hold the queue's
// task->done move on network I/O or hub availability.
scheduleAutoExecHubSideEffects(task, v, { questions: hubQuestionLink, outcome: hubLink });
return v;
};
const guarded = exec.singleFlight(runnerBoundAutoExecPass(cfg.runner, dirs, runOne));
// Auto-distill producer+consumer (#106): register the distillObserver on THIS bus, then a producer tick scans
// the session dirs and records material on THIS Ingestor → material.batch_ready → observer drafts a quarantined
// gene (A1) → human `review --approve` lifts it into inject/cursor (A2a/A2b). Off via EVOLVER_AUTO_DISTILL=0.
const distill = resolveDistillProducer(process.env, { ingestor, store, review });
if (distill.observer)
bus.register(distill.observer);
const llmDistill = resolveAutoDistillLlm(process.env, { store, review, ingestor, cwd: process.cwd() });
const antiGeneDistill = resolveAutoDistillAntiGene(process.env, { store, review, ingestor });
const transcriptDistill = resolveAutoDistillTranscript(process.env, { store, review, ingestor });
const atpAutoDeliver = resolveAtpAutoDeliver(process.env);
const taskReceiver = resolveTaskReceiver(process.env, {
repo: taskReceiverRepo(process.env, cfg.allowedRoots),
queueDir: dirs.tasks,
memoryEvents: () => memoryEventsFromCycleLog(events.readEvents(events.rootEventsPath())),
// An unreadable inflight directory is not an empty one: answering "nothing is running"
// there would hand the executor a task it may already be running. Say "busy" instead —
// a task that waits one beat is recoverable, one run twice is not.
inFlight: (queueTaskId) => { try {
return hasInflightClaim(dirs, queueTaskId);
}
catch {
return true;
} },
});
const uninstallBrokenPipeGuards = installAutoexecBrokenPipeGuards();
const reuseEnabled = process.env['EVOLVER_REUSE_BEFORE_SOLVE'] === '1' && hubLink !== undefined;
process.stdout.write(`evolver autoexec: runner=${cfg.runner} queue=${dirs.tasks} allowlist=${JSON.stringify(cfg.allowedRoots)} poll=${cfg.pollMs}ms reuse=${reuseEnabled ? 'on' : 'off'} reuse-signal=${reuseSignalOn ? 'on' : 'off'} semantic-idf=${semanticIdfOn ? 'on' : 'off'} selection-policy=${selectionPolicy} selection-guard=${selectionGuard} selection-floor=${selectionFloor === undefined ? 'unset' : selectionFloor} probation=${probationOn ? 'on' : 'off'} questions=${hubQuestionLink ? 'on' : 'off'} permit=${solidifyPermit ? 'on' : 'off'} value-digest=${digest.enabled ? 'on' : 'off'} reflection=${reflection.enabled ? 'on' : 'off'} learning-trace=${learningTrace.enabled ? `on(upload=${learningTrace.upload})` : 'off'} memory-event-mirror=${memoryEventMirror.enabled ? 'on' : `off(${memoryEventMirror.reason ?? 'no_hub'})`} cursor-rewrite=${cursorRewrite.enabled ? 'on' : `off(${cursorRewrite.reason})`} auto-distill=${distill.enabled ? 'on' : 'off'} auto-distill-llm=${llmDistill.enabled ? llmDistill.mode : 'off'} auto-distill-anti-gene=${antiGeneDistill.enabled ? antiGeneDistill.mode : 'off'} auto-distill-transcript=${transcriptDistill.enabled ? transcriptDistill.mode : 'off'} atp-autodeliver=${atpAutoDeliver.enabled ? 'on' : `off(${atpAutoDeliver.reason})`} task-receiver=${taskReceiver.enabled ? `on(repo=${taskReceiverRepo(process.env, cfg.allowedRoots)})` : `off(${taskReceiver.reason})`}\n`);
if (cfg.allowedRoots.length === 0)
process.stdout.write(' (allowlist empty → deny-by-default: nothing runs until you add a repo to config.json)\n');
// Single-instance lock (#106): a second daemon on the same home would double-process the queue. That is harmless
// for correctness (store + event writes are file-locked and watermark-idempotent) but wasteful, so refuse rather
// than pile on. Stale locks (a crashed prior daemon) are auto-reclaimed by acquireLock's pid-liveness check.
const lockPath = join(home, 'autoexec.lock');
// maxTries must allow a stale-lock reclaim (a crashed prior daemon) PLUS the acquire that follows it: reclaim
// consumes one try (unlink + continue), so maxTries:1 would reclaim then immediately time out, wrongly refusing
// the FIRST restart (Bugbot #149). A few short tries reclaim a dead owner's lock yet still refuse a LIVE holder
// fast (~maxTries×waitMs ≈ 50ms) — reclaim/refuse behavior is covered by fileLock.test.
try {
util.acquireLock(lockPath, { maxTries: 5 });
}
catch (error) {
process.stderr.write(autoexecLockFailureMessage(error));
uninstallBrokenPipeGuards();
return 1;
}
// First-run auto-buyer opt-in (PORT v1 #10): introduce the autonomous spend path to interactive operators once,
// AFTER the single-instance lock is held so two concurrent `evolver autoexec` starts on the same home cannot both
// pass the no-ack check, both prompt, and race writing the ack file (Bugbot). Still before the resident loop.
// No-op under systemd/Docker/CI (non-TTY) and once the ack/env is set, so it never blocks a daemon; wrapped so a
// prompt failure can never wedge `autoexec`.
try {
await runAutobuyPrompt();
}
catch (e) {
process.stderr.write(`[ATP-AutoBuyer] first-run prompt failed: ${e instanceof Error ? e.message : String(e)}\n`);
}
const uninstallUnhandledRejectionGuard = daemonNs.installUnhandledRejectionWindow({
beforeExit: () => { releaseAutoexecLock(lockPath); },
});
let runtimeCleaned = false;
const cleanupAutoexecRuntime = () => {
if (runtimeCleaned)
return;
runtimeCleaned = true;
uninstallUnhandledRejectionGuard();
releaseAutoexecLock(lockPath);
uninstallBrokenPipeGuards();
};
// Durable workflow recovery uses the same policy/review-gated runtime factory as `evolver workflow start/resume`.
// Pass the already-loaded config so a custom autoexec home uses the same allowlist, runner, and validation profiles.
initializeWorkflowStartupRecovery({ autoExecHome: home, autoExecConfig: cfg });
// SINGLE-FLIGHT the producer tick (same as autoExecPass): a session scan that outlasts pollMs must not let two
// ticks interleave in recordSessionMaterial (which would re-emit material.batch_ready before watermarks settle).
const guardedDistill = distill.enabled && distill.observer ? exec.singleFlight(() => distill.tick()) : null;
const guardedLlmDistill = llmDistill.enabled ? exec.singleFlight(() => llmDistill.tick()) : null;
const guardedAntiGeneDistill = antiGeneDistill.enabled ? exec.singleFlight(() => antiGeneDistill.tick()) : null;
const guardedTranscriptDistill = transcriptDistill.enabled ? exec.singleFlight(() => transcriptDistill.tick()) : null;
const guardedAtpAutoDeliver = atpAutoDeliver.enabled ? exec.singleFlight(() => atpAutoDeliver.tick()) : null;
const guardedTaskReceiver = taskReceiver.enabled ? exec.singleFlight(() => taskReceiver.tick()) : null;
if (distill.observer)
distill.observer.kick(); // recover any un-acked backlog from a prior run (restart)
// One idle-aware resident beat runs the exec pass, ATP auto-delivery, THEN the distill scan (single-flight
// guarded). Idle-aware pacing (#106): an idle machine polls more often, an active one backs off to the base
// cadence. Off via EVOLVER_IDLE_AWARE=0 → fixed cfg.pollMs (exactly the previous behavior).
const completionLine = (ack, assetId) => {
if (ack.completed)
return 'reported';
// The asset outlived the claim: name it, or the work is lost with no way back to it.
if (ack.error === 'commitment_expired')
return `commitment expired, asset ${assetId} not sent`;
return `${ack.pending ? 'owed, will retry' : 'failed'} ${ack.error ?? ''}`;
};
const tick = async (beat) => {
let failed = false;
const r = await guarded();
if (r && !('skipped' in r) && r.length) {
process.stdout.write(` pass: ${r.map((v) => `${v.taskId}=${v.status}`).join(' ')}\n`);
// A cycle "failed" (for solo rollback) when a task's self-edit did not
// solidify cleanly — i.e. any verdict is 'failed'. 'refused' (deny-by-
// default / not in allowlist) is a no-op, not a broken edit.
if (r.some((v) => v.status === 'failed'))
failed = true;
// A claim is a promise. The Hub only learns it was kept when the produced capsule
// comes back — but a cycle solo rollback is about to revert has not kept it, and the
// Hub cannot take a completion back. Stay silent and let the commitment expire.
// Only a registered claim is Hub work: the `hub-` prefix is a naming convention a
// local task may also use, and reporting its capsule would complete someone's task
// with an unrelated asset. The generation narrows it further — a queue id can be
// claimed, run and claimed again, and a result from the earlier filing is not this
// claim's to hand over.
const hubDone = r.flatMap((v) => {
const claim = v.status === 'solidified' && v.resultAssetId ? taskReceiver.claimFor(v.taskId) : null;
if (!claim || !v.resultAssetId)
return [];
if (claim.generation !== v.generation) {
process.stdout.write(` task-complete: ${claim.hubTaskId} result is from an earlier claim (${v.generation ?? 'none'}${claim.generation}); not reporting it\n`);
return [];
}
return [{ hubTaskId: claim.hubTaskId, resultAssetId: v.resultAssetId, generation: claim.generation }];
});
if (failed && solo && hubDone.length > 0) {
process.stdout.write(` task-complete: holding ${hubDone.length} report(s) — this cycle rolls back\n`);
}
else {
for (const task of hubDone) {
const ack = await taskReceiver.complete(task.hubTaskId, task.resultAssetId, task.generation);
process.stdout.write(` task-complete: ${task.hubTaskId} ${completionLine(ack, task.resultAssetId)}\n`);
}
}
}
// Evidence-based auto-promote (#306 phase 2): after a pass, a probation gene that has proven itself (>= K clean
// successes, 0 failures) is auto-approved — the cross-AI loop self-closes without a human quality gate. Bad ones
// stay quarantined / get banned. Best-effort: promotion never breaks the loop. Only when probation is opted in.
if (probationOn) {
try {
const promoted = await algo.autoPromoteProbationGenes(store, review);
if (promoted.length > 0)
process.stdout.write(` auto-promote: ${promoted.length} probation gene(s) proven → approved\n`);
}
catch { /* never break the loop on a promotion side-effect */ }
}
// Take work before delivering it: a task claimed this beat lands in the queue the
// next pass reads, and the Hub only accepts delivery for tasks this node holds.
// Debts before new work, and outside the receive switch: a claim taken by an earlier run
// still owes the Hub its report, and a delivery the Hub never acknowledged gets
// re-dispatched when the commitment expires. Turning the receiver off stops new work
// only. A debt this beat just created is not due yet, so it waits for the next one.
const requeued = await taskReceiver.retryQueueWrites();
if (requeued.retried > 0 || requeued.dropped > 0)
process.stdout.write(` task-receiver: requeued=${requeued.queued}/${requeued.retried} still-unqueued=${requeued.pending}${requeued.dropped > 0 ? ` dropped=${requeued.dropped}` : ''}\n`);
const owed = await taskReceiver.retryCompletions();
if (owed.retried > 0)
process.stdout.write(` task-complete: retried=${owed.retried} reported=${owed.completed} abandoned=${owed.abandoned} owed=${owed.pending}${owed.unrecorded > 0 ? ` unrecorded=${owed.unrecorded}` : ''}\n`);
for (const gone of owed.abandonedTasks) {
// Dropped from the ledger after the retry budget: nothing will mention it again.
process.stdout.write(` task-complete: GAVE UP on ${gone.taskId} (asset ${gone.resultAssetId}) — ${gone.lastError}\n`);
}
if (guardedTaskReceiver) {
const t = await guardedTaskReceiver();
if (t && !('skipped' in t) && t.claimed && t.task) {
process.stdout.write(` task-receiver: ${t.reason} ${t.task.task_id}${t.commitmentDeadline ? ` until=${t.commitmentDeadline}` : ''}\n`);
}
else if (t && !('skipped' in t) && t.reason === 'claim_failed') {
process.stdout.write(` task-receiver: claim failed ${t.task?.task_id ?? ''} ${t.error ?? ''}\n`);
}
else if (t && !('skipped' in t) && t.reason === 'expired') {
process.stdout.write(` task-receiver: commitment on ${t.task?.task_id ?? ''} already expired, not resuming\n`);
}
else if (t && !('skipped' in t) && t.reason === 'fetch_failed') {
process.stdout.write(` task-receiver: fetch failed ${t.error ?? ''}\n`);
}
else if (t && !('skipped' in t) && t.reason === 'queue_failed') {
process.stdout.write(` task-receiver: claimed ${t.task?.task_id ?? ''} but could not queue it — ${t.error ?? ''}\n`);
}
else if (t && !('skipped' in t) && (t.seen > 0 || (t.rejected ?? 0) > 0)) {
// The Hub had work but none of it was ours to take. Saying so is the only
// way an operator can tell "fetching works" from "nothing ever ran".
process.stdout.write(` task-receiver: none (seen=${t.seen}${t.rejected ? ` rejected=${t.rejected}` : ''})\n`);
}
}
if (guardedAtpAutoDeliver) {
const d = await guardedAtpAutoDeliver();
if (d && !('skipped' in d) && (d.delivered > 0 || d.terminalFailures > 0 || d.transientFailures > 0 || d.cooldownFailures > 0)) {
process.stdout.write(` atp-autodeliver: checked=${d.checked} delivered=${d.delivered} terminal=${d.terminalFailures} transient=${d.transientFailures} cooldown=${d.cooldownFailures}\n`);
}
}
if (guardedDistill) {
const d = await guardedDistill();
void submitDistillTickExplorationQuestion(hubQuestionLink, d).catch(() => undefined);
}
await runIdleLlmDistillForBeat(beat, guardedLlmDistill);
await runIdleAntiGeneDistillForBeat(beat, guardedAntiGeneDistill);
await runIdleTranscriptDistillForBeat(beat, guardedTranscriptDistill);
return { failed };
};
// Solo tick wrapper: snapshot the target repo before each cycle; on a failed
// cycle (a 'failed' verdict OR an unexpected throw) roll the repo back to the
// snapshot and advance the circuit breaker. At the threshold, stop the loop
// and exit non-zero — the wild loop's blind retry is replaced by a hard stop.
// The resident loop swallows tick errors, so the breaker owns the exit here.
const soloMax = solomode.soloMaxFails(process.env);
let soloState = { consecutiveFailures: 0 };
let soloTripped = false;
const soloTick = async (beat) => {
const snap = gitGuard.snapshot(soloRepoRoot);
let ok = true;
try {
const r = await tick(beat);
if (r.failed)
ok = false;
}
catch (e) {
ok = false;
process.stderr.write(`[Solo] cycle 抛错:${e instanceof Error ? e.message : String(e)}\n`);
}
const b = breaker.step(soloState, ok, soloMax);
soloState = b.state;
if (!ok) {
const rolledBack = gitGuard.rollbackTo(soloRepoRoot, snap);
process.stdout.write(rolledBack
? `[Solo] cycle 失败,已 git 回滚目标仓到 ${String(snap).slice(0, 12)} (连续失败 ${soloState.consecutiveFailures}/${soloMax})\n`
: `[Solo] cycle 失败且回滚未成功(无快照或 git 出错)。连续失败 ${soloState.consecutiveFailures}/${soloMax}\n`);
}
if (b.tripped && !soloTripped) {
soloTripped = true;
process.stderr.write(`[Solo] 连续失败 ${soloState.consecutiveFailures} 次达阈值,熔断停机(非盲重生)。\n`);
// Stop scheduling, release the lock, and exit non-zero. Detached so we
// don't await our own loop.stop() from inside a tick.
void stopRuntime().catch((error) => {
process.stderr.write(`[Solo] shutdown drain failed: ${error instanceof Error ? error.message : String(error)}\n`);
}).finally(() => { cleanupAutoexecRuntime(); process.exit(1); });
}
};
// Heartbeat (#106): record liveness + pacing on the AE spine for the WebUI console, THROTTLED so a fast poll does
// not flood the log — emit on an intensity change or at most once per EVOLVER_HEARTBEAT_MS (default 60s).
const heartbeatMinMs = Math.max(0, Number(process.env['EVOLVER_HEARTBEAT_MS'] ?? 60_000));
let lastBeatAt = 0;
let lastIntensity = '';
await cursorRewrite.start?.();
const loop = startResidentLoop({
tick: solo ? soloTick : tick,
basePollMs: cfg.pollMs,
idleAware: process.env['EVOLVER_IDLE_AWARE'] !== '0',
onBeat: (b) => {
const now = Date.now();
if (b.intensity === lastIntensity && now - lastBeatAt < heartbeatMinMs)
return;
lastBeatAt = now;
lastIntensity = b.intensity;
void ingestor.ingest({
type: 'cycle.heartbeat', // a registered EVENT_TYPE (an unknown type would throw UnknownEventTypeError)
human: { title: `autoexec 心跳 (${b.intensity})`, severity: 'info' },
payload: { intensity: b.intensity, idleSeconds: b.idleSeconds, nextDelayMs: b.delayMs, runner: cfg.runner },
actor: { kind: 'machine', id: 'autoexec' },
}).catch(() => { });
},
});
// SIGINT/SIGTERM drain the in-flight beat before cleanup. SIGHUP cannot hot-reload this composition safely,
// so it is explicit and non-destructive: keep running until the operator performs a normal restart.
async function stopRuntime() {
try {
await loop.stop();
await bus.drain();
}
finally {
await cursorRewrite.stop?.();
}
}
return await waitForAutoexecShutdown({ stop: stopRuntime, cleanup: cleanupAutoexecRuntime });
}