ef5e5fd27c
- Fixed cold revival flow so parked subagents are restored from persisted sessions at startup. - Fixed session-init persistence to include spawns and readSummarize fields for replay accuracy. - Fixed latest-session lookup by adding peekSessionInit for lock-free persisted contract access. - Added lifecycle and session tests for cold-revive success, decline, and retry paths.
129 lines
5.6 KiB
TypeScript
129 lines
5.6 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
|
|
import type { ModelRegistry } from "../config/model-registry";
|
|
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 { createMCPProxyTools, createSubagentSettings } from "./executor";
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
return async () => {
|
|
// 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);
|
|
// Reuse the parent's live MCP connections via proxy tools (no
|
|
// re-discovery), exactly as the executor does for live subagents.
|
|
const mcpManager = MCPManager.instance();
|
|
const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : [];
|
|
const { session } = await createAgentSession({
|
|
cwd: ctx.session.sessionManager.getCwd(),
|
|
authStorage: ctx.authStorage,
|
|
modelRegistry: ctx.modelRegistry,
|
|
settings: createSubagentSettings(
|
|
ctx.settings,
|
|
init.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
|
|
),
|
|
sessionManager: reopened,
|
|
agentId: ref.id,
|
|
agentDisplayName: ref.displayName,
|
|
parentTaskPrefix: ref.id,
|
|
parentAgentId: ref.parentId,
|
|
taskDepth,
|
|
toolNames: init.tools,
|
|
outputSchema: init.outputSchema,
|
|
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: ctx.enableLsp,
|
|
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);
|
|
// 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");
|
|
else if (event.type === "agent_end") registry.setStatus(ref.id, "idle");
|
|
});
|
|
return session;
|
|
};
|
|
};
|
|
}
|