diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index adabc28ce..7cc73f28d 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -17,6 +17,7 @@ import { import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts"; import type { SessionManager } from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; +import { registerSubagentRuntimeDiagnosticSink } from "./subagents/runtime-diagnostics.ts"; /** * Non-fatal issues collected while creating services or sessions. @@ -227,6 +228,7 @@ export async function createAgentSessionServices( export async function createAgentSessionFromServices( options: CreateAgentSessionFromServicesOptions, ): Promise { + registerSubagentRuntimeDiagnosticSink(options.sessionManager, options.services.diagnostics); return createAgentSession({ cwd: options.services.cwd, agentDir: options.services.agentDir, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 3ac3ac921..bc8330394 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -122,6 +122,7 @@ import { import type { SlashCommandInfo } from "./slash-commands.ts"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; import type { SubagentController } from "./subagents/controller.ts"; +import { hasFixedHarnessSubagentSession } from "./subagents/session-runtime.ts"; import type { SubagentBatchResult, SubagentDeliveryBatch, SubagentSummary } from "./subagents/types.ts"; import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts"; import { type CodingAgentTelemetrySpan, startCodingAgentSpan } from "./telemetry.ts"; @@ -1260,7 +1261,7 @@ export class AgentSession { } private async _refreshSelectedHarnessAtPromptBoundary(): Promise { - if (!this._harnessStateStore) return; + if (!this._harnessStateStore || hasFixedHarnessSubagentSession(this.sessionManager)) return; const selected = await this._harnessStateStore.loadCurrent(); if (selected.manifest.version === this.activeHarnessVersion) return; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f7ff0ba30..2a4937ab7 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -25,9 +25,22 @@ import type { ResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts"; import { getDefaultSessionDir, persistSessionState, SessionManager } from "./session-manager.ts"; import { SettingsManager } from "./settings-manager.ts"; -import { SubagentController } from "./subagents/controller.ts"; -import { encodeSubagentRosterSnapshot, SUBAGENT_ROSTER_CUSTOM_TYPE } from "./subagents/persistence.ts"; -import { captureSubagentSessionConfig, createSubagentSession } from "./subagents/services.ts"; +import { type PreparedSubagentWake, SubagentController } from "./subagents/controller.ts"; +import { + type ChildSnapshotV1, + encodeSubagentRosterSnapshot, + ParentOwnedSubagentSessionStore, + readSelectedBranchSubagentRoster, + SUBAGENT_ROSTER_CUSTOM_TYPE, +} from "./subagents/persistence.ts"; +import { reportSubagentRuntimeWarning } from "./subagents/runtime-diagnostics.ts"; +import { + capturePersistedSubagentConfig, + captureSubagentSessionConfig, + createSubagentSession, + type ResolvedRestoredSubagentConfig, + resolveRestoredSubagentConfig, +} from "./subagents/services.ts"; import { RootLocalEphemeralSubagentSessionManagerFactory } from "./subagents/session-manager-factory.ts"; import { time } from "./timings.ts"; import { @@ -218,12 +231,7 @@ function createRootSubagentController( ); return Object.freeze({ sessionManager, - config: Object.freeze({ - model: Object.freeze({ provider: config.model.provider, id: config.model.id }), - thinkingLevel: config.thinkingLevel, - tools: config.tools, - harnessRevision: config.harnessVersionBundle?.manifest.version ?? null, - }), + config: capturePersistedSubagentConfig(config), createSession: (signal: AbortSignal) => createSubagentSession(config, { signal, @@ -252,6 +260,108 @@ function createRootSubagentController( }); } +function createPreparedSubagentWake( + childName: string, + sessionManager: SessionManager, + resolved: ResolvedRestoredSubagentConfig, +): PreparedSubagentWake { + return Object.freeze({ + sessionManager, + config: resolved.persistedConfig, + ...(resolved.warning === undefined ? {} : { warning: resolved.warning }), + createSession: (signal: AbortSignal) => + createSubagentSession(resolved.sessionConfig, { signal, childName, sessionManager }), + }); +} + +async function createReplacementSubagentWake( + parent: AgentSession, + agentDir: string, + store: ParentOwnedSubagentSessionStore, + childName: string, +): Promise { + const sessionConfig = captureSubagentSessionConfig(parent, agentDir); + const sessionManager = store.createChild(childName); + return createPreparedSubagentWake(childName, sessionManager, { + sessionConfig, + persistedConfig: capturePersistedSubagentConfig(sessionConfig), + }); +} + +async function prepareRestoredSubagentWake( + parent: AgentSession, + agentDir: string, + store: ParentOwnedSubagentSessionStore, + snapshot: ChildSnapshotV1, +): Promise { + const resolved = await resolveRestoredSubagentConfig(parent, agentDir, snapshot.config, snapshot.name); + if (resolved.warning) reportSubagentRuntimeWarning(parent.sessionManager, resolved.warning); + try { + return createPreparedSubagentWake( + snapshot.name, + store.openChild(snapshot.name, snapshot.childSessionId, snapshot.childLeafId), + resolved, + ); + } catch (error) { + const message = `Persisted subagent ${snapshot.name} history became unavailable: ${error instanceof Error ? error.message : String(error)}; creating a replacement child session`; + reportSubagentRuntimeWarning(parent.sessionManager, message); + const replacement = await createReplacementSubagentWake(parent, agentDir, store, snapshot.name); + return Object.freeze({ ...replacement, warning: message }); + } +} + +function restoreRootSubagents( + controller: SubagentController, + parent: AgentSession, + agentDir: string, + parentSessionManager: SessionManager, +): void { + if (!parentSessionManager.isPersisted()) return; + const roster = readSelectedBranchSubagentRoster(parentSessionManager); + for (const warning of roster.warnings) reportSubagentRuntimeWarning(parentSessionManager, warning); + if (!roster.found || roster.children.length === 0) return; + + let store: ParentOwnedSubagentSessionStore; + try { + store = new ParentOwnedSubagentSessionStore(parentSessionManager); + } catch (error) { + const message = `Persisted subagent storage is unavailable: ${error instanceof Error ? error.message : String(error)}`; + reportSubagentRuntimeWarning(parentSessionManager, message); + for (const child of roster.children) { + const name = child.kind === "restorable" ? child.snapshot.name : child.name; + controller.restoreIsolated(name, child.kind === "isolated" ? child.error : message, () => + Promise.reject(new Error(message)), + ); + } + return; + } + + for (const child of roster.children) { + if (child.kind === "isolated") { + controller.restoreIsolated(child.name, child.error, () => + createReplacementSubagentWake(parent, agentDir, store, child.name), + ); + continue; + } + + const snapshot = child.snapshot; + let historyError: string | undefined; + try { + store.validateChild(snapshot.name, snapshot.childSessionId, snapshot.childLeafId); + } catch (error) { + historyError = `Persisted subagent ${snapshot.name} history is unavailable: ${error instanceof Error ? error.message : String(error)}`; + reportSubagentRuntimeWarning(parentSessionManager, historyError); + } + controller.restore( + snapshot, + historyError + ? () => createReplacementSubagentWake(parent, agentDir, store, snapshot.name) + : () => prepareRestoredSubagentWake(parent, agentDir, store, snapshot), + historyError, + ); + } +} + /** * Create an AgentSession with the specified options. * @@ -534,6 +644,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } subagentController?.bindParent(session); + if (subagentController) restoreRootSubagents(subagentController, session, agentDir, sessionManager); const extensionsResult = resourceLoader.getExtensions(); return { diff --git a/packages/coding-agent/src/core/subagents/controller.ts b/packages/coding-agent/src/core/subagents/controller.ts index 4cc3bc02b..f520858a2 100644 --- a/packages/coding-agent/src/core/subagents/controller.ts +++ b/packages/coding-agent/src/core/subagents/controller.ts @@ -107,9 +107,20 @@ interface SubagentRecord { sessionManager?: CapturedSubagentSession["sessionManager"]; persistenceConfig?: CapturedSubagentSession["config"]; persistenceError?: string; + runtimeWarning?: string; + persistedChildSessionId?: string; + persistedChildLeafId?: string | null; + persistable?: boolean; + prepareWake?: PrepareSubagentWake; leafUnsubscribe?: () => void; } +export interface PreparedSubagentWake extends CapturedSubagentSession { + readonly warning?: string; +} + +export type PrepareSubagentWake = () => Promise; + function isTerminalStatus(status: SubagentStatus): status is SubagentTerminalStatus { return status === "idle" || status === "failed" || status === "interrupted"; } @@ -248,6 +259,51 @@ export class SubagentController { })); } + restore(snapshot: ChildSnapshotV1, prepareWake: PrepareSubagentWake, isolationError?: string): void { + this.#assertReservable(snapshot.name); + const interrupted = snapshot.status === "queued" || snapshot.status === "running"; + const isolated = isolationError !== undefined; + const timestamp = interrupted || isolated ? Date.now() : snapshot.updatedAt; + this.#records.set(snapshot.name, { + id: uuidv7(), + name: snapshot.name, + task: snapshot.task, + status: isolated ? "failed" : interrupted ? "interrupted" : snapshot.status, + createdAt: snapshot.createdAt, + updatedAt: timestamp, + ...(snapshot.startedAt === undefined ? {} : { startedAt: snapshot.startedAt }), + ...(interrupted || isolated + ? { completedAt: timestamp } + : snapshot.completedAt === undefined + ? {} + : { completedAt: snapshot.completedAt }), + ...(snapshot.result === undefined ? {} : { finalText: snapshot.result }), + ...((isolationError ?? snapshot.error) === undefined ? {} : { error: isolationError ?? snapshot.error }), + persistenceConfig: snapshot.config, + persistedChildSessionId: snapshot.childSessionId, + persistedChildLeafId: snapshot.childLeafId, + persistable: true, + prepareWake, + }); + } + + restoreIsolated(name: string, error: string, prepareWake: PrepareSubagentWake): void { + this.#assertReservable(name); + const timestamp = Date.now(); + this.#records.set(name, { + id: uuidv7(), + name, + task: "Persisted subagent restoration failed", + status: "failed", + createdAt: timestamp, + updatedAt: timestamp, + completedAt: timestamp, + error, + persistable: false, + prepareWake, + }); + } + spawn( name: string, task: string, @@ -832,9 +888,9 @@ export class SubagentController { status: agent.status, ...(record.coordinatedRun === undefined ? {} : { runId: record.coordinatedRun.id }), ...(record.finalText === undefined ? {} : { result: record.finalText }), - ...((record.error ?? record.persistenceError) === undefined + ...((record.error ?? record.persistenceError ?? record.runtimeWarning) === undefined ? {} - : { error: record.error ?? record.persistenceError }), + : { error: record.error ?? record.persistenceError ?? record.runtimeWarning }), }), ]; }); @@ -869,9 +925,9 @@ export class SubagentController { attempt: run.attempt, retryAvailable, ...(record.finalText === undefined ? {} : { result: record.finalText }), - ...((record.error ?? record.persistenceError) === undefined + ...((record.error ?? record.persistenceError ?? record.runtimeWarning) === undefined ? {} - : { error: record.error ?? record.persistenceError }), + : { error: record.error ?? record.persistenceError ?? record.runtimeWarning }), }); } @@ -879,40 +935,47 @@ export class SubagentController { const interruptionTimestamp = interruptActive ? Date.now() : undefined; return [...this.#records.values()] .sort((left, right) => left.name.localeCompare(right.name)) - .map((record) => { + .flatMap((record) => { + if (record.persistable === false) return []; const config = record.persistenceConfig; const sessionManager = record.sessionManager; - if (!config || !sessionManager) { + const childSessionId = sessionManager ? sessionManager.getSessionId() : record.persistedChildSessionId; + const childLeafId = sessionManager ? sessionManager.getLeafId() : record.persistedChildLeafId; + if (!config || !childSessionId || childLeafId === undefined) { throw new Error(`Persistent subagent state is unavailable: ${record.name}`); } const interrupted = interruptActive && (record.status === "queued" || record.status === "running"); const snapshotTimestamp = interruptionTimestamp ?? record.updatedAt; const error = record.error ?? record.persistenceError; - return { - name: record.name, - task: record.task, - status: interrupted ? "interrupted" : record.status, - createdAt: record.createdAt, - updatedAt: interrupted ? snapshotTimestamp : record.updatedAt, - ...(record.startedAt === undefined ? {} : { startedAt: record.startedAt }), - ...(interrupted - ? { completedAt: snapshotTimestamp } - : record.completedAt === undefined - ? {} - : { completedAt: record.completedAt }), - ...(record.finalText === undefined ? {} : { result: record.finalText }), - ...(error === undefined ? {} : { error }), - childSessionId: sessionManager.getSessionId(), - childLeafId: sessionManager.getLeafId(), - config, - }; + return [ + { + name: record.name, + task: record.task, + status: interrupted ? "interrupted" : record.status, + createdAt: record.createdAt, + updatedAt: interrupted ? snapshotTimestamp : record.updatedAt, + ...(record.startedAt === undefined ? {} : { startedAt: record.startedAt }), + ...(interrupted + ? { completedAt: snapshotTimestamp } + : record.completedAt === undefined + ? {} + : { completedAt: record.completedAt }), + ...(record.finalText === undefined ? {} : { result: record.finalText }), + ...(error === undefined ? {} : { error }), + childSessionId, + childLeafId, + config, + }, + ]; }); } #persistRosterBestEffort(changedName?: string, interruptActive = false): void { if (!this.#persistRoster || this.#records.size === 0) return; try { - this.#persistRoster(this.#createPersistenceSnapshot(interruptActive), false); + const snapshot = this.#createPersistenceSnapshot(interruptActive); + if (snapshot.length === 0) return; + this.#persistRoster(snapshot, false); } catch (error) { const message = `Subagent roster persistence failed: ${normalizeError(error)}`; if (changedName) { @@ -1082,6 +1145,18 @@ export class SubagentController { if (record.status === "queued") { throw new Error(`Cannot message queued subagent: ${name}`); } + if (!record.session && record.prepareWake) { + if (!wake) throw new Error(`Cannot message restored subagent without wake: ${name}`); + const prepared = await record.prepareWake(); + record.createSession = prepared.createSession; + record.sessionManager = prepared.sessionManager; + record.persistenceConfig = prepared.config; + record.persistedChildSessionId = prepared.sessionManager.getSessionId(); + record.persistedChildLeafId = prepared.sessionManager.getLeafId(); + record.persistable = true; + record.runtimeWarning = prepared.warning; + record.prepareWake = undefined; + } if (record.status === "failed" || record.status === "interrupted") { if (!wake) { throw new Error(`Cannot message ${record.status} subagent without wake: ${name}`); @@ -1098,17 +1173,17 @@ export class SubagentController { this.#scheduleRun(record, message); return { target: name, action: "message", status: "queued", delivery: "prompt" }; } + if (record.status === "idle" && wake) { + record.coordinatedRun = undefined; + this.#scheduleRun(record, message); + return { target: name, action: "message", status: "queued", delivery: "prompt" }; + } const session = record.session; if (!session) { throw new Error(`Subagent session is unavailable: ${name}`); } if (record.status === "idle") { - if (wake) { - record.coordinatedRun = undefined; - this.#scheduleRun(record, message); - return { target: name, action: "message", status: "queued", delivery: "prompt" }; - } await session.followUp(message); return { target: name, action: "message", status: "idle", delivery: "follow_up" }; } diff --git a/packages/coding-agent/src/core/subagents/persistence.ts b/packages/coding-agent/src/core/subagents/persistence.ts index 35b4cfb47..7783da3ad 100644 --- a/packages/coding-agent/src/core/subagents/persistence.ts +++ b/packages/coding-agent/src/core/subagents/persistence.ts @@ -346,7 +346,7 @@ export class ParentOwnedSubagentSessionStore { return child; } - openChild(childName: string, childSessionId: string, childLeafId: string | null): SessionManager { + #validateChildFile(childName: string, childSessionId: string, childLeafId: string | null): string { if (!SUBAGENT_NAME_PATTERN.test(childName)) throw new Error(`Invalid subagent name: ${childName}`); assertValidChildSessionId(childSessionId); if (childLeafId !== null && !isNonEmptyString(childLeafId)) { @@ -380,7 +380,15 @@ export class ParentOwnedSubagentSessionStore { if (childLeafId !== null && !entries.some((entry) => entry.type !== "session" && entry.id === childLeafId)) { throw new Error(`Persisted child leaf ${childLeafId} is unavailable for ${childName}`); } + return childFile; + } + validateChild(childName: string, childSessionId: string, childLeafId: string | null): void { + this.#validateChildFile(childName, childSessionId, childLeafId); + } + + openChild(childName: string, childSessionId: string, childLeafId: string | null): SessionManager { + const childFile = this.#validateChildFile(childName, childSessionId, childLeafId); const child = SessionManager.open(childFile, this.#ownedDirectory, this.#parent.getCwd()); if (childLeafId === null) child.resetLeaf(); else child.branch(childLeafId); diff --git a/packages/coding-agent/src/core/subagents/runtime-diagnostics.ts b/packages/coding-agent/src/core/subagents/runtime-diagnostics.ts new file mode 100644 index 000000000..a8ef8997e --- /dev/null +++ b/packages/coding-agent/src/core/subagents/runtime-diagnostics.ts @@ -0,0 +1,15 @@ +import type { AgentSessionRuntimeDiagnostic } from "../agent-session-services.ts"; +import type { SessionManager } from "../session-manager.ts"; + +const diagnosticSinks = new WeakMap(); + +export function registerSubagentRuntimeDiagnosticSink( + sessionManager: SessionManager, + diagnostics: AgentSessionRuntimeDiagnostic[], +): void { + diagnosticSinks.set(sessionManager, diagnostics); +} + +export function reportSubagentRuntimeWarning(sessionManager: SessionManager, message: string): void { + diagnosticSinks.get(sessionManager)?.push({ type: "warning", message }); +} diff --git a/packages/coding-agent/src/core/subagents/services.ts b/packages/coding-agent/src/core/subagents/services.ts index 628cac3cb..02d4c136f 100644 --- a/packages/coding-agent/src/core/subagents/services.ts +++ b/packages/coding-agent/src/core/subagents/services.ts @@ -1,5 +1,6 @@ import type { TelemetryContext, ThinkingLevel } from "@earendil-works/diffex-agent-core"; import type { Model } from "@earendil-works/diffex-ai"; +import { clampThinkingLevel } from "@earendil-works/diffex-ai/compat"; import type { HarnessStateStore, HarnessVersionBundle } from "@earendil-works/diffex-harness-state"; import type { AgentSession } from "../agent-session.ts"; import { @@ -15,7 +16,8 @@ import { RootLocalEphemeralSubagentSessionManagerFactory, type SubagentSessionManagerFactory, } from "./session-manager-factory.ts"; -import type { SubagentSpawnOrigin } from "./types.ts"; +import { markFixedHarnessSubagentSession } from "./session-runtime.ts"; +import type { CapturedSubagentConfig, SubagentSpawnOrigin } from "./types.ts"; export interface CreateSubagentServicesOptions { cwd: string; @@ -50,6 +52,12 @@ export interface CreateSubagentSessionOptions { readonly sessionManager?: SessionManager; } +export interface ResolvedRestoredSubagentConfig { + readonly sessionConfig: SubagentSessionConfig; + readonly persistedConfig: CapturedSubagentConfig; + readonly warning?: string; +} + const SUBAGENT_SYSTEM_PROMPT = [ "You are a subagent working on a focused task delegated by a parent agent.", "Work independently in the shared working directory and return a concise, complete result to the parent.", @@ -109,6 +117,73 @@ export function captureSubagentSessionConfig(parent: AgentSession, agentDir: str }); } +export function capturePersistedSubagentConfig(config: SubagentSessionConfig): CapturedSubagentConfig { + return Object.freeze({ + model: Object.freeze({ provider: config.model.provider, id: config.model.id }), + thinkingLevel: config.thinkingLevel, + tools: config.tools, + harnessRevision: config.harnessVersionBundle?.manifest.version ?? null, + }); +} + +export async function resolveRestoredSubagentConfig( + parent: AgentSession, + agentDir: string, + captured: CapturedSubagentConfig, + childName: string, +): Promise { + const current = captureSubagentSessionConfig(parent, agentDir); + const unavailable: string[] = []; + const model = current.modelRuntime.getModel(captured.model.provider, captured.model.id); + if (!model) unavailable.push(`model ${captured.model.provider}/${captured.model.id} is unavailable`); + else if (clampThinkingLevel(model, captured.thinkingLevel) !== captured.thinkingLevel) { + unavailable.push(`thinking level ${captured.thinkingLevel} is unavailable`); + } + + const availableTools = new Set( + parent + .getAllTools() + .filter((tool) => tool.sourceInfo.source === "builtin") + .map((tool) => tool.name), + ); + const missingTools = captured.tools.filter((tool) => !availableTools.has(tool)); + if (missingTools.length > 0) unavailable.push(`tools ${missingTools.join(", ")} are unavailable`); + + let harnessVersionBundle: HarnessVersionBundle | undefined; + if (captured.harnessRevision !== null) { + if (current.harnessVersionBundle?.manifest.version === captured.harnessRevision) { + harnessVersionBundle = current.harnessVersionBundle; + } else { + try { + const listing = await current.harnessStateStore?.listVersions(); + harnessVersionBundle = listing?.versions.find( + (bundle) => bundle.manifest.version === captured.harnessRevision, + ); + } catch { + // The complete current parent configuration is selected below. + } + if (!harnessVersionBundle) unavailable.push(`harness revision ${captured.harnessRevision} is unavailable`); + } + } + + if (!model || unavailable.length > 0) { + return { + sessionConfig: current, + persistedConfig: capturePersistedSubagentConfig(current), + warning: `Persisted subagent ${childName} configuration is unusable (${unavailable.join("; ")}); using the complete current parent configuration`, + }; + } + + const sessionConfig = Object.freeze({ + ...current, + model, + thinkingLevel: captured.thinkingLevel, + tools: captured.tools, + harnessVersionBundle, + }); + return { sessionConfig, persistedConfig: captured }; +} + export async function createSubagentSession( config: SubagentSessionConfig, options: CreateSubagentSessionOptions = {}, @@ -136,6 +211,7 @@ export async function createSubagentSession( origin: options.origin ?? Object.freeze({ type: "programmatic", contextEntryId: null }), }), ); + markFixedHarnessSubagentSession(sessionManager); const { session } = await createAgentSessionFromServices({ services, sessionManager, diff --git a/packages/coding-agent/src/core/subagents/session-runtime.ts b/packages/coding-agent/src/core/subagents/session-runtime.ts new file mode 100644 index 000000000..27f867f2a --- /dev/null +++ b/packages/coding-agent/src/core/subagents/session-runtime.ts @@ -0,0 +1,11 @@ +import type { SessionManager } from "../session-manager.ts"; + +const fixedHarnessSessions = new WeakSet(); + +export function markFixedHarnessSubagentSession(sessionManager: SessionManager): void { + fixedHarnessSessions.add(sessionManager); +} + +export function hasFixedHarnessSubagentSession(sessionManager: SessionManager): boolean { + return fixedHarnessSessions.has(sessionManager); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 275050789..897f0b56f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -817,15 +817,15 @@ export async function main(args: string[], options?: MainOptions) { }, }); const { settingsManager, modelRuntime, resourceLoader } = services; - const diagnostics: AgentSessionRuntimeDiagnostic[] = [ - ...projectTrustDiagnostics, - ...services.diagnostics, + const diagnostics = services.diagnostics; + diagnostics.unshift(...projectTrustDiagnostics); + diagnostics.push( ...collectSettingsDiagnostics(settingsManager, "runtime creation"), ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ type: "error" as const, message: `Failed to load extension "${path}": ${error}`, })), - ]; + ); const modelPatterns = parsed.models ?? settingsManager.getEnabledModels(); const scopedModels = diff --git a/packages/coding-agent/test/suite/agent-session-runtime.test.ts b/packages/coding-agent/test/suite/agent-session-runtime.test.ts index 51c8151e9..6c25d3d83 100644 --- a/packages/coding-agent/test/suite/agent-session-runtime.test.ts +++ b/packages/coding-agent/test/suite/agent-session-runtime.test.ts @@ -297,13 +297,25 @@ describe("AgentSessionRuntime characterization", () => { const nextAgents = runtime.session.getToolDefinition("agents")!; await expect( - nextAgents.execute("list-empty", { timeout_ms: 0 }, undefined, undefined, extensionContext), - ).resolves.toMatchObject({ details: { timed_out: false, agents: [] } }); + nextAgents.execute("list-replacement", { timeout_ms: 0 }, undefined, undefined, extensionContext), + ).resolves.toMatchObject({ + details: { + timed_out: false, + agents: + flow === "import" + ? [ + { name: "queued", status: "interrupted" }, + { name: "worker", status: "interrupted" }, + ] + : [], + }, + }); faux.setResponses([fauxAssistantMessage("replacement child done")]); const nextSpawn = runtime.session.getToolDefinition("spawn_agent")!; + const replacementName = flow === "import" ? "replacement" : "worker"; await nextSpawn.execute( "reuse-worker", - { name: "worker", task: "again" }, + { name: replacementName, task: "again" }, undefined, undefined, extensionContext, @@ -311,13 +323,13 @@ describe("AgentSessionRuntime characterization", () => { await expect( nextAgents.execute( "wait-worker", - { timeout_ms: 1_000, targets: ["worker"], until: "all" }, + { timeout_ms: 1_000, targets: [replacementName], until: "all" }, undefined, undefined, extensionContext, ), ).resolves.toMatchObject({ - details: { timed_out: false, agents: [{ name: "worker", status: "idle" }] }, + details: { timed_out: false, agents: [{ name: replacementName, status: "idle" }] }, }); }, ); diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts index 705d89f4b..7bfffca45 100644 --- a/packages/coding-agent/test/suite/subagent-persistence.test.ts +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -1,4 +1,5 @@ import { + copyFileSync, existsSync, mkdirSync, mkdtempSync, @@ -12,10 +13,16 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; 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 { createAgentSessionFromServices, createAgentSessionServices } from "../../src/core/agent-session-services.ts"; -import { loadEntriesFromFile, persistSessionHeader, SessionManager } from "../../src/core/session-manager.ts"; +import { + loadEntriesFromFile, + persistSessionHeader, + persistSessionState, + SessionManager, +} from "../../src/core/session-manager.ts"; import { type ChildSnapshotV1, decodeSubagentRosterSnapshot, @@ -25,10 +32,12 @@ import { SUBAGENT_ROSTER_CUSTOM_TYPE, } from "../../src/core/subagents/persistence.ts"; import { createHarness, type Harness } from "./harness.ts"; -import { deferred } from "./subagent-harness.ts"; +import { deferred, getLastUserText } from "./subagent-harness.ts"; const CHILD_ID_A = "018f4f7c-43b7-7abc-8def-0123456789ab"; const CHILD_ID_B = "018f4f7c-43b7-7abc-8def-0123456789ac"; +const CHILD_ID_C = "018f4f7c-43b7-7abc-8def-0123456789ad"; +const CHILD_ID_D = "018f4f7c-43b7-7abc-8def-0123456789ae"; function childSnapshot(overrides: Partial = {}): ChildSnapshotV1 { return { @@ -232,9 +241,18 @@ describe("durable subagent lifecycle", () => { while (harnesses.length > 0) await harnesses.pop()?.cleanup(); }); - async function createRoot(options: { persist?: boolean; subagents?: false } = {}) { + async function createRoot( + options: { + persist?: boolean; + subagents?: false; + models?: FauxModelDefinition[]; + modelId?: string; + thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + tools?: string[]; + } = {}, + ) { const harness = await createHarness({ - models: [{ id: "faux-lifecycle", name: "Lifecycle", reasoning: true }], + models: options.models ?? [{ id: "faux-lifecycle", name: "Lifecycle", reasoning: true }], settings: { retry: { enabled: false } }, }); harnesses.push(harness); @@ -261,12 +279,44 @@ describe("durable subagent lifecycle", () => { const { session } = await createAgentSessionFromServices({ services, sessionManager, - model: harness.getModel("faux-lifecycle"), - thinkingLevel: "high", + model: harness.getModel(options.modelId ?? "faux-lifecycle"), + thinkingLevel: options.thinkingLevel ?? "high", + tools: options.tools, subagents: options.subagents, }); sessions.push(session); - return { harness, session, sessionManager, cwd, sessionDir }; + return { harness, services, session, sessionManager, cwd, agentDir, sessionDir }; + } + + async function reopenRoot( + root: Awaited>, + options: { modelId?: string; thinkingLevel?: "low" | "high"; tools?: string[] } = {}, + ) { + const sessionFile = root.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Missing parent session file"); + const services = await createAgentSessionServices({ + cwd: root.cwd, + agentDir: root.agentDir, + modelRuntime: root.harness.session.modelRuntime, + settingsManager: root.harness.settingsManager, + resourceLoaderOptions: { + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + reloadSettings: false, + }, + }); + const sessionManager = SessionManager.open(sessionFile, root.sessionDir, root.cwd); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager, + model: root.harness.getModel(options.modelId ?? "faux-lifecycle"), + thinkingLevel: options.thinkingLevel ?? "high", + tools: options.tools, + }); + sessions.push(session); + return { ...root, services, session, sessionManager }; } function getTool(session: AgentSession, name: string): AgentTool { @@ -283,6 +333,20 @@ describe("durable subagent lifecycle", () => { }); } + function persistRoster(sessionManager: SessionManager, agents: unknown[]): void { + sessionManager.appendCustomEntry(SUBAGENT_ROSTER_CUSTOM_TYPE, { schemaVersion: 1, agents }); + persistSessionState(sessionManager); + } + + function persistChild( + root: Awaited>, + config: ChildSnapshotV1["config"], + name = "worker", + ): void { + new ParentOwnedSubagentSessionStore(root.sessionManager).createChild(name, CHILD_ID_A); + persistRoster(root.sessionManager, [childSnapshot({ name, childSessionId: CHILD_ID_A, config })]); + } + 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(); @@ -464,6 +528,374 @@ describe("durable subagent lifecycle", () => { expect(sessionManager.getLeafId()).toBe(previousLeafId); }); + 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(); + if (!sessionFile) throw new Error("Missing parent session file"); + const store = new ParentOwnedSubagentSessionStore(root.sessionManager); + store.createChild("alpha", CHILD_ID_A); + store.createChild("beta", CHILD_ID_B); + const corrupt = store.createChild("corrupt", CHILD_ID_C); + const corruptFile = corrupt.getSessionFile(); + if (!corruptFile) throw new Error("Missing corrupt child file"); + const corruptHeader = JSON.parse(readFileSync(corruptFile, "utf8")) as Record; + corruptHeader.parentSession = "another-parent"; + writeFileSync(corruptFile, `${JSON.stringify(corruptHeader)}\n`); + const config = { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "high" as const, + tools: ["read"] as const, + harnessRevision: root.session.activeHarnessVersion ?? null, + }; + persistRoster(root.sessionManager, [ + childSnapshot({ name: "alpha", childSessionId: CHILD_ID_A, config }), + childSnapshot({ + name: "beta", + childSessionId: CHILD_ID_B, + status: "running", + result: undefined, + completedAt: undefined, + config, + }), + childSnapshot({ name: "corrupt", childSessionId: CHILD_ID_C, config }), + childSnapshot({ name: "missing", childSessionId: CHILD_ID_D, config }), + ]); + await root.session.dispose(); + + let providerCalls = 0; + const seenPrompts: string[] = []; + root.harness.setResponses([ + (context) => { + providerCalls += 1; + seenPrompts.push(getLastUserText(context)); + return fauxAssistantMessage("continued"); + }, + (context) => { + providerCalls += 1; + seenPrompts.push(getLastUserText(context)); + return fauxAssistantMessage("recovered"); + }, + ]); + const openChild = vi.spyOn(ParentOwnedSubagentSessionStore.prototype, "openChild"); + const reopened = await reopenRoot(root); + expect(providerCalls).toBe(0); + expect(openChild).not.toHaveBeenCalled(); + expect(reopened.session.messages).toEqual([]); + expect( + reopened.sessionManager + .getEntries() + .filter((entry) => entry.type === "message" || entry.type === "custom_message"), + ).toEqual([]); + await expect(getTool(reopened.session, "agents").execute("restored", { timeout_ms: 0 })).resolves.toMatchObject({ + details: { + agents: [ + { name: "alpha", status: "idle", result: "done" }, + { name: "beta", status: "interrupted" }, + { name: "corrupt", status: "failed", error: expect.stringContaining("identity mismatch") }, + { name: "missing", status: "failed", error: expect.stringContaining("found 0") }, + ], + }, + }); + expect(reopened.services.diagnostics).toEqual( + expect.arrayContaining([ + { type: "warning", message: expect.stringContaining("corrupt history is unavailable") }, + { type: "warning", message: expect.stringContaining("missing history is unavailable") }, + ]), + ); + + await getTool(reopened.session, "agent_action").execute("wake-valid", { + target: "alpha", + action: "message", + message: "continue valid", + wake: true, + }); + await getTool(reopened.session, "agents").execute("wait-valid", { + timeout_ms: 1_000, + targets: ["alpha"], + until: "all", + }); + expect(openChild).toHaveBeenCalledTimes(1); + + await getTool(reopened.session, "agent_action").execute("wake-isolated", { + target: "corrupt", + action: "message", + message: "recovery only", + wake: true, + }); + await getTool(reopened.session, "agents").execute("wait-isolated", { + timeout_ms: 1_000, + targets: ["corrupt"], + until: "all", + }); + expect(providerCalls).toBe(2); + expect(seenPrompts).toEqual(["continue valid", "recovery only"]); + const latest = readSnapshots(sessionFile).at(-1) ?? []; + expect(latest.find((entry) => entry.name === "corrupt")?.childSessionId).not.toBe(CHILD_ID_C); + expect(existsSync(corruptFile)).toBe(true); + }); + + it("isolates duplicate persisted names without blocking a valid sibling", async () => { + const root = await createRoot(); + new ParentOwnedSubagentSessionStore(root.sessionManager).createChild("alpha", CHILD_ID_A); + persistRoster(root.sessionManager, [ + childSnapshot({ config: { ...childSnapshot().config, model: { provider: "faux", id: "faux-lifecycle" } } }), + childSnapshot({ name: "duplicate", childSessionId: CHILD_ID_B }), + childSnapshot({ name: "duplicate", childSessionId: CHILD_ID_C }), + ]); + await root.session.dispose(); + const reopened = await reopenRoot(root); + await expect(getTool(reopened.session, "agents").execute("duplicates", { timeout_ms: 0 })).resolves.toMatchObject( + { + details: { + agents: [ + { name: "alpha", status: "idle" }, + { name: "duplicate", status: "failed", error: "Duplicate persisted subagent name: duplicate" }, + ], + }, + }, + ); + expect(reopened.services.diagnostics).toContainEqual({ + type: "warning", + message: "Duplicate persisted subagent name: duplicate", + }); + }); + + it("uses the complete captured configuration on wake without treating authentication as fallback", async () => { + const root = await createRoot({ + models: [ + { id: "captured-model", name: "Captured", reasoning: true }, + { id: "current-model", name: "Current", reasoning: true }, + ], + modelId: "captured-model", + }); + const captured = { + model: { provider: "faux", id: "captured-model" }, + thinkingLevel: "high" as const, + tools: ["grep"] as const, + harnessRevision: null, + }; + persistChild(root, captured); + await root.session.dispose(); + const reopened = await reopenRoot(root, { modelId: "current-model", thinkingLevel: "low" }); + reopened.session.setActiveToolsByName(["read", "agent_action", "agents"]); + vi.spyOn(reopened.session.modelRuntime, "hasConfiguredAuth").mockImplementation(() => { + throw new Error("authentication must not participate in restoration fallback"); + }); + root.harness.setResponses([fauxAssistantMessage("captured configuration used")]); + + await getTool(reopened.session, "agent_action").execute("captured", { + target: "worker", + action: "message", + message: "wake captured", + wake: true, + }); + await getTool(reopened.session, "agents").execute("captured-wait", { + timeout_ms: 1_000, + targets: ["worker"], + until: "all", + }); + const sessionFile = reopened.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Missing parent session file"); + expect(readSnapshots(sessionFile).at(-1)?.[0]?.config).toEqual(captured); + expect( + reopened.services.diagnostics.filter((entry) => entry.message.includes("configuration is unusable")), + ).toEqual([]); + }); + + it("keeps a loadable captured harness revision active for the provider request", async () => { + const root = await createRoot(); + const store = root.session.harnessStateStore; + const capturedRevision = root.session.activeHarnessVersion; + if (!store || !capturedRevision) throw new Error("Missing initialized harness state"); + persistChild(root, { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "high", + tools: ["read"], + harnessRevision: capturedRevision, + }); + await root.session.dispose(); + const advanced = await store.advance({ memory: "Use only the newer parent memory." }, capturedRevision); + expect(advanced.status).toBe("advanced"); + if (advanced.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + const providerPrompts: string[] = []; + root.harness.setResponses([ + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("captured harness used"); + }, + ]); + const reopened = await reopenRoot(root); + expect(reopened.session.activeHarnessVersion).toBe(advanced.bundle.manifest.version); + + await getTool(reopened.session, "agent_action").execute("captured-harness", { + target: "worker", + action: "message", + message: "wake captured harness", + wake: true, + }); + await getTool(reopened.session, "agents").execute("captured-harness-wait", { + timeout_ms: 1_000, + targets: ["worker"], + until: "all", + }); + + expect(providerPrompts).toHaveLength(1); + expect(providerPrompts[0]).not.toContain("Use only the newer parent memory."); + const sessionFile = reopened.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Missing parent session file"); + expect(readSnapshots(sessionFile).at(-1)?.[0]?.config.harnessRevision).toBe(capturedRevision); + }); + + it("falls back to the complete wake-time parent configuration with one diagnostic", async () => { + const cases: Array<{ + name: string; + config: ChildSnapshotV1["config"]; + removeTool?: string; + }> = [ + { + name: "model", + config: { + model: { provider: "faux", id: "missing-model" }, + thinkingLevel: "high", + tools: ["read"], + harnessRevision: null, + }, + }, + { + name: "thinking", + config: { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "max", + tools: ["read"], + harnessRevision: null, + }, + }, + { + name: "tool", + config: { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "high", + tools: ["grep"], + harnessRevision: null, + }, + removeTool: "grep", + }, + { + name: "harness", + config: { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "high", + tools: ["read"], + harnessRevision: `sha256:${"f".repeat(64)}`, + }, + }, + ]; + + for (const testCase of cases) { + const root = await createRoot(); + persistChild(root, testCase.config); + await root.session.dispose(); + const reopened = await reopenRoot(root); + reopened.session.setThinkingLevel("low"); + reopened.session.setActiveToolsByName(["read", "agent_action", "agents"]); + const availableTools = reopened.session.getAllTools(); + const toolSpy = testCase.removeTool + ? vi + .spyOn(reopened.session, "getAllTools") + .mockReturnValue(availableTools.filter((tool) => tool.name !== testCase.removeTool)) + : undefined; + root.harness.setResponses([fauxAssistantMessage(`fallback ${testCase.name}`)]); + const diagnosticCount = reopened.services.diagnostics.length; + + await getTool(reopened.session, "agent_action").execute(`fallback-${testCase.name}`, { + target: "worker", + action: "message", + message: `wake ${testCase.name}`, + wake: true, + }); + await getTool(reopened.session, "agents").execute(`fallback-wait-${testCase.name}`, { + timeout_ms: 1_000, + targets: ["worker"], + until: "all", + }); + toolSpy?.mockRestore(); + + const addedDiagnostics = reopened.services.diagnostics.slice(diagnosticCount); + expect(addedDiagnostics, testCase.name).toHaveLength(1); + expect(addedDiagnostics[0]?.message).toContain("configuration is unusable"); + const sessionFile = reopened.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Missing parent session file"); + expect(readSnapshots(sessionFile).at(-1)?.[0]?.config, testCase.name).toEqual({ + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "low", + tools: ["read"], + harnessRevision: reopened.session.activeHarnessVersion ?? null, + }); + await expect( + getTool(reopened.session, "agents").execute(`fallback-result-${testCase.name}`, { + timeout_ms: 0, + targets: ["worker"], + }), + ).resolves.toMatchObject({ + details: { agents: [{ status: "idle", error: expect.stringContaining("configuration is unusable") }] }, + }); + } + }); + + it("isolates unavailable child histories in forked and imported parent files", async () => { + const root = await createRoot(); + persistChild(root, { + model: { provider: "faux", id: "faux-lifecycle" }, + thinkingLevel: "high", + tools: ["read"], + harnessRevision: null, + }); + await root.session.dispose(); + const sourceFile = root.sessionManager.getSessionFile(); + if (!sourceFile) throw new Error("Missing source parent session file"); + const forkDirectory = join(root.harness.tempDir, "fork-sessions"); + const importDirectory = join(root.harness.tempDir, "import-sessions"); + mkdirSync(forkDirectory, { recursive: true }); + mkdirSync(importDirectory, { recursive: true }); + const fork = SessionManager.forkFrom(sourceFile, root.cwd, forkDirectory); + const importedFile = join(importDirectory, "imported.jsonl"); + copyFileSync(sourceFile, importedFile); + const imported = SessionManager.open(importedFile, importDirectory, root.cwd); + + for (const sessionManager of [fork, imported]) { + const services = await createAgentSessionServices({ + cwd: root.cwd, + agentDir: root.agentDir, + modelRuntime: root.harness.session.modelRuntime, + settingsManager: root.harness.settingsManager, + resourceLoaderOptions: { + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + reloadSettings: false, + }, + }); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager, + model: root.harness.getModel("faux-lifecycle"), + thinkingLevel: "high", + }); + sessions.push(session); + await expect(getTool(session, "agents").execute("unavailable", { timeout_ms: 0 })).resolves.toMatchObject({ + details: { + agents: [{ name: "worker", status: "failed", error: expect.stringContaining("history is unavailable") }], + }, + }); + expect(services.diagnostics).toContainEqual({ + type: "warning", + message: expect.stringContaining("worker history is unavailable"), + }); + } + }); + it("keeps in-memory, disabled, and unsaved modes ephemeral", async () => { const inMemory = await createRoot({ persist: false }); inMemory.harness.setResponses([fauxAssistantMessage("done")]);