mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
587 lines
32 KiB
JavaScript
587 lines
32 KiB
JavaScript
import { normalizeForPut } from '../assetstore/provider.js';
|
|
import { ingestUntrusted } from '../assetstore/provenance.js';
|
|
import { mergePendingSignalsForStore } from '../assetstore/pendingSignals.js';
|
|
import { intakeGene } from '../algo/geneIntake.js';
|
|
import { runEvolutionCycle } from '../algo/orchestrator.js';
|
|
import { makeSafeExecute, makeTrustedGeneResolver } from './autonomousCycle.js';
|
|
import { defaultGitRunner, isWithinRoot } from './claudeBridge.js';
|
|
import { findSignalHints } from './openPrRegistry.js';
|
|
import { AgentRunTraceRecorder, buildLearningPacketDraft } from '../trace/learningTrace.js';
|
|
import { collectRunLlmTurns } from '../trace/proxyTurns.js';
|
|
import { uniqueSessionId } from '../trace/trajectory.js';
|
|
import { ExecutionBindingError, ExecutionBindingJournal, freezeExecutionBinding, preflightExecutionBinding, } from './executionBinding.js';
|
|
/** Same path-containment as the bridge guard — used here to refuse before running anything (clean verdict). */
|
|
function withinAllowlist(repo, roots) {
|
|
return roots.some((root) => isWithinRoot(repo, root));
|
|
}
|
|
function cleanForcedGeneId(value) {
|
|
if (typeof value !== 'string')
|
|
return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
export function canonicalForcedGeneId(task) {
|
|
return cleanForcedGeneId(task.forcedGeneId)
|
|
?? cleanForcedGeneId(task.preferredGeneId)
|
|
?? cleanForcedGeneId(task.selected_gene_id)
|
|
?? cleanForcedGeneId(task.selectedGeneId);
|
|
}
|
|
export function normalizeAutoExecTask(task) {
|
|
const forcedGeneId = canonicalForcedGeneId(task);
|
|
const normalized = { ...task };
|
|
if (forcedGeneId === undefined)
|
|
delete normalized.forcedGeneId;
|
|
else
|
|
normalized.forcedGeneId = forcedGeneId;
|
|
return normalized;
|
|
}
|
|
async function landHubAssetIfPresent(deps, candidate) {
|
|
if (!candidate.hubAsset)
|
|
return candidate;
|
|
if (!deps.provenance)
|
|
return null;
|
|
let normalized;
|
|
try {
|
|
normalized = normalizeForPut(candidate.hubAsset).record;
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
const existing = await deps.store.get(normalized.asset_id);
|
|
if (!existing) {
|
|
try {
|
|
await ingestUntrusted(deps.store, deps.provenance, normalized, 'hub');
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
return candidate.assetId === normalized.asset_id ? candidate : { ...candidate, assetId: normalized.asset_id };
|
|
}
|
|
async function hasTrustedResolvedStrategy(deps, candidate) {
|
|
if (!deps.provenance)
|
|
return false;
|
|
const resolveGene = makeTrustedGeneResolver(deps.store, deps.provenance, deps.review, false, deps.benchmark);
|
|
const ids = candidate.hubAsset
|
|
? [candidate.assetId].filter((id) => typeof id === 'string' && id.length > 0)
|
|
: [candidate.geneId, candidate.assetId].filter((id) => typeof id === 'string' && id.length > 0);
|
|
const uniqueIds = [...new Set(ids)];
|
|
for (const id of uniqueIds) {
|
|
const info = await resolveGene(id);
|
|
if (info?.trusted === true && (info.strategy?.length ?? 0) > 0)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
async function executableHubCandidates(deps, candidates) {
|
|
const out = [];
|
|
for (const candidate of candidates) {
|
|
const landed = await landHubAssetIfPresent(deps, candidate);
|
|
if (!landed)
|
|
continue;
|
|
if (await hasTrustedResolvedStrategy(deps, landed))
|
|
out.push(landed);
|
|
}
|
|
return out;
|
|
}
|
|
/**
|
|
* Run one autonomous task end to end with every safety control composed (makeSafeExecute). Deny-by-default:
|
|
* if task.repo is not within safety.allowedRoots, returns a 'refused' verdict and runs nothing. Otherwise seeds
|
|
* the task's strategy as a local (trusted) gene, then drives a real evolution cycle and maps the result.
|
|
*/
|
|
export async function runAutoExecTask(deps, rawTask, safety) {
|
|
const task = normalizeAutoExecTask(rawTask);
|
|
if (!withinAllowlist(task.repo, safety.allowedRoots)) {
|
|
return { taskId: task.id, status: 'refused', reason: `repo not in allowlist: ${task.repo}` };
|
|
}
|
|
const cycleId = `autoexec-${task.id}`;
|
|
let binding;
|
|
let bindingClaimed = false;
|
|
let baseValidate;
|
|
if (task.execution_binding !== undefined) {
|
|
try {
|
|
binding = freezeExecutionBinding(task.execution_binding);
|
|
if (!deps.executionBinding)
|
|
throw new ExecutionBindingError('binding_missing', 'execution binding journal and authority checks are required');
|
|
const recovery = deps.executionBinding.journal.recover(task.id, binding.binding_digest);
|
|
if (recovery.kind === 'terminal') {
|
|
const recoveredStatus = recovery.terminal.disposition === 'unsafe_to_replay'
|
|
? 'unsafe_to_replay'
|
|
: recovery.terminal.disposition === 'completed' && recovery.terminal.final_stage === 'solidified'
|
|
? 'solidified'
|
|
: recovery.terminal.disposition === 'completed' && recovery.terminal.final_stage === 'innovated'
|
|
? 'innovated'
|
|
: recovery.terminal.disposition === 'denied'
|
|
? 'refused'
|
|
: 'failed';
|
|
return {
|
|
taskId: task.id,
|
|
status: recoveredStatus,
|
|
binding_digest: recovery.binding_digest,
|
|
run_id: binding.correlation.run_id,
|
|
execution_terminal: {
|
|
status: recovery.terminal.outcome?.status === 'success' ? 'success' : 'failed',
|
|
disposition: recovery.terminal.disposition,
|
|
},
|
|
hub_lifecycle: { state: 'not_submitted' },
|
|
...(recovery.terminal.outcome ? { outcome: recovery.terminal.outcome } : {}),
|
|
};
|
|
}
|
|
if (recovery.kind === 'unsafe_to_replay' || recovery.kind === 'mismatched') {
|
|
return { taskId: task.id, status: 'unsafe_to_replay', binding_digest: recovery.binding_digest, run_id: binding.correlation.run_id, reason: recovery.reason };
|
|
}
|
|
await preflightExecutionBinding(binding, {
|
|
taskId: task.id,
|
|
runId: binding.correlation.run_id,
|
|
cycleId,
|
|
repoPath: task.repo,
|
|
target: task.target,
|
|
expectedEffect: task.expectedEffect,
|
|
allowedRoots: safety.allowedRoots,
|
|
now: deps.executionBinding.now ?? Date.now,
|
|
currentRevision: binding.target_descriptor.base_revision === null
|
|
? null
|
|
: (deps.git ?? defaultGitRunner)(['rev-parse', 'HEAD'], task.repo).then((revision) => revision.trim() || null),
|
|
maxRuntimeMs: deps.executionBinding.maxRuntimeMs ?? safety.timeoutMs,
|
|
maxFiles: deps.executionBinding.maxFiles,
|
|
maxLines: deps.executionBinding.maxLines,
|
|
requiredConsentScope: 'execution',
|
|
authoritative: deps.executionBinding.authority,
|
|
});
|
|
if (!deps.validate && binding.resource_grant.validation_commands.length > 0) {
|
|
throw new ExecutionBindingError('binding_acceptance_invalid', 'bound execution requires the declared validation plan to be wired to a validator');
|
|
}
|
|
baseValidate = deps.validate?.({ ...task, validationCmds: binding.resource_grant.validation_commands });
|
|
if (baseValidate && baseValidate.length < 4) {
|
|
throw new ExecutionBindingError('binding_acceptance_invalid', 'bound execution validator must accept AbortSignal');
|
|
}
|
|
if (recovery.kind === 'new')
|
|
await deps.executionBinding.journal.recordCreated(binding);
|
|
if (recovery.kind === 'claimed')
|
|
bindingClaimed = true;
|
|
}
|
|
catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
const unsafe = error instanceof ExecutionBindingError && error.unsafeToReplay;
|
|
return { taskId: task.id, status: unsafe ? 'unsafe_to_replay' : 'refused', ...(binding ? { binding_digest: binding.binding_digest, run_id: binding.correlation.run_id } : {}), reason: detail };
|
|
}
|
|
}
|
|
// Open-PR dedup (opt-in): if an open PR already covers this task's signals, skip before spawning an agent
|
|
// so the daemon doesn't re-implement in-flight work. Graceful — a lister that returns [] is a no-op.
|
|
if (deps.prLister) {
|
|
const hints = findSignalHints(task.signals, await deps.prLister(task.repo), { threshold: deps.dedupThreshold ?? 0.5 });
|
|
const top = hints[0];
|
|
if (top) {
|
|
return { taskId: task.id, status: 'skipped', reason: `open-pr-dedup: signals overlap PR #${top.number} (${top.headRefName}) @ ${top.tokenOverlap.toFixed(2)}` };
|
|
}
|
|
}
|
|
let cycleSignals = [...task.signals];
|
|
try {
|
|
const explicit = mergePendingSignalsForStore(deps.store, task.signals, { repoRoot: task.repo });
|
|
cycleSignals = explicit.signals;
|
|
if (explicit.injected > 0) {
|
|
console.log(`[ExplicitSignals] Injected ${explicit.injected} user-declared signal(s) from pending_signals.json.`);
|
|
}
|
|
}
|
|
catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[ExplicitSignals] Failed to consume pending signals (non-fatal): ${message}`);
|
|
}
|
|
let seededStrategyGeneId;
|
|
if (task.strategy && task.strategy.length > 0) {
|
|
const gi = intakeGene({
|
|
category: 'repair', signals_match: cycleSignals, strategy: [...task.strategy],
|
|
summary: task.expectedEffect.slice(0, 80), ...(task.validationCmds ? { validation: [...task.validationCmds] } : {}),
|
|
// A task's seeded strategy is an execution-entry seed (the operator/daemon supplied a learned strategy to
|
|
// run/evolve), not a skill/session transcription → `evolved` per V1 #302 classifyProvenance.
|
|
generation_meta: { source: 'evolved' },
|
|
});
|
|
if (gi.ok && gi.gene) {
|
|
await deps.store.put(gi.gene);
|
|
seededStrategyGeneId = cleanForcedGeneId(gi.gene.id);
|
|
}
|
|
}
|
|
// Reuse-before-solve (#110): if the seam is wired, resolve hub candidates BEFORE the cycle so they compete
|
|
// in the same selection pool as local genes. Reuse is an optimization, never a hard dependency — a seam
|
|
// that throws degrades to solving fresh (no hub candidates) rather than failing the task.
|
|
let hubCandidates = [];
|
|
// Learning trace (slice 2): one recorder per task run, traceId = cycleId so the trace joins the event log.
|
|
// Purely observational — every recorder call and the packet submit are wrapped so they can never change
|
|
// the verdict or fail the task.
|
|
const traceRecorder = deps.learningTrace
|
|
? new AgentRunTraceRecorder({
|
|
runId: cycleId,
|
|
taskId: task.id,
|
|
...(deps.learningTrace.traceSink ? { sink: deps.learningTrace.traceSink } : {}),
|
|
})
|
|
: undefined;
|
|
// Wall-clock window of this run, used to correlate the proxy's llm_turn records (slice 5). Captured
|
|
// unconditionally-cheaply only when the fold is configured.
|
|
const proxyTraceClock = deps.learningTrace?.proxyTraces?.now ?? Date.now;
|
|
let runStartMs = 0;
|
|
try {
|
|
traceRecorder?.runStarted({ taskSummary: task.expectedEffect, signals: cycleSignals, metadata: { repo: task.repo, target: task.target } });
|
|
}
|
|
catch { /* observability only */ }
|
|
if (deps.hubReuse) {
|
|
try {
|
|
hubCandidates = await executableHubCandidates(deps, await deps.hubReuse(cycleSignals, { cycleId }));
|
|
}
|
|
catch {
|
|
hubCandidates = [];
|
|
}
|
|
}
|
|
runStartMs = deps.learningTrace?.proxyTraces ? proxyTraceClock() : 0;
|
|
// evaluation fill-in (slice 6): the validate hook is the run's external verifier (sandboxed validation
|
|
// commands), so its result — when it actually RAN — becomes the packet's evaluation.verification
|
|
// (verifier 'automated_test'). Observation is a pass-through wrapper: the hook's result reaches the
|
|
// bridge unchanged, and a run where validation never fired keeps the evaluation placeholder.
|
|
let observedVerification;
|
|
baseValidate ??= deps.validate?.(task);
|
|
const observingValidate = baseValidate
|
|
? async (mutation, decision, cwd, signal) => {
|
|
const v = await baseValidate(mutation, decision, cwd, signal);
|
|
observedVerification = { verifier: 'automated_test', passed: v.passed, ...(v.score !== undefined ? { score: v.score } : {}) };
|
|
return v;
|
|
}
|
|
: undefined;
|
|
const observedProvenance = {
|
|
gene_ids: [], capsule_ids: [], tool_decisions: [],
|
|
policy_decisions: [], validator: null,
|
|
result_asset_refs: [], proof_refs: [], terminal_disposition: 'crashed',
|
|
};
|
|
const executionObserver = binding ? {
|
|
onToolDecision: (decision) => { observedProvenance.tool_decisions.push(decision); },
|
|
onPolicyDecision: (decision) => { observedProvenance.policy_decisions.push(decision); },
|
|
onValidatorDecision: (decision) => { observedProvenance.validator = decision; },
|
|
onProofReference: (reference) => { observedProvenance.proof_refs.push(reference); },
|
|
} : undefined;
|
|
const safeExecute = makeSafeExecute(task.repo, deps.store, safety, {
|
|
...(deps.provenance ? { provenance: deps.provenance } : {}),
|
|
...(deps.benchmark ? { benchmark: deps.benchmark } : {}),
|
|
...(deps.review ? { review: deps.review } : {}),
|
|
...(deps.includeProbation ? { includeProbation: true } : {}),
|
|
...(task.validationCmds ? { validationCmds: task.validationCmds } : {}),
|
|
...(observingValidate ? { validate: observingValidate } : {}),
|
|
...(deps.personality ? { personality: deps.personality } : {}),
|
|
...(deps.agent ? { agent: deps.agent } : {}),
|
|
...(deps.git ? { git: deps.git } : {}),
|
|
...(traceRecorder ? { traceRecorder } : {}),
|
|
...(executionObserver ? { executionObserver } : {}),
|
|
...(binding ? { executionLimits: {
|
|
maxRuntimeMs: binding.resource_grant.max_runtime_ms,
|
|
maxFiles: binding.resource_grant.max_files,
|
|
maxLines: binding.resource_grant.max_lines,
|
|
} } : {}),
|
|
});
|
|
const execute = binding && deps.executionBinding
|
|
? async (mutation, decision) => {
|
|
const selectedContext = binding.selected_context;
|
|
try {
|
|
if (selectedContext.capsule_id !== null || selectedContext.capsule_asset_id !== null) {
|
|
throw new ExecutionBindingError('binding_mismatched', 'preselected capsule context is not supported by the CycleEngine execution seam');
|
|
}
|
|
if (selectedContext.gene_id !== null && decision.selectedGeneId !== selectedContext.gene_id) {
|
|
throw new ExecutionBindingError('binding_mismatched', 'selected gene_id does not match frozen binding context');
|
|
}
|
|
if (selectedContext.gene_asset_id !== null && decision.selectedAssetId !== selectedContext.gene_asset_id) {
|
|
throw new ExecutionBindingError('binding_mismatched', 'selected gene_asset_id does not match frozen binding context');
|
|
}
|
|
if (!bindingClaimed) {
|
|
await preflightExecutionBinding(binding, {
|
|
taskId: task.id,
|
|
runId: binding.correlation.run_id,
|
|
cycleId,
|
|
repoPath: task.repo,
|
|
target: task.target,
|
|
expectedEffect: task.expectedEffect,
|
|
allowedRoots: safety.allowedRoots,
|
|
now: deps.executionBinding.now ?? Date.now,
|
|
currentRevision: binding.target_descriptor.base_revision === null
|
|
? null
|
|
: (deps.git ?? defaultGitRunner)(['rev-parse', 'HEAD'], task.repo).then((revision) => revision.trim() || null),
|
|
maxRuntimeMs: deps.executionBinding.maxRuntimeMs ?? safety.timeoutMs,
|
|
maxFiles: deps.executionBinding.maxFiles,
|
|
maxLines: deps.executionBinding.maxLines,
|
|
requiredConsentScope: 'execution',
|
|
authoritative: deps.executionBinding.authority,
|
|
});
|
|
const started = await deps.executionBinding.journal.claim(binding);
|
|
if (started.kind !== 'claimed')
|
|
throw new ExecutionBindingError('binding_replay_unsafe', `binding execution claim ended in ${started.kind}`, true);
|
|
bindingClaimed = true;
|
|
}
|
|
const result = await safeExecute(mutation, decision);
|
|
const policyDenied = observedProvenance.policy_decisions.some((policy) => !policy.allowed);
|
|
const disposition = result.outcome.status === 'success'
|
|
? 'completed'
|
|
: result.failureKind === 'timeout'
|
|
? 'timed_out'
|
|
: result.failureKind === 'cancelled'
|
|
? 'cancelled'
|
|
: policyDenied ? 'denied' : 'rejected';
|
|
observedProvenance.terminal_disposition = disposition;
|
|
return {
|
|
...result,
|
|
provenance: { ...observedProvenance, terminal_disposition: disposition },
|
|
bindingCorrelation: { bindingDigest: binding.binding_digest, runId: binding.correlation.run_id },
|
|
executionTerminal: { status: result.outcome.status, disposition },
|
|
hubLifecycle: { state: 'not_submitted' },
|
|
};
|
|
}
|
|
catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const disposition = error instanceof ExecutionBindingError
|
|
? error.unsafeToReplay ? 'unsafe_to_replay' : 'denied'
|
|
: 'crashed';
|
|
observedProvenance.terminal_disposition = disposition;
|
|
return {
|
|
outcome: { status: 'failed', score: 0, reason: message },
|
|
bindingCorrelation: { bindingDigest: binding.binding_digest, runId: binding.correlation.run_id },
|
|
executionTerminal: { status: 'failed', disposition },
|
|
provenance: { ...observedProvenance, terminal_disposition: disposition },
|
|
hubLifecycle: { state: 'not_submitted' },
|
|
};
|
|
}
|
|
}
|
|
: safeExecute;
|
|
const strategyName = task.strategyName ?? deps.strategyName;
|
|
// Intentional semantics (see #308 review M1): a task carrying `strategy` — even without an
|
|
// explicit forcedGeneId — force-selects the gene seeded from that strategy above, rather than
|
|
// letting it compete in normal ranking. Supplying a strategy is treated as "use this strategy".
|
|
// The forced pick still passes every hard gate downstream (candidate pool, ban, epigenetic
|
|
// suppression), so this never bypasses trust/review/inert filtering.
|
|
const cycleForcedGeneId = task.forcedGeneId ?? seededStrategyGeneId;
|
|
let memoryGraphAdvice;
|
|
if (deps.memoryGraph) {
|
|
try {
|
|
memoryGraphAdvice = await deps.memoryGraph.query({ workspace: task.repo, signals: cycleSignals });
|
|
}
|
|
catch {
|
|
memoryGraphAdvice = undefined;
|
|
}
|
|
}
|
|
let res;
|
|
try {
|
|
res = await runEvolutionCycle(deps.engine, deps.store, {
|
|
...(deps.benchmark ? { benchmark: deps.benchmark } : {}),
|
|
...(deps.provenance ? { provenance: deps.provenance } : {}),
|
|
...(deps.review ? { review: deps.review } : {}),
|
|
...(deps.includeProbation ? { includeProbation: true } : {}),
|
|
...(hubCandidates.length > 0 ? { hubCandidates } : {}),
|
|
...(deps.solidifyPermit ? { solidifyPermit: deps.solidifyPermit } : {}),
|
|
...(binding ? { executionBinding: binding } : {}),
|
|
...(deps.reuseOutcomes ? { reuseOutcomes: deps.reuseOutcomes } : {}),
|
|
...(deps.recallEvents ? { recallEvents: deps.recallEvents } : {}),
|
|
...(memoryGraphAdvice ? { memoryGraphAdvice } : {}),
|
|
...(strategyName !== undefined ? { strategyName } : {}),
|
|
...(deps.disableSemanticIdf ? { disableSemanticIdf: true } : {}),
|
|
...(deps.selectionPolicy ? { selectionPolicy: deps.selectionPolicy } : {}),
|
|
...(deps.selectionGuard ? { selectionGuard: deps.selectionGuard } : {}),
|
|
...(deps.selectionFloor !== undefined ? { selectionFloor: deps.selectionFloor } : {}),
|
|
...(cycleForcedGeneId !== undefined ? { forcedGeneId: cycleForcedGeneId } : {}),
|
|
cycleId,
|
|
problem: {
|
|
id: task.id, signature: `sig:${task.id}`, signatureV: 1,
|
|
firstSeenAt: new Date(0).toISOString(), lastSeenAt: new Date(0).toISOString(),
|
|
occurrences: 1, linkedSignals: cycleSignals, resolvedBy: null, status: 'open',
|
|
value: { severity: 0.7, reach: 1, strategicFit: 0.9, novelty: 0, costEst: 0.2 },
|
|
consecutiveFailures: 0, cooldownUntil: null, extensions: {},
|
|
},
|
|
signals: cycleSignals, category: 'repair', target: task.target, expectedEffect: task.expectedEffect,
|
|
summary: `autonomous: ${task.id}`, confidence: 0.85, execute, consumePendingSignals: false,
|
|
});
|
|
}
|
|
catch (error) {
|
|
if (binding && deps.executionBinding) {
|
|
const recovery = deps.executionBinding.journal.recover(task.id, binding.binding_digest);
|
|
if (recovery.kind === 'unsafe_to_replay') {
|
|
try {
|
|
await deps.executionBinding.journal.recordTerminal({
|
|
binding_digest: binding.binding_digest,
|
|
task_id: task.id,
|
|
run_id: binding.correlation.run_id,
|
|
disposition: 'unsafe_to_replay',
|
|
outcome: { status: 'failed', score: 0, reason: recovery.reason },
|
|
provenance: { ...observedProvenance, terminal_disposition: 'unsafe_to_replay' },
|
|
});
|
|
}
|
|
catch { /* the existing started marker remains fail-closed on recovery */ }
|
|
return { taskId: task.id, status: 'unsafe_to_replay', binding_digest: binding.binding_digest, run_id: binding.correlation.run_id, reason: `binding_replay_unsafe: ${recovery.reason}` };
|
|
}
|
|
}
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
return { taskId: task.id, status: 'failed', ...(binding ? { binding_digest: binding.binding_digest, run_id: binding.correlation.run_id } : {}), reason: detail };
|
|
}
|
|
if (binding && deps.executionBinding) {
|
|
const execution = res.execution;
|
|
if (!execution?.provenance || !execution.executionTerminal) {
|
|
const deniedReason = res.reasons.join('; ') || `cycle ended at ${res.finalStage} before execution`;
|
|
try {
|
|
await deps.executionBinding.journal.recordTerminal({
|
|
binding_digest: binding.binding_digest,
|
|
task_id: task.id,
|
|
run_id: binding.correlation.run_id,
|
|
disposition: 'denied',
|
|
final_stage: res.finalStage,
|
|
outcome: { status: 'failed', score: 0, reason: deniedReason },
|
|
provenance: { ...observedProvenance, terminal_disposition: 'denied' },
|
|
});
|
|
}
|
|
catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
return { taskId: task.id, status: 'failed', binding_digest: binding.binding_digest, run_id: binding.correlation.run_id, reason: `binding_provenance_unavailable: ${detail}` };
|
|
}
|
|
return {
|
|
taskId: task.id,
|
|
status: 'refused',
|
|
binding_digest: binding.binding_digest,
|
|
run_id: binding.correlation.run_id,
|
|
execution_terminal: { status: 'failed', disposition: 'denied' },
|
|
hub_lifecycle: { state: 'not_submitted' },
|
|
reason: deniedReason,
|
|
};
|
|
}
|
|
const terminal = {
|
|
binding_digest: binding.binding_digest,
|
|
task_id: task.id,
|
|
run_id: binding.correlation.run_id,
|
|
disposition: execution.executionTerminal.disposition,
|
|
final_stage: res.finalStage,
|
|
outcome: execution.outcome,
|
|
...(execution.proofOfWork ? { proof_of_work: execution.proofOfWork } : {}),
|
|
provenance: {
|
|
...execution.provenance,
|
|
gene_ids: res.decision?.selectedGeneId ? [res.decision.selectedGeneId] : [],
|
|
capsule_ids: res.capsule?.asset_id ? [res.capsule.asset_id] : [],
|
|
result_asset_refs: res.capsule?.asset_id ? [res.capsule.asset_id] : [],
|
|
proof_refs: execution.provenance.proof_refs.length > 0
|
|
? execution.provenance.proof_refs
|
|
: execution.proofOfWork?.kind === 'git_diff' && execution.proofOfWork.git_diff?.patch_ref
|
|
? [{ kind: 'git_diff', ref: execution.proofOfWork.git_diff.patch_ref }]
|
|
: execution.provenance.proof_refs,
|
|
},
|
|
};
|
|
try {
|
|
await deps.executionBinding.journal.recordTerminal(terminal);
|
|
}
|
|
catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
return { taskId: task.id, status: 'failed', binding_digest: binding.binding_digest, run_id: binding.correlation.run_id, reason: `binding_provenance_unavailable: ${detail}` };
|
|
}
|
|
}
|
|
const status = res.execution?.executionTerminal?.disposition === 'unsafe_to_replay'
|
|
? 'unsafe_to_replay'
|
|
: res.execution?.executionTerminal?.disposition === 'denied'
|
|
? 'refused'
|
|
: res.finalStage === 'solidified' ? 'solidified' : res.finalStage === 'failed' ? 'failed' : res.finalStage === 'aborted' ? 'refused' : 'innovated';
|
|
const cap = res.capsule;
|
|
if (traceRecorder && deps.learningTrace) {
|
|
// Proxy llm_turn fold (slice 5): fold the run window's per-request turns BEFORE run.completed so the
|
|
// trajectory stays sequence-ordered (model/tool detail inside the run, completion last). Own try — a
|
|
// throwing sink mid-fold must not cost the run its completion event or packet draft.
|
|
try {
|
|
const proxyTraces = deps.learningTrace.proxyTraces;
|
|
if (proxyTraces) {
|
|
const turns = collectRunLlmTurns(proxyTraces.dir, { startMs: runStartMs, endMs: proxyTraceClock() }, proxyTraces.readOptions ? { readOptions: proxyTraces.readOptions } : {});
|
|
// Exact-join key for Darwin: bind the unique proxy session before folding turns so
|
|
// every subsequent event (and backfilled earlier ones) carries the same sessionId.
|
|
// Fail closed when 0 or >1 sessions appear — never invent a correlation key.
|
|
const sid = uniqueSessionId(turns);
|
|
if (sid !== null) {
|
|
try {
|
|
traceRecorder.bindSessionId(sid);
|
|
}
|
|
catch { /* observability only */ }
|
|
}
|
|
for (const turn of turns)
|
|
traceRecorder.recordLlmTurn(turn);
|
|
}
|
|
}
|
|
catch { /* observability only */ }
|
|
try {
|
|
traceRecorder.runCompleted({
|
|
status: res.finalStage === 'solidified' ? 'success' : 'failed',
|
|
...(cap?.outcome?.score !== undefined ? { score: cap.outcome.score } : {}),
|
|
...(res.reasons.length > 0 ? { reason: res.reasons.join('; ') } : {}),
|
|
...(res.producedValue !== undefined ? { producedValue: res.producedValue } : {}),
|
|
...(res.failureKind !== undefined ? { failureKind: res.failureKind } : {}),
|
|
});
|
|
await deps.learningTrace.packetSink.submit(buildLearningPacketDraft(traceRecorder, {
|
|
sourceRepo: deps.learningTrace.sourceRepo ?? 'evolver-v2',
|
|
taskSummary: task.expectedEffect,
|
|
signals: cycleSignals,
|
|
environment: {
|
|
repo: task.repo,
|
|
runner: safety.runner ?? (deps.agent ? 'claude' : 'codex'),
|
|
},
|
|
...(observedVerification !== undefined ? { verification: observedVerification } : {}),
|
|
}));
|
|
}
|
|
catch { /* packet delivery is best-effort; never fail the task */ }
|
|
}
|
|
if (deps.memoryGraph && res.decision?.selectedGeneId && (res.finalStage === 'solidified' || res.finalStage === 'failed')) {
|
|
const producedSuccess = res.finalStage === 'solidified' && res.producedValue === true;
|
|
try {
|
|
await deps.memoryGraph.recordOutcome({
|
|
workspace: task.repo,
|
|
signals: cycleSignals,
|
|
geneId: res.decision.selectedGeneId,
|
|
// MemoryGraph has no inert status yet, so record a no-op as conservative failed evidence instead of reward.
|
|
status: producedSuccess ? 'success' : 'failed',
|
|
score: res.finalStage === 'solidified' && !res.producedValue
|
|
? 0
|
|
: cap?.outcome?.score ?? (producedSuccess ? 1 : 0),
|
|
at: new Date().toISOString(),
|
|
});
|
|
}
|
|
catch {
|
|
// Memory persistence is advisory and must never fail the autonomous task.
|
|
}
|
|
}
|
|
const hubAssetIds = new Set(hubCandidates.map((c) => c.assetId).filter((id) => typeof id === 'string' && id.length > 0));
|
|
const selectedAssetId = res.decision?.selectedAssetId;
|
|
const usedAssetIds = selectedAssetId && hubAssetIds.has(selectedAssetId) ? [selectedAssetId] : [];
|
|
return {
|
|
taskId: task.id, status, finalStage: res.finalStage,
|
|
...(res.reasons.length > 0 && status !== 'solidified' ? { reason: res.reasons.join('; ') } : {}),
|
|
...(cap?.outcome ? { outcome: cap.outcome } : {}),
|
|
...(cap?.proof_of_work ? { proofOfWork: cap.proof_of_work } : {}),
|
|
...(res.failureKind !== undefined ? { failureKind: res.failureKind } : {}),
|
|
...(res.exitCode !== undefined ? { exitCode: res.exitCode } : {}),
|
|
...(res.bindingDigest ? { binding_digest: res.bindingDigest } : {}),
|
|
...(res.runId ? { run_id: res.runId } : {}),
|
|
...(res.execution ? { execution_terminal: res.execution.executionTerminal } : {}),
|
|
...(res.execution?.hubLifecycle ? { hub_lifecycle: res.execution.hubLifecycle } : {}),
|
|
...(usedAssetIds.length > 0 ? { usedAssetIds } : {}),
|
|
...(res.capsule?.asset_id ? { resultAssetId: res.capsule.asset_id } : {}),
|
|
...(typeof task.generation === 'string' ? { generation: task.generation } : {}),
|
|
};
|
|
}
|
|
/**
|
|
* Single-flight re-entrancy guard for a poll-driven resident loop. A resident autoexec daemon polls on an
|
|
* interval; if a pass outlives the poll period (a hung/slow agent), the next tick must NOT start a second
|
|
* overlapping pass — that piled up runaway nested agents in an early scratch run. Wrap the pass: while one is
|
|
* in flight, subsequent calls return { skipped: true } immediately instead of starting another.
|
|
*/
|
|
export function singleFlight(fn) {
|
|
let inFlight = null;
|
|
return () => {
|
|
if (inFlight)
|
|
return Promise.resolve({ skipped: true });
|
|
const p = fn().finally(() => { inFlight = null; });
|
|
inFlight = p;
|
|
return p;
|
|
};
|
|
}
|
|
/**
|
|
* Process tasks strictly SEQUENTIALLY (one at a time, never overlapping) through `runOne`, collecting verdicts.
|
|
* Sequential by construction — an autonomous agent edits a worktree and runs tools; concurrent passes would
|
|
* contend. Combine with {@link singleFlight} so a poll tick that fires mid-drain is skipped, not stacked.
|
|
*/
|
|
export async function drainTasks(tasks, runOne) {
|
|
const out = [];
|
|
for (const t of tasks)
|
|
out.push(await runOne(t));
|
|
return out;
|
|
} |