fix(coding-agent): fixed parked subagent restoration from persisted sessions
- 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.
This commit is contained in:
@@ -2098,6 +2098,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
systemPrompt: session.agent.state.systemPrompt.join("\n\n"),
|
||||
task,
|
||||
tools: session.getActiveToolNames(),
|
||||
spawns: spawnsEnv,
|
||||
readSummarize: agent.readSummarize,
|
||||
outputSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user