174 lines
7.5 KiB
TypeScript
174 lines
7.5 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import type { ModelRegistry } from "../config/model-registry";
|
|
import { formatModelRoleAlias } from "../config/model-roles";
|
|
import type { Settings } from "../config/settings";
|
|
import { MCPManager } from "../mcp/manager";
|
|
import type { PersistedSubagentReviverFactory } from "../registry/agent-lifecycle";
|
|
import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
import { createAgentSession } from "../sdk";
|
|
import type { AgentSession } from "../session/agent-session";
|
|
import type { AuthStorage } from "../session/auth-storage";
|
|
import { SessionManager } from "../session/session-manager";
|
|
import type { EventBus } from "../utils/event-bus";
|
|
import { attachIrcWakeTurnMonitor, createMCPProxyTools, createSubagentSettings } from "./executor";
|
|
import type { AgentDefinition } from "./types";
|
|
|
|
/**
|
|
* Ambient context the reviver needs at revive time. The top-level session is
|
|
* kept LIVE (cwd / artifact manager read on demand) so a later `/new` or cwd
|
|
* move is followed rather than snapshotted; auth/models/settings are
|
|
* process-stable and captured by reference.
|
|
*/
|
|
export interface PersistedSubagentReviveContext {
|
|
session: AgentSession;
|
|
authStorage: AuthStorage;
|
|
modelRegistry: ModelRegistry;
|
|
settings: Settings;
|
|
/** LSP policy of the top-level session; revived subagents inherit it rather than defaulting on. */
|
|
enableLsp: boolean;
|
|
/**
|
|
* Shared event bus feeding RPC/collab subagent subscriptions. Passed through
|
|
* to the wake-turn monitor so an IRC send to a cold-revived subagent emits
|
|
* the same lifecycle/progress frames a live run does.
|
|
*/
|
|
eventBus?: EventBus;
|
|
}
|
|
|
|
/**
|
|
* Build the factory the {@link AgentLifecycleManager} uses to cold-revive a
|
|
* `parked` subagent ref restored from disk (Agent Hub scan, collab mirror, or a
|
|
* resumed process). Such a ref carries a sessionFile but no in-memory adoption —
|
|
* the executor's live reviver closure died with the process/turn that spawned
|
|
* it — so `ensureLive` (IRC sends, hub focus) would otherwise refuse it.
|
|
*
|
|
* This rebuilds the subagent the same way `--resume` rebuilds a session: reopen
|
|
* the JSONL and replay it through {@link createAgentSession}. The catch is that
|
|
* resume restores only conversation/model from the file — the runtime contract
|
|
* (tools / system prompt / output schema / kind) is built from options, so a
|
|
* bare reopen would resurrect a wrong (top-level) session. We source that
|
|
* contract from the persisted `session_init` entry instead, and mirror the
|
|
* executor's subagent wiring (MCP proxy tools, depth-derived gating,
|
|
* yield-required, active-tool clamp, registry status sync).
|
|
*/
|
|
export function createPersistedSubagentReviverFactory(
|
|
ctx: PersistedSubagentReviveContext,
|
|
): PersistedSubagentReviverFactory {
|
|
const registry = AgentRegistry.global();
|
|
return async ref => {
|
|
const sessionFile = ref.sessionFile;
|
|
if (!sessionFile) return undefined;
|
|
const peek = await SessionManager.peekSessionInit(sessionFile);
|
|
// No persisted contract (pre-session_init file) or the recorded workspace
|
|
// is gone (isolated/merged worktree, moved dir): leave it transcript-only
|
|
// (history://) rather than resurrect a wrong or broken session.
|
|
if (!peek?.init) return undefined;
|
|
try {
|
|
await fs.stat(peek.cwd);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
const init = peek.init;
|
|
// taskDepth drives real capability gating (task-spawn allowance, memory
|
|
// startup, …); derive it from the persisted parent chain rather than
|
|
// assuming a fixed level.
|
|
let taskDepth = 1;
|
|
let parentId = ref.parentId;
|
|
const seen = new Set<string>();
|
|
while (parentId && parentId !== MAIN_AGENT_ID && !seen.has(parentId)) {
|
|
seen.add(parentId);
|
|
taskDepth++;
|
|
parentId = registry.get(parentId)?.parentId;
|
|
}
|
|
const subagentSettings = createSubagentSettings(
|
|
ctx.settings,
|
|
init.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
|
|
);
|
|
const persistedModelPattern =
|
|
init.modelRole && init.modelRole !== "default"
|
|
? [formatModelRoleAlias(init.modelRole), ...(init.resolvedModel ? [init.resolvedModel] : [])]
|
|
: init.resolvedModel;
|
|
return async expectedRef => {
|
|
// Re-open fresh on every revive: park closes the writer, so this takes
|
|
// the single-writer lock cleanly and restores the full message history.
|
|
const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
|
|
suppressBreadcrumb: true,
|
|
});
|
|
const artifactManager = ctx.session.sessionManager.getArtifactManager();
|
|
if (artifactManager) reopened.adoptArtifactManager(artifactManager);
|
|
// A restricted persisted contract must not consult process-global MCP
|
|
// state: same-name MCP tools are untrusted capability sources.
|
|
const restrictToolNames = init.restrictToolNames === true;
|
|
const mcpManager = restrictToolNames ? undefined : MCPManager.instance();
|
|
const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : [];
|
|
const { session } = await createAgentSession({
|
|
cwd: ctx.session.sessionManager.getCwd(),
|
|
authStorage: ctx.authStorage,
|
|
modelRegistry: ctx.modelRegistry,
|
|
...(persistedModelPattern ? { modelPattern: persistedModelPattern } : {}),
|
|
modelPatternAuthFallback: init.resolvedModel,
|
|
settings: subagentSettings,
|
|
sessionManager: reopened,
|
|
agentId: ref.id,
|
|
agentDisplayName: ref.displayName,
|
|
parentTaskPrefix: ref.id,
|
|
parentAgentId: ref.parentId,
|
|
expectedAgentRef: expectedRef,
|
|
taskDepth,
|
|
toolNames: init.tools,
|
|
outputSchema: init.outputSchema,
|
|
outputSchemaMode: init.outputSchemaMode,
|
|
restrictToolNames: restrictToolNames || undefined,
|
|
requireYieldTool: true,
|
|
systemPrompt: () => [init.systemPrompt],
|
|
// Old files predate persisted spawns: deny re-spawning rather than let
|
|
// createAgentSession default to wildcard ("*").
|
|
spawns: init.spawns ?? "",
|
|
hasUI: false,
|
|
enableLsp: restrictToolNames ? false : ctx.enableLsp,
|
|
...(restrictToolNames
|
|
? {
|
|
enableIrc: false,
|
|
enableMCP: false,
|
|
preloadedExtensionPaths: [],
|
|
preloadedCustomToolPaths: [],
|
|
}
|
|
: {
|
|
enableMCP: !mcpManager,
|
|
mcpManager,
|
|
customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
|
|
}),
|
|
});
|
|
// Clamp the active set to the persisted list: createAgentSession's
|
|
// `alwaysInclude` can re-add non-defaultInactive extension/custom tools
|
|
// the original run didn't carry. Unknown/missing names are ignored.
|
|
await session.setActiveToolsByName([...init.tools, ...session.getMountedXdevToolNames()]);
|
|
// Cold revives must drive registry status themselves — createAgentSession
|
|
// doesn't wire this generically (the live path does it in the executor).
|
|
// Without it the idle-TTL timer never clears on a turn and the lifecycle
|
|
// could park the agent mid-run.
|
|
session.subscribe(event => {
|
|
if (event.type === "agent_start") registry.setStatus(ref.id, "running", session);
|
|
else if (event.type === "agent_end") registry.setStatus(ref.id, "idle", session);
|
|
});
|
|
// Persisted files predate an agent-source field, so cold-revived frames
|
|
// report the runtime-neutral `user` source; name comes from the ref.
|
|
const wakeAgent: AgentDefinition = {
|
|
name: ref.displayName,
|
|
description: "",
|
|
systemPrompt: init.systemPrompt,
|
|
source: "user",
|
|
};
|
|
attachIrcWakeTurnMonitor(session, {
|
|
id: ref.id,
|
|
agent: wakeAgent,
|
|
eventBus: ctx.eventBus,
|
|
sessionFile,
|
|
outputSchema: init.outputSchema,
|
|
outputSchemaMode: init.outputSchemaMode,
|
|
artifactsDir: ctx.session.sessionFile?.slice(0, -6),
|
|
});
|
|
return session;
|
|
};
|
|
};
|
|
}
|