From 845fa7bf8e1a461806e153c86a733442e2f92d16 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Fri, 9 Oct 2026 05:33:44 -0700 Subject: [PATCH] feat(coding-agent): reconcile selected harness revisions --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/evolution.md | 2 +- packages/coding-agent/docs/skills.md | 2 +- .../src/core/agent-session-services.ts | 6 +- .../coding-agent/src/core/agent-session.ts | 26 +++- .../src/core/harness-state-runtime.ts | 19 ++- packages/coding-agent/src/core/sdk.ts | 11 +- .../suite/agent-session-compaction.test.ts | 98 +++++++++++++++ .../agent-session-harness-version.test.ts | 112 ++++++++++++++++++ packages/coding-agent/test/suite/harness.ts | 2 + 11 files changed, 274 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c46ab0f97..72214f7c8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Regular root sessions now reconcile selected harness memory and evolved skills at construction, idle reload, and eligible manual compaction boundaries. + ## [0.3.2] - 2026-09-29 ### New Features diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 7d6096236..9600d5737 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -235,7 +235,7 @@ Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUp ### Harness Evolution -`/evolve` shows evidence eligibility, jobs, memory proposals, generated skill candidates, readable supporting and contradicting evidence, draft diffs, validation, evaluation, target revisions, activation, and rollback. `/version` selects a complete immutable harness revision. Memory and evolved skills change together at the next prompt boundary; in-flight and admitted queued requests remain pinned. The footer shows the active harness and background activity. See [Harness Evolution](docs/evolution.md). +`/evolve` shows evidence eligibility, jobs, memory proposals, generated skill candidates, readable supporting and contradicting evidence, draft diffs, validation, evaluation, target revisions, activation, and rollback. `/version` selects a complete immutable harness revision. Memory and evolved skills change together at regular session construction, an idle prompt boundary, an idle reload, or successful manual compaction without a queued continuation; in-flight and admitted queued requests remain pinned. The footer shows the active harness and background activity. See [Harness Evolution](docs/evolution.md). --- diff --git a/packages/coding-agent/docs/evolution.md b/packages/coding-agent/docs/evolution.md index dcec4e9dc..80473222a 100644 --- a/packages/coding-agent/docs/evolution.md +++ b/packages/coding-agent/docs/evolution.md @@ -30,7 +30,7 @@ A proposed skill is a candidate, not a bundle member. An accepted candidate has ## Request boundaries -Harness selection is resolved at prompt admission. A fresh prompt observes the selected revision; in-flight work and already admitted queued messages keep their pinned memory and skill versions. Background candidate validation reads the atomically selected revision directly, so it does not depend on a session reaching its next prompt boundary first. If `/version` changes while an evolution job is running, its memory activation continues from the frozen base and cannot replace the newer selection. Separate skill work can still activate a compatible descendant later. +Harness selection is resolved when a regular root session is constructed, at idle prompt admission, during an idle reload after resources rebuild, and after a successful manual compaction when no continuation was already queued. A fresh prompt observes the selected revision; in-flight work and already admitted queued messages keep their pinned memory and skill versions. Fixed and explicitly pinned subagent sessions do not follow workspace selection automatically. Background candidate validation reads the atomically selected revision directly, so it does not depend on a session reaching its next prompt boundary first. If `/version` changes while an evolution job is running, its memory activation continues from the frozen base and cannot replace the newer selection. Separate skill work can still activate a compatible descendant later. The footer identifies the active harness and shows background evolution activity. `/skills` lists only evolved skills from the active revision whose workspace identity matches the current workspace. Harness selection is workspace-scoped: sessions in the same workspace observe each other's selections at prompt boundaries, while sessions in other workspaces keep their own selected revision. diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 1794ec26a..fd4eca0ef 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -74,7 +74,7 @@ This is progressive disclosure: only names, descriptions, and locations are alwa ### Evolved Skills -Evolved skills are immutable, workspace-scoped packages in a harness revision. The package version covers `SKILL.md` and every bundled UTF-8 supporting file, while the content digest identifies `SKILL.md` itself. Diffex exposes a package only when the active revision and workspace identity match. Selecting another revision changes `EVOLVE.md` memory and the evolved-skill set together at the next prompt boundary; in-flight and already admitted queued requests remain pinned. +Evolved skills are immutable, workspace-scoped packages in a harness revision. The package version covers `SKILL.md` and every bundled UTF-8 supporting file, while the content digest identifies `SKILL.md` itself. Diffex exposes a package only when the active revision and workspace identity match. Selecting another revision changes `EVOLVE.md` memory and the evolved-skill set together at regular root-session construction, an idle prompt boundary, an idle reload, or successful manual compaction without a queued continuation; in-flight and already admitted queued requests remain pinned. An evolved reference binds its skill ID, content version, digest, workspace, and harness revision. A reference from an inactive revision is rejected. `/reload` refreshes installed skills while preserving the evolved overlay from the active harness. Accepted evolved skills activate automatically after validation and any configured evaluation finish. Use `/evolve` to inspect provenance, validation, available evaluation results, activation, and rollback. See [Harness Evolution](evolution.md). diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 01aea6ccc..2709698e4 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -239,7 +239,7 @@ export async function createInternalAgentSessionFromServices( internalCustomTools: readonly ToolDefinition[] = [], ): Promise { registerSubagentRuntimeDiagnosticSink(options.sessionManager, options.services.diagnostics); - return createInternalAgentSession( + const result = await createInternalAgentSession( { backgroundProcessPolicy: options.backgroundProcessPolicy, cwd: options.services.cwd, @@ -265,4 +265,8 @@ export async function createInternalAgentSessionFromServices( }, internalCustomTools, ); + if (result.harnessStateWarning) { + options.services.diagnostics.push({ type: "warning", message: result.harnessStateWarning }); + } + return result; } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9cb758052..45f96f99e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1475,7 +1475,7 @@ export class AgentSession { await this._notifyHarnessActivated(activatedBundle.manifest.version); } - private async _refreshSelectedHarnessAtPromptBoundary(): Promise { + private async _reconcileSelectedHarness(): Promise { if (!this._harnessStateStore || this._pinHarnessVersion || hasFixedHarnessSubagentSession(this.sessionManager)) { return; } @@ -2344,7 +2344,7 @@ export class AgentSession { ); } - if (!this.isStreaming) await this._refreshSelectedHarnessAtPromptBoundary(); + if (!this.isStreaming) await this._reconcileSelectedHarness(); signal?.throwIfAborted(); // Emit input event for extension interception (before skill/template expansion) @@ -3381,6 +3381,14 @@ export class AgentSession { usage, willRetry: false, }); + let refreshError: string | undefined; + if (!this.agent.hasQueuedMessages()) { + try { + await this._reconcileSelectedHarness(); + } catch (error) { + refreshError = error instanceof Error ? error.message : String(error); + } + } // compaction_end listeners may submit queued prompts, so expose idle state before notifying them. this._compactionAbortController = undefined; this._emit({ @@ -3389,6 +3397,7 @@ export class AgentSession { result: compactionResult, aborted: false, willRetry: false, + errorMessage: refreshError, }); return compactionResult; } catch (error) { @@ -4175,6 +4184,18 @@ export class AgentSession { } async reload(options?: { beforeSessionStart?: () => void | Promise }): Promise { + if ( + this._promptAdmissionInProgress || + this._harnessSwitchInProgress || + this.isStreaming || + this.agent.hasQueuedMessages() || + this.isRetrying || + this._manualCompactionOperations > 0 || + this.isCompacting || + this.isBashRunning + ) { + throw new Error("Cannot reload while the session is busy."); + } await this._subagentController?.reset(); const oldRunner = this._extensionRunner; const previousFlagValues = oldRunner.getFlagValues(); @@ -4189,6 +4210,7 @@ export class AgentSession { flagValues: previousFlagValues, includeAllExtensionTools: true, }); + await this._reconcileSelectedHarness(); const hasBindings = this._extensionUIContext || diff --git a/packages/coding-agent/src/core/harness-state-runtime.ts b/packages/coding-agent/src/core/harness-state-runtime.ts index 311ef6d26..461dfca2b 100644 --- a/packages/coding-agent/src/core/harness-state-runtime.ts +++ b/packages/coding-agent/src/core/harness-state-runtime.ts @@ -42,6 +42,23 @@ export interface LoadedHarnessState { warning: string | undefined; } +/** + * Load the selected harness from an existing store. This preserves the + * startup fallback used when a late selection read cannot be completed. + */ +export async function loadSelectedHarnessState(store: HarnessStateStore): Promise { + try { + return { store, bundle: await store.loadCurrent(), warning: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + store, + bundle: undefined, + warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`, + }; + } +} + /** * Initialize the shared harness revision store and load this workspace's selected revision. * Invalid selected state is deliberately left untouched so `/version` can repair it. @@ -53,7 +70,6 @@ export async function loadWorkspaceHarnessState( const store = new HarnessStateStore(agentDir, { workspace }); try { await store.initialize(); - return { store, bundle: await store.loadCurrent(), warning: undefined }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { @@ -62,4 +78,5 @@ export async function loadWorkspaceHarnessState( warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`, }; } + return loadSelectedHarnessState(store); } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index ceea86385..b3a35fc55 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -20,6 +20,7 @@ import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { + loadSelectedHarnessState, loadWorkspaceHarnessState, resolveEvolutionWorkspaceStateDirectory, resolveHarnessWorkspace, @@ -51,6 +52,7 @@ import { resolveRestoredSubagentConfig, } from "./subagents/services.ts"; import { RootLocalEphemeralSubagentSessionManagerFactory } from "./subagents/session-manager-factory.ts"; +import { hasFixedHarnessSubagentSession } from "./subagents/session-runtime.ts"; import type { SubagentEscalationCallback, SubagentSupervisionOptions } from "./subagents/types.ts"; import { time } from "./timings.ts"; import { @@ -440,9 +442,6 @@ export async function createInternalAgentSession( const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); const workspace = await resolveHarnessWorkspace(cwd); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); - const loadedHarnessState = options.harnessStateStore - ? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined } - : await loadWorkspaceHarnessState(agentDir, workspace.workspace); const subagentOptions = normalizeSubagentOptions(options.subagents); const telemetryContext = options.telemetryContext ?? NOOP_TELEMETRY_CONTEXT; assertNoReservedSubagentTools(options.customTools); @@ -454,6 +453,7 @@ export async function createInternalAgentSession( const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); + const usesFixedHarness = options.pinHarnessVersion === true || hasFixedHarnessSubagentSession(sessionManager); const subagentController = subagentOptions === false ? undefined @@ -667,6 +667,11 @@ export async function createInternalAgentSession( sessionManager.appendThinkingLevelChange(thinkingLevel); } + const loadedHarnessState = options.harnessStateStore + ? usesFixedHarness + ? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined } + : await loadSelectedHarnessState(options.harnessStateStore) + : await loadWorkspaceHarnessState(agentDir, workspace.workspace); const session = new AgentSession({ backgroundProcessPolicy: options.backgroundProcessPolicy, agent, diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index f197caa57..d967b1942 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,9 +1,13 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage, type Model, } from "@diffexai/diffex-ai"; +import { HarnessStateStore } from "@diffexai/diffex-harness-state"; import { afterEach, describe, expect, it, vi } from "vitest"; import { estimateTokens } from "../../src/core/compaction/index.ts"; import { createHarness, getUserTexts, type Harness } from "./harness.ts"; @@ -186,6 +190,100 @@ describe("AgentSession compaction characterization", () => { expect(harness.session.getLastAssistantText()).toBe("queued response"); }); + it("reconciles the selected harness after committed manual compaction without queued work", async () => { + const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const harness = await createHarness({ + harnessStateStore: store, + harnessVersionBundle: root, + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "manual compacted", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + try { + seedCompactableSession(harness); + const successor = await store.advance({ memory: "Use the compacted selection." }, root.manifest.version); + expect(successor.status).toBe("advanced"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + await harness.session.compact(); + + expect(harness.session.activeHarnessVersion).toBe(successor.bundle.manifest.version); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("keeps a queued continuation pinned and reports a post-commit harness refresh failure", async () => { + const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const harness = await createHarness({ + harnessStateStore: store, + harnessVersionBundle: root, + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "manual compacted", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + try { + seedCompactableSession(harness); + const successor = await store.advance({ memory: "Do not activate while queued." }, root.manifest.version); + expect(successor.status).toBe("advanced"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); + harness.session.agent.followUp({ + role: "custom", + customType: "test", + content: [{ type: "text", text: "queued continuation" }], + display: false, + timestamp: Date.now(), + }); + + await harness.session.compact(); + + expect(harness.session.activeHarnessVersion).toBe(root.manifest.version); + harness.session.agent.clearAllQueues(); + seedCompactableSession(harness); + vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selection read failed")); + + const result = await harness.session.compact(); + + expect(result.summary).toContain("manual compacted"); + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "manual", + aborted: false, + result, + errorMessage: "selection read failed", + }); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); + it("throws when compacting without a model", async () => { const harness = await createHarness(); harnesses.push(harness); 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 c54952962..ba6686036 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 @@ -16,7 +16,10 @@ import { harnessVersionDirectoryName, } from "@diffexai/diffex-harness-state"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAgentSessionFromServices, createAgentSessionServices } from "../../src/core/agent-session-services.ts"; import { resolveHarnessWorkspace } from "../../src/core/harness-state-runtime.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { markFixedHarnessSubagentSession } from "../../src/core/subagents/session-runtime.ts"; import { createHarness, getMessageText, type Harness } from "./harness.ts"; describe("AgentSession harness revisions", () => { @@ -661,6 +664,115 @@ describe("AgentSession harness revisions", () => { expect(providerPrompts[1]).toContain("Use the newly selected memory."); }); + it("reconciles the selected harness before regular construction while preserving pinned and fixed bundles", async () => { + const { harness, store, rootVersion, agentDir } = await createVersionedHarness(); + const successor = await store.saveVersion({ memory: "Use the construction selection." }, rootVersion); + await store.selectVersion(successor.manifest.version); + const createSession = async (options: { pinHarnessVersion?: boolean; fixed?: boolean } = {}) => { + const services = await createAgentSessionServices({ + cwd: harness.tempDir, + agentDir, + harnessStateStore: store, + harnessVersionBundle: await store.loadVersion(rootVersion), + }); + const sessionManager = SessionManager.inMemory(); + if (options.fixed) markFixedHarnessSubagentSession(sessionManager); + const created = await createAgentSessionFromServices({ + services, + sessionManager, + subagents: false, + pinHarnessVersion: options.pinHarnessVersion, + }); + return created.session; + }; + + const regular = await createSession(); + const pinned = await createSession({ pinHarnessVersion: true }); + const fixed = await createSession({ fixed: true }); + try { + expect(regular.activeHarnessVersion).toBe(successor.manifest.version); + expect(pinned.activeHarnessVersion).toBe(rootVersion); + expect(fixed.activeHarnessVersion).toBe(rootVersion); + } finally { + await Promise.all([regular.dispose(), pinned.dispose(), fixed.dispose()]); + } + }); + + it("falls back to the built-in harness when selected state cannot be read during construction", async () => { + const { harness, store, rootVersion, agentDir } = await createVersionedHarness(); + const services = await createAgentSessionServices({ + cwd: harness.tempDir, + agentDir, + harnessStateStore: store, + harnessVersionBundle: await store.loadVersion(rootVersion), + }); + vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selected state is unavailable")); + const created = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(), + subagents: false, + }); + try { + expect(created.session.activeHarnessVersion).toBeUndefined(); + expect(created.harnessStateWarning).toBe( + "Harness state could not be activated: selected state is unavailable. Continuing with the built-in harness.", + ); + } finally { + await created.session.dispose(); + } + }); + + it("reconciles the selected revision after an idle reload", async () => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const successor = await store.advance({ memory: "Use the reload selection." }, rootVersion); + expect(successor.status).toBe("advanced"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + await harness.session.reload(); + + expect(harness.session.activeHarnessVersion).toBe(successor.bundle.manifest.version); + expect(harness.session.systemPrompt).toContain("Use the reload selection."); + }); + + it("retains the active harness when reload cannot reconcile the selected revision", async () => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const successor = await store.saveVersion({ memory: "Do not activate after a failed reload." }, rootVersion); + await store.selectVersion(successor.manifest.version); + vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selection read failed")); + + await expect(harness.session.reload()).rejects.toThrow("selection read failed"); + + expect(harness.session.activeHarnessVersion).toBe(rootVersion); + }); + + it("rejects reload before rebuilding runtime while work is active", async () => { + const { harness } = await createVersionedHarness(); + const sessionInternals = harness.session as unknown as { _buildRuntime: () => void }; + const rebuild = vi.spyOn(sessionInternals, "_buildRuntime"); + vi.spyOn(harness.session, "isStreaming", "get").mockReturnValue(true); + + await expect(harness.session.reload()).rejects.toThrow("Cannot reload while the session is busy."); + + expect(rebuild).not.toHaveBeenCalled(); + }); + + it("rejects reload before rebuilding runtime while a continuation is queued", async () => { + const { harness } = await createVersionedHarness(); + const sessionInternals = harness.session as unknown as { _buildRuntime: () => void }; + const rebuild = vi.spyOn(sessionInternals, "_buildRuntime"); + harness.session.agent.followUp({ + role: "custom", + customType: "test", + content: [{ type: "text", text: "queued continuation" }], + display: false, + timestamp: Date.now(), + }); + + await expect(harness.session.reload()).rejects.toThrow("Cannot reload while the session is busy."); + + expect(rebuild).not.toHaveBeenCalled(); + }); + it("keeps an in-flight request pinned while the next request refreshes", async () => { const { harness, store, rootVersion } = await createVersionedHarness(); const providerPrompts: string[] = []; diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index f7e346198..0d2369fc6 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -81,6 +81,7 @@ export interface HarnessOptions { sessionStartEvent?: SessionStartEvent; harnessStateStore?: HarnessStateStore; harnessVersionBundle?: HarnessVersionBundle; + pinHarnessVersion?: boolean; telemetryContext?: TelemetryContext; lifecycleObserver?: AgentSessionLifecycleObserver; } @@ -208,6 +209,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise