From 4a2751beecd860eee1fa57ce08a4d9b98a2bd55a Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Thu, 20 Aug 2026 00:08:54 -0700 Subject: [PATCH 1/4] feat(agent): add evolution controls - Exposes durable status, resume, cancellation, and progress APIs for non-blocking clients - Uses explicit recovery and compare-and-swap rollback to prevent duplicate or stale state transitions --- packages/evolution/src/contracts.ts | 1 + packages/evolution/src/control-plane.ts | 67 +++ packages/evolution/src/control-service.ts | 456 ++++++++++++++++++ packages/evolution/src/index.ts | 12 + packages/evolution/src/worker.ts | 2 +- .../evolution/test/control-service.test.ts | 219 +++++++++ packages/evolution/test/worker.test.ts | 36 +- packages/harness-state/src/index.ts | 30 ++ .../harness-state/test/harness-state.test.ts | 20 + 9 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 packages/evolution/src/control-service.ts create mode 100644 packages/evolution/test/control-service.test.ts diff --git a/packages/evolution/src/contracts.ts b/packages/evolution/src/contracts.ts index ac409dfec..39de1ff6b 100644 --- a/packages/evolution/src/contracts.ts +++ b/packages/evolution/src/contracts.ts @@ -615,6 +615,7 @@ export type EvolutionJobOutcome = export type EvolutionFailureReasonCode = | "artifact-corrupt" + | "cancelled-by-operator" | "harness-base-conflict" | "lease-lost" | "model-call-failed" diff --git a/packages/evolution/src/control-plane.ts b/packages/evolution/src/control-plane.ts index f3f566187..04395f1b1 100644 --- a/packages/evolution/src/control-plane.ts +++ b/packages/evolution/src/control-plane.ts @@ -830,6 +830,73 @@ export class EvolutionControlPlane implements EvolutionJobRunner { }); } + async requestResume(jobId: string): Promise { + const initial = await this.#dependencies.jobs.load(jobId); + if (initial === null) throw new Error(`Unknown evolution job "${jobId}"`); + if (initial.outcome !== "interrupted" && initial.outcome !== "retryable-failed") { + throw new Error(`Evolution job "${jobId}" cannot be resumed from outcome "${initial.outcome}"`); + } + if (initial.resolution !== null) throw new Error(`Resolved evolution job "${jobId}" cannot be resumed`); + const ownerId = this.#dependencies.ids.nextId("lease-owner"); + const initialLease = await this.#acquireScopeLease(initial.metadata.scope, ownerId); + if (initialLease === null) throw new Error(`Evolution scope for job "${jobId}" is already leased`); + return this.#withHeartbeat(initialLease, undefined, async (lease) => { + const current = await this.#dependencies.jobs.load(jobId); + if (current === null) throw new Error(`Unknown evolution job "${jobId}"`); + if (current.outcome !== "interrupted" && current.outcome !== "retryable-failed") { + throw new Error(`Evolution job "${jobId}" cannot be resumed from outcome "${current.outcome}"`); + } + const resumed = this.#withJobRevision(current, { + outcome: "active", + reasonCode: null, + failureMessage: null, + terminalAt: null, + retryNotBefore: null, + attempt: current.attempt + 1, + }); + await this.#dependencies.jobs.save(resumed, current.revision, lease()); + await this.#dependencies.events.emit({ + jobId: resumed.jobId, + emittedAt: resumed.updatedAt, + type: "started", + stage: resumed.stage, + }); + return resumed; + }); + } + + async interruptJob(jobId: string): Promise { + const initial = await this.#dependencies.jobs.load(jobId); + if (initial === null) throw new Error(`Unknown evolution job "${jobId}"`); + if (initial.outcome !== "active") { + throw new Error(`Evolution job "${jobId}" cannot be interrupted from outcome "${initial.outcome}"`); + } + const ownerId = this.#dependencies.ids.nextId("lease-owner"); + const initialLease = await this.#acquireScopeLease(initial.metadata.scope, ownerId); + if (initialLease === null) throw new Error(`Evolution scope for job "${jobId}" is already leased`); + return this.#withHeartbeat(initialLease, undefined, async (lease) => { + const current = await this.#dependencies.jobs.load(jobId); + if (current === null) throw new Error(`Unknown evolution job "${jobId}"`); + if (current.outcome !== "active") return current; + const interrupted = this.#withJobRevision(current, { + outcome: "interrupted", + reasonCode: "cancelled-by-operator", + failureMessage: "Evolution job was cancelled by the operator", + terminalAt: this.#dependencies.clock.now(), + retryNotBefore: null, + }); + await this.#dependencies.jobs.save(interrupted, current.revision, lease()); + await this.#syncLedger(interrupted); + await this.#dependencies.events.emit({ + jobId: interrupted.jobId, + emittedAt: interrupted.updatedAt, + type: "failed", + stage: interrupted.stage, + }); + return interrupted; + }); + } + async #resolveJob( jobId: string, kind: EvolutionJobResolution["kind"], diff --git a/packages/evolution/src/control-service.ts b/packages/evolution/src/control-service.ts new file mode 100644 index 000000000..78acfb5dc --- /dev/null +++ b/packages/evolution/src/control-service.ts @@ -0,0 +1,456 @@ +import type { HarnessSelectResult, HarnessVersion, HarnessVersionList } from "@earendil-works/diffex-harness-state"; +import type { + EvolutionFailureReasonCode, + EvolutionInputSnapshot, + EvolutionJob, + EvolutionJobOutcome, + EvolutionJobStage, + EvolutionLedgerEvent, + EvolutionMemoryExactDiff, + EvolutionProposal, + EvolutionScope, + HarnessActivatedLedgerEvent, + ScoredProposal, + ValidationReport, +} from "./contracts.ts"; +import type { EvolutionControlPlane, EvolutionJobCreationBlockReason } from "./control-plane.ts"; +import type { + EvolutionArtifact, + EvolutionArtifactStore, + EvolutionClock, + EvolutionCursorStore, + EvolutionEventSink, + EvolutionEvidenceCatalog, + EvolutionJobStore, + EvolutionLedger, + EvolutionProgressEvent, +} from "./ports.ts"; +import { + AUTOMATIC_EVOLUTION_COOLDOWN_MS, + AUTOMATIC_EVOLUTION_MINIMUM_INVOCATIONS, + AUTOMATIC_EVOLUTION_TOKEN_THRESHOLD, + estimateEvolutionTokens, + evaluateAutomaticEvolutionEligibility, +} from "./token-estimator.ts"; +import type { EvolutionBackgroundWorker } from "./worker.ts"; + +export interface EvolutionHarnessControl { + listVersions(): Promise; + selectVersionIfCurrent(version: HarnessVersion, expectedCurrent: HarnessVersion): Promise; +} + +export interface EvolutionJobSummary { + readonly jobId: string; + readonly trigger: EvolutionJob["trigger"]; + readonly stage: EvolutionJobStage; + readonly outcome: EvolutionJobOutcome; + readonly reasonCode: EvolutionFailureReasonCode | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly terminalAt: string | null; + readonly resumable: boolean; + readonly cancellable: boolean; +} + +export interface EvolutionRollbackAction { + readonly jobId: string; + readonly fromHarnessVersion: HarnessVersion; + readonly toHarnessVersion: HarnessVersion; + readonly proposalIds: readonly string[]; +} + +export interface EvolutionOverview { + readonly scope: EvolutionScope; + readonly processedEvidenceCursor: number; + readonly ingestionHighWaterCursor: number; + readonly unprocessedEstimatedTokens: number; + readonly settledInvocationCount: number; + readonly automaticTokenThreshold: number; + readonly automaticMinimumInvocations: number; + readonly lastAutomaticRunAt: string | null; + readonly cooldownRemainingMs: number; + readonly automaticEligibility: + | { readonly eligible: true } + | { readonly eligible: false; readonly reason: EvolutionJobCreationBlockReason }; + readonly activeJob: EvolutionJobSummary | null; + readonly newestResumableJob: EvolutionJobSummary | null; + readonly currentHarnessVersion: HarnessVersion | null; + readonly observedHarnessVersion: HarnessVersion | null; + readonly nextPromptWillRefresh: boolean; + readonly rollbackAction: EvolutionRollbackAction | null; +} + +export interface EvolutionJobDetail extends EvolutionJobSummary { + readonly evidenceHighWaterCursor: number; + readonly completedSlices: number; + readonly discoveredSlices: number; + readonly modelCalls: number; + readonly modelRetries: number; + readonly emittedProposals: number; + readonly eligibleProposals: number; + readonly selectedOperations: number; + readonly lastCheckpointAt: string | null; + readonly errorMessage: string | null; + readonly baseHarnessVersion: HarnessVersion; + readonly activatedHarnessVersion: HarnessVersion | null; + readonly nextPromptWillRefresh: boolean; + readonly proposalIds: readonly string[]; +} + +export interface EvolutionProposalDetail { + readonly proposal: EvolutionProposal; + readonly score: ScoredProposal | null; + readonly validationDisposition: string | null; + readonly exactAppliedDiff: EvolutionMemoryExactDiff | null; + readonly rollbackTarget: HarnessVersion | null; +} + +export type EvolutionStartResult = + | { readonly status: "created"; readonly job: EvolutionJobSummary } + | { readonly status: "attached"; readonly job: EvolutionJobSummary } + | { readonly status: "not-created"; readonly reason: EvolutionJobCreationBlockReason }; + +export interface EvolutionControlServiceDependencies { + readonly scope: EvolutionScope; + readonly clock: EvolutionClock; + readonly controlPlane: EvolutionControlPlane; + readonly worker: EvolutionBackgroundWorker; + readonly jobs: EvolutionJobStore; + readonly cursors: EvolutionCursorStore; + readonly evidence: EvolutionEvidenceCatalog; + readonly artifacts: EvolutionArtifactStore; + readonly ledger: EvolutionLedger; + readonly harnesses: EvolutionHarnessControl; + readonly progress: EvolutionProgressBus; + readonly observedHarnessVersion: () => HarnessVersion | undefined; +} + +function isInputSnapshot(artifact: EvolutionArtifact): artifact is EvolutionInputSnapshot { + return "ingestionHighWaterCursor" in artifact && "evidenceSources" in artifact && "harness" in artifact; +} + +function isScoredProposal(artifact: EvolutionArtifact): artifact is ScoredProposal { + return "proposal" in artifact && "evidenceRankingScore" in artifact; +} + +function isValidationReport(artifact: EvolutionArtifact): artifact is ValidationReport { + return "proposalResults" in artifact && "validatorVersion" in artifact; +} + +function stringArray(value: unknown): readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : []; +} + +function summary(job: EvolutionJob): EvolutionJobSummary { + return { + jobId: job.jobId, + trigger: job.trigger, + stage: job.stage, + outcome: job.outcome, + reasonCode: job.reasonCode, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + terminalAt: job.terminalAt, + resumable: job.resolution === null && (job.outcome === "interrupted" || job.outcome === "retryable-failed"), + cancellable: job.resolution === null && job.outcome === "active", + }; +} + +function newest(jobs: readonly EvolutionJob[]): EvolutionJob | null { + return ( + [...jobs].sort( + (left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.jobId.localeCompare(left.jobId), + )[0] ?? null + ); +} + +function activeJob(jobs: readonly EvolutionJob[]): EvolutionJob | null { + return newest(jobs.filter((job) => job.resolution === null && job.outcome === "active")); +} + +function resumableJob(jobs: readonly EvolutionJob[]): EvolutionJob | null { + return newest( + jobs.filter( + (job) => job.resolution === null && (job.outcome === "interrupted" || job.outcome === "retryable-failed"), + ), + ); +} + +async function loadArtifacts(job: EvolutionJob, store: EvolutionArtifactStore): Promise { + const artifacts: EvolutionArtifact[] = [await store.get(job.inputSnapshot)]; + for (const checkpoint of job.checkpoints) { + for (const reference of checkpoint.outputArtifacts) artifacts.push(await store.get(reference)); + } + return artifacts; +} + +function harnessActivation(events: readonly EvolutionLedgerEvent[]): HarnessActivatedLedgerEvent | null { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === "harness-activated") return event; + } + return null; +} + +export class EvolutionProgressBus implements EvolutionEventSink { + readonly #listeners = new Set<(event: EvolutionProgressEvent) => void>(); + + emit(event: EvolutionProgressEvent): void { + for (const listener of this.#listeners) listener(event); + } + + subscribe(listener: (event: EvolutionProgressEvent) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } +} + +export class EvolutionControlService { + readonly #dependencies: EvolutionControlServiceDependencies; + #started = false; + + constructor(dependencies: EvolutionControlServiceDependencies) { + this.#dependencies = dependencies; + } + + start(): void { + if (this.#started) return; + this.#started = true; + this.#dependencies.worker.start(); + } + + subscribe(listener: (event: EvolutionProgressEvent) => void): () => void { + return this.#dependencies.progress.subscribe(listener); + } + + async stop(): Promise { + if (!this.#started) return; + this.#started = false; + await this.#dependencies.worker.stop(new Error("Evolution shutdown interrupted active work")); + } + + async #rollbackAction( + jobs: readonly EvolutionJob[], + versions: HarnessVersionList, + ): Promise { + if (versions.currentVersion === null) return null; + const validVersions = new Set(versions.versions.map((bundle) => bundle.manifest.version)); + for (const job of [...jobs].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))) { + const activation = harnessActivation(await this.#dependencies.ledger.read(job.jobId)); + if ( + activation?.resultingHarnessVersion === versions.currentVersion && + validVersions.has(activation.rollbackParentHarnessVersion) + ) { + return { + jobId: job.jobId, + fromHarnessVersion: activation.resultingHarnessVersion, + toHarnessVersion: activation.rollbackParentHarnessVersion, + proposalIds: activation.proposalIds, + }; + } + } + return null; + } + + async overview(): Promise { + const [cursor, highWater, jobs, versions] = await Promise.all([ + this.#dependencies.cursors.load(this.#dependencies.scope), + this.#dependencies.evidence.highWaterCursor(this.#dependencies.scope), + this.#dependencies.jobs.list(this.#dependencies.scope), + this.#dependencies.harnesses.listVersions(), + ]); + const materializations = await this.#dependencies.evidence.listSuccessful( + this.#dependencies.scope, + cursor.sequence, + highWater, + ); + const estimatedTokens = estimateEvolutionTokens(materializations.map((entry) => entry.invocation)); + const lastAutomaticRunAt = + jobs + .filter((job) => job.trigger === "automatic" && job.terminalAt !== null) + .map((job) => job.terminalAt as string) + .sort() + .at(-1) ?? null; + const currentTime = Date.parse(this.#dependencies.clock.now()); + const cooldownRemainingMs = + lastAutomaticRunAt === null + ? 0 + : Math.max(0, AUTOMATIC_EVOLUTION_COOLDOWN_MS - (currentTime - Date.parse(lastAutomaticRunAt))); + const active = activeJob(jobs); + const resumable = resumableJob(jobs); + const eligibility = + active !== null + ? ({ eligible: false, reason: "blocking-job" } as const) + : evaluateAutomaticEvolutionEligibility({ + invocationCount: materializations.length, + estimatedTokens, + now: new Date(currentTime).toISOString(), + previousAutomaticTerminalAt: lastAutomaticRunAt, + }); + const observedHarnessVersion = this.#dependencies.observedHarnessVersion() ?? null; + return { + scope: this.#dependencies.scope, + processedEvidenceCursor: cursor.sequence, + ingestionHighWaterCursor: highWater, + unprocessedEstimatedTokens: estimatedTokens, + settledInvocationCount: materializations.length, + automaticTokenThreshold: AUTOMATIC_EVOLUTION_TOKEN_THRESHOLD, + automaticMinimumInvocations: AUTOMATIC_EVOLUTION_MINIMUM_INVOCATIONS, + lastAutomaticRunAt, + cooldownRemainingMs, + automaticEligibility: eligibility, + activeJob: active === null ? null : summary(active), + newestResumableJob: resumable === null ? null : summary(resumable), + currentHarnessVersion: versions.currentVersion, + observedHarnessVersion, + nextPromptWillRefresh: + versions.currentVersion !== null && + observedHarnessVersion !== null && + versions.currentVersion !== observedHarnessVersion, + rollbackAction: await this.#rollbackAction(jobs, versions), + }; + } + + async listJobs(): Promise { + return (await this.#dependencies.jobs.list(this.#dependencies.scope)) + .slice() + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.jobId.localeCompare(left.jobId)) + .map(summary); + } + + async #start(trigger: "automatic" | "manual"): Promise { + const result = await this.#dependencies.controlPlane.createJob({ scope: this.#dependencies.scope, trigger }); + if (result.status === "created") return { status: "created", job: summary(result.job) }; + if (result.reason === "blocking-job" && result.blockingJobId !== undefined) { + const blocking = await this.#dependencies.jobs.load(result.blockingJobId); + if (blocking !== null) return { status: "attached", job: summary(blocking) }; + } + return { status: "not-created", reason: result.reason }; + } + + startManual(): Promise { + return this.#start("manual"); + } + + triggerAutomatic(): Promise { + return this.#start("automatic"); + } + + async resumeNewest(): Promise { + const job = resumableJob(await this.#dependencies.jobs.list(this.#dependencies.scope)); + if (job === null) throw new Error("No interrupted or retryable evolution job is available"); + return summary(await this.#dependencies.controlPlane.requestResume(job.jobId)); + } + + async cancelActive(): Promise { + const active = activeJob(await this.#dependencies.jobs.list(this.#dependencies.scope)); + if (active === null) throw new Error("No active evolution job is available"); + await this.#dependencies.worker.stop(new Error("Evolution job cancelled by the operator")); + let current = await this.#dependencies.jobs.load(active.jobId); + if (current?.outcome === "active") current = await this.#dependencies.controlPlane.interruptJob(active.jobId); + if (this.#started) this.#dependencies.worker.start(); + if (current === null) throw new Error(`Unknown evolution job "${active.jobId}"`); + return summary(current); + } + + async rollback(): Promise { + const jobs = await this.#dependencies.jobs.list(this.#dependencies.scope); + const versions = await this.#dependencies.harnesses.listVersions(); + const action = await this.#rollbackAction(jobs, versions); + if (action === null) throw new Error("No valid evolution rollback is available"); + const result = await this.#dependencies.harnesses.selectVersionIfCurrent( + action.toHarnessVersion, + action.fromHarnessVersion, + ); + if (result.status === "conflict") { + throw new Error( + `Harness revision changed before rollback: expected ${result.expectedCurrent}, found ${result.actualCurrent}`, + ); + } + return action; + } + + async getJob(jobId: string): Promise { + const job = await this.#dependencies.jobs.load(jobId); + if (job === null || job.metadata.scope.repositoryId !== this.#dependencies.scope.repositoryId) { + throw new Error(`Unknown evolution job "${jobId}"`); + } + const [artifacts, events, versions] = await Promise.all([ + loadArtifacts(job, this.#dependencies.artifacts), + this.#dependencies.ledger.read(jobId), + this.#dependencies.harnesses.listVersions(), + ]); + const input = artifacts.find(isInputSnapshot); + if (input === undefined) throw new Error(`Evolution job "${jobId}" has no input snapshot`); + const scored = artifacts.filter(isScoredProposal); + const discoveredSlices = new Set( + artifacts.flatMap((artifact) => ("sliceIds" in artifact ? stringArray(artifact.sliceIds) : [])), + ).size; + const completedSlices = new Set( + artifacts.flatMap((artifact) => + "sliceIds" in artifact && "status" in artifact && artifact.status === "complete" + ? stringArray(artifact.sliceIds) + : [], + ), + ).size; + const proposalIds = [ + ...new Set( + job.checkpoints.flatMap((checkpoint) => + checkpoint.stage === "curating" ? stringArray(checkpoint.state.proposalIds) : [], + ), + ), + ]; + const activation = harnessActivation(events); + const modelCalls = events.filter((event) => event.type === "reflector-model-call").length; + const modelRetries = events.filter((event) => event.type === "reflector-model-call" && event.attempt > 1).length; + const selectedOperations = activation?.operationIds.length ?? 0; + const observed = this.#dependencies.observedHarnessVersion() ?? null; + return { + ...summary(job), + evidenceHighWaterCursor: input.ingestionHighWaterCursor, + completedSlices, + discoveredSlices, + modelCalls, + modelRetries, + emittedProposals: proposalIds.length, + eligibleProposals: scored.filter( + (entry) => entry.disposition === "selected" || entry.disposition === "unselected", + ).length, + selectedOperations, + lastCheckpointAt: job.checkpoints.at(-1)?.completedAt ?? null, + errorMessage: job.failureMessage, + baseHarnessVersion: input.expectedHarnessBaseVersion, + activatedHarnessVersion: activation?.resultingHarnessVersion ?? null, + nextPromptWillRefresh: + versions.currentVersion !== null && observed !== null && versions.currentVersion !== observed, + proposalIds, + }; + } + + async getProposal(jobId: string, proposalId: string): Promise { + const job = await this.#dependencies.jobs.load(jobId); + if (job === null) throw new Error(`Unknown evolution job "${jobId}"`); + const [artifacts, events] = await Promise.all([ + loadArtifacts(job, this.#dependencies.artifacts), + this.#dependencies.ledger.read(jobId), + ]); + const score = + artifacts.filter(isScoredProposal).find((entry) => entry.proposal.proposalId === proposalId) ?? null; + const proposal = + score?.proposal ?? + (artifacts.find( + (artifact): artifact is EvolutionProposal => "proposalId" in artifact && artifact.proposalId === proposalId, + ) as EvolutionProposal | undefined); + if (proposal === undefined) throw new Error(`Unknown evolution proposal "${proposalId}"`); + const validation = artifacts.find(isValidationReport); + const activation = harnessActivation(events); + return { + proposal, + score, + validationDisposition: + validation?.proposalResults.find((result) => result.proposalId === proposalId)?.disposition ?? null, + exactAppliedDiff: activation?.proposalIds.includes(proposalId) ? activation.exactDiff : null, + rollbackTarget: activation?.proposalIds.includes(proposalId) ? activation.rollbackParentHarnessVersion : null, + }; + } +} diff --git a/packages/evolution/src/index.ts b/packages/evolution/src/index.ts index 5ecd7fe44..5ef620551 100644 --- a/packages/evolution/src/index.ts +++ b/packages/evolution/src/index.ts @@ -118,6 +118,18 @@ export { type EvolutionJobCreationBlockReason, type EvolutionJobCreationResult, } from "./control-plane.ts"; +export { + EvolutionControlService, + type EvolutionControlServiceDependencies, + type EvolutionHarnessControl, + type EvolutionJobDetail, + type EvolutionJobSummary, + type EvolutionOverview, + EvolutionProgressBus, + type EvolutionProposalDetail, + type EvolutionRollbackAction, + type EvolutionStartResult, +} from "./control-service.ts"; export { DisabledPreReflectionStage } from "./counter.ts"; export { CURATOR_OUTPUT_SCHEMA, diff --git a/packages/evolution/src/worker.ts b/packages/evolution/src/worker.ts index eccd3ea2b..4013a915a 100644 --- a/packages/evolution/src/worker.ts +++ b/packages/evolution/src/worker.ts @@ -54,7 +54,7 @@ export class PollingEvolutionWorkSource implements EvolutionWorkSource { .filter( (job) => job.resolution === null && - (job.outcome === "active" || job.outcome === "interrupted" || job.outcome === "retryable-failed") && + job.outcome === "active" && job.stage !== "completed" && (job.retryNotBefore === null || Date.parse(job.retryNotBefore) <= nowMs), ) diff --git a/packages/evolution/test/control-service.test.ts b/packages/evolution/test/control-service.test.ts new file mode 100644 index 000000000..751488865 --- /dev/null +++ b/packages/evolution/test/control-service.test.ts @@ -0,0 +1,219 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HarnessStateStore } from "@earendil-works/diffex-harness-state"; +import type { MaterializedInvocation } from "@earendil-works/diffex-harness-trace"; +import { afterEach, describe, expect, it } from "vitest"; +import { + computeEvolutionContentDigest, + EvolutionBackgroundWorker, + EvolutionControlPlane, + EvolutionControlService, + EvolutionProgressBus, + type EvolutionScope, + type EvolutionSnapshotContext, + FileEvolutionPersistence, + PollingEvolutionWorkSource, + type SuccessfulMaterialization, +} from "../src/index.ts"; + +const scope: EvolutionScope = { repositoryId: "diffex", scopeId: "repository" }; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +class SequenceIds { + #next = 1; + + nextId(kind: string): string { + const value = `${kind}-${this.#next}`; + this.#next += 1; + return value; + } +} + +class FixedClock { + #timestamp = Date.parse("2026-08-20T12:00:00.000Z"); + + now(): string { + const value = new Date(this.#timestamp).toISOString(); + this.#timestamp += 1; + return value; + } +} + +function invocation(sequence: number): MaterializedInvocation { + return { + version: 1, + tracePath: `/traces/${sequence}.jsonl`, + traceId: `trace-${sequence}`, + producer: null, + integrity: "complete", + terminationStatus: "completed", + health: null, + ordering: { + criteria: ["trace_sequence", "session_cursor", "timestamp", "span_parentage", "causal_id"], + ambiguity: [], + }, + evidence: [], + diagnostics: [], + }; +} + +function materialization(sequence: number): SuccessfulMaterialization { + const materialized = invocation(sequence); + return { + materializationId: `materialization-${sequence}`, + ingestionCursor: sequence, + origin: "product-runtime", + contentDigest: computeEvolutionContentDigest(materialized), + invocation: materialized, + }; +} + +interface ServiceFixture { + readonly service: EvolutionControlService; + readonly store: FileEvolutionPersistence; + readonly progress: EvolutionProgressBus; +} + +async function serviceFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "diffex-evolution-service-")); + temporaryDirectories.push(root); + const store = new FileEvolutionPersistence(join(root, "evolution")); + const harnesses = new HarnessStateStore(join(root, "agent")); + const harness = await harnesses.initialize(); + const clock = new FixedClock(); + const progress = new EvolutionProgressBus(); + const materializations = [materialization(1), materialization(2), materialization(3)]; + const snapshots = { + async capture(): Promise { + const memory = harness.state.memory; + return { + repository: { repositoryId: "diffex", rootPath: "/repo", revision: "abc123" }, + harness: { + harnessVersion: harness.manifest.version, + baseVersion: harness.manifest.base, + memory, + memoryContentDigest: computeEvolutionContentDigest(memory), + activeMemoryItems: [], + }, + instructions: [], + modelConfiguration: { + provider: "faux", + model: "fixture", + configurationDigest: computeEvolutionContentDigest("fixture"), + }, + pipelineVersion: "pipeline-v1", + promptVersions: { + reflector: "reflector-v1", + curator: "curator-v1", + validator: "validator-v1", + writer: "writer-v1", + }, + }; + }, + }; + const evidence = { + async highWaterCursor() { + return 3; + }, + async listSuccessful(_scope: EvolutionScope, afterCursor: number, throughCursor: number) { + return materializations.filter( + (entry) => entry.ingestionCursor > afterCursor && entry.ingestionCursor <= throughCursor, + ); + }, + }; + const controlPlane = new EvolutionControlPlane( + { + version: "1.0.0", + clock, + ids: new SequenceIds(), + artifacts: store, + jobs: store, + cursors: store, + leases: store, + ledger: store, + events: progress, + evidence, + snapshots, + executor: { + async execute() { + throw new Error("The service fixture does not execute pipeline stages"); + }, + }, + }, + { leaseTtlMs: 5_000, heartbeatIntervalMs: 1_000 }, + ); + const worker = new EvolutionBackgroundWorker({ + clock, + source: new PollingEvolutionWorkSource(store, clock, { pollIntervalMs: 60_000 }), + runner: controlPlane, + priority: { async waitUntilBackgroundAllowed() {} }, + }); + const service = new EvolutionControlService({ + scope, + clock, + controlPlane, + worker, + jobs: store, + cursors: store, + evidence, + artifacts: store, + ledger: store, + harnesses, + progress, + observedHarnessVersion: () => harness.manifest.version, + }); + return { service, store, progress }; +} + +describe("evolution control service", () => { + it("projects eligibility and attaches a concurrent manual trigger", async () => { + const fixture = await serviceFixture(); + const events: string[] = []; + const unsubscribe = fixture.service.subscribe((event) => events.push(`${event.type}:${event.jobId}`)); + + const initial = await fixture.service.overview(); + expect(initial).toMatchObject({ + processedEvidenceCursor: 0, + ingestionHighWaterCursor: 3, + settledInvocationCount: 3, + automaticEligibility: { eligible: false, reason: "insufficient-tokens" }, + activeJob: null, + }); + + const started = await fixture.service.startManual(); + expect(started.status).toBe("created"); + const attached = await fixture.service.startManual(); + expect(attached).toMatchObject({ + status: "attached", + job: { jobId: started.status === "created" ? started.job.jobId : "" }, + }); + expect(events).toHaveLength(1); + unsubscribe(); + await fixture.service.cancelActive(); + expect(events).toHaveLength(1); + }); + + it("cancels queued work and explicitly resumes the newest interrupted job", async () => { + const fixture = await serviceFixture(); + const started = await fixture.service.startManual(); + if (started.status !== "created") throw new Error("Expected a created job"); + + const cancelled = await fixture.service.cancelActive(); + expect(cancelled).toMatchObject({ + jobId: started.job.jobId, + outcome: "interrupted", + reasonCode: "cancelled-by-operator", + resumable: true, + }); + expect((await fixture.service.overview()).newestResumableJob?.jobId).toBe(started.job.jobId); + + const resumed = await fixture.service.resumeNewest(); + expect(resumed).toMatchObject({ jobId: started.job.jobId, outcome: "active", resumable: false }); + expect(await fixture.store.load(started.job.jobId)).toMatchObject({ attempt: 1, retryNotBefore: null }); + }); +}); diff --git a/packages/evolution/test/worker.test.ts b/packages/evolution/test/worker.test.ts index ef72a43a4..ec187a57c 100644 --- a/packages/evolution/test/worker.test.ts +++ b/packages/evolution/test/worker.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { EvolutionBackgroundWorker, type EvolutionWorkSource } from "../src/index.ts"; +import { + EvolutionBackgroundWorker, + type EvolutionJob, + type EvolutionJobStore, + type EvolutionWorkSource, + PollingEvolutionWorkSource, +} from "../src/index.ts"; function waitForAbort(signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason); @@ -9,6 +15,34 @@ function waitForAbort(signal: AbortSignal): Promise { } describe("evolution background worker", () => { + it("does not resume interrupted work without an explicit request", async () => { + const jobs: EvolutionJobStore = { + async create() {}, + async save() {}, + async load() { + return null; + }, + async list() { + return [ + { + jobId: "job-interrupted", + outcome: "interrupted", + resolution: null, + stage: "reflecting", + retryNotBefore: null, + trigger: "manual", + createdAt: "2026-08-19T12:00:00.000Z", + } as EvolutionJob, + ]; + }, + }; + const source = new PollingEvolutionWorkSource(jobs, { + now: () => "2026-08-20T12:00:00.000Z", + }); + + expect(await source.nextRunnableJob("2026-08-20T12:00:00.000Z")).toBeNull(); + }); + it("admits durable work only after the interactive priority gate", async () => { const order: string[] = []; const worker = new EvolutionBackgroundWorker({ diff --git a/packages/harness-state/src/index.ts b/packages/harness-state/src/index.ts index e1227936a..db0b1e01a 100644 --- a/packages/harness-state/src/index.ts +++ b/packages/harness-state/src/index.ts @@ -116,6 +116,19 @@ export interface HarnessAdvanceConflict { export type HarnessAdvanceResult = HarnessAdvanceSuccess | HarnessAdvanceConflict; +export interface HarnessSelectSuccess { + readonly status: "selected"; + readonly bundle: HarnessVersionBundle; +} + +export interface HarnessSelectConflict { + readonly status: "conflict"; + readonly expectedCurrent: HarnessVersion; + readonly actualCurrent: HarnessVersion; +} + +export type HarnessSelectResult = HarnessSelectSuccess | HarnessSelectConflict; + export interface HarnessVersionInput { readonly schemaVersion: 1; readonly base: HarnessVersion | null; @@ -685,6 +698,23 @@ export class HarnessStateStore { }); } + async selectVersionIfCurrent( + version: HarnessVersion, + expectedCurrent: HarnessVersion, + ): Promise { + return this.#withLock(async () => { + await this.#ensureDirectories(); + if (!Check(HarnessVersionSchema, expectedCurrent)) throw new Error("Invalid expected current harness version"); + const current = await this.#loadPointer(); + if (current.version !== expectedCurrent) { + return { status: "conflict", expectedCurrent, actualCurrent: current.version }; + } + const bundle = await this.#loadVersion(version); + if (current.version !== version) await this.#replacePointer(version); + return { status: "selected", bundle }; + }); + } + async advance(state: HarnessState, expectedBase: HarnessVersion): Promise { return this.#withLock(async () => { await this.#ensureDirectories(); diff --git a/packages/harness-state/test/harness-state.test.ts b/packages/harness-state/test/harness-state.test.ts index c7af064ca..a39a11413 100644 --- a/packages/harness-state/test/harness-state.test.ts +++ b/packages/harness-state/test/harness-state.test.ts @@ -426,6 +426,26 @@ describe("HarnessStateStore", () => { expect((await readdir(join(agentDir, "harness-state"))).filter((name) => name.includes(".tmp"))).toEqual([]); }); + test("selects a rollback target only from the expected current revision", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const first = await store.advance({ memory: "first" }, root.manifest.version); + if (first.status !== "advanced") throw new Error("Expected first advance to succeed"); + const second = await store.advance({ memory: "second" }, first.bundle.manifest.version); + if (second.status !== "advanced") throw new Error("Expected second advance to succeed"); + + expect(await store.selectVersionIfCurrent(first.bundle.manifest.version, root.manifest.version)).toEqual({ + status: "conflict", + expectedCurrent: root.manifest.version, + actualCurrent: second.bundle.manifest.version, + }); + expect(await store.selectVersionIfCurrent(first.bundle.manifest.version, second.bundle.manifest.version)).toEqual( + { status: "selected", bundle: first.bundle }, + ); + expect(await store.loadCurrent()).toEqual(first.bundle); + }); + test("leaves invalid current state untouched", async () => { const agentDir = await createTemporaryRoot(); const store = new HarnessStateStore(agentDir); From 947848fd462b8335ae9f63bb7292f60987d735ec Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Thu, 20 Aug 2026 00:12:20 -0700 Subject: [PATCH 2/4] feat(coding-agent): refresh evolved harness - Activates a newly selected harness before constructing the next provider request - Keeps admitted prompts pinned while sharing one activation path with manual selection --- .../coding-agent/src/core/agent-session.ts | 27 ++++++++++---- .../agent-session-harness-version.test.ts | 35 +++++++++++++++++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2e3710367..db1ca13a8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1196,18 +1196,31 @@ export class AgentSession { ); } - await this._subagentController?.reset(); const selected = await this._harnessStateStore.selectVersion(version); - this._harnessVersionBundle = selected; - this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); - this._systemPromptOverride = undefined; - this.agent.state.systemPrompt = this._baseSystemPrompt; - await this._notifyHarnessActivated(version); + await this._activateHarnessBundle(selected); } finally { this._harnessSwitchInProgress = false; } } + private async _activateHarnessBundle(bundle: HarnessVersionBundle): Promise { + await this._subagentController?.reset(); + this._harnessVersionBundle = bundle; + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this._systemPromptOverride = undefined; + this.agent.state.systemPrompt = this._baseSystemPrompt; + await this._notifyHarnessActivated(bundle.manifest.version); + } + + private async _refreshSelectedHarnessAtPromptBoundary(): Promise { + if (!this._harnessStateStore) return; + + const selected = await this._harnessStateStore.loadCurrent(); + if (selected.manifest.version === this.activeHarnessVersion) return; + + await this._activateHarnessBundle(selected); + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. @@ -1646,6 +1659,8 @@ export class AgentSession { return; } + await this._refreshSelectedHarnessAtPromptBoundary(); + // Flush any pending bash messages before the new prompt this._flushPendingBashMessages(); diff --git a/packages/coding-agent/test/suite/agent-session-harness-version.test.ts b/packages/coding-agent/test/suite/agent-session-harness-version.test.ts index c9a9f6f4a..c50fee5bb 100644 --- a/packages/coding-agent/test/suite/agent-session-harness-version.test.ts +++ b/packages/coding-agent/test/suite/agent-session-harness-version.test.ts @@ -72,6 +72,32 @@ describe("AgentSession harness revisions", () => { ); }); + it("refreshes the selected harness before the next admitted request", async () => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const providerPrompts: string[] = []; + harness.setResponses([ + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("before activation"); + }, + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("after activation"); + }, + ]); + + await harness.session.prompt("first request"); + const advance = await store.advance({ memory: "Use the newly selected memory." }, rootVersion); + expect(advance.status).toBe("advanced"); + if (advance.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + await harness.session.prompt("second request"); + + expect(harness.session.activeHarnessVersion).toBe(advance.bundle.manifest.version); + expect(providerPrompts[0]).not.toContain(" { const activatedVersions: HarnessVersion[] = []; const agentDir = join(tmpdir(), `diffex-harness-observer-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -79,7 +105,6 @@ describe("AgentSession harness revisions", () => { agentDirs.push(agentDir); const store = new HarnessStateStore(agentDir); const root = await store.initialize(); - const successor = await store.saveVersion({ memory: "Observed memory." }, root.manifest.version); const harness = await createHarness({ harnessStateStore: store, harnessVersionBundle: root, @@ -91,10 +116,14 @@ describe("AgentSession harness revisions", () => { }, }); harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("observed")]); + const successor = await store.advance({ memory: "Observed memory." }, root.manifest.version); + expect(successor.status).toBe("advanced"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); - await harness.session.switchHarnessVersion(successor.manifest.version); + await harness.session.prompt("observe the selected harness"); - expect(activatedVersions).toEqual([successor.manifest.version]); + expect(activatedVersions).toEqual([successor.bundle.manifest.version]); }); it.each([ From 96eb32f6346774e73a522433356ff75339b42d9d Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Thu, 20 Aug 2026 00:30:01 -0700 Subject: [PATCH 3/4] feat(coding-agent): integrate evolution runtime - Wires durable evidence, pipeline execution, automatic checks, recovery, and graceful shutdown - Adds a responsive /evolve menu for status, proposals, resume, cancellation, and rollback --- packages/coding-agent/package.json | 5 +- .../src/core/evolution-runtime.ts | 627 ++++++++++++++++++ .../coding-agent/src/core/slash-commands.ts | 1 + packages/coding-agent/src/main.ts | 24 +- .../interactive/components/evolution-menu.ts | 410 ++++++++++++ .../src/modes/interactive/interactive-mode.ts | 34 + .../coding-agent/test/evolution-menu.test.ts | 195 ++++++ .../test/evolution-runtime.test.ts | 86 +++ .../test/interactive-mode-evolution.test.ts | 117 ++++ .../coding-agent/test/slash-commands.test.ts | 7 + packages/coding-agent/tsconfig.build.json | 1 + packages/evolution/src/control-plane.ts | 32 +- packages/evolution/src/curator.ts | 1 + .../evolution/src/evolution-memory-writer.ts | 4 + packages/evolution/src/ports.ts | 2 + packages/evolution/src/reflector.ts | 1 + packages/evolution/test/control-plane.test.ts | 16 + tsconfig.json | 1 + vitest.base.ts | 2 + 19 files changed, 1558 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/src/core/evolution-runtime.ts create mode 100644 packages/coding-agent/src/modes/interactive/components/evolution-menu.ts create mode 100644 packages/coding-agent/test/evolution-menu.test.ts create mode 100644 packages/coding-agent/test/evolution-runtime.test.ts create mode 100644 packages/coding-agent/test/interactive-mode-evolution.test.ts diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f209dcd39..a7b9621f8 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -4,6 +4,7 @@ "description": "Coding agent CLI with read, bash, edit, write, update_plan tools and session management", "type": "module", "imports": { + "#evolution": "./dist/vendor/evolution/index.js", "#harness-trace": "./dist/vendor/harness-trace/index.js" }, "diffexConfig": { @@ -38,8 +39,8 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", - "build:binary": "npm --prefix ../harness-state run build && npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../harness-trace run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", - "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/ && shx mkdir -p dist/vendor && shx cp -r ../harness-trace/dist dist/vendor/harness-trace", + "build:binary": "npm --prefix ../harness-state run build && npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../harness-trace run build && npm --prefix ../evolution run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/ && shx mkdir -p dist/vendor && shx cp -r ../harness-trace/dist dist/vendor/harness-trace && shx cp -r ../evolution/dist dist/vendor/evolution", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", "shrinkwrap": "node ../../scripts/generate-coding-agent-shrinkwrap.mjs", diff --git a/packages/coding-agent/src/core/evolution-runtime.ts b/packages/coding-agent/src/core/evolution-runtime.ts new file mode 100644 index 000000000..e818dab1e --- /dev/null +++ b/packages/coding-agent/src/core/evolution-runtime.ts @@ -0,0 +1,627 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readdir, readFile, realpath } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { contentText, type Message, type Model, type Tool } from "@earendil-works/diffex-ai"; +import type { HarnessStateStore, HarnessVersion } from "@earendil-works/diffex-harness-state"; +import type { TSchema } from "typebox"; +import { + computeEvolutionContentDigest, + DeterministicProposalRanker, + EvolutionBackgroundWorker, + EvolutionControlPlane, + EvolutionControlService, + type EvolutionEvidenceCatalog, + type EvolutionJobDetail, + type EvolutionJobSummary, + EvolutionMemoryWriter, + type EvolutionModelExecutor, + type EvolutionModelRequest, + type EvolutionModelResponse, + type EvolutionOverview, + EvolutionPipeline, + EvolutionProgressBus, + type EvolutionProgressEvent, + type EvolutionProposalDetail, + type EvolutionRollbackAction, + type EvolutionScope, + type EvolutionSnapshotSource, + type EvolutionStartResult, + EvolutionTargetAdapterRegistry, + FileEvolutionPersistence, + type InteractivePriorityGate, + type JsonObject, + PollingEvolutionWorkSource, + RecursiveReflector, + StructuredDeltaCurator, + type SuccessfulMaterialization, +} from "#evolution"; +import { materializeJsonlInvocation } from "#harness-trace"; +import { VERSION } from "../config.ts"; +import type { AgentSession } from "./agent-session.ts"; +import type { AgentSessionLifecycleObserver, AgentSessionOperationSettlement } from "./agent-session-lifecycle.ts"; +import type { InvocationTrace } from "./invocation-trace.ts"; +import type { ModelRuntime } from "./model-runtime.ts"; +import type { ResourceLoader } from "./resource-loader.ts"; + +const execFileAsync = promisify(execFile); +const EVOLUTION_DIRECTORY_NAME = "evolution"; +const TRACE_DIRECTORY_NAME = "traces"; +const RESPONSE_TOOL_NAME = "submit_evolution_response"; +const MAX_MODEL_ROUNDS = 24; + +const REFLECTOR_RESPONSE_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["complete", "lessons"], + properties: { + complete: { type: "boolean" }, + lessons: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: [ + "kind", + "summary", + "preconditions", + "observedOutcome", + "supportingEvidenceIds", + "counterevidenceIds", + "applicabilityBoundaries", + "uncertainty", + "actionableTakeaway", + ], + properties: { + kind: { + enum: [ + "counterexample", + "direct-instruction", + "failure-workaround", + "harmful-memory", + "incorrect-source-of-truth", + "missing-capability", + "missing-verification", + "orchestration-inefficiency", + "repository-constraint", + "successful-strategy", + "tool-behavior", + "unresolved-work", + ], + }, + summary: { type: "string", minLength: 1 }, + preconditions: { type: "array", items: { type: "string", minLength: 1 } }, + observedOutcome: { type: "string", minLength: 1 }, + supportingEvidenceIds: { type: "array", minItems: 1, items: { type: "string", minLength: 1 } }, + counterevidenceIds: { type: "array", items: { type: "string", minLength: 1 } }, + applicabilityBoundaries: { type: "array", items: { type: "string", minLength: 1 } }, + uncertainty: { type: ["string", "null"] }, + actionableTakeaway: { type: "string", minLength: 1 }, + }, + }, + }, + }, +} as const satisfies JsonObject; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonObject(text: string): JsonObject | null { + const candidate = text + .trim() + .replace(/^```(?:json)?\s*/u, "") + .replace(/\s*```$/u, ""); + try { + const parsed: unknown = JSON.parse(candidate); + return isRecord(parsed) ? (parsed as JsonObject) : null; + } catch { + return null; + } +} + +function abortableDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolveDelay, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolveDelay(); + }, delayMs); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +class SessionPriorityGate implements InteractivePriorityGate { + readonly #session: AgentSession; + + constructor(session: AgentSession) { + this.#session = session; + } + + async waitUntilBackgroundAllowed(signal: AbortSignal): Promise { + while (this.#session.isStreaming) await abortableDelay(50, signal); + } +} + +class CodingAgentEvolutionModelExecutor implements EvolutionModelExecutor { + readonly #session: AgentSession; + readonly #modelRuntime: ModelRuntime; + readonly #priority: InteractivePriorityGate; + + constructor(session: AgentSession, modelRuntime: ModelRuntime, priority: InteractivePriorityGate) { + this.#session = session; + this.#modelRuntime = modelRuntime; + this.#priority = priority; + } + + #model(request: EvolutionModelRequest): Model { + const configured = this.#modelRuntime.getModel(request.model.provider, request.model.model); + if (configured) return configured as Model; + const current = this.#session.model; + if (current?.provider === request.model.provider && current.id === request.model.model) { + return current as Model; + } + throw new Error(`Evolution model ${request.model.provider}/${request.model.model} is unavailable`); + } + + async execute(request: EvolutionModelRequest): Promise { + const model = this.#model(request); + const evolutionTools = request.stage === "reflector" ? request.tools : []; + const responseSchema = request.stage === "curator" ? request.outputSchema : REFLECTOR_RESPONSE_SCHEMA; + const responseTool: Tool = { + name: RESPONSE_TOOL_NAME, + description: "Submit the complete structured response after inspecting all required evidence.", + parameters: responseSchema as TSchema, + constrainedSampling: { type: "json_schema", strict: "prefer" }, + }; + const tools: Tool[] = [ + ...evolutionTools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.inputSchema as TSchema, + })), + responseTool, + ]; + const messages: Message[] = [ + { + role: "user", + content: JSON.stringify(request.input), + timestamp: Date.now(), + }, + ]; + for (let round = 0; round < MAX_MODEL_ROUNDS; round += 1) { + request.signal?.throwIfAborted(); + await this.#priority.waitUntilBackgroundAllowed(request.signal ?? new AbortController().signal); + const assistant = await this.#modelRuntime.completeSimple( + model, + { + systemPrompt: + "You are Diffex's evidence evolution engine. Inspect cited evidence, follow the supplied schema exactly, and use submit_evolution_response only when the result is complete.", + messages, + tools, + }, + { + signal: request.signal, + cacheRetention: "none", + sessionId: request.idempotencyKey, + }, + ); + if (assistant.stopReason === "error" || assistant.stopReason === "aborted") { + throw new Error(assistant.errorMessage ?? `Evolution model stopped with ${assistant.stopReason}`); + } + messages.push(assistant); + const calls = assistant.content.filter((part) => part.type === "toolCall"); + for (const call of calls) { + if (call.name === RESPONSE_TOOL_NAME) { + return { output: call.arguments as JsonObject, model: { provider: model.provider, model: model.id } }; + } + const tool = evolutionTools.find((candidate) => candidate.name === call.name); + const output = tool + ? await tool.execute(call.arguments as JsonObject) + : { error: `Unknown evolution tool ${call.name}` }; + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: [{ type: "text", text: JSON.stringify(output) }], + isError: tool === undefined, + timestamp: Date.now(), + }); + } + if (calls.length === 0) { + const parsed = parseJsonObject(contentText(assistant.content)); + if (parsed) return { output: parsed, model: { provider: model.provider, model: model.id } }; + messages.push({ + role: "user", + content: `Return the result through ${RESPONSE_TOOL_NAME}.`, + timestamp: Date.now(), + }); + } + } + throw new Error(`Evolution model exceeded ${MAX_MODEL_ROUNDS} tool rounds`); + } +} + +class TraceEvidenceCatalog implements EvolutionEvidenceCatalog { + readonly #traceDirectory: string; + readonly #repositoryRoot: string; + readonly #scope: EvolutionScope; + readonly #excludedTracePath: string | undefined; + readonly #cache = new Map>(); + + constructor(options: { + agentDir: string; + repositoryRoot: string; + scope: EvolutionScope; + excludedTracePath?: string; + }) { + this.#traceDirectory = join(options.agentDir, TRACE_DIRECTORY_NAME); + this.#repositoryRoot = resolve(options.repositoryRoot); + this.#scope = options.scope; + this.#excludedTracePath = options.excludedTracePath; + } + + #assertScope(scope: EvolutionScope): void { + if (scope.repositoryId !== this.#scope.repositoryId || scope.scopeId !== this.#scope.scopeId) { + throw new Error("Evolution evidence catalog scope does not match the repository runtime"); + } + } + + async #paths(): Promise { + try { + return (await readdir(this.#traceDirectory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => join(this.#traceDirectory, entry.name)) + .filter((path) => path !== this.#excludedTracePath) + .sort(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; + } + } + + async #sessionBelongsToRepository(path: string): Promise { + try { + const firstLine = (await readFile(path, "utf8")).split("\n", 1)[0]; + const header: unknown = JSON.parse(firstLine ?? ""); + if (!isRecord(header) || typeof header.cwd !== "string") return false; + const sessionRoot = await realpath(header.cwd).catch(() => resolve(header.cwd as string)); + return sessionRoot === this.#repositoryRoot; + } catch { + return false; + } + } + + async #materialize(path: string, ingestionCursor: number): Promise { + const invocation = await materializeJsonlInvocation(path); + const sessionPaths = new Set( + invocation.evidence.flatMap((evidence) => + evidence.sessionSource === undefined + ? [] + : [evidence.sessionSource.resolvedPath ?? evidence.sessionSource.path], + ), + ); + let belongs = false; + for (const sessionPath of sessionPaths) { + if (await this.#sessionBelongsToRepository(sessionPath)) { + belongs = true; + break; + } + } + if (!belongs) return null; + const contentDigest = computeEvolutionContentDigest(invocation); + return { + materializationId: invocation.traceId ?? `materialization-${contentDigest.slice("sha256:".length)}`, + ingestionCursor, + origin: "product-runtime", + contentDigest, + invocation, + }; + } + + async highWaterCursor(scope: EvolutionScope): Promise { + this.#assertScope(scope); + return (await this.#paths()).length; + } + + async listSuccessful( + scope: EvolutionScope, + afterCursor: number, + throughCursor: number, + ): Promise { + this.#assertScope(scope); + const paths = await this.#paths(); + const selected = paths.slice(afterCursor, Math.min(throughCursor, paths.length)); + const materializations = await Promise.all( + selected.map((path, index) => { + const ingestionCursor = afterCursor + index + 1; + const key = `${ingestionCursor}:${path}`; + const cached = this.#cache.get(key); + if (cached) return cached; + const created = this.#materialize(path, ingestionCursor); + this.#cache.set(key, created); + return created; + }), + ); + return materializations.filter((entry): entry is SuccessfulMaterialization => entry !== null); + } +} + +class CodingAgentSnapshotSource implements EvolutionSnapshotSource { + readonly #cwd: string; + readonly #scope: EvolutionScope; + readonly #harnessState: HarnessStateStore; + readonly #resourceLoader: ResourceLoader; + readonly #session: AgentSession; + readonly #writer: EvolutionMemoryWriter; + + constructor(options: { + cwd: string; + scope: EvolutionScope; + harnessState: HarnessStateStore; + resourceLoader: ResourceLoader; + session: AgentSession; + writer: EvolutionMemoryWriter; + }) { + this.#cwd = options.cwd; + this.#scope = options.scope; + this.#harnessState = options.harnessState; + this.#resourceLoader = options.resourceLoader; + this.#session = options.session; + this.#writer = options.writer; + } + + async #revision(): Promise { + try { + const result = await execFileAsync("git", ["-C", this.#cwd, "rev-parse", "HEAD"]); + return result.stdout.trim(); + } catch { + return `unversioned:${computeEvolutionContentDigest(this.#cwd).slice("sha256:".length)}`; + } + } + + async capture(scope: EvolutionScope, signal?: AbortSignal) { + if (scope.repositoryId !== this.#scope.repositoryId || scope.scopeId !== this.#scope.scopeId) { + throw new Error("Evolution snapshot scope does not match the repository runtime"); + } + signal?.throwIfAborted(); + const bundle = await this.#harnessState.loadCurrent(); + const model = this.#session.model; + if (!model) throw new Error("Evolution requires an active model"); + const instructions = this.#resourceLoader.getAgentsFiles().agentsFiles.map((file, precedence) => ({ + path: file.path, + precedence, + content: file.content, + contentDigest: computeEvolutionContentDigest(file.content), + })); + return { + repository: { + repositoryId: scope.repositoryId, + rootPath: this.#cwd, + revision: await this.#revision(), + }, + harness: { + harnessVersion: bundle.manifest.version, + baseVersion: bundle.manifest.base, + memory: bundle.state.memory, + memoryContentDigest: computeEvolutionContentDigest(bundle.state.memory), + activeMemoryItems: await this.#writer.loadActiveMemoryItems(bundle), + }, + instructions, + modelConfiguration: { + provider: model.provider, + model: model.id, + configurationDigest: computeEvolutionContentDigest({ + provider: model.provider, + model: model.id, + api: model.api, + baseUrl: model.baseUrl, + }), + }, + pipelineVersion: VERSION, + promptVersions: { + reflector: "reflector-v1", + curator: "curator-v1", + validator: "validator-v1", + writer: "writer-v1", + }, + }; + } +} + +export interface CodingAgentEvolutionRuntime { + readonly control: EvolutionControlService; + start(): Promise; + evaluateAutomatic(): void; + stop(): Promise; +} + +export interface CreateCodingAgentEvolutionRuntimeOptions { + readonly agentDir: string; + readonly cwd: string; + readonly session: AgentSession; + readonly harnessState: HarnessStateStore; + readonly modelRuntime: ModelRuntime; + readonly resourceLoader: ResourceLoader; + readonly currentTracePath?: string; +} + +export async function createCodingAgentEvolutionRuntime( + options: CreateCodingAgentEvolutionRuntimeOptions, +): Promise { + const repositoryRoot = await realpath(options.cwd).catch(() => resolve(options.cwd)); + const repositoryId = computeEvolutionContentDigest(repositoryRoot); + const scope: EvolutionScope = { repositoryId, scopeId: repositoryId }; + const storageRoot = join(options.agentDir, EVOLUTION_DIRECTORY_NAME, repositoryId.replace("sha256:", "sha256-")); + const persistence = new FileEvolutionPersistence(storageRoot); + const progress = new EvolutionProgressBus(); + const clock = { now: () => new Date().toISOString() }; + const priority = new SessionPriorityGate(options.session); + const evidence = new TraceEvidenceCatalog({ + agentDir: options.agentDir, + repositoryRoot, + scope, + excludedTracePath: options.currentTracePath, + }); + const writer = new EvolutionMemoryWriter({ + version: VERSION, + harnessState: options.harnessState, + statePath: join(storageRoot, "writer"), + }); + const targets = new EvolutionTargetAdapterRegistry({ writers: [writer] }); + const model = new CodingAgentEvolutionModelExecutor(options.session, options.modelRuntime, priority); + const reflector = new RecursiveReflector({ version: VERSION, artifacts: persistence, ledger: persistence }); + const curator = new StructuredDeltaCurator({ version: VERSION }); + const ranker = new DeterministicProposalRanker({ version: VERSION }); + const pipeline = new EvolutionPipeline({ + version: VERSION, + model, + reflector, + curator, + validator: ranker, + scorer: ranker, + targets, + }); + const controlPlane = new EvolutionControlPlane({ + version: VERSION, + clock, + ids: { nextId: (kind) => `${kind}-${randomUUID()}` }, + artifacts: persistence, + jobs: persistence, + cursors: persistence, + leases: persistence, + ledger: persistence, + events: progress, + evidence, + snapshots: new CodingAgentSnapshotSource({ + cwd: repositoryRoot, + scope, + harnessState: options.harnessState, + resourceLoader: options.resourceLoader, + session: options.session, + writer, + }), + executor: pipeline, + }); + const worker = new EvolutionBackgroundWorker({ + clock, + source: new PollingEvolutionWorkSource(persistence, clock), + runner: controlPlane, + priority, + }); + const control = new EvolutionControlService({ + scope, + clock, + controlPlane, + worker, + jobs: persistence, + cursors: persistence, + evidence, + artifacts: persistence, + ledger: persistence, + harnesses: options.harnessState, + progress, + observedHarnessVersion: () => options.session.activeHarnessVersion, + }); + return { + control, + async start() { + const abandoned = (await persistence.list(scope)).filter((job) => job.outcome === "active"); + for (const job of abandoned) await controlPlane.recoverAbandonedJob(job.jobId); + control.start(); + }, + evaluateAutomatic() { + void control.triggerAutomatic().catch(() => {}); + }, + stop: () => control.stop(), + }; +} + +/** Process-owned bridge that keeps trace flushing ordered before automatic evolution checks. */ +export class EvolutionRuntimeCoordinator implements AgentSessionLifecycleObserver { + readonly #trace: InvocationTrace; + readonly #listeners = new Set<(event: EvolutionProgressEvent) => void>(); + #runtime: CodingAgentEvolutionRuntime | null = null; + #unsubscribe: (() => void) | null = null; + + constructor(trace: InvocationTrace) { + this.#trace = trace; + } + + async replace(runtime: CodingAgentEvolutionRuntime): Promise { + const previous = this.#runtime; + this.#unsubscribe?.(); + this.#unsubscribe = null; + this.#runtime = null; + await previous?.stop(); + await runtime.start(); + this.#runtime = runtime; + this.#unsubscribe = runtime.control.subscribe((event) => { + for (const listener of this.#listeners) listener(event); + }); + } + + async operationSettled(settlement: AgentSessionOperationSettlement): Promise { + await this.#trace.operationSettled(settlement); + this.#runtime?.evaluateAutomatic(); + } + + harnessActivated(version: HarnessVersion): Promise { + return this.#trace.harnessActivated(version); + } + + subscribe(listener: (event: EvolutionProgressEvent) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + #control(): EvolutionControlService { + if (!this.#runtime) throw new Error("Evolution runtime is unavailable"); + return this.#runtime.control; + } + + overview(): Promise { + return this.#control().overview(); + } + + listJobs(): Promise { + return this.#control().listJobs(); + } + + jobDetail(jobId: string): Promise { + return this.#control().getJob(jobId); + } + + proposalDetail(jobId: string, proposalId: string): Promise { + return this.#control().getProposal(jobId, proposalId); + } + + startManual(): Promise { + return this.#control().startManual(); + } + + resumeNewest(): Promise { + return this.#control().resumeNewest(); + } + + cancelActive(): Promise { + return this.#control().cancelActive(); + } + + rollback(): Promise { + return this.#control().rollback(); + } + + async stop(): Promise { + this.#unsubscribe?.(); + this.#unsubscribe = null; + const runtime = this.#runtime; + this.#runtime = null; + await runtime?.stop(); + } +} diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 7aab17e72..1a221ab0a 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -26,6 +26,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, + { name: "evolve", description: "Inspect and control harness evolution" }, { name: "version", description: "Select harness version" }, { name: "subagents", description: "Show sub-agents in the current session" }, { name: "changelog", description: "Show changelog entries" }, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 270cf155b..d80a74a69 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -46,6 +46,7 @@ import { } from "./core/agent-session-services.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; +import { createCodingAgentEvolutionRuntime, EvolutionRuntimeCoordinator } from "./core/evolution-runtime.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; @@ -715,6 +716,7 @@ export async function main(args: string[], options?: MainOptions) { } const invocationTraceResult = await createInvocationTrace({ agentDir, sessionManager }); const invocationTrace = invocationTraceResult.trace; + const evolutionCoordinator = new EvolutionRuntimeCoordinator(invocationTrace); if (invocationTraceResult.diagnostic) { reportDiagnostics([invocationTraceResult.diagnostic]); } @@ -854,7 +856,7 @@ export async function main(args: string[], options?: MainOptions) { excludeTools: sessionOptions.excludeTools, noTools: sessionOptions.noTools, customTools: sessionOptions.customTools, - lifecycleObserver: invocationTrace, + lifecycleObserver: evolutionCoordinator, }); const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel; if (created.session.model && cliThinkingOverride) { @@ -866,6 +868,17 @@ export async function main(args: string[], options?: MainOptions) { services, diagnostics, }; + await evolutionCoordinator.replace( + await createCodingAgentEvolutionRuntime({ + agentDir, + cwd, + session: created.session, + harnessState: services.harnessStateStore, + modelRuntime, + resourceLoader, + currentTracePath: invocationTrace.destinationPath, + }), + ); await invocationTrace.recordDiagnostics(diagnostics); return result; }; @@ -878,7 +891,13 @@ export async function main(args: string[], options?: MainOptions) { sessionManager, invocationSessionReason: initialSessionAttachmentReason(parsed, sessionManager), telemetryContext: invocationTrace.telemetryContext, - onDispose: (status) => invocationTrace.close(status), + onDispose: async (status) => { + try { + await evolutionCoordinator.stop(); + } finally { + await invocationTrace.close(status); + } + }, }); } catch (error) { await invocationTrace.recordDiagnostics([ @@ -970,6 +989,7 @@ export async function main(args: string[], options?: MainOptions) { await runRpcMode(runtime); } else if (appMode === "interactive") { const interactiveMode = new InteractiveMode(runtime, { + evolution: evolutionCoordinator, migratedProviders, modelFallbackMessage, autoTrustOnReloadCwd, diff --git a/packages/coding-agent/src/modes/interactive/components/evolution-menu.ts b/packages/coding-agent/src/modes/interactive/components/evolution-menu.ts new file mode 100644 index 000000000..48c0d87a3 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/evolution-menu.ts @@ -0,0 +1,410 @@ +import { + Container, + type Focusable, + getKeybindings, + SELECTED_LIST_PREFIX, + Spacer, + Text, + TruncatedText, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; +import type { + EvolutionJobDetail, + EvolutionJobSummary, + EvolutionOverview, + EvolutionProgressEvent, + EvolutionProposalDetail, + EvolutionRollbackAction, + EvolutionStartResult, +} from "#evolution"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface EvolutionMenuClient { + subscribe(listener: (event: EvolutionProgressEvent) => void): () => void; + overview(): Promise; + listJobs(): Promise; + jobDetail(jobId: string): Promise; + proposalDetail(jobId: string, proposalId: string): Promise; + startManual(): Promise; + resumeNewest(): Promise; + cancelActive(): Promise; + rollback(): Promise; +} + +type MenuView = + | { readonly kind: "overview" } + | { readonly kind: "jobs" } + | { readonly kind: "job"; readonly jobId: string } + | { readonly kind: "proposal"; readonly jobId: string; readonly proposalId: string } + | { readonly kind: "confirm"; readonly action: "cancel" | "resume" | "rollback" | "start" }; + +interface MenuEntry { + readonly label: string; + readonly enabled: boolean; + readonly select: () => void; +} + +export function sanitizeEvolutionText(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " "); +} + +function abbreviate(value: string | null): string { + if (value === null) return "none"; + const [algorithm, digest] = value.split(":", 2); + return algorithm && digest ? `${algorithm}:${digest.slice(0, 12)}` : sanitizeEvolutionText(value); +} + +function duration(milliseconds: number): string { + if (milliseconds <= 0) return "ready"; + const minutes = Math.ceil(milliseconds / 60_000); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; +} + +function timestamp(value: string | null): string { + return value === null ? "never" : sanitizeEvolutionText(value); +} + +export class EvolutionMenuComponent extends Container implements Focusable { + readonly #client: EvolutionMenuClient; + readonly #onClose: () => void; + readonly #onStatus: (message: string, error: boolean) => void; + readonly #requestRender: () => void; + readonly #unsubscribe: () => void; + readonly #viewStack: MenuView[] = [{ kind: "overview" }]; + #overview: EvolutionOverview | null = null; + #jobs: readonly EvolutionJobSummary[] = []; + #job: EvolutionJobDetail | null = null; + #proposal: EvolutionProposalDetail | null = null; + #entries: readonly MenuEntry[] = []; + #selectedIndex = 0; + #loading = true; + #disposed = false; + #focused = false; + + get focused(): boolean { + return this.#focused; + } + + set focused(value: boolean) { + this.#focused = value; + } + + constructor( + client: EvolutionMenuClient, + onClose: () => void, + onStatus: (message: string, error: boolean) => void, + requestRender: () => void, + ) { + super(); + this.#client = client; + this.#onClose = onClose; + this.#onStatus = onStatus; + this.#requestRender = requestRender; + this.#unsubscribe = client.subscribe(() => { + void this.refresh(); + }); + this.rebuild(); + void this.refresh(); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#unsubscribe(); + } + + #view(): MenuView { + return this.#viewStack[this.#viewStack.length - 1] ?? { kind: "overview" }; + } + + #push(view: MenuView): void { + this.#viewStack.push(view); + this.#selectedIndex = 0; + void this.refresh(); + } + + #back(): void { + if (this.#viewStack.length > 1) { + this.#viewStack.pop(); + this.#selectedIndex = 0; + void this.refresh(); + return; + } + this.dispose(); + this.#onClose(); + } + + async refresh(): Promise { + if (this.#disposed) return; + this.#loading = true; + this.rebuild(); + try { + this.#overview = await this.#client.overview(); + const view = this.#view(); + if (view.kind === "jobs") this.#jobs = await this.#client.listJobs(); + if (view.kind === "job") this.#job = await this.#client.jobDetail(view.jobId); + if (view.kind === "proposal") { + this.#proposal = await this.#client.proposalDetail(view.jobId, view.proposalId); + } + } catch (error) { + this.#onStatus(error instanceof Error ? error.message : String(error), true); + } finally { + this.#loading = false; + this.rebuild(); + this.#requestRender(); + } + } + + #run(action: () => Promise, success: string): void { + void action() + .then(() => { + this.#onStatus(success, false); + this.#viewStack.splice(1); + return this.refresh(); + }) + .catch((error: unknown) => { + this.#onStatus(error instanceof Error ? error.message : String(error), true); + this.#viewStack.pop(); + return this.refresh(); + }); + } + + #confirm(action: "cancel" | "resume" | "rollback" | "start"): void { + this.#push({ kind: "confirm", action }); + } + + #overviewEntries(overview: EvolutionOverview): readonly MenuEntry[] { + return [ + { label: "Refresh overview", enabled: true, select: () => void this.refresh() }, + { + label: "Start a new evolution job", + enabled: overview.activeJob === null, + select: () => this.#confirm("start"), + }, + { + label: overview.activeJob ? `View active job ${overview.activeJob.jobId}` : "View active job", + enabled: overview.activeJob !== null, + select: () => { + if (overview.activeJob) this.#push({ kind: "job", jobId: overview.activeJob.jobId }); + }, + }, + { + label: overview.newestResumableJob + ? `Resume ${overview.newestResumableJob.jobId}` + : "Resume newest interrupted job", + enabled: overview.newestResumableJob !== null && overview.activeJob === null, + select: () => this.#confirm("resume"), + }, + { + label: "Browse job history, proposals, and applied diffs", + enabled: true, + select: () => this.#push({ kind: "jobs" }), + }, + { + label: overview.rollbackAction + ? `Roll back ${abbreviate(overview.rollbackAction.fromHarnessVersion)} to ${abbreviate(overview.rollbackAction.toHarnessVersion)}` + : "Roll back the active evolution revision", + enabled: overview.rollbackAction !== null, + select: () => this.#confirm("rollback"), + }, + { + label: overview.activeJob ? `Cancel ${overview.activeJob.jobId}` : "Cancel active job", + enabled: overview.activeJob?.cancellable === true, + select: () => this.#confirm("cancel"), + }, + ]; + } + + #confirmationLines(action: "cancel" | "resume" | "rollback" | "start"): readonly string[] { + const overview = this.#overview; + if (action === "start") { + return [ + "Start a manual evolution job?", + "The job will snapshot unprocessed evidence and run in the background.", + ]; + } + if (action === "resume") { + return [ + `Resume ${overview?.newestResumableJob?.jobId ?? "the newest interrupted job"}?`, + "The durable checkpoint will become active background work.", + ]; + } + if (action === "cancel") { + return [ + `Cancel ${overview?.activeJob?.jobId ?? "the active job"}?`, + "The current stage will be interrupted and remain resumable.", + ]; + } + return [ + `Roll back ${abbreviate(overview?.rollbackAction?.fromHarnessVersion ?? null)} to ${abbreviate(overview?.rollbackAction?.toHarnessVersion ?? null)}?`, + "The selected harness pointer will change. The next fresh prompt will observe it.", + ]; + } + + #confirmEntries(action: "cancel" | "resume" | "rollback" | "start"): readonly MenuEntry[] { + const run = () => { + if (action === "start") this.#run(() => this.#client.startManual(), "Evolution job started"); + if (action === "resume") this.#run(() => this.#client.resumeNewest(), "Evolution job resumed"); + if (action === "cancel") this.#run(() => this.#client.cancelActive(), "Evolution job interrupted"); + if (action === "rollback") this.#run(() => this.#client.rollback(), "Harness revision rolled back"); + }; + return [ + { label: `Confirm ${action}`, enabled: true, select: run }, + { label: "Back without changing state", enabled: true, select: () => this.#back() }, + ]; + } + + #addLine(value: string, color: "accent" | "muted" | "text" | "warning" = "text"): void { + this.addChild(new Text(theme.fg(color, sanitizeEvolutionText(value)), 1, 0)); + } + + #renderOverview(overview: EvolutionOverview): void { + this.#addLine(`Unprocessed materialized tokens: ${overview.unprocessedEstimatedTokens.toLocaleString()}`); + this.#addLine(`Automatic threshold: > ${overview.automaticTokenThreshold.toLocaleString()} tokens`); + this.#addLine(`Settled invocations: ${overview.settledInvocationCount}`); + this.#addLine(`Last automatic run: ${timestamp(overview.lastAutomaticRunAt)}`); + this.#addLine(`Cooldown: ${duration(overview.cooldownRemainingMs)}`); + this.#addLine( + overview.automaticEligibility.eligible + ? "Next automatic run: eligible" + : `Next automatic run: ${overview.automaticEligibility.reason}`, + ); + this.#addLine(`Selected harness: ${abbreviate(overview.currentHarnessVersion)}`); + this.#addLine(`Session harness: ${abbreviate(overview.observedHarnessVersion)}`); + if (overview.nextPromptWillRefresh) this.#addLine("Next prompt will refresh the selected harness", "accent"); + } + + #renderJob(job: EvolutionJobDetail): void { + for (const line of [ + `Job: ${job.jobId}`, + `Trigger: ${job.trigger}`, + `Evidence high-water cursor: ${job.evidenceHighWaterCursor}`, + `Stage: ${job.stage} (${job.outcome})`, + `Slices: ${job.completedSlices} completed / ${job.discoveredSlices} discovered`, + `Model calls: ${job.modelCalls}; retries: ${job.modelRetries}`, + `Proposals: ${job.emittedProposals} emitted / ${job.eligibleProposals} eligible`, + `Selected operations: ${job.selectedOperations}`, + `Last checkpoint: ${timestamp(job.lastCheckpointAt)}`, + `Last error: ${job.errorMessage ?? "none"}`, + `Base harness: ${abbreviate(job.baseHarnessVersion)}`, + `Activated harness: ${abbreviate(job.activatedHarnessVersion)}`, + `Next prompt will refresh: ${job.nextPromptWillRefresh ? "yes" : "no"}`, + ]) { + this.#addLine(line); + } + } + + #renderProposal(detail: EvolutionProposalDetail): void { + this.#addLine(`Proposal: ${detail.proposal.proposalId}`); + this.#addLine(`Summary: ${detail.proposal.summary}`); + this.#addLine(`Action: ${detail.proposal.action}`); + this.#addLine(`Validation: ${detail.validationDisposition ?? "pending"}`); + this.#addLine(`Structured delta: ${JSON.stringify(detail.proposal.exactDelta)}`); + this.#addLine(`Evidence ranking: ${detail.score ? JSON.stringify(detail.score.scoreComponents) : "pending"}`); + this.#addLine(`Supporting evidence: ${detail.proposal.supportingEvidenceIds.join(", ") || "none"}`); + this.#addLine(`Contradicting evidence: ${detail.proposal.contradictingEvidenceIds.join(", ") || "none"}`); + this.#addLine( + `Applied diff: ${detail.exactAppliedDiff ? JSON.stringify(detail.exactAppliedDiff) : "not applied"}`, + ); + this.#addLine(`Rollback target: ${abbreviate(detail.rollbackTarget)}`); + } + + #renderEntries(entries: readonly MenuEntry[]): void { + this.#entries = entries; + this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, Math.max(0, entries.length - 1))); + entries.forEach((entry, index) => { + const selected = index === this.#selectedIndex; + const prefix = selected ? SELECTED_LIST_PREFIX : UNSELECTED_LIST_PREFIX; + const disabled = entry.enabled ? "" : " [unavailable]"; + const line = `${prefix}${entry.label}${disabled}`; + this.addChild( + new TruncatedText(theme.fg(entry.enabled ? (selected ? "selectionAccent" : "text") : "muted", line), 1, 0), + ); + }); + } + + private rebuild(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.#addLine("Evolution", "accent"); + this.addChild(new Spacer(1)); + if (this.#loading) { + this.#addLine("Loading durable evolution state...", "muted"); + } else { + const view = this.#view(); + if (view.kind === "overview" && this.#overview) { + this.#renderOverview(this.#overview); + this.addChild(new Spacer(1)); + this.#renderEntries(this.#overviewEntries(this.#overview)); + } else if (view.kind === "jobs") { + this.#renderEntries( + this.#jobs.map((job) => ({ + label: `${job.jobId} - ${job.trigger} - ${job.stage} - ${job.outcome}`, + enabled: true, + select: () => this.#push({ kind: "job", jobId: job.jobId }), + })), + ); + if (this.#jobs.length === 0) this.#addLine("No evolution jobs", "muted"); + } else if (view.kind === "job" && this.#job) { + this.#renderJob(this.#job); + this.addChild(new Spacer(1)); + this.#renderEntries( + this.#job.proposalIds.map((proposalId) => ({ + label: `Proposal ${proposalId}`, + enabled: true, + select: () => this.#push({ kind: "proposal", jobId: view.jobId, proposalId }), + })), + ); + if (this.#job.proposalIds.length === 0) this.#addLine("No proposals emitted", "muted"); + } else if (view.kind === "proposal" && this.#proposal) { + this.#renderProposal(this.#proposal); + this.#entries = []; + } else if (view.kind === "confirm") { + for (const line of this.#confirmationLines(view.action)) this.#addLine(line, "warning"); + this.addChild(new Spacer(1)); + this.#renderEntries(this.#confirmEntries(view.action)); + } + } + this.addChild(new Spacer(1)); + this.addChild( + new TruncatedText( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "open") + + " " + + keyHint("tui.select.cancel", "back"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + handleInput(keyData: string): void { + const keybindings = getKeybindings(); + if (keybindings.matches(keyData, "tui.select.cancel")) { + this.#back(); + return; + } + if (keybindings.matches(keyData, "tui.select.up")) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); + this.rebuild(); + this.#requestRender(); + return; + } + if (keybindings.matches(keyData, "tui.select.down")) { + this.#selectedIndex = Math.min(Math.max(0, this.#entries.length - 1), this.#selectedIndex + 1); + this.rebuild(); + this.#requestRender(); + return; + } + if (keybindings.matches(keyData, "tui.select.confirm")) { + const entry = this.#entries[this.#selectedIndex]; + if (entry?.enabled) entry.select(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a88cf854c..66632896c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -134,6 +134,7 @@ import { } from "./components/diffex-welcome.ts"; import { DynamicBorder } from "./components/dynamic-border.ts"; import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts"; +import { type EvolutionMenuClient, EvolutionMenuComponent } from "./components/evolution-menu.ts"; import { ExtensionEditorComponent } from "./components/extension-editor.ts"; import { ExtensionInputComponent } from "./components/extension-input.ts"; import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; @@ -333,6 +334,8 @@ function formatLoginProviderCompletionDescription(provider: LoginProviderComplet * Options for InteractiveMode initialization. */ export interface InteractiveModeOptions { + /** Process-owned evolution controls for the current repository runtime. */ + evolution?: EvolutionMenuClient; /** Providers that were migrated to auth.json (shows warning) */ migratedProviders?: string[]; /** Warning message if session model couldn't be restored */ @@ -2948,6 +2951,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/evolve") { + this.editor.setText(""); + this.showEvolutionMenu(""); + return; + } if (text === "/version") { this.editor.setText(""); await this.showHarnessVersionSelector(); @@ -4678,6 +4686,32 @@ export class InteractiveMode { } } + private showEvolutionMenu(editorText: string): void { + const evolution = this.options.evolution; + if (!evolution) { + this.showError("Evolution runtime is unavailable"); + this.editor.setText(editorText); + return; + } + this.showSelector((done) => { + const close = () => { + done(); + this.editor.setText(editorText); + this.ui.requestRender(); + }; + const menu = new EvolutionMenuComponent( + evolution, + close, + (message, error) => { + if (error) this.showError(message); + else this.showStatus(message); + }, + () => this.ui.requestRender(), + ); + return { component: menu, focus: menu, dispose: () => menu.dispose() }; + }); + } + private async handleModelCommand(searchTerm?: string): Promise { if (!searchTerm) { this.showModelSelector(); diff --git a/packages/coding-agent/test/evolution-menu.test.ts b/packages/coding-agent/test/evolution-menu.test.ts new file mode 100644 index 000000000..03eb4200b --- /dev/null +++ b/packages/coding-agent/test/evolution-menu.test.ts @@ -0,0 +1,195 @@ +import type { HarnessVersion } from "@earendil-works/diffex-harness-state"; +import { setKeybindings } from "@earendil-works/diffex-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EvolutionJobSummary, EvolutionOverview, EvolutionProgressEvent } from "#evolution"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { + type EvolutionMenuClient, + EvolutionMenuComponent, + sanitizeEvolutionText, +} from "../src/modes/interactive/components/evolution-menu.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +const rootVersion = `sha256:${"a".repeat(64)}` as HarnessVersion; + +function job(overrides: Partial = {}): EvolutionJobSummary { + return { + jobId: "job-active", + trigger: "manual", + stage: "reflecting", + outcome: "active", + reasonCode: null, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-20T00:01:00.000Z", + terminalAt: null, + resumable: false, + cancellable: true, + ...overrides, + }; +} + +function overview(overrides: Partial = {}): EvolutionOverview { + return { + scope: { repositoryId: "repository", scopeId: "repository" }, + processedEvidenceCursor: 2, + ingestionHighWaterCursor: 8, + unprocessedEstimatedTokens: 1_100_001, + settledInvocationCount: 6, + automaticTokenThreshold: 1_000_000, + automaticMinimumInvocations: 5, + lastAutomaticRunAt: null, + cooldownRemainingMs: 0, + automaticEligibility: { eligible: true }, + activeJob: null, + newestResumableJob: null, + currentHarnessVersion: rootVersion, + observedHarnessVersion: rootVersion, + nextPromptWillRefresh: false, + rollbackAction: null, + ...overrides, + }; +} + +function client(currentOverview: EvolutionOverview): { + client: EvolutionMenuClient; + emit: (event: EvolutionProgressEvent) => void; + unsubscribed: ReturnType; + startManual: ReturnType; + overviewCall: ReturnType; +} { + let listener: ((event: EvolutionProgressEvent) => void) | undefined; + const unsubscribed = vi.fn(); + const startManual = vi.fn().mockResolvedValue({ status: "created", job: job() }); + const overviewCall = vi.fn().mockResolvedValue(currentOverview); + return { + client: { + subscribe: (next) => { + listener = next; + return unsubscribed; + }, + overview: overviewCall, + listJobs: vi.fn().mockResolvedValue([]), + jobDetail: vi.fn().mockRejectedValue(new Error("unused")), + proposalDetail: vi.fn().mockRejectedValue(new Error("unused")), + startManual, + resumeNewest: vi.fn().mockRejectedValue(new Error("unused")), + cancelActive: vi.fn().mockRejectedValue(new Error("unused")), + rollback: vi.fn().mockRejectedValue(new Error("unused")), + }, + emit: (event) => listener?.(event), + unsubscribed, + startManual, + overviewCall, + }; +} + +describe("EvolutionMenuComponent", () => { + beforeAll(() => initTheme("dark")); + beforeEach(() => setKeybindings(new KeybindingsManager())); + + it("renders automatic eligibility and durable disabled actions", async () => { + const active = job(); + const fixture = client( + overview({ + activeJob: active, + automaticEligibility: { eligible: false, reason: "blocking-job" }, + }), + ); + const component = new EvolutionMenuComponent( + fixture.client, + () => {}, + () => {}, + () => {}, + ); + await vi.waitFor(() => expect(fixture.overviewCall).toHaveBeenCalled()); + + const output = stripAnsi(component.render(180).join("\n")); + expect(output).toContain("Unprocessed materialized tokens: 1,100,001"); + expect(output).toContain("Automatic threshold: > 1,000,000 tokens"); + expect(output).toContain("Next automatic run: blocking-job"); + expect(output).toContain("Start a new evolution job [unavailable]"); + expect(output).toContain("Resume newest interrupted job [unavailable]"); + }); + + it("confirms a manual start before invoking the control service", async () => { + const fixture = client(overview()); + const component = new EvolutionMenuComponent( + fixture.client, + () => {}, + () => {}, + () => {}, + ); + await vi.waitFor(() => + expect(stripAnsi(component.render(160).join("\n"))).toContain("Start a new evolution job"), + ); + + component.handleInput("\u001b[B"); + component.handleInput("\n"); + await vi.waitFor(() => + expect(stripAnsi(component.render(160).join("\n"))).toContain("Start a manual evolution job?"), + ); + expect(fixture.startManual).not.toHaveBeenCalled(); + + component.handleInput("\n"); + await vi.waitFor(() => expect(fixture.startManual).toHaveBeenCalledOnce()); + }); + + it("pops a nested view before closing and disposes its progress subscription", async () => { + const fixture = client(overview()); + const close = vi.fn(); + const component = new EvolutionMenuComponent( + fixture.client, + close, + () => {}, + () => {}, + ); + await vi.waitFor(() => expect(fixture.overviewCall).toHaveBeenCalledOnce()); + component.handleInput("\u001b[B"); + component.handleInput("\n"); + await vi.waitFor(() => + expect(stripAnsi(component.render(160).join("\n"))).toContain("Start a manual evolution job?"), + ); + + component.handleInput("\u001b"); + expect(close).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(stripAnsi(component.render(160).join("\n"))).toContain("Refresh overview")); + component.handleInput("\u001b"); + + expect(close).toHaveBeenCalledOnce(); + expect(fixture.unsubscribed).toHaveBeenCalledOnce(); + }); + + it("refreshes from progress events until disposed", async () => { + const fixture = client(overview()); + const render = vi.fn(); + const component = new EvolutionMenuComponent( + fixture.client, + () => {}, + () => {}, + render, + ); + await vi.waitFor(() => expect(fixture.overviewCall).toHaveBeenCalledOnce()); + fixture.emit({ + jobId: "job-active", + emittedAt: "2026-08-20T00:02:00.000Z", + type: "checkpoint", + stage: "reflecting", + }); + await vi.waitFor(() => expect(fixture.overviewCall).toHaveBeenCalledTimes(2)); + expect(render).toHaveBeenCalled(); + + component.dispose(); + fixture.emit({ + jobId: "job-active", + emittedAt: "2026-08-20T00:03:00.000Z", + type: "completed", + stage: "completed", + }); + expect(fixture.overviewCall).toHaveBeenCalledTimes(2); + }); + + it("sanitizes terminal control text", () => { + expect(sanitizeEvolutionText("unsafe\u001b[2J\rvalue")).toBe("unsafe [2J value"); + }); +}); diff --git a/packages/coding-agent/test/evolution-runtime.test.ts b/packages/coding-agent/test/evolution-runtime.test.ts new file mode 100644 index 000000000..055b77a3c --- /dev/null +++ b/packages/coding-agent/test/evolution-runtime.test.ts @@ -0,0 +1,86 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HarnessStateStore } from "@earendil-works/diffex-harness-state"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { EvolutionControlService } from "#evolution"; +import { + type CodingAgentEvolutionRuntime, + createCodingAgentEvolutionRuntime, + EvolutionRuntimeCoordinator, +} from "../src/core/evolution-runtime.ts"; +import type { InvocationTrace } from "../src/core/invocation-trace.ts"; +import { createHarness, type Harness } from "./suite/harness.ts"; + +describe("coding-agent evolution runtime", () => { + const harnesses: Harness[] = []; + const temporaryDirectories: string[] = []; + + afterEach(async () => { + while (harnesses.length > 0) await harnesses.pop()?.cleanup(); + while (temporaryDirectories.length > 0) { + const path = temporaryDirectories.pop(); + if (path) rmSync(path, { recursive: true, force: true }); + } + }); + + it("composes a repository-scoped control service without evidence", async () => { + const agentDir = join(tmpdir(), `diffex-evolution-runtime-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + temporaryDirectories.push(agentDir); + const store = new HarnessStateStore(agentDir); + const bundle = await store.initialize(); + const harness = await createHarness({ harnessStateStore: store, harnessVersionBundle: bundle }); + harnesses.push(harness); + const runtime = await createCodingAgentEvolutionRuntime({ + agentDir, + cwd: harness.tempDir, + session: harness.session, + harnessState: store, + modelRuntime: harness.session.modelRuntime, + resourceLoader: harness.session.resourceLoader, + }); + await runtime.start(); + + try { + const state = await runtime.control.overview(); + expect(state.currentHarnessVersion).toBe(bundle.manifest.version); + expect(state.settledInvocationCount).toBe(0); + expect(await runtime.control.startManual()).toEqual({ status: "not-created", reason: "no-new-evidence" }); + } finally { + await runtime.stop(); + } + }); + + it("flushes the trace before scheduling an automatic eligibility check", async () => { + const order: string[] = []; + const trace = { + operationSettled: vi.fn(async () => { + order.push("trace"); + }), + harnessActivated: vi.fn(async () => {}), + } as unknown as InvocationTrace; + const control = { + subscribe: () => () => {}, + } as unknown as EvolutionControlService; + const runtime: CodingAgentEvolutionRuntime = { + control, + start: vi.fn(async () => {}), + evaluateAutomatic: () => order.push("automatic"), + stop: vi.fn(async () => {}), + }; + const coordinator = new EvolutionRuntimeCoordinator(trace); + await coordinator.replace(runtime); + expect(runtime.start).toHaveBeenCalledOnce(); + + await coordinator.operationSettled({ + operationId: "operation-1", + sessionManager: {} as never, + firstEntryOrdinal: 0, + }); + + expect(order).toEqual(["trace", "automatic"]); + await coordinator.stop(); + expect(runtime.stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-evolution.test.ts b/packages/coding-agent/test/interactive-mode-evolution.test.ts new file mode 100644 index 000000000..a2a2ddc68 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-evolution.test.ts @@ -0,0 +1,117 @@ +import { setKeybindings } from "@earendil-works/diffex-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EvolutionOverview } from "#evolution"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { + EvolutionMenuClient, + EvolutionMenuComponent, +} from "../src/modes/interactive/components/evolution-menu.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +interface SubmitFixture { + defaultEditor: { + onSubmit?: (text: string) => Promise; + }; + editor: { + setText: ReturnType; + }; + showEvolutionMenu: ReturnType; +} + +interface MenuFixture { + options: { evolution?: EvolutionMenuClient }; + editor: { setText: ReturnType }; + ui: { requestRender: ReturnType }; + showSelector: ( + create: (done: () => void) => { + component: EvolutionMenuComponent; + focus: EvolutionMenuComponent; + dispose?: () => void; + }, + ) => void; + showStatus: ReturnType; + showError: ReturnType; +} + +const prototype = InteractiveMode.prototype as unknown as { + setupEditorSubmitHandler(this: SubmitFixture): void; + showEvolutionMenu(this: MenuFixture, editorText: string): void; +}; + +function overview(): EvolutionOverview { + return { + scope: { repositoryId: "repository", scopeId: "repository" }, + processedEvidenceCursor: 0, + ingestionHighWaterCursor: 0, + unprocessedEstimatedTokens: 0, + settledInvocationCount: 0, + automaticTokenThreshold: 1_000_000, + automaticMinimumInvocations: 5, + lastAutomaticRunAt: null, + cooldownRemainingMs: 0, + automaticEligibility: { eligible: false, reason: "insufficient-tokens" }, + activeJob: null, + newestResumableJob: null, + currentHarnessVersion: null, + observedHarnessVersion: null, + nextPromptWillRefresh: false, + rollbackAction: null, + }; +} + +function client(): EvolutionMenuClient { + return { + subscribe: () => () => {}, + overview: vi.fn().mockResolvedValue(overview()), + listJobs: vi.fn().mockResolvedValue([]), + jobDetail: vi.fn().mockRejectedValue(new Error("unused")), + proposalDetail: vi.fn().mockRejectedValue(new Error("unused")), + startManual: vi.fn().mockRejectedValue(new Error("unused")), + resumeNewest: vi.fn().mockRejectedValue(new Error("unused")), + cancelActive: vi.fn().mockRejectedValue(new Error("unused")), + rollback: vi.fn().mockRejectedValue(new Error("unused")), + }; +} + +describe("InteractiveMode evolution menu", () => { + beforeAll(() => initTheme("dark")); + beforeEach(() => setKeybindings(new KeybindingsManager())); + + it("routes only plain /evolve to the menu", async () => { + const fixture: SubmitFixture = { + defaultEditor: {}, + editor: { setText: vi.fn() }, + showEvolutionMenu: vi.fn(), + }; + prototype.setupEditorSubmitHandler.call(fixture); + + await fixture.defaultEditor.onSubmit?.("/evolve"); + + expect(fixture.editor.setText).toHaveBeenCalledWith(""); + expect(fixture.showEvolutionMenu).toHaveBeenCalledWith(""); + }); + + it("restores editor contents and closes through Escape", async () => { + let component: EvolutionMenuComponent | undefined; + const done = vi.fn(); + const fixture: MenuFixture = { + options: { evolution: client() }, + editor: { setText: vi.fn() }, + ui: { requestRender: vi.fn() }, + showSelector: (create) => { + component = create(done).component; + }, + showStatus: vi.fn(), + showError: vi.fn(), + }; + prototype.showEvolutionMenu.call(fixture, "draft input"); + await vi.waitFor(() => expect(component).toBeDefined()); + + component?.handleInput("\u001b"); + + expect(done).toHaveBeenCalledOnce(); + expect(fixture.editor.setText).toHaveBeenCalledWith("draft input"); + expect(fixture.ui.requestRender).toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts index 1a744a211..bdc5c90fb 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -24,4 +24,11 @@ describe("BUILTIN_SLASH_COMMANDS", () => { description: "Select harness version", }); }); + + it("includes the evolution menu command", () => { + expect(BUILTIN_SLASH_COMMANDS).toContainEqual({ + name: "evolve", + description: "Inspect and control harness evolution", + }); + }); }); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 36c614db9..eb517a81f 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "./dist", "paths": { + "#evolution": ["../evolution/dist/index.d.ts"], "#harness-trace": ["../harness-trace/dist/index.d.ts"], "@earendil-works/diffex-agent-core": ["../agent/dist/index.d.ts"], "@earendil-works/diffex-agent-core/*": ["../agent/dist/*.d.ts"], diff --git a/packages/evolution/src/control-plane.ts b/packages/evolution/src/control-plane.ts index 04395f1b1..bf66c7d7c 100644 --- a/packages/evolution/src/control-plane.ts +++ b/packages/evolution/src/control-plane.ts @@ -865,23 +865,32 @@ export class EvolutionControlPlane implements EvolutionJobRunner { }); } - async interruptJob(jobId: string): Promise { + async #markInterrupted( + jobId: string, + reasonCode: "cancelled-by-operator" | "lease-lost", + failureMessage: string, + allowBusy: boolean, + ): Promise { const initial = await this.#dependencies.jobs.load(jobId); if (initial === null) throw new Error(`Unknown evolution job "${jobId}"`); if (initial.outcome !== "active") { + if (allowBusy) return initial; throw new Error(`Evolution job "${jobId}" cannot be interrupted from outcome "${initial.outcome}"`); } const ownerId = this.#dependencies.ids.nextId("lease-owner"); const initialLease = await this.#acquireScopeLease(initial.metadata.scope, ownerId); - if (initialLease === null) throw new Error(`Evolution scope for job "${jobId}" is already leased`); + if (initialLease === null) { + if (allowBusy) return null; + throw new Error(`Evolution scope for job "${jobId}" is already leased`); + } return this.#withHeartbeat(initialLease, undefined, async (lease) => { const current = await this.#dependencies.jobs.load(jobId); if (current === null) throw new Error(`Unknown evolution job "${jobId}"`); if (current.outcome !== "active") return current; const interrupted = this.#withJobRevision(current, { outcome: "interrupted", - reasonCode: "cancelled-by-operator", - failureMessage: "Evolution job was cancelled by the operator", + reasonCode, + failureMessage, terminalAt: this.#dependencies.clock.now(), retryNotBefore: null, }); @@ -897,6 +906,21 @@ export class EvolutionControlPlane implements EvolutionJobRunner { }); } + async interruptJob(jobId: string): Promise { + const interrupted = await this.#markInterrupted( + jobId, + "cancelled-by-operator", + "Evolution job was cancelled by the operator", + false, + ); + if (interrupted === null) throw new Error(`Evolution scope for job "${jobId}" is already leased`); + return interrupted; + } + + async recoverAbandonedJob(jobId: string): Promise { + return this.#markInterrupted(jobId, "lease-lost", "Evolution job was interrupted after its lease expired", true); + } + async #resolveJob( jobId: string, kind: EvolutionJobResolution["kind"], diff --git a/packages/evolution/src/curator.ts b/packages/evolution/src/curator.ts index 56bcf0c20..fc0a958fe 100644 --- a/packages/evolution/src/curator.ts +++ b/packages/evolution/src/curator.ts @@ -935,6 +935,7 @@ export class StructuredDeltaCurator implements Curator { const response = await abortable( input.model.execute({ stage: "curator", + model: input.input.modelConfiguration, promptVersion: input.input.promptVersions.curator, idempotencyKey: input.idempotencyKey, input: modelInput(input, lessons), diff --git a/packages/evolution/src/evolution-memory-writer.ts b/packages/evolution/src/evolution-memory-writer.ts index 3544d6ca8..dd78faeea 100644 --- a/packages/evolution/src/evolution-memory-writer.ts +++ b/packages/evolution/src/evolution-memory-writer.ts @@ -551,6 +551,10 @@ export class EvolutionMemoryWriter implements EvolutionTargetWriter { } } + async loadActiveMemoryItems(bundle: HarnessVersionBundle): Promise { + return activeItems(await this.#loadCatalog(bundle)); + } + async #loadCandidate(batchId: string): Promise { const contents = await this.#readOptional(this.#candidateFile(batchId)); if (contents === null) return null; diff --git a/packages/evolution/src/ports.ts b/packages/evolution/src/ports.ts index c8080218c..090e06fa1 100644 --- a/packages/evolution/src/ports.ts +++ b/packages/evolution/src/ports.ts @@ -63,6 +63,7 @@ export interface EvolutionModelTool { export interface ReflectorModelRequest { readonly stage: "reflector"; + readonly model: EvolutionModelIdentity; readonly promptVersion: string; readonly idempotencyKey: string; readonly input: JsonObject; @@ -72,6 +73,7 @@ export interface ReflectorModelRequest { export interface CuratorModelRequest { readonly stage: "curator"; + readonly model: EvolutionModelIdentity; readonly promptVersion: string; readonly idempotencyKey: string; readonly input: JsonObject; diff --git a/packages/evolution/src/reflector.ts b/packages/evolution/src/reflector.ts index d061caaa7..1438ebe00 100644 --- a/packages/evolution/src/reflector.ts +++ b/packages/evolution/src/reflector.ts @@ -428,6 +428,7 @@ export class RecursiveReflector implements Reflector { response = await abortable( input.model.execute({ stage: "reflector", + model: input.input.modelConfiguration, promptVersion: input.input.promptVersions.reflector, idempotencyKey: `${input.idempotencyKey}:${plan.nodeId}:${round}`, input: this.#modelInput(input, plan, completed, round), diff --git a/packages/evolution/test/control-plane.test.ts b/packages/evolution/test/control-plane.test.ts index d8b599c5e..70c411622 100644 --- a/packages/evolution/test/control-plane.test.ts +++ b/packages/evolution/test/control-plane.test.ts @@ -302,6 +302,20 @@ describe("durable evolution control plane", () => { expect(replacement.status).toBe("created"); }); + it("marks an abandoned active job as explicitly resumable", async () => { + const fixture = await controlPlaneFixture(new RecordingExecutor()); + const created = await createManualJob(fixture); + + const recovered = await fixture.controlPlane.recoverAbandonedJob(created.jobId); + + expect(recovered).toMatchObject({ + outcome: "interrupted", + reasonCode: "lease-lost", + failureMessage: "Evolution job was interrupted after its lease expired", + }); + expect((await fixture.persistence.load(created.jobId))?.outcome).toBe("interrupted"); + }); + it("reports no evidence instead of creating an empty manual job", async () => { const fixture = await controlPlaneFixture(new RecordingExecutor(), []); @@ -399,6 +413,7 @@ describe("checkpointed evolution pipeline", () => { reflectorCounterReports.push(input.counterReport); await input.model.execute({ stage: "reflector", + model: input.input.modelConfiguration, promptVersion: "reflector-v1", idempotencyKey: input.idempotencyKey, input: {}, @@ -453,6 +468,7 @@ describe("checkpointed evolution pipeline", () => { async curate(input) { await input.model.execute({ stage: "curator", + model: input.input.modelConfiguration, promptVersion: "curator-v1", idempotencyKey: input.idempotencyKey, input: {}, diff --git a/tsconfig.json b/tsconfig.json index 53fe022ba..aba913085 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "NodeNext", "paths": { "*": ["./*"], + "#evolution": ["./packages/evolution/src/index.ts"], "#harness-trace": ["./packages/harness-trace/src/index.ts"], "@earendil-works/diffex-evolution": ["./packages/evolution/src/index.ts"], "@earendil-works/diffex-harness-state": ["./packages/harness-state/src/index.ts"], diff --git a/vitest.base.ts b/vitest.base.ts index 2939974e0..8e38e83c4 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -11,6 +11,7 @@ export const workspaceSourcePaths = { agentIndex: fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)), harnessStateIndex: fileURLToPath(new URL("./packages/harness-state/src/index.ts", import.meta.url)), harnessTraceIndex: fileURLToPath(new URL("./packages/harness-trace/src/index.ts", import.meta.url)), + evolutionIndex: fileURLToPath(new URL("./packages/evolution/src/index.ts", import.meta.url)), codingAgentIndex: fileURLToPath(new URL("./packages/coding-agent/src/index.ts", import.meta.url)), tuiIndex: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)), } as const; @@ -18,6 +19,7 @@ export const workspaceSourcePaths = { export default defineConfig({ resolve: { alias: [ + { find: /^#evolution$/, replacement: workspaceSourcePaths.evolutionIndex }, { find: /^#harness-trace$/, replacement: workspaceSourcePaths.harnessTraceIndex }, { find: /^@earendil-works\/diffex-telemetry$/, replacement: workspaceSourcePaths.telemetryIndex }, { find: /^@earendil-works\/diffex-telemetry\/testing$/, replacement: workspaceSourcePaths.telemetryTesting }, From e7a1064a9536b88a1e19f648a171cd081758125d Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Thu, 20 Aug 2026 00:35:05 -0700 Subject: [PATCH 4/4] fix(agent): report empty evolution corpus - Returns zero estimated tokens when no materialized invocations are available - Keeps the /evolve overview aligned with its settled invocation count --- packages/evolution/src/token-estimator.ts | 1 + packages/evolution/test/control-plane.test.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/evolution/src/token-estimator.ts b/packages/evolution/src/token-estimator.ts index 8915bc40b..28503077a 100644 --- a/packages/evolution/src/token-estimator.ts +++ b/packages/evolution/src/token-estimator.ts @@ -9,6 +9,7 @@ export const AUTOMATIC_EVOLUTION_COOLDOWN_MS = 20 * 60 * 1_000; const CHARACTERS_PER_ESTIMATED_TOKEN = 4; export function estimateEvolutionTokens(invocations: readonly MaterializedInvocation[]): number { + if (invocations.length === 0) return 0; const canonicalCorpus = serializeEvolutionValue(invocations); return Math.ceil(new TextEncoder().encode(canonicalCorpus).byteLength / CHARACTERS_PER_ESTIMATED_TOKEN); } diff --git a/packages/evolution/test/control-plane.test.ts b/packages/evolution/test/control-plane.test.ts index 70c411622..92506e5f1 100644 --- a/packages/evolution/test/control-plane.test.ts +++ b/packages/evolution/test/control-plane.test.ts @@ -23,6 +23,7 @@ import { type EvolutionStageExecutor, EvolutionTargetAdapterRegistry, type EvolutionTargetWriter, + estimateEvolutionTokens, evaluateAutomaticEvolutionEligibility, FileEvolutionPersistence, type Lesson, @@ -609,6 +610,10 @@ describe("checkpointed evolution pipeline", () => { }); describe("automatic evolution policy", () => { + it("reports zero tokens for an empty evidence corpus", () => { + expect(estimateEvolutionTokens([])).toBe(0); + }); + it("requires a strict token threshold and at least three invocations", () => { expect( evaluateAutomaticEvolutionEligibility({