From caff3c80ea29ff9a0b26f7f38dc2fccaf242d293 Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 20 Aug 2026 04:53:15 -0700 Subject: [PATCH 1/2] feat(coding-agent): reconcile persistent subagent contexts --- .../coding-agent/src/core/agent-session.ts | 49 ++-- packages/coding-agent/src/core/sdk.ts | 1 + .../src/core/subagents/controller.ts | 144 +++++++--- .../test/suite/subagent-persistence.test.ts | 251 +++++++++++++++++- 4 files changed, 379 insertions(+), 66 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index bc8330394..25a8deb6c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3768,32 +3768,33 @@ export class AgentSession { // Switch leaf (with or without summary) // Summary is attached at the navigation target position (newLeafId), not the old branch let summaryEntry: BranchSummaryEntry | undefined; - if (summaryText) { - // Create summary at target position (can be null for root) - const summaryId = this.sessionManager.branchWithSummary( - newLeafId, - summaryText, - summaryDetails, - fromExtension, - summaryUsage, - ); - summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; - - // Attach label to the summary entry - if (label) { - this.sessionManager.appendLabelChange(summaryId, label); + const mutateLeaf = (): void => { + if (summaryText) { + const summaryId = this.sessionManager.branchWithSummary( + newLeafId, + summaryText, + summaryDetails, + fromExtension, + summaryUsage, + ); + summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + if (label) this.sessionManager.appendLabelChange(summaryId, label); + } else if (newLeafId === null) { + this.sessionManager.resetLeaf(); + } else { + this.sessionManager.branch(newLeafId); } - } else if (newLeafId === null) { - // No summary, navigating to root - reset leaf - this.sessionManager.resetLeaf(); - } else { - // No summary, navigating to non-root - this.sessionManager.branch(newLeafId); - } - // Attach label to target entry when not summarizing (no summary entry to label) - if (label && !summaryText) { - this.sessionManager.appendLabelChange(targetId, label); + if (label && !summaryText) this.sessionManager.appendLabelChange(targetId, label); + }; + + try { + if (this._subagentController) await this._subagentController.reconcileBranch(mutateLeaf); + else mutateLeaf(); + } catch (error) { + if (oldLeafId === null) this.sessionManager.resetLeaf(); + else this.sessionManager.branch(oldLeafId); + throw error; } // Update agent state diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 2a4937ab7..cdc12f964 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -218,6 +218,7 @@ function createRootSubagentController( return new SubagentController({ maxConcurrent, telemetryContext, + restoreRoster: (controller, parent) => restoreRootSubagents(controller, parent, agentDir, parentSessionManager), captureSession: (parent, childName, origin) => { const config = captureSubagentSessionConfig(parent, agentDir); const sessionManager = sessionManagerFactory.create( diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index f520858a2..6dafb2ed5 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -5,6 +5,7 @@ import type { ToolDefinition } from "../extensions/types.ts"; import { startCodingAgentSpan } from "../telemetry.ts"; import { createAgentActionTool, createAgentsTool, createSpawnAgentTool } from "../tools/subagents.ts"; import type { ChildSnapshotV1 } from "./persistence.ts"; +import { reportSubagentRuntimeWarning } from "./runtime-diagnostics.ts"; import type { CapturedSubagentSession, CaptureSubagentSession, @@ -165,6 +166,7 @@ export class SubagentController { readonly #maxConcurrent: number; readonly #captureSession: CaptureSubagentSession | undefined; readonly #persistRoster: ((agents: readonly ChildSnapshotV1[], admission: boolean) => void) | undefined; + readonly #restoreRoster: ((controller: SubagentController, parent: AgentSession) => void) | undefined; readonly #telemetryContext: TelemetryContext; readonly #records = new Map(); readonly #listeners = new Set(); @@ -187,6 +189,7 @@ export class SubagentController { captureSession?: CaptureSubagentSession; telemetryContext?: TelemetryContext; persistRoster?: (agents: readonly ChildSnapshotV1[], admission: boolean) => void; + restoreRoster?: (controller: SubagentController, parent: AgentSession) => void; }) { if (!Number.isInteger(options.maxConcurrent) || options.maxConcurrent <= 0) { throw new RangeError("maxConcurrent must be a positive integer"); @@ -195,6 +198,7 @@ export class SubagentController { this.#captureSession = options.captureSession; this.#telemetryContext = options.telemetryContext ?? NOOP_TELEMETRY_CONTEXT; this.#persistRoster = options.persistRoster; + this.#restoreRoster = options.restoreRoster; } createToolDefinitions(): ToolDefinition[] { @@ -730,29 +734,108 @@ export class SubagentController { }); this.#resetPromise = resetPromise; + void this.#resetContext().then(resolveReset, rejectReset); + return resetPromise; + } + + reconcileBranch(mutateLeaf: () => void): Promise { + if (this.#closed) return Promise.reject(this.#closedError()); + if (this.#resetting) return Promise.reject(this.#resetError()); + return this.#runContextBoundary(async () => { + await this.#quiesceActiveRecords(); + if (this.#closed) throw this.#closedError(); + mutateLeaf(); + await this.#replaceRecordsFromSelectedBranch(); + }); + } + + async #resetContext(): Promise { try { - this.#resetting = true; - const resetError = this.#resetError(); - this.#rejectPermitQueue(resetError); - const records = this.#detachRecords(resetError); - const cleanup = this.#cleanupRecords(records); - this.#resetCleanupPromise = cleanup; - this.#publish( - (revision) => ({ - type: "subagent_controller_reset", - revision, - timestamp: Date.now(), - }), - "reset", - ); - void this.#finishReset(cleanup).then(resolveReset, rejectReset); - } catch (error) { - if (!this.#closed) this.#resetting = false; - this.#resetCleanupPromise = undefined; + await this.#runContextBoundary(async () => { + await this.#quiesceActiveRecords(); + if (this.#closed) throw this.#closedError(); + this.#persistRoster?.([], true); + await this.#clearRecords(); + if (this.#closed) throw this.#closedError(); + }); + } finally { this.#resetPromise = undefined; - rejectReset(error); } - return resetPromise; + } + + async #runContextBoundary(operation: () => Promise): Promise { + this.#resetting = true; + this.#rejectPermitQueue(this.#resetError()); + try { + await operation(); + } finally { + if (!this.#closed) { + this.#resetting = false; + this.#drainPermitQueue(); + } + } + } + + async #quiesceActiveRecords(): Promise { + const activeRecords = [...this.#records.values()].filter( + (record) => record.status === "queued" || record.status === "running", + ); + const results = await Promise.allSettled(activeRecords.map((record) => this.#quiesceRecord(record))); + const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (failures.length > 0) throw new AggregateError(failures, "Failed to quiesce subagent controller"); + } + + async #quiesceRecord(record: SubagentRecord): Promise { + record.session?.clearQueue(); + record.interruptionRequested = true; + if (record.status === "queued") { + record.runController?.abort(new Error(`Subagent interrupted: ${record.name}`)); + } else if (record.session) { + await record.session.abort(); + } + await record.run; + if (record.status === "queued" || record.status === "running") this.markInterrupted(record.name); + } + + async #clearRecords(): Promise { + const records = this.#detachRecords(this.#resetError()); + const cleanup = this.#cleanupRecords(records); + this.#resetCleanupPromise = cleanup; + this.#publish( + (revision) => ({ + type: "subagent_controller_reset", + revision, + timestamp: Date.now(), + }), + "reset", + ); + const failures = await cleanup; + if (this.#resetCleanupPromise === cleanup) this.#resetCleanupPromise = undefined; + this.#reportCleanupFailures(failures); + } + + async #replaceRecordsFromSelectedBranch(): Promise { + await this.#clearRecords(); + if (this.#closed) return; + this.#resetting = false; + try { + this.#restoreRoster?.(this, this.#getBoundParent()); + } catch (error) { + reportSubagentRuntimeWarning( + this.#getBoundParent().sessionManager, + `Subagent roster reconciliation failed: ${normalizeError(error)}`, + ); + } finally { + if (!this.#closed) this.#resetting = true; + } + } + + #reportCleanupFailures(failures: readonly unknown[]): void { + if (failures.length === 0 || !this.#parent) return; + reportSubagentRuntimeWarning( + this.#parent.sessionManager, + `Subagent context cleanup failed after reset: ${failures.map(normalizeError).join("; ")}`, + ); } #transition(name: string, status: SubagentStatus, fields: TransitionFields = {}): void { @@ -824,7 +907,7 @@ export class SubagentController { } #getRecordForUpdate(name: string): SubagentRecord | undefined { - if (this.#closed || this.#resetting) return undefined; + if (this.#closed) return undefined; const record = this.#records.get(name); if (!record) { throw new Error(`Unknown subagent: ${name}`); @@ -1411,25 +1494,6 @@ export class SubagentController { return failures; } - async #finishReset(cleanup: Promise): Promise { - const failures = await cleanup; - if (!this.#closed) { - this.#resetting = false; - this.#drainPermitQueue(); - } - if (this.#resetCleanupPromise === cleanup) { - this.#resetCleanupPromise = undefined; - } - this.#resetPromise = undefined; - - if (this.#closed) { - throw this.#closedError(); - } - if (failures.length > 0) { - throw new AggregateError(failures, "Failed to reset subagent controller"); - } - } - async #finishShutdown(shutdownCleanup: Promise, resetCleanup?: Promise): Promise { const [resetFailures, shutdownFailures] = await Promise.all([ resetCleanup ?? Promise.resolve([]), diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts index 7bfffca45..4acacf46b 100644 --- a/packages/coding-agent/test/suite/subagent-persistence.test.ts +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -15,8 +15,9 @@ import type { AgentTool } from "@earendil-works/diffex-agent-core"; import { fauxAssistantMessage } from "@earendil-works/diffex-ai"; import type { FauxModelDefinition } from "@earendil-works/diffex-ai/compat"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { AgentSession } from "../../src/core/agent-session.ts"; +import { AgentSession } from "../../src/core/agent-session.ts"; import { createAgentSessionFromServices, createAgentSessionServices } from "../../src/core/agent-session-services.ts"; +import type { InlineExtension } from "../../src/core/extensions/types.ts"; import { loadEntriesFromFile, persistSessionHeader, @@ -249,6 +250,7 @@ describe("durable subagent lifecycle", () => { modelId?: string; thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; tools?: string[]; + extensionFactories?: InlineExtension[]; } = {}, ) { const harness = await createHarness({ @@ -267,7 +269,8 @@ describe("durable subagent lifecycle", () => { modelRuntime: harness.session.modelRuntime, settingsManager: harness.settingsManager, resourceLoaderOptions: { - noExtensions: true, + noExtensions: options.extensionFactories === undefined, + extensionFactories: options.extensionFactories, noSkills: true, noPromptTemplates: true, noThemes: true, @@ -347,6 +350,20 @@ describe("durable subagent lifecycle", () => { persistRoster(root.sessionManager, [childSnapshot({ name, childSessionId: CHILD_ID_A, config })]); } + function createRosterBranch(root: Awaited>, agents: unknown[]): string { + const branchPoint = root.sessionManager.getLeafId(); + if (!branchPoint) throw new Error("Missing branch point"); + persistRoster(root.sessionManager, agents); + const targetId = root.sessionManager.appendMessage({ role: "user", content: "target branch", timestamp: 1 }); + root.sessionManager.branch(branchPoint); + root.sessionManager.appendMessage({ + role: "user", + content: "current branch", + timestamp: 2, + }); + return targetId; + } + it("persists the child header and queued roster before direct spawn starts provider work", async () => { const started = deferred(); const { harness, session, sessionManager, cwd, sessionDir } = await createRoot(); @@ -528,6 +545,236 @@ describe("durable subagent lifecycle", () => { expect(sessionManager.getLeafId()).toBe(previousLeafId); }); + it("completes extension cancellation before touching active children", async () => { + const release = deferred(); + const started = deferred(); + const root = await createRoot({ + extensionFactories: [ + (api) => { + api.on("session_before_tree", () => ({ cancel: true })); + }, + ], + }); + const targetId = createRosterBranch(root, []); + root.harness.setResponses([ + async () => { + started.resolve(); + await release.promise; + return fauxAssistantMessage("finished later"); + }, + ]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "keep running" }); + await started.promise; + const currentLeafId = root.sessionManager.getLeafId(); + if (!currentLeafId) throw new Error("Missing current leaf"); + + await expect(root.session.navigateTree(targetId)).resolves.toEqual({ cancelled: true }); + expect(root.sessionManager.getLeafId()).toBe(currentLeafId); + expect(root.session.getSubagents()).toEqual([{ name: "worker", status: "running", task: "keep running" }]); + release.resolve(); + }); + + it("finishes optional summarization before quiescing and replacing the target roster", async () => { + const childStarted = deferred(); + const summaryStarted = deferred(); + const releaseSummary = deferred(); + const root = await createRoot(); + const store = new ParentOwnedSubagentSessionStore(root.sessionManager); + store.createChild("target", CHILD_ID_B); + const config = { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "high" as const, + tools: ["read"] as const, + harnessRevision: root.session.activeHarnessVersion ?? null, + }; + const targetId = createRosterBranch(root, [ + childSnapshot({ name: "target", childSessionId: CHILD_ID_B, config }), + ]); + root.harness.setResponses([ + async (_context, request) => { + childStarted.resolve(); + await new Promise((resolvePromise) => { + request?.signal?.addEventListener("abort", () => resolvePromise(), { once: true }); + }); + return fauxAssistantMessage("interrupted child"); + }, + async () => { + summaryStarted.resolve(); + await releaseSummary.promise; + return fauxAssistantMessage("branch summary"); + }, + ]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "keep running" }); + await childStarted.promise; + const currentLeafId = root.sessionManager.getLeafId(); + if (!currentLeafId) throw new Error("Missing current leaf"); + + const navigation = root.session.navigateTree(targetId, { summarize: true }); + await summaryStarted.promise; + expect(root.sessionManager.getLeafId()).toBe(currentLeafId); + expect(root.session.getSubagents()[0]).toMatchObject({ name: "worker", status: "running" }); + releaseSummary.resolve(); + await expect(navigation).resolves.toMatchObject({ cancelled: false }); + expect(root.session.getSubagents()).toEqual([{ name: "target", status: "idle", task: "Inspect the change" }]); + }); + + it("retains the old leaf and roster when child quiescing fails", async () => { + const childStarted = deferred(); + const releaseChild = deferred(); + const root = await createRoot(); + const targetId = createRosterBranch(root, []); + root.harness.setResponses([ + async () => { + childStarted.resolve(); + await releaseChild.promise; + return fauxAssistantMessage("finished later"); + }, + ]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "keep running" }); + await childStarted.promise; + const currentLeafId = root.sessionManager.getLeafId(); + if (!currentLeafId) throw new Error("Missing current leaf"); + const abort = vi.spyOn(AgentSession.prototype, "abort").mockRejectedValueOnce(new Error("quiesce unavailable")); + + await expect(root.session.navigateTree(targetId)).rejects.toThrow("Failed to quiesce subagent controller"); + expect(root.sessionManager.getLeafId()).toBe(currentLeafId); + expect(root.session.getSubagents()[0]).toMatchObject({ name: "worker", status: "running" }); + abort.mockRestore(); + releaseChild.resolve(); + }); + + it("does not block a successful leaf change when a quiesce status snapshot fails", async () => { + const childStarted = deferred(); + const root = await createRoot(); + const targetId = createRosterBranch(root, []); + root.harness.setResponses([ + async (_context, request) => { + childStarted.resolve(); + await new Promise((resolvePromise) => { + request?.signal?.addEventListener("abort", () => resolvePromise(), { once: true }); + }); + return fauxAssistantMessage("interrupted child"); + }, + ]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "keep running" }); + await childStarted.promise; + const currentLeafId = root.sessionManager.getLeafId(); + if (!currentLeafId) throw new Error("Missing current leaf"); + vi.spyOn(root.sessionManager, "appendCustomEntry").mockImplementationOnce(() => { + throw new Error("status snapshot unavailable"); + }); + + await expect(root.session.navigateTree(targetId)).resolves.toMatchObject({ cancelled: false }); + expect(root.sessionManager.getLeafId()).not.toBe(currentLeafId); + expect(root.session.getSubagents()).toEqual([]); + }); + + it("uses the corrupt newest target snapshot to mask older target children", async () => { + const root = await createRoot(); + const branchPoint = root.sessionManager.getLeafId(); + if (!branchPoint) throw new Error("Missing branch point"); + persistRoster(root.sessionManager, [childSnapshot()]); + root.sessionManager.appendCustomEntry(SUBAGENT_ROSTER_CUSTOM_TYPE, { schemaVersion: 2, agents: [] }); + const targetId = root.sessionManager.appendMessage({ role: "user", content: "corrupt target", timestamp: 1 }); + root.sessionManager.branch(branchPoint); + root.sessionManager.appendMessage({ role: "user", content: "current branch", timestamp: 2 }); + + await root.session.navigateTree(targetId); + expect(root.session.getSubagents()).toEqual([]); + expect(root.services.diagnostics).toContainEqual({ + type: "warning", + message: "Latest subagent roster snapshot is malformed or uses an unsupported schema version", + }); + }); + + it.each(["reload", "manual harness switch", "prompt-boundary harness refresh"] as const)( + "writes an empty reset barrier through %s", + async (boundary) => { + const root = await createRoot(); + root.harness.setResponses([fauxAssistantMessage("child complete")]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "finish" }); + await getTool(root.session, "agents").execute("wait", { timeout_ms: 1_000, until: "all" }); + const store = root.session.harnessStateStore; + const currentVersion = root.session.activeHarnessVersion; + + if (boundary === "reload") { + await root.session.reload(); + } else { + if (!store || !currentVersion) throw new Error("Missing initialized harness state"); + if (boundary === "manual harness switch") { + const successor = await store.saveVersion({ memory: "manual successor" }, currentVersion); + await root.session.switchHarnessVersion(successor.manifest.version); + } else { + const successor = await store.advance({ memory: "prompt successor" }, currentVersion); + expect(successor.status).toBe("advanced"); + root.harness.setResponses([fauxAssistantMessage("parent response")]); + await root.session.prompt("activate selected harness"); + } + } + + expect(root.session.getSubagents()).toEqual([]); + const selected = readSelectedBranchSubagentRoster(root.sessionManager); + expect(selected).toMatchObject({ found: true, children: [], warnings: [] }); + }, + ); + + it("aborts a shared harness reset before context mutation when the empty barrier fails", async () => { + const root = await createRoot(); + root.harness.setResponses([fauxAssistantMessage("child complete")]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "finish" }); + await getTool(root.session, "agents").execute("wait", { timeout_ms: 1_000, until: "all" }); + const store = root.session.harnessStateStore; + const currentVersion = root.session.activeHarnessVersion; + if (!store || !currentVersion) throw new Error("Missing initialized harness state"); + const successor = await store.saveVersion({ memory: "must not activate" }, currentVersion); + const originalAppend = root.sessionManager.appendCustomEntry.bind(root.sessionManager); + vi.spyOn(root.sessionManager, "appendCustomEntry").mockImplementation((customType, data) => { + if ( + customType === SUBAGENT_ROSTER_CUSTOM_TYPE && + typeof data === "object" && + data !== null && + "agents" in data && + Array.isArray(data.agents) && + data.agents.length === 0 + ) { + throw new Error("reset barrier unavailable"); + } + return originalAppend(customType, data); + }); + + await expect(root.session.switchHarnessVersion(successor.manifest.version)).rejects.toThrow( + "reset barrier unavailable", + ); + expect(root.session.activeHarnessVersion).toBe(currentVersion); + expect(root.session.getSubagents()).toEqual([{ name: "worker", status: "idle", task: "finish" }]); + expect(readSelectedBranchSubagentRoster(root.sessionManager).children).toHaveLength(1); + }); + + it("keeps a reset committed and reports cleanup failure after the durable barrier", async () => { + const root = await createRoot(); + root.harness.setResponses([fauxAssistantMessage("child complete")]); + await getTool(root.session, "spawn_agent").execute("spawn", { name: "worker", task: "finish" }); + await getTool(root.session, "agents").execute("wait", { timeout_ms: 1_000, until: "all" }); + const store = root.session.harnessStateStore; + const currentVersion = root.session.activeHarnessVersion; + if (!store || !currentVersion) throw new Error("Missing initialized harness state"); + const successor = await store.saveVersion({ memory: "activate despite cleanup failure" }, currentVersion); + vi.spyOn(AgentSession.prototype, "dispose").mockRejectedValueOnce(new Error("cleanup unavailable")); + + await expect(root.session.switchHarnessVersion(successor.manifest.version)).resolves.toBeUndefined(); + expect(root.session.activeHarnessVersion).toBe(successor.manifest.version); + expect(root.session.getSubagents()).toEqual([]); + expect(readSelectedBranchSubagentRoster(root.sessionManager)).toMatchObject({ + found: true, + children: [], + warnings: [], + }); + expect(root.services.diagnostics).toContainEqual({ + type: "warning", + message: "Subagent context cleanup failed after reset: cleanup unavailable", + }); + }); + it("restores terminal and interrupted records without calls, isolates bad histories, and opens lazily on wake", async () => { const root = await createRoot(); const sessionFile = root.sessionManager.getSessionFile(); From a4ff225325d294cd077068d62b3d961a4a03a6df Mon Sep 17 00:00:00 2001 From: issacboy123 Date: Thu, 20 Aug 2026 05:01:08 -0700 Subject: [PATCH 2/2] fix(coding-agent): preserve reset boundaries --- .../coding-agent/src/core/agent-session.ts | 13 +++--- .../test/subagent-controller.test.ts | 45 ++++++++++++++----- .../test/suite/subagent-persistence.test.ts | 6 +++ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 25a8deb6c..246dd999d 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1244,20 +1244,23 @@ export class AgentSession { ); } - const selected = await this._harnessStateStore.selectVersion(version); - await this._activateHarnessBundle(selected); + await this._activateHarnessBundle(target, () => this._harnessStateStore!.selectVersion(version)); } finally { this._harnessSwitchInProgress = false; } } - private async _activateHarnessBundle(bundle: HarnessVersionBundle): Promise { + private async _activateHarnessBundle( + bundle: HarnessVersionBundle, + selectBundle?: () => Promise, + ): Promise { await this._subagentController?.reset(); - this._harnessVersionBundle = bundle; + const activatedBundle = selectBundle ? await selectBundle() : bundle; + this._harnessVersionBundle = activatedBundle; this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); this._systemPromptOverride = undefined; this.agent.state.systemPrompt = this._baseSystemPrompt; - await this._notifyHarnessActivated(bundle.manifest.version); + await this._notifyHarnessActivated(activatedBundle.manifest.version); } private async _refreshSelectedHarnessAtPromptBoundary(): Promise { diff --git a/packages/coding-agent/test/subagent-controller.test.ts b/packages/coding-agent/test/subagent-controller.test.ts index 37eba6eed..25084f868 100644 --- a/packages/coding-agent/test/subagent-controller.test.ts +++ b/packages/coding-agent/test/subagent-controller.test.ts @@ -428,29 +428,37 @@ describe("SubagentController", () => { const firstReset = controller.reset(); const secondReset = controller.reset(); expect(firstReset).toBe(secondReset); - expect(controller.snapshot().agents).toEqual([]); + expect(controller.snapshot().agents).toMatchObject([{ name: "worker", status: "running" }]); expect(() => controller.reserve("later", "task")).toThrow("Subagent controller is resetting"); expect(() => controller.message("worker", "message")).toThrow("Subagent controller is resetting"); await expect(controller.queryAgents({ timeoutMs: 0 })).rejects.toThrow("Subagent controller is resetting"); await expect(controller.acquirePermit()).rejects.toThrow("Subagent controller is resetting"); await expect(queuedPermit).rejects.toThrow("Subagent controller is resetting"); - await expect(waiter).resolves.toEqual({ reason: "reset" }); + await expect(waiter).resolves.toMatchObject({ + reason: "changed", + snapshot: { agents: [{ name: "worker", status: "interrupted" }] }, + }); await expect(controller.waitForChange(0, { timeoutMs: 100 })).resolves.toEqual({ reason: "reset" }); activePermit.release(); await disposeStarted.promise; + expect(controller.snapshot().agents).toEqual([]); expect(await isPending(firstReset)).toBe(true); disposeGate.resolve(); await firstReset; expect(dispose).toHaveBeenCalledOnce(); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ type: "subagent_controller_reset" }); - expect(JSON.stringify(events[0])).not.toContain("private task"); + expect(events).toHaveLength(2); + expect(events.map((event) => event.type)).toEqual(["subagent_status_changed", "subagent_controller_reset"]); + expect(JSON.stringify(events)).not.toContain("private task"); expect(() => controller.reserve("worker", "reused task")).not.toThrow(); - expect(events.map((event) => event.type)).toEqual(["subagent_controller_reset", "subagent_reserved"]); + expect(events.map((event) => event.type)).toEqual([ + "subagent_status_changed", + "subagent_controller_reset", + "subagent_reserved", + ]); }); - it("reopens after aggregating reset cleanup failures", async () => { + it("reopens after treating reset cleanup failures as best effort", async () => { const controller = new SubagentController({ maxConcurrent: 2 }); const first = await harness(); const second = await harness(); @@ -462,19 +470,34 @@ describe("SubagentController", () => { controller.reserve("first", "task"); controller.attachSession("first", first.session); controller.markRunning("first"); + controller.markIdle("first"); controller.reserve("second", "task"); controller.attachSession("second", second.session); controller.markRunning("second"); controller.markIdle("second"); - const error = await controller.reset().catch((reason: unknown) => reason); - expect(error).toBeInstanceOf(AggregateError); - expect((error as AggregateError).errors).toEqual([abortFailure, disposeFailure]); + await expect(controller.reset()).resolves.toBeUndefined(); + expect(first.session.dispose).toHaveBeenCalledOnce(); expect(secondDispose).toHaveBeenCalledOnce(); expect(controller.snapshot().agents).toEqual([]); expect(() => controller.reserve("first", "reused")).not.toThrow(); }); + it("reopens admission without clearing the roster when quiescing fails", async () => { + const controller = new SubagentController({ maxConcurrent: 1 }); + const child = await harness(); + vi.spyOn(child.session, "abort").mockRejectedValue(new Error("quiesce failed")); + const dispose = vi.spyOn(child.session, "dispose").mockResolvedValue(); + controller.reserve("worker", "task"); + controller.attachSession("worker", child.session); + controller.markRunning("worker"); + + await expect(controller.reset()).rejects.toThrow("Failed to quiesce subagent controller"); + expect(controller.snapshot().agents).toMatchObject([{ name: "worker", status: "running" }]); + expect(dispose).not.toHaveBeenCalled(); + expect(() => controller.reserve("later", "task")).not.toThrow(); + }); + it("keeps shutdown permanent when it races reset", async () => { const controller = new SubagentController({ maxConcurrent: 1 }); const child = await harness(); @@ -510,8 +533,8 @@ describe("SubagentController", () => { }); const reset = controller.reset(); - expect(nestedReset).toBe(reset); await reset; + expect(nestedReset).toBe(reset); const shutdown = controller.shutdown(); expect(nestedShutdown).toBe(shutdown); await shutdown; diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts index 4acacf46b..541dca050 100644 --- a/packages/coding-agent/test/suite/subagent-persistence.test.ts +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -746,8 +746,14 @@ describe("durable subagent lifecycle", () => { "reset barrier unavailable", ); expect(root.session.activeHarnessVersion).toBe(currentVersion); + expect((await store.loadCurrent()).manifest.version).toBe(currentVersion); expect(root.session.getSubagents()).toEqual([{ name: "worker", status: "idle", task: "finish" }]); expect(readSelectedBranchSubagentRoster(root.sessionManager).children).toHaveLength(1); + await expect( + getTool(root.session, "agents").execute("after-failed-reset", { timeout_ms: 0 }), + ).resolves.toMatchObject({ + details: { agents: [{ name: "worker", status: "idle" }] }, + }); }); it("keeps a reset committed and reports cleanup failure after the durable barrier", async () => {