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:
@@ -33,6 +33,7 @@
|
||||
- Fixed `omp --continue`/`-c` sometimes resuming into a subagent transcript instead of the interactive session. Subagent (and HTML-export) `SessionManager.open()` calls run in the parent's terminal and were clobbering the per-TTY `--continue` breadcrumb with their own artifact-dir session file; these headless opens now suppress the breadcrumb. `continueRecent()` also recovers already-poisoned breadcrumbs by resolving any session file inside a parent's artifacts dir (`<parent>/<agentId>.jsonl`) back up to the top-level session.
|
||||
- Fixed the Agent Hub stacking duplicate `Agent Hub · N running` frames and stranding garbage rows in scrollback while navigating with subagents still streaming. The hub was a non-fullscreen overlay composited over a live transcript, so each time a running subagent's progress grew the frame and scrolled the window the previously-painted hub copy was pushed permanently into the terminal's native scrollback (which the engine can't rewrite). It now renders inline in the editor slot — the same anchored region every other selector and the `ask` tool use — riding the normal append-only commit path, so the transcript commits above it exactly once and the hub repaints in place instead of leaking copies. (Avoids borrowing the alternate screen.)
|
||||
- Fixed every subagent registering itself as its own parent in the agent registry (`parentId === id`), so the Agent Hub rendered each agent as `sub · of <itself>` and the ←← parent-navigation gesture looped on the same agent. The SDK was reusing `parentTaskPrefix` — the agent's own artifact/output-id prefix — as the registry parent link; spawns now pass a separate `parentAgentId` (the spawning agent's id: `Main` for top-level `task` spawns, the parent subagent for nested spawns and eval `agent()`, the focused agent for `/tan`) and the registry records that as the parent.
|
||||
- Fixed messaging a `parked` subagent that was restored from disk (Agent Hub scan, or a resumed/restarted session) failing with `cannot be revived (no reviver registered)` even though its transcript was intact. Such refs carry a session file but no in-memory reviver — the executor's live reviver closure dies with the spawning turn/process — so IRC sends and Agent Hub focus refused them. `AgentLifecycleManager.ensureLive` now cold-revives them through a persisted-subagent reviver factory (installed by the top-level interactive/RPC session) that rebuilds the subagent from its JSONL the way `--resume` rebuilds a session: it reopens the file and replays it through `createAgentSession`, but sources the runtime contract from a now-readable `session_init` record (`SessionManager.peekSessionInit`) so tools, system prompt, output schema, and kind are restored rather than resurrected as a default top-level session. `session_init` now also persists the effective `spawns` allowlist and read-summarization flag so a cold revive keeps the original capability surface (old files without them deny re-spawning rather than defaulting to wildcard). Isolated runs and pre-`session_init` files whose recorded workspace no longer exists stay transcript-only (`history://`).
|
||||
|
||||
## [16.0.2] - 2026-06-16
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { PrintModeOptions } from "./modes/print-mode";
|
||||
import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
|
||||
import type { SubmittedUserInput } from "./modes/types";
|
||||
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
|
||||
import {
|
||||
type CreateAgentSessionOptions,
|
||||
type CreateAgentSessionResult,
|
||||
@@ -68,6 +69,7 @@ import { resolveResumableSession, type SessionInfo } from "./session/session-lis
|
||||
import { SessionManager } from "./session/session-manager";
|
||||
import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
|
||||
import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
|
||||
import { createPersistedSubagentReviverFactory } from "./task/persisted-revive";
|
||||
import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
|
||||
import { AUTO_THINKING } from "./thinking";
|
||||
import type { LspStartupServerInfo } from "./tools";
|
||||
@@ -1262,6 +1264,24 @@ export async function runRootCommand(
|
||||
eventBus,
|
||||
preloadedExtensions: extensionsResult,
|
||||
});
|
||||
|
||||
// Cold-revive support: a `parked` subagent ref restored from disk (Agent Hub
|
||||
// scan, collab mirror, resumed process) has a sessionFile but no in-memory
|
||||
// reviver, so `ensureLive` (IRC sends, hub focus) would refuse it. Install a
|
||||
// factory — bound to THIS top-level session — that rebuilds the subagent from
|
||||
// its persisted JSONL (see persisted-revive.ts). Scoped to the non-ACP
|
||||
// bootstrap: ACP keeps several concurrent top-level sessions and a single
|
||||
// process-global factory must not be clobbered by the most recent one.
|
||||
AgentLifecycleManager.global().setPersistedSubagentReviverFactory(
|
||||
createPersistedSubagentReviverFactory({
|
||||
session,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings: settingsInstance,
|
||||
enableLsp: sessionOptions.enableLsp ?? true,
|
||||
}),
|
||||
Math.trunc(Number(settingsInstance.get("task.agentIdleTtlMs") ?? 420_000) || 0),
|
||||
);
|
||||
if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
|
||||
authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,19 @@
|
||||
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import type { AgentSession } from "../session/agent-session";
|
||||
import { AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "./agent-registry";
|
||||
import { type AgentRef, AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "./agent-registry";
|
||||
|
||||
export type AgentReviver = () => Promise<AgentSession>;
|
||||
|
||||
/**
|
||||
* Builds a reviver for a `parked` ref restored from disk (Agent Hub scan,
|
||||
* collab mirror, resumed process) that carries a sessionFile but no in-memory
|
||||
* adoption. Returns undefined when the ref cannot be faithfully rebuilt (no
|
||||
* persisted session contract, or its workspace is gone). Injected from the
|
||||
* top-level session so this manager stays free of sdk/SessionManager imports.
|
||||
*/
|
||||
export type PersistedSubagentReviverFactory = (ref: AgentRef) => Promise<AgentReviver | undefined>;
|
||||
|
||||
export interface AdoptOptions {
|
||||
/** TTL before an idle agent is parked. <= 0 disables parking. */
|
||||
idleTtlMs: number;
|
||||
@@ -51,6 +60,7 @@ export class AgentLifecycleManager {
|
||||
current.#adopted.clear();
|
||||
current.#revivals.clear();
|
||||
current.#parking.clear();
|
||||
current.#persistedReviverFactory = undefined;
|
||||
}
|
||||
AgentLifecycleManager.#global = undefined;
|
||||
}
|
||||
@@ -62,12 +72,26 @@ export class AgentLifecycleManager {
|
||||
/** In-flight revives, so concurrent {@link ensureLive} calls coalesce. */
|
||||
readonly #revivals = new Map<string, Promise<AgentSession>>();
|
||||
#unsubscribe: (() => void) | undefined;
|
||||
#persistedReviverFactory: PersistedSubagentReviverFactory | undefined;
|
||||
/** TTL applied when a cold-revived ref is adopted on demand. */
|
||||
#persistedReviveTtlMs = 0;
|
||||
|
||||
constructor(registry: AgentRegistry = AgentRegistry.global()) {
|
||||
this.#registry = registry;
|
||||
this.#unsubscribe = registry.onChange(event => this.#onRegistryEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the factory used to cold-revive `parked` refs restored from disk
|
||||
* (Agent Hub scan, collab mirror, resumed process) — they carry a sessionFile
|
||||
* but no adoption. Set by the top-level session, which owns the ambient deps
|
||||
* (auth, models, MCP, artifacts) the factory needs at revive time.
|
||||
*/
|
||||
setPersistedSubagentReviverFactory(factory: PersistedSubagentReviverFactory, idleTtlMs: number): void {
|
||||
this.#persistedReviverFactory = factory;
|
||||
this.#persistedReviveTtlMs = idleTtlMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of a finished subagent. Caller has already set registry
|
||||
* status to "idle". Arms the TTL timer (idleTtlMs <= 0 adopts without one).
|
||||
@@ -137,13 +161,7 @@ export class AgentLifecycleManager {
|
||||
if (ref.session) return ref.session;
|
||||
const inflight = this.#revivals.get(id);
|
||||
if (inflight) return inflight;
|
||||
const adopted = this.#adopted.get(id);
|
||||
if (ref.status !== "parked" || !adopted?.revive) {
|
||||
throw new Error(
|
||||
`Agent "${id}" is ${ref.status} and cannot be revived${adopted?.revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`,
|
||||
);
|
||||
}
|
||||
const revival = this.#revive(id, adopted.revive, ref.sessionFile);
|
||||
const revival = this.#resolveAndRevive(id, ref);
|
||||
this.#revivals.set(id, revival);
|
||||
try {
|
||||
return await revival;
|
||||
@@ -152,6 +170,39 @@ export class AgentLifecycleManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a reviver and bring the agent back to a live session. A ref
|
||||
* restored from disk is `parked` with a sessionFile but no in-memory
|
||||
* adoption; build a reviver via the injected persisted-subagent factory and
|
||||
* adopt it so the agent rejoins the normal idle↔parked lifecycle. Throws
|
||||
* when the agent is not revivable or no reviver can be produced.
|
||||
*/
|
||||
async #resolveAndRevive(id: string, ref: AgentRef): Promise<AgentSession> {
|
||||
let revive = this.#adopted.get(id)?.revive;
|
||||
let coldAdopted = false;
|
||||
if (!revive && ref.status === "parked" && ref.sessionFile && this.#persistedReviverFactory) {
|
||||
revive = await this.#persistedReviverFactory(ref);
|
||||
if (revive) {
|
||||
this.#adopted.set(id, { idleTtlMs: this.#persistedReviveTtlMs, revive });
|
||||
coldAdopted = true;
|
||||
}
|
||||
}
|
||||
if (ref.status !== "parked" || !revive) {
|
||||
throw new Error(
|
||||
`Agent "${id}" is ${ref.status} and cannot be revived${revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.#revive(id, revive, ref.sessionFile);
|
||||
} catch (error) {
|
||||
// A failed cold revive (stale ctx, missing cwd, bad MCP) must not leave a
|
||||
// poisoned reviver stuck in #adopted — drop it so a later ensureLive
|
||||
// rebuilds via the factory (which may have fresher context by then).
|
||||
if (coldAdopted) this.#adopted.delete(id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hard removal: dispose if live, unregister from registry, drop timers. */
|
||||
async release(id: string): Promise<void> {
|
||||
const adopted = this.#adopted.get(id);
|
||||
@@ -176,6 +227,7 @@ export class AgentLifecycleManager {
|
||||
await Promise.all(ids.map(id => this.release(id)));
|
||||
this.#revivals.clear();
|
||||
this.#parking.clear();
|
||||
this.#persistedReviverFactory = undefined;
|
||||
}
|
||||
|
||||
async #revive(id: string, revive: AgentReviver, sessionFile: string | null): Promise<AgentSession> {
|
||||
|
||||
@@ -124,6 +124,10 @@ export interface SessionInitEntry extends SessionEntryBase {
|
||||
tools: string[];
|
||||
/** Output schema if structured output was requested */
|
||||
outputSchema?: unknown;
|
||||
/** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */
|
||||
spawns?: string;
|
||||
/** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */
|
||||
readSummarize?: boolean;
|
||||
}
|
||||
|
||||
/** Mode change entry - tracks agent mode transitions (e.g. plan mode). */
|
||||
|
||||
@@ -1178,7 +1178,14 @@ export class SessionManager {
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
|
||||
appendSessionInit(init: {
|
||||
systemPrompt: string;
|
||||
task: string;
|
||||
tools: string[];
|
||||
outputSchema?: unknown;
|
||||
spawns?: string;
|
||||
readSummarize?: boolean;
|
||||
}): string {
|
||||
const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init };
|
||||
this.#recordEntry(entry);
|
||||
return entry.id;
|
||||
@@ -1561,6 +1568,62 @@ export class SessionManager {
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-free peek for cold subagent revival: returns the recorded working
|
||||
* directory (session header) and the latest `session_init` contract (system
|
||||
* prompt / tools / output schema) WITHOUT taking the single-writer lock that
|
||||
* {@link open} acquires — the caller re-opens for the actual revive. Returns
|
||||
* null when the file can't be read; `init` is null for files written before
|
||||
* `session_init` was recorded (no faithful contract to rebuild from).
|
||||
*/
|
||||
static async peekSessionInit(
|
||||
filePath: string,
|
||||
storage: SessionStorage = new FileSessionStorage(),
|
||||
): Promise<{
|
||||
cwd: string;
|
||||
init: {
|
||||
systemPrompt: string;
|
||||
task: string;
|
||||
tools: string[];
|
||||
outputSchema?: unknown;
|
||||
spawns?: string;
|
||||
readSummarize?: boolean;
|
||||
} | null;
|
||||
} | null> {
|
||||
let loaded: FileEntry[];
|
||||
try {
|
||||
loaded = await loadEntriesFromFile(filePath, storage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// A missing/empty file has no usable session — nothing to revive from.
|
||||
if (loaded.length === 0) return null;
|
||||
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
||||
let init: {
|
||||
systemPrompt: string;
|
||||
task: string;
|
||||
tools: string[];
|
||||
outputSchema?: unknown;
|
||||
spawns?: string;
|
||||
readSummarize?: boolean;
|
||||
} | null = null;
|
||||
for (let index = loaded.length - 1; index >= 0; index--) {
|
||||
const entry = loaded[index];
|
||||
if (entry.type === "session_init") {
|
||||
init = {
|
||||
systemPrompt: entry.systemPrompt,
|
||||
task: entry.task,
|
||||
tools: entry.tools,
|
||||
outputSchema: entry.outputSchema,
|
||||
readSummarize: entry.readSummarize,
|
||||
spawns: entry.spawns,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { cwd: header?.cwd ?? getProjectDir(), init };
|
||||
}
|
||||
|
||||
/** Continue the most recent session, or create a new one if none exists. */
|
||||
static async continueRecent(
|
||||
cwd: string,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -156,6 +156,81 @@ describe("AgentLifecycleManager", () => {
|
||||
await expect(lifecycle.ensureLive("5-Sub")).rejects.toThrow(/cannot be revived.*no reviver registered/);
|
||||
});
|
||||
|
||||
it("ensureLive cold-revives a parked ref via the persisted factory and rejoins the lifecycle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const revived = makeSessionStub();
|
||||
// Restored from disk (hub scan / resume): parked with a sessionFile but NEVER adopted.
|
||||
registry.register({
|
||||
id: "6-Sub",
|
||||
displayName: "task",
|
||||
kind: "sub",
|
||||
session: null,
|
||||
sessionFile: "/tmp/6-Sub.jsonl",
|
||||
status: "parked",
|
||||
});
|
||||
let factoryCalls = 0;
|
||||
lifecycle.setPersistedSubagentReviverFactory(async () => {
|
||||
factoryCalls++;
|
||||
return async () => revived.session;
|
||||
}, TTL);
|
||||
|
||||
const session = await lifecycle.ensureLive("6-Sub");
|
||||
|
||||
expect(factoryCalls).toBe(1);
|
||||
expect(session).toBe(revived.session);
|
||||
expect(registry.get("6-Sub")?.status).toBe("idle");
|
||||
expect(registry.get("6-Sub")?.session).toBe(revived.session);
|
||||
|
||||
// Adopted on demand with the configured TTL: it re-parks like any idle subagent.
|
||||
vi.advanceTimersByTime(TTL);
|
||||
await flushAsync();
|
||||
expect(registry.get("6-Sub")?.status).toBe("parked");
|
||||
expect(revived.disposeCalls()).toBe(1);
|
||||
});
|
||||
|
||||
it("a persisted factory that declines leaves the parked ref transcript-only", async () => {
|
||||
registry.register({
|
||||
id: "7-Sub",
|
||||
displayName: "task",
|
||||
kind: "sub",
|
||||
session: null,
|
||||
sessionFile: "/tmp/7-Sub.jsonl",
|
||||
status: "parked",
|
||||
});
|
||||
lifecycle.setPersistedSubagentReviverFactory(async () => undefined, TTL);
|
||||
|
||||
await expect(lifecycle.ensureLive("7-Sub")).rejects.toThrow(/cannot be revived.*no reviver registered/);
|
||||
});
|
||||
|
||||
it("a failed cold revive is not sticky: the next ensureLive re-runs the factory", async () => {
|
||||
const revived = makeSessionStub();
|
||||
registry.register({
|
||||
id: "8-Sub",
|
||||
displayName: "task",
|
||||
kind: "sub",
|
||||
session: null,
|
||||
sessionFile: "/tmp/8-Sub.jsonl",
|
||||
status: "parked",
|
||||
});
|
||||
let factoryCalls = 0;
|
||||
lifecycle.setPersistedSubagentReviverFactory(async () => {
|
||||
factoryCalls++;
|
||||
const failFirst = factoryCalls === 1;
|
||||
return async () => {
|
||||
if (failFirst) throw new Error("stale context");
|
||||
return revived.session;
|
||||
};
|
||||
}, TTL);
|
||||
|
||||
await expect(lifecycle.ensureLive("8-Sub")).rejects.toThrow(/stale context/);
|
||||
expect(registry.get("8-Sub")?.status).toBe("parked");
|
||||
|
||||
const session = await lifecycle.ensureLive("8-Sub");
|
||||
expect(factoryCalls).toBe(2);
|
||||
expect(session).toBe(revived.session);
|
||||
expect(registry.get("8-Sub")?.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("release disposes a live adopted agent, unregisters it, and leaves no pending park", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stub = makeSessionStub();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const tempDirs: TempDir[] = [];
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = TempDir.createSync(prefix);
|
||||
tempDirs.push(dir);
|
||||
return dir.path();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map(dir => dir.remove()));
|
||||
});
|
||||
|
||||
function assistantMessage(text: string) {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Expected built-in anthropic model to exist");
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionManager.peekSessionInit", () => {
|
||||
it("returns the latest session_init contract (tools/spawns/readSummarize) and the header cwd", async () => {
|
||||
const cwd = makeTempDir("@pi-peek-cwd-");
|
||||
const manager = SessionManager.create(cwd, path.join(cwd, "sessions"));
|
||||
const sessionFile = manager.getSessionFile();
|
||||
if (!sessionFile) throw new Error("Expected a persisted session file path");
|
||||
|
||||
manager.appendSessionInit({ systemPrompt: "first", task: "t1", tools: ["read"], spawns: "" });
|
||||
manager.appendSessionInit({
|
||||
systemPrompt: "second",
|
||||
task: "t2",
|
||||
tools: ["read", "bash", "yield"],
|
||||
spawns: "task",
|
||||
readSummarize: false,
|
||||
});
|
||||
// Flush buffered entries (header + inits) so the lock-free peek can read them off disk.
|
||||
manager.appendMessage(assistantMessage("flush"));
|
||||
|
||||
const peek = await SessionManager.peekSessionInit(sessionFile);
|
||||
expect(peek?.cwd).toBe(manager.getCwd());
|
||||
// Latest init wins — the reviver must rebuild from the most recent contract.
|
||||
expect(peek?.init?.systemPrompt).toBe("second");
|
||||
expect(peek?.init?.tools).toEqual(["read", "bash", "yield"]);
|
||||
expect(peek?.init?.spawns).toBe("task");
|
||||
expect(peek?.init?.readSummarize).toBe(false);
|
||||
});
|
||||
|
||||
it("returns init: null for a session file with no session_init (a main/legacy session)", async () => {
|
||||
const cwd = makeTempDir("@pi-peek-legacy-");
|
||||
const manager = SessionManager.create(cwd, path.join(cwd, "sessions"));
|
||||
const sessionFile = manager.getSessionFile();
|
||||
if (!sessionFile) throw new Error("Expected a persisted session file path");
|
||||
manager.appendMessage(assistantMessage("hi"));
|
||||
|
||||
const peek = await SessionManager.peekSessionInit(sessionFile);
|
||||
expect(peek?.cwd).toBe(manager.getCwd());
|
||||
expect(peek?.init).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a file that cannot be read", async () => {
|
||||
const peek = await SessionManager.peekSessionInit(path.join(makeTempDir("@pi-peek-missing-"), "nope.jsonl"));
|
||||
expect(peek).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user