mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
fix(timeline): retain child state and recover rewinds on resume
Provider children now live in the parent's timeline next to its history, so their descriptors, rows and retirements survive close, archive and a manager rebuilt over the same backend, and deleting the agent removes them all. Resuming settles a rewind that was still open when the runtime stopped instead of leaving its rows half-retired. A stored child's status is only the last report, possibly from provider history or an earlier runtime. Workspace activity and idle cleanup now read the children the current runtime reported running, and closing a runtime cancels only those, so a child stored as running from an earlier runtime no longer keeps the agent busy or blocks cleanup.
This commit is contained in:
@@ -811,6 +811,96 @@ test("Reload agent keeps the history and its identity, and adds only what the pr
|
||||
}
|
||||
});
|
||||
|
||||
test("Reload agent adds the provider children of new turns and leaves the children Paseo shows alone", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-reload-children-"));
|
||||
const providerHistory: AgentStreamEvent[] = [
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "explore", messageId: "provider-1" },
|
||||
},
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: { type: "upsert", id: "explorer", title: "Explorer", status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "explorer",
|
||||
item: { type: "assistant_message", text: "found it" },
|
||||
},
|
||||
},
|
||||
{ type: "timeline", provider: "codex", item: { type: "assistant_message", text: "done" } },
|
||||
];
|
||||
class HistorySession extends TestAgentSession {
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield* providerHistory;
|
||||
}
|
||||
}
|
||||
class HistoryClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new HistorySession(config);
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
||||
}
|
||||
}
|
||||
const manager = new AgentManager({ clients: { codex: new HistoryClient() }, logger });
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
expect(await reloadAgent(manager, agent.id)).toEqual({ kind: "appended" });
|
||||
|
||||
// The conversation was continued outside Paseo, with a second child in the new turn.
|
||||
providerHistory.push(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "review", messageId: "provider-2" },
|
||||
},
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: { type: "upsert", id: "reviewer", title: "Reviewer", status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "reviewer",
|
||||
item: { type: "assistant_message", text: "looks good" },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(await reloadAgent(manager, agent.id)).toEqual({ kind: "appended" });
|
||||
|
||||
const childTexts = async (subagentId: string) =>
|
||||
(await manager.fetchProviderSubagentTimeline(agent.id, subagentId)).rows.map((row) =>
|
||||
row.item.type === "assistant_message" ? row.item.text : row.item.type,
|
||||
);
|
||||
expect((await manager.listProviderSubagents(agent.id)).map((child) => child.id)).toEqual([
|
||||
"explorer",
|
||||
"reviewer",
|
||||
]);
|
||||
expect(await childTexts("explorer")).toEqual(["found it"]);
|
||||
expect(await childTexts("reviewer")).toEqual(["looks good"]);
|
||||
} finally {
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Reload agent keeps the history when the provider history differs or cannot be read", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-reload-fail-"));
|
||||
let providerHistory: AgentTimelineItem[] = [
|
||||
@@ -1883,6 +1973,14 @@ function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
|
||||
}
|
||||
|
||||
/** Resumes a codex agent whose history Paseo does not hold, so it imports the provider's. */
|
||||
function runtimeIsCollectable(manager: AgentManager, agentId: string): boolean {
|
||||
const candidate = manager
|
||||
.getRuntimeCleanupCandidates()
|
||||
.find((runtime) => runtime.agentId === agentId);
|
||||
if (!candidate) throw new Error(`No runtime for agent '${agentId}'`);
|
||||
return candidate.isEligible();
|
||||
}
|
||||
|
||||
function resumeWithProviderHistory(manager: AgentManager, cwd: string) {
|
||||
return manager.resumeAgentFromPersistence(
|
||||
{ provider: "codex", sessionId: "provider-history" },
|
||||
@@ -4662,7 +4760,7 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(manager.listProviderSubagents(imported.id)).toEqual([
|
||||
expect(await manager.listProviderSubagents(imported.id)).toEqual([
|
||||
expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }),
|
||||
]);
|
||||
expect((await manager.fetchProviderSubagentTimeline(imported.id, "thread-child")).rows).toEqual([
|
||||
@@ -4881,11 +4979,13 @@ test("Reload agent keeps provider children and ends the running ones", async ()
|
||||
provider: "codex",
|
||||
event: { type: "upsert", id: "stale-child", title: "Stale child", status: "running" },
|
||||
});
|
||||
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
|
||||
await vi.waitFor(async () =>
|
||||
expect(await manager.listProviderSubagents(snapshot.id)).toHaveLength(1),
|
||||
);
|
||||
|
||||
await reloadAgent(manager, snapshot.id);
|
||||
|
||||
expect(manager.listProviderSubagents(snapshot.id)).toEqual([
|
||||
expect(await manager.listProviderSubagents(snapshot.id)).toEqual([
|
||||
expect.objectContaining({ id: "stale-child", status: "canceled" }),
|
||||
]);
|
||||
});
|
||||
@@ -4924,11 +5024,13 @@ test("reloadAgentSession terminalizes running provider children when preserving
|
||||
provider: "codex",
|
||||
event: { type: "upsert", id: "running-child", title: "Running child", status: "running" },
|
||||
});
|
||||
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
|
||||
await vi.waitFor(async () =>
|
||||
expect(await manager.listProviderSubagents(snapshot.id)).toHaveLength(1),
|
||||
);
|
||||
|
||||
await manager.reloadAgentSession(snapshot.id);
|
||||
|
||||
expect(manager.listProviderSubagents(snapshot.id)).toEqual([
|
||||
expect(await manager.listProviderSubagents(snapshot.id)).toEqual([
|
||||
expect.objectContaining({ id: "running-child", status: "canceled" }),
|
||||
]);
|
||||
});
|
||||
@@ -4938,6 +5040,7 @@ test("resuming without stored history restores provider children from the provid
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
class ProviderChildHistorySession extends TestAgentSession {
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
// The history's last report on this child is that it started.
|
||||
yield {
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
@@ -4945,7 +5048,7 @@ test("resuming without stored history restores provider children from the provid
|
||||
type: "upsert",
|
||||
id: "restored-child",
|
||||
title: "Restored child",
|
||||
status: "completed",
|
||||
status: "running",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4967,20 +5070,24 @@ test("resuming without stored history restores provider children from the provid
|
||||
|
||||
const snapshot = await resumeWithProviderHistory(manager, workdir);
|
||||
|
||||
expect(manager.listProviderSubagents(snapshot.id)).toEqual([
|
||||
expect(await manager.listProviderSubagents(snapshot.id)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "restored-child",
|
||||
parentAgentId: snapshot.id,
|
||||
title: "Restored child",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
expect(manager.listProviderSubagentActivity()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "restored-child",
|
||||
parentAgentId: snapshot.id,
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
// A history report is not the runtime's report: it does not keep the workspace or runtime busy.
|
||||
expect(await manager.listProviderSubagentActivity()).toEqual([]);
|
||||
expect(runtimeIsCollectable(manager, snapshot.id)).toBe(true);
|
||||
|
||||
// Closing the runtime does not invent an outcome for a child that runtime never reported.
|
||||
await manager.closeAgent(snapshot.id);
|
||||
expect((await manager.getProviderSubagent(snapshot.id, "restored-child"))?.status).toBe(
|
||||
"running",
|
||||
);
|
||||
await storage.flush();
|
||||
});
|
||||
|
||||
test("reloadAgentSession preserves current title when config title is unset", async () => {
|
||||
@@ -8352,16 +8459,16 @@ test("subscribe hides provider subagents of internal parents from global subscri
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(() => manager.listProviderSubagents(internalAgentId)).toThrow(
|
||||
await expect(manager.listProviderSubagents(internalAgentId)).rejects.toThrow(
|
||||
`Unknown agent '${internalAgentId}'`,
|
||||
);
|
||||
expect(() => manager.getProviderSubagent(internalAgentId, "hidden-child")).toThrow(
|
||||
await expect(manager.getProviderSubagent(internalAgentId, "hidden-child")).rejects.toThrow(
|
||||
`Unknown agent '${internalAgentId}'`,
|
||||
);
|
||||
await expect(
|
||||
manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child"),
|
||||
).rejects.toThrow(`Unknown agent '${internalAgentId}'`);
|
||||
expect(manager.listProviderSubagentActivity()).toEqual([]);
|
||||
expect(await manager.listProviderSubagentActivity()).toEqual([]);
|
||||
});
|
||||
|
||||
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
|
||||
@@ -10680,10 +10787,10 @@ test("explicit close cancels running provider subagents before resume", async ()
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(manager.getProviderSubagent(parent.id, "provider-child-running")?.status).toBe(
|
||||
expect((await manager.getProviderSubagent(parent.id, "provider-child-running"))?.status).toBe(
|
||||
"canceled",
|
||||
);
|
||||
expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe(
|
||||
expect((await manager.getProviderSubagent(parent.id, "provider-child-finishing"))?.status).toBe(
|
||||
"completed",
|
||||
);
|
||||
} finally {
|
||||
@@ -10695,6 +10802,78 @@ test("explicit close cancels running provider subagents before resume", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("a child an earlier runtime reported running is live activity only once the provider reports it again", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-restarted-provider-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const backend = new DeferredTimelineBackend();
|
||||
class ResumeRecordingClient extends SessionRecordingAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const session = new TestAgentSession({ provider: "codex", cwd: config?.cwd ?? workdir });
|
||||
session.describePersistence = () => handle;
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
const firstClient = new ResumeRecordingClient();
|
||||
const first = new AgentManager({
|
||||
clients: { codex: firstClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
timeline: new AgentTimeline(backend),
|
||||
});
|
||||
const nextClient = new ResumeRecordingClient();
|
||||
const next = new AgentManager({
|
||||
clients: { codex: nextClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
timeline: new AgentTimeline(backend),
|
||||
});
|
||||
const childReport = (status: "running" | "completed"): AgentStreamEvent => ({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
event: { type: "upsert", id: "provider-child", title: "Provider child", status },
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
firstClient.sessions[0]!.pushEvent(childReport("running"));
|
||||
await first.flush();
|
||||
expect(await first.listProviderSubagentActivity()).toEqual([
|
||||
expect.objectContaining({ id: "provider-child", status: "running" }),
|
||||
]);
|
||||
await storage.flush();
|
||||
|
||||
// The first daemon stops without closing its agent, as a crash does; the next one resumes it.
|
||||
await ensureAgentLoaded(parent.id, { agentManager: next, agentStorage: storage, logger });
|
||||
expect((await next.getProviderSubagent(parent.id, "provider-child"))?.status).toBe("running");
|
||||
expect(await next.listProviderSubagentActivity()).toEqual([]);
|
||||
expect(runtimeIsCollectable(next, parent.id)).toBe(true);
|
||||
|
||||
nextClient.sessions[0]!.pushEvent(childReport("running"));
|
||||
await next.flush();
|
||||
expect(await next.listProviderSubagentActivity()).toEqual([
|
||||
expect.objectContaining({ id: "provider-child", parentAgentId: parent.id }),
|
||||
]);
|
||||
expect(runtimeIsCollectable(next, parent.id)).toBe(false);
|
||||
|
||||
nextClient.sessions[0]!.pushEvent(childReport("completed"));
|
||||
await next.flush();
|
||||
expect(await next.listProviderSubagentActivity()).toEqual([]);
|
||||
expect(runtimeIsCollectable(next, parent.id)).toBe(true);
|
||||
} finally {
|
||||
await Promise.all(next.listAgents().map((agent) => next.closeAgent(agent.id))).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await storage.flush().catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("load waits for an in-flight explicit close and creates one resumed runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
|
||||
@@ -804,6 +804,12 @@ export class AgentManager {
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timeline: AgentTimeline;
|
||||
private readonly providerSubagents: ProviderSubagentStore;
|
||||
/**
|
||||
* Provider children each parent's current runtime reported running. A stored descriptor's status
|
||||
* is only the last report, possibly from provider history or an earlier runtime, so workspace
|
||||
* activity and idle cleanup read this instead.
|
||||
*/
|
||||
private readonly runningProviderSubagents = new Map<string, Set<string>>();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
|
||||
@@ -1359,28 +1365,30 @@ export class AgentManager {
|
||||
return await this.timeline.fetch({ agentId: id }, options);
|
||||
}
|
||||
|
||||
listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] {
|
||||
async listProviderSubagents(parentAgentId: string): Promise<ProviderSubagentDescriptor[]> {
|
||||
this.requirePublicParent(parentAgentId);
|
||||
return this.providerSubagents.list(parentAgentId);
|
||||
return await this.providerSubagents.list(parentAgentId);
|
||||
}
|
||||
|
||||
listProviderSubagentActivity(): ProviderSubagentDescriptor[] {
|
||||
const publicParentIds = new Set(
|
||||
Array.from(this.agents.values())
|
||||
.filter((agent) => !agent.internal)
|
||||
.map((agent) => agent.id),
|
||||
/** The children each loaded public agent's current runtime reports running. */
|
||||
async listProviderSubagentActivity(): Promise<ProviderSubagentDescriptor[]> {
|
||||
const publicParents = Array.from(this.agents.values()).filter((agent) => !agent.internal);
|
||||
const children = await Promise.all(
|
||||
publicParents.map(async (agent) => {
|
||||
const listed = await this.providerSubagents.list(agent.id);
|
||||
const running = this.runningProviderSubagents.get(agent.id);
|
||||
return listed.filter((child) => running?.has(child.id));
|
||||
}),
|
||||
);
|
||||
return this.providerSubagents
|
||||
.listAll()
|
||||
.filter((subagent) => publicParentIds.has(subagent.parentAgentId));
|
||||
return children.flat();
|
||||
}
|
||||
|
||||
getProviderSubagent(
|
||||
async getProviderSubagent(
|
||||
parentAgentId: string,
|
||||
subagentId: string,
|
||||
): ProviderSubagentDescriptor | null {
|
||||
): Promise<ProviderSubagentDescriptor | null> {
|
||||
this.requirePublicParent(parentAgentId);
|
||||
return this.providerSubagents.get(parentAgentId, subagentId);
|
||||
return await this.providerSubagents.get(parentAgentId, subagentId);
|
||||
}
|
||||
|
||||
async fetchProviderSubagentTimeline(
|
||||
@@ -1574,13 +1582,14 @@ export class AgentManager {
|
||||
currentResumeOptions,
|
||||
);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
const registered = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
restoring: true,
|
||||
importMissingHistory: true,
|
||||
});
|
||||
return await this.settleStoredRewind(registered);
|
||||
}
|
||||
|
||||
importProviderSession(input: {
|
||||
@@ -1659,7 +1668,7 @@ export class AgentManager {
|
||||
});
|
||||
for (const event of imported.providerSubagentEvents ?? []) {
|
||||
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
if (update) this.dispatch({ type: "provider_subagent", event: update });
|
||||
if (update) this.publishProviderSubagent(update);
|
||||
}
|
||||
return agent;
|
||||
} finally {
|
||||
@@ -1772,7 +1781,7 @@ export class AgentManager {
|
||||
restoring: true,
|
||||
});
|
||||
return settleRewind
|
||||
? registered.then((snapshot) => this.settleRewindAfterReload(snapshot))
|
||||
? registered.then((snapshot) => this.settleRewindAfterResume(snapshot))
|
||||
: registered.then((agent) => ({ agent, rewind: "none" as const }));
|
||||
} catch (error) {
|
||||
if (closedExisting) {
|
||||
@@ -1981,7 +1990,7 @@ export class AgentManager {
|
||||
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
||||
!this.foregroundMutationTails.has(agent.id) &&
|
||||
!this.sessionEventTails.has(agent.id) &&
|
||||
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
||||
!this.runningProviderSubagents.has(agent.id)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2050,17 +2059,21 @@ export class AgentManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing a runtime stops the children it reported running. A child it never reported keeps its
|
||||
* last reported status: nothing this daemon observed says how it ended.
|
||||
*/
|
||||
private async cancelRunningProviderSubagents(parentAgentId: string): Promise<void> {
|
||||
for (const subagent of this.providerSubagents.list(parentAgentId)) {
|
||||
if (subagent.status !== "running") {
|
||||
continue;
|
||||
}
|
||||
const running = this.runningProviderSubagents.get(parentAgentId);
|
||||
this.runningProviderSubagents.delete(parentAgentId);
|
||||
for (const subagent of await this.providerSubagents.list(parentAgentId)) {
|
||||
if (!running?.has(subagent.id)) continue;
|
||||
const event = await this.providerSubagents.apply(parentAgentId, subagent.provider, {
|
||||
type: "upsert",
|
||||
id: subagent.id,
|
||||
status: "canceled",
|
||||
});
|
||||
if (event) this.dispatch({ type: "provider_subagent", event });
|
||||
if (event) this.publishProviderSubagent(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3708,8 +3721,17 @@ export class AgentManager {
|
||||
return "open";
|
||||
}
|
||||
|
||||
/** The reload itself succeeded; a rewind that still cannot settle stays open and is reported. */
|
||||
private async settleRewindAfterReload(
|
||||
/** Stored history can hold a rewind an earlier runtime, or an earlier daemon, left open. */
|
||||
private async settleStoredRewind(agent: ManagedAgent): Promise<ManagedAgent> {
|
||||
if (!(await this.timeline.describe({ agentId: agent.id }))?.rewind) return agent;
|
||||
return (await this.settleRewindAfterResume(agent)).agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judges an open rewind from the history of a session that just resumed. The resume itself
|
||||
* succeeded; a rewind that still cannot settle stays open and is reported.
|
||||
*/
|
||||
private async settleRewindAfterResume(
|
||||
snapshot: ManagedAgent,
|
||||
): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> {
|
||||
let rewind: RewindSettlement = "open";
|
||||
@@ -4174,9 +4196,8 @@ export class AgentManager {
|
||||
events: readonly ProviderSubagentStreamEvent[],
|
||||
options: { dispatch: boolean },
|
||||
): Promise<void> {
|
||||
for (const event of events) {
|
||||
const update = await this.providerSubagents.apply(agentId, event.provider, event.event);
|
||||
if (update && options.dispatch) this.dispatch({ type: "provider_subagent", event: update });
|
||||
for (const update of await this.providerSubagents.applyHistory(agentId, events)) {
|
||||
if (options.dispatch) this.dispatch({ type: "provider_subagent", event: update });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4279,12 +4300,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async discardRetainedAgentState(agentId: string): Promise<void> {
|
||||
await this.timeline.delete({ agentId });
|
||||
const removed = await this.providerSubagents.deleteParent(agentId);
|
||||
this.paseoToolPolicies.delete(agentId);
|
||||
this.detachPluginTools(agentId);
|
||||
for (const event of await this.providerSubagents.deleteParent(agentId)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
for (const event of removed) this.publishProviderSubagent(event);
|
||||
}
|
||||
|
||||
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
|
||||
@@ -4391,7 +4410,11 @@ export class AgentManager {
|
||||
): Promise<void> {
|
||||
if (event.type === "provider_subagent") {
|
||||
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event);
|
||||
if (update) this.dispatch({ type: "provider_subagent", event: update });
|
||||
if (!update) return;
|
||||
if (event.event.type === "upsert" && event.event.status === "running") {
|
||||
this.markProviderSubagentRunning(agent.id, event.event.id);
|
||||
}
|
||||
this.publishProviderSubagent(update);
|
||||
return;
|
||||
}
|
||||
const turnId = getAgentStreamEventTurnId(event);
|
||||
@@ -5092,13 +5115,31 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Only the live session marks a child running; any report that ends or removes it unmarks it. */
|
||||
private publishProviderSubagent(event: ProviderSubagentStoreEvent): void {
|
||||
if (event.type === "remove") {
|
||||
this.unmarkProviderSubagentRunning(event.parentAgentId, event.subagentId);
|
||||
} else if (event.type === "upsert" && event.subagent.status !== "running") {
|
||||
this.unmarkProviderSubagentRunning(event.subagent.parentAgentId, event.subagent.id);
|
||||
}
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
|
||||
private markProviderSubagentRunning(parentAgentId: string, subagentId: string): void {
|
||||
const running = this.runningProviderSubagents.get(parentAgentId) ?? new Set<string>();
|
||||
this.runningProviderSubagents.set(parentAgentId, running.add(subagentId));
|
||||
}
|
||||
|
||||
private unmarkProviderSubagentRunning(parentAgentId: string, subagentId: string): void {
|
||||
const running = this.runningProviderSubagents.get(parentAgentId);
|
||||
if (!running?.delete(subagentId) || running.size > 0) return;
|
||||
this.runningProviderSubagents.delete(parentAgentId);
|
||||
}
|
||||
|
||||
/** Children spawned by retired tool calls leave with them, before clients learn of the retirement. */
|
||||
private publishRetirement(agentId: string, commit: TimelineRetirementCommit): void {
|
||||
const toolCallIds = new Set(
|
||||
commit.retiredRows.flatMap((row) => (row.item.type === "tool_call" ? [row.item.callId] : [])),
|
||||
);
|
||||
for (const event of this.providerSubagents.retireSpawnedBy(agentId, toolCallIds)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
for (const subagentId of commit.retiredChildIds) {
|
||||
this.publishProviderSubagent({ type: "remove", parentAgentId: agentId, subagentId });
|
||||
}
|
||||
this.dispatch({
|
||||
type: "timeline_retirement",
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
StaleProviderSessionError,
|
||||
} from "./stale-provider-session-error.js";
|
||||
|
||||
test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => {
|
||||
test("long concurrent plugin and child streams retain text-sized history through archive and release it on delete", () => {
|
||||
const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
@@ -41,8 +41,12 @@ test("long concurrent plugin and child streams retain text-sized history and rel
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
const memory = JSON.parse(output) as { retained: number; closed: number };
|
||||
const memory = JSON.parse(output) as { retained: number; archived: number; closed: number };
|
||||
expect(memory.retained).toBeLessThan(24 * 1024 * 1024);
|
||||
// Archiving closes the runtimes and keeps the stored history; it adds nothing.
|
||||
expect(memory.archived).toBeLessThanOrEqual(memory.retained);
|
||||
// Deleting the agents releases their histories and their children's.
|
||||
expect(memory.closed).toBeLessThan(memory.archived);
|
||||
expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
|
||||
});
|
||||
|
||||
@@ -642,7 +646,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
state: "completed",
|
||||
});
|
||||
await nextTurn();
|
||||
const [child] = manager.listProviderSubagents(agent.id);
|
||||
const [child] = await manager.listProviderSubagents(agent.id);
|
||||
expect(child).toMatchObject({
|
||||
id: "reusable-child",
|
||||
title: "Plugin child",
|
||||
@@ -656,7 +660,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
state: "started",
|
||||
});
|
||||
await nextTurn();
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
expect(await manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: "running" },
|
||||
]);
|
||||
|
||||
@@ -667,7 +671,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
state: outcome,
|
||||
});
|
||||
await nextTurn();
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
expect(await manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: outcome },
|
||||
]);
|
||||
} finally {
|
||||
|
||||
@@ -60,7 +60,9 @@ test("projects Codex child history and confines old-client degradation to the ch
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length)
|
||||
.poll(
|
||||
async () => (await ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id)).length,
|
||||
)
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { AgentTimeline } from "../timeline/index.js";
|
||||
import { ProviderSubagentStore } from "./store.js";
|
||||
|
||||
function spawningCall(callId: string): AgentTimelineItem {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "Task",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProviderSubagentStore", () => {
|
||||
test("keeps provider children and their timelines scoped to the parent agent", async () => {
|
||||
const subagents = new ProviderSubagentStore(new AgentTimeline());
|
||||
@@ -34,7 +46,7 @@ describe("ProviderSubagentStore", () => {
|
||||
timestamp: "2026-07-12T10:00:03.000Z",
|
||||
});
|
||||
|
||||
expect(subagents.list("parent-a")).toEqual([
|
||||
expect(await subagents.list("parent-a")).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "child-1",
|
||||
parentAgentId: "parent-a",
|
||||
@@ -53,12 +65,15 @@ describe("ProviderSubagentStore", () => {
|
||||
item: { type: "assistant_message", text: "Found it." },
|
||||
},
|
||||
]);
|
||||
expect(subagents.list("parent-b")[0]).toMatchObject({ provider: "claude", title: "Review" });
|
||||
expect((await subagents.list("parent-b"))[0]).toMatchObject({
|
||||
provider: "claude",
|
||||
title: "Review",
|
||||
});
|
||||
expect(await subagents.deleteParent("parent-a")).toEqual([
|
||||
{ type: "remove", parentAgentId: "parent-a", subagentId: "child-1" },
|
||||
]);
|
||||
expect(subagents.list("parent-a")).toEqual([]);
|
||||
expect(subagents.list("parent-b")).toHaveLength(1);
|
||||
expect(await subagents.list("parent-a")).toEqual([]);
|
||||
expect(await subagents.list("parent-b")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("limits oversized provider child tool output before storage", async () => {
|
||||
@@ -86,7 +101,16 @@ describe("ProviderSubagentStore", () => {
|
||||
});
|
||||
|
||||
test("retires the children of rewound tool calls and drops their late events", async () => {
|
||||
const subagents = new ProviderSubagentStore(new AgentTimeline());
|
||||
const timeline = new AgentTimeline();
|
||||
const subagents = new ProviderSubagentStore(timeline);
|
||||
const parent = { agentId: "parent-a" };
|
||||
await timeline.create(parent, {});
|
||||
await timeline.append(parent, [
|
||||
{ item: { type: "user_message", text: "A", messageId: "a" }, turnId: "t1" },
|
||||
{ item: spawningCall("call-a"), turnId: "t1" },
|
||||
{ item: { type: "user_message", text: "B", messageId: "b" }, turnId: "t2" },
|
||||
{ item: spawningCall("call-b"), turnId: "t2" },
|
||||
]);
|
||||
const upsert = (id: string, fields: { toolCallId?: string; parentSubagentId?: string }) =>
|
||||
subagents.apply("parent-a", "claude", {
|
||||
type: "upsert",
|
||||
@@ -104,11 +128,13 @@ describe("ProviderSubagentStore", () => {
|
||||
item: { type: "assistant_message", text: "Working on B." },
|
||||
});
|
||||
|
||||
expect(subagents.retireSpawnedBy("parent-a", new Set(["call-b"]))).toEqual([
|
||||
{ type: "remove", parentAgentId: "parent-a", subagentId: "rewound" },
|
||||
{ type: "remove", parentAgentId: "parent-a", subagentId: "grandchild" },
|
||||
]);
|
||||
expect(subagents.list("parent-a").map((subagent) => subagent.id)).toEqual(["kept"]);
|
||||
const intent = await timeline.beginRewind(parent, "b");
|
||||
await timeline.recordRewindCut(parent, intent.id, { kind: "at_target" });
|
||||
expect(await timeline.completeRewind(parent, intent.id)).toMatchObject({
|
||||
kind: "retired",
|
||||
retiredChildIds: ["rewound", "grandchild"],
|
||||
});
|
||||
expect((await subagents.list("parent-a")).map((subagent) => subagent.id)).toEqual(["kept"]);
|
||||
|
||||
// The retired child's history stays stored; a late event neither revives nor extends it.
|
||||
expect(
|
||||
@@ -119,7 +145,10 @@ describe("ProviderSubagentStore", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(await upsert("rewound", { toolCallId: "call-b" })).toBeNull();
|
||||
expect(subagents.list("parent-a").map((subagent) => subagent.id)).toEqual(["kept"]);
|
||||
expect(
|
||||
await subagents.apply("parent-a", "claude", { type: "remove", id: "rewound" }),
|
||||
).toBeNull();
|
||||
expect((await subagents.list("parent-a")).map((subagent) => subagent.id)).toEqual(["kept"]);
|
||||
expect(
|
||||
(await subagents.fetchTimeline("parent-a", "rewound")).rows.map((row) => row.item),
|
||||
).toEqual([{ type: "assistant_message", text: "Working on B." }]);
|
||||
|
||||
@@ -61,10 +61,6 @@ export type ProviderSubagentStoreEvent =
|
||||
}
|
||||
| { type: "remove"; parentAgentId: string; subagentId: string };
|
||||
|
||||
function storeKey(parentAgentId: string, subagentId: string): string {
|
||||
return `${parentAgentId}\0${subagentId}`;
|
||||
}
|
||||
|
||||
function childScope(parentAgentId: string, subagentId: string): TimelineScope {
|
||||
return { agentId: parentAgentId, subagentId };
|
||||
}
|
||||
@@ -78,11 +74,11 @@ function stickyField<T>(next: T | undefined, previous: T | null | undefined): T
|
||||
return next === undefined ? (previous ?? null) : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies provider child events to the parent's timeline, which stores each child's descriptor,
|
||||
* history and retirement next to the parent's own history.
|
||||
*/
|
||||
export class ProviderSubagentStore {
|
||||
private readonly descriptors = new Map<string, ProviderSubagentDescriptor>();
|
||||
/** Children of rewound turns. Their history stays stored; late provider events are dropped. */
|
||||
private readonly retired = new Set<string>();
|
||||
|
||||
constructor(private readonly timeline: AgentTimeline) {}
|
||||
|
||||
/** Resolves null when the event belongs to a retired child; nothing is recorded. */
|
||||
@@ -91,20 +87,18 @@ export class ProviderSubagentStore {
|
||||
provider: AgentProvider,
|
||||
event: ProviderSubagentInputEvent,
|
||||
): Promise<ProviderSubagentStoreEvent | null> {
|
||||
const key = storeKey(parentAgentId, event.id);
|
||||
const scope = childScope(parentAgentId, event.id);
|
||||
if (this.retired.has(key)) return null;
|
||||
if (event.type === "remove") {
|
||||
this.descriptors.delete(key);
|
||||
await this.timeline.delete(scope);
|
||||
if (!(await this.timeline.removeChild(scope))) return null;
|
||||
return { type: "remove", parentAgentId, subagentId: event.id };
|
||||
}
|
||||
|
||||
if (event.type === "timeline") {
|
||||
await this.timeline.open(scope);
|
||||
const {
|
||||
commits: [{ row, epoch }],
|
||||
} = await this.timeline.append(scope, [{ item: event.item, timestamp: event.timestamp }]);
|
||||
const commits = await this.timeline.recordChildEntries(scope, [
|
||||
{ item: event.item, timestamp: event.timestamp },
|
||||
]);
|
||||
if (!commits) return null;
|
||||
const [{ row, epoch }] = commits;
|
||||
return {
|
||||
type: "timeline",
|
||||
parentAgentId,
|
||||
@@ -115,39 +109,55 @@ export class ProviderSubagentStore {
|
||||
};
|
||||
}
|
||||
|
||||
const previous = this.descriptors.get(key);
|
||||
const timestamp = event.timestamp ?? new Date().toISOString();
|
||||
const subagent: ProviderSubagentDescriptor = {
|
||||
id: event.id,
|
||||
parentAgentId,
|
||||
provider,
|
||||
title: stickyField(event.title, previous?.title),
|
||||
description: stickyField(event.description, previous?.description),
|
||||
status: event.status ?? previous?.status ?? "running",
|
||||
createdAt: previous?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
toolCallId: stickyField(event.toolCallId, previous?.toolCallId),
|
||||
cwd: stickyField(event.cwd, previous?.cwd),
|
||||
subtitle: stickyField(event.subtitle, previous?.subtitle),
|
||||
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
|
||||
};
|
||||
this.descriptors.set(key, subagent);
|
||||
await this.timeline.open(scope);
|
||||
return { type: "upsert", subagent };
|
||||
const subagent = await this.timeline.recordChild(scope, (previous) => {
|
||||
const timestamp = event.timestamp ?? new Date().toISOString();
|
||||
return {
|
||||
id: event.id,
|
||||
parentAgentId,
|
||||
provider,
|
||||
title: stickyField(event.title, previous?.title),
|
||||
description: stickyField(event.description, previous?.description),
|
||||
status: event.status ?? previous?.status ?? "running",
|
||||
createdAt: previous?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
toolCallId: stickyField(event.toolCallId, previous?.toolCallId),
|
||||
cwd: stickyField(event.cwd, previous?.cwd),
|
||||
subtitle: stickyField(event.subtitle, previous?.subtitle),
|
||||
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
|
||||
};
|
||||
});
|
||||
return subagent ? { type: "upsert", subagent } : null;
|
||||
}
|
||||
|
||||
list(parentAgentId: string): ProviderSubagentDescriptor[] {
|
||||
return [...this.descriptors.values()]
|
||||
.filter((subagent) => subagent.parentAgentId === parentAgentId)
|
||||
/**
|
||||
* Applies the child events of a provider history read. Children the parent already holds,
|
||||
* listed or retired, are skipped: like the parent's, their owned history is never changed.
|
||||
*/
|
||||
async applyHistory(
|
||||
parentAgentId: string,
|
||||
events: readonly { provider: AgentProvider; event: ProviderSubagentInputEvent }[],
|
||||
): Promise<ProviderSubagentStoreEvent[]> {
|
||||
const held = new Set(
|
||||
(await this.timeline.listChildren(parentAgentId)).map((child) => child.subagentId),
|
||||
);
|
||||
const updates: ProviderSubagentStoreEvent[] = [];
|
||||
for (const { provider, event } of events) {
|
||||
if (held.has(event.id)) continue;
|
||||
const update = await this.apply(parentAgentId, provider, event);
|
||||
if (update) updates.push(update);
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
/** The parent's listed children, oldest first. Retired children are not listed. */
|
||||
async list(parentAgentId: string): Promise<ProviderSubagentDescriptor[]> {
|
||||
return (await this.timeline.listChildren(parentAgentId))
|
||||
.flatMap((child) => (!child.retired && child.descriptor ? [child.descriptor] : []))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
listAll(): ProviderSubagentDescriptor[] {
|
||||
return [...this.descriptors.values()];
|
||||
}
|
||||
|
||||
get(parentAgentId: string, subagentId: string): ProviderSubagentDescriptor | null {
|
||||
return this.descriptors.get(storeKey(parentAgentId, subagentId)) ?? null;
|
||||
async get(parentAgentId: string, subagentId: string): Promise<ProviderSubagentDescriptor | null> {
|
||||
return (await this.list(parentAgentId)).find((child) => child.id === subagentId) ?? null;
|
||||
}
|
||||
|
||||
fetchTimeline(
|
||||
@@ -158,53 +168,10 @@ export class ProviderSubagentStore {
|
||||
return this.timeline.fetch(childScope(parentAgentId, subagentId), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the children spawned by retired tool calls of the parent, and their descendants. They
|
||||
* leave the listing; their history stays stored.
|
||||
*/
|
||||
retireSpawnedBy(
|
||||
parentAgentId: string,
|
||||
toolCallIds: ReadonlySet<string>,
|
||||
): Extract<ProviderSubagentStoreEvent, { type: "remove" }>[] {
|
||||
const children = this.list(parentAgentId);
|
||||
const retiredIds = new Set(
|
||||
children
|
||||
.filter((subagent) => subagent.toolCallId !== null && toolCallIds.has(subagent.toolCallId))
|
||||
.map((subagent) => subagent.id),
|
||||
);
|
||||
let grew = retiredIds.size > 0;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const subagent of children) {
|
||||
if (retiredIds.has(subagent.id) || subagent.parentSubagentId === null) continue;
|
||||
if (!retiredIds.has(subagent.parentSubagentId)) continue;
|
||||
retiredIds.add(subagent.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
return [...retiredIds].map((subagentId) => {
|
||||
const key = storeKey(parentAgentId, subagentId);
|
||||
this.descriptors.delete(key);
|
||||
this.retired.add(key);
|
||||
return { type: "remove", parentAgentId, subagentId };
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes the parent's history and every child's, retired children included. */
|
||||
async deleteParent(parentAgentId: string): Promise<ProviderSubagentStoreEvent[]> {
|
||||
const prefix = storeKey(parentAgentId, "");
|
||||
const retired = [...this.retired]
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length));
|
||||
for (const subagentId of retired) this.retired.delete(storeKey(parentAgentId, subagentId));
|
||||
const removed = this.list(parentAgentId).map((subagent) => subagent.id);
|
||||
for (const subagentId of removed) {
|
||||
this.descriptors.delete(storeKey(parentAgentId, subagentId));
|
||||
}
|
||||
await Promise.all(
|
||||
[...removed, ...retired].map((subagentId) =>
|
||||
this.timeline.delete(childScope(parentAgentId, subagentId)),
|
||||
),
|
||||
);
|
||||
return removed.map((subagentId) => ({ type: "remove", parentAgentId, subagentId }));
|
||||
const listed = await this.list(parentAgentId);
|
||||
await this.timeline.delete({ agentId: parentAgentId });
|
||||
return listed.map((child) => ({ type: "remove", parentAgentId, subagentId: child.id }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2155,7 +2155,7 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"]));
|
||||
expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" });
|
||||
expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
|
||||
expect(await codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
|
||||
toolCallId: "spawn-child",
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
@@ -344,6 +344,115 @@ describe("AgentManager conversation rewind", () => {
|
||||
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]);
|
||||
});
|
||||
|
||||
test("a restarted daemon whose stored history holds an unknown outcome settles it on resume", async () => {
|
||||
const registry = await createFlakyStorage();
|
||||
const harness = await createConversationHarness({ registry });
|
||||
const { manager, agentId, session, client, backend } = harness;
|
||||
await harness.prompt("A");
|
||||
await harness.prompt("B");
|
||||
session.nextRewind = { kind: "unknown", applied: true };
|
||||
await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow();
|
||||
await manager.closeAgent(agentId);
|
||||
await manager.flush();
|
||||
|
||||
// The next daemon reads the same stored history; only construction differs.
|
||||
const restarted = new AgentManager({
|
||||
clients: { claude: client },
|
||||
logger: createTestLogger(),
|
||||
timeline: new AgentTimeline(backend),
|
||||
registry,
|
||||
});
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: registry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
for await (const _event of restarted.streamAgent(agentId, "C", { clientMessageId: "C" })) {
|
||||
// Drain the turn.
|
||||
}
|
||||
await restarted.flush();
|
||||
|
||||
expect(session.recordedRewinds).toHaveLength(1);
|
||||
const page = await restarted.fetchTimeline(agentId, { limit: 0 });
|
||||
expect(page.rows.map((row) => ("text" in row.item ? row.item.text : row.item.type))).toEqual([
|
||||
"A",
|
||||
"reply to A",
|
||||
"C",
|
||||
"reply to C",
|
||||
]);
|
||||
expect(page.retirementCount).toBe(1);
|
||||
await restarted.closeAgent(agentId);
|
||||
await restarted.flush();
|
||||
await registry.flush();
|
||||
});
|
||||
|
||||
test("a restarted daemon keeps the provider children it listed and the ones a rewind retired", async () => {
|
||||
const registry = await createFlakyStorage();
|
||||
const harness = await createConversationHarness({ registry });
|
||||
const { manager, agentId, session, client, backend } = harness;
|
||||
session.subagentPrompts.add("A").add("B");
|
||||
await harness.prompt("A");
|
||||
await harness.prompt("B");
|
||||
await manager.rewind(agentId, "B", "conversation");
|
||||
await manager.closeAgent(agentId);
|
||||
await manager.flush();
|
||||
|
||||
// The next daemon reads the same stored history; only construction differs.
|
||||
const restarted = new AgentManager({
|
||||
clients: { claude: client },
|
||||
logger: createTestLogger(),
|
||||
timeline: new AgentTimeline(backend),
|
||||
registry,
|
||||
});
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: registry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const listed = async () =>
|
||||
(await restarted.listProviderSubagents(agentId)).map((child) => child.id);
|
||||
const childTexts = async (subagentId: string) =>
|
||||
(await restarted.fetchProviderSubagentTimeline(agentId, subagentId)).rows.map((row) =>
|
||||
"text" in row.item ? row.item.text : row.item.type,
|
||||
);
|
||||
|
||||
expect(await listed()).toEqual(["child-of-A"]);
|
||||
expect(await childTexts("child-of-A")).toEqual(["working on A"]);
|
||||
expect(await childTexts("child-of-B")).toEqual(["working on B"]);
|
||||
|
||||
// The provider's history still holds the rewound child; importing it must not bring it back.
|
||||
session.history.push({ type: "assistant_message", text: "continued outside Paseo" });
|
||||
session.historySubagentEvents = [
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
event: { type: "upsert", id: "child-of-B", toolCallId: "call-2", status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "child-of-B",
|
||||
item: { type: "assistant_message", text: "late output" },
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(await reloadAgent(restarted, agentId)).toEqual({ kind: "appended" });
|
||||
|
||||
expect(await listed()).toEqual(["child-of-A"]);
|
||||
expect(await childTexts("child-of-B")).toEqual(["working on B"]);
|
||||
|
||||
await restarted.closeAgent(agentId);
|
||||
await restarted.deleteAgentState(agentId);
|
||||
await restarted.flush();
|
||||
const stored = new AgentTimeline(backend);
|
||||
expect(await stored.describe({ agentId })).toBeNull();
|
||||
expect(await stored.describe({ agentId, subagentId: "child-of-A" })).toBeNull();
|
||||
expect(await stored.describe({ agentId, subagentId: "child-of-B" })).toBeNull();
|
||||
await registry.flush();
|
||||
});
|
||||
|
||||
test("reloading settles an unknown outcome the provider history proves unchanged", async () => {
|
||||
const harness = await createConversationHarness();
|
||||
const { manager, agentId, session } = harness;
|
||||
@@ -544,14 +653,16 @@ describe("AgentManager conversation rewind", () => {
|
||||
await harness.prompt("A");
|
||||
await harness.prompt("B");
|
||||
await manager.flush();
|
||||
expect(manager.listProviderSubagents(agentId).map((child) => child.id)).toEqual([
|
||||
expect((await manager.listProviderSubagents(agentId)).map((child) => child.id)).toEqual([
|
||||
"child-of-A",
|
||||
"child-of-B",
|
||||
]);
|
||||
|
||||
await manager.rewind(agentId, "B", "conversation");
|
||||
|
||||
expect(manager.listProviderSubagents(agentId).map((child) => child.id)).toEqual(["child-of-A"]);
|
||||
expect((await manager.listProviderSubagents(agentId)).map((child) => child.id)).toEqual([
|
||||
"child-of-A",
|
||||
]);
|
||||
expect(
|
||||
harness.events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "remove" ? [event.event] : [],
|
||||
|
||||
@@ -58,6 +58,8 @@ export class FakeRewindSession implements AgentSession {
|
||||
nextRewind: FakeRewindOutcome = { kind: "applied" };
|
||||
/** Prompts whose turn spawns a provider subagent through a tool call. */
|
||||
readonly subagentPrompts = new Set<string>();
|
||||
/** Provider child events the provider's history replays after its conversation. */
|
||||
historySubagentEvents: AgentStreamEvent[] = [];
|
||||
private promptCount = 0;
|
||||
|
||||
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
@@ -118,6 +120,15 @@ export class FakeRewindSession implements AgentSession {
|
||||
provider: this.provider,
|
||||
event: { type: "upsert", id: `child-of-${text}`, toolCallId: callId, status: "running" },
|
||||
});
|
||||
this.emit({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: `child-of-${text}`,
|
||||
item: { type: "assistant_message", text: `working on ${text}` },
|
||||
},
|
||||
});
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.emit({ type: "turn_completed", provider: this.provider, turnId });
|
||||
@@ -136,6 +147,7 @@ export class FakeRewindSession implements AgentSession {
|
||||
for (const item of this.history) {
|
||||
yield { type: "timeline", provider: this.provider, item };
|
||||
}
|
||||
yield* this.historySubagentEvents;
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
|
||||
@@ -149,7 +149,7 @@ for (let index = 0; index < sessions.length; index++) {
|
||||
const item = items[step * 2]!;
|
||||
assert.equal(item.type === "reasoning" && item.text, expected.get(`${index}:${step}`));
|
||||
}
|
||||
const children = manager.listProviderSubagents(agents[index]!.id);
|
||||
const children = await manager.listProviderSubagents(agents[index]!.id);
|
||||
assert.equal(children.length, 1);
|
||||
let rootItems = 0;
|
||||
let childItems = 0;
|
||||
@@ -160,17 +160,32 @@ for (let index = 0; index < sessions.length; index++) {
|
||||
assert.equal(rootItems, steps * 2);
|
||||
assert.equal(childItems, steps * 2);
|
||||
await manager.archiveAgent(agents[index]!.id);
|
||||
assert.equal(await timeline.describe({ agentId: agents[index]!.id }), null);
|
||||
assert.equal(
|
||||
manager.listAgents().some((agent) => agent.id === agents[index]!.id),
|
||||
false,
|
||||
);
|
||||
// An archived chat reads its history and provider children without its provider.
|
||||
assert.equal((await timeline.readRows({ agentId: agents[index]!.id })).length, steps * 2);
|
||||
assert.equal((await timeline.listChildren(agents[index]!.id)).length, 1);
|
||||
}
|
||||
global.gc();
|
||||
const archived = process.memoryUsage().heapUsed - before;
|
||||
for (const agent of agents) {
|
||||
const stored = await timeline.listChildren(agent.id);
|
||||
await manager.deleteAgentState(agent.id);
|
||||
assert.equal(await timeline.describe({ agentId: agent.id }), null);
|
||||
for (const child of stored) {
|
||||
assert.equal(
|
||||
await timeline.describe({ agentId: agent.id, subagentId: child.subagentId }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
await plugins.shutdown();
|
||||
await manager.flushForShutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
global.gc();
|
||||
const closed = process.memoryUsage().heapUsed - before;
|
||||
// Keep closed session references alive, as callers can retain them after archive.
|
||||
// Keep closed session references alive, as callers can retain them after deletion.
|
||||
assert.equal(sessions.length, 3);
|
||||
process.stdout.write(JSON.stringify({ retained, closed }));
|
||||
process.stdout.write(JSON.stringify({ retained, archived, closed }));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ export {
|
||||
TimelineRewindPendingError,
|
||||
TimelineRewindTargetError,
|
||||
type TimelineAppendResult,
|
||||
type TimelineChild,
|
||||
type TimelineCommit,
|
||||
type TimelineEntry,
|
||||
type TimelineHead,
|
||||
|
||||
@@ -7,8 +7,10 @@ import type {
|
||||
AgentTimelineRow,
|
||||
} from "../../agent-timeline-store-types.js";
|
||||
import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js";
|
||||
import type { ProviderSubagentDescriptor } from "../../provider-subagents/store.js";
|
||||
import type {
|
||||
AgentTimelineBackend,
|
||||
TimelineChild,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelinePrompt,
|
||||
@@ -21,6 +23,7 @@ import { MemoryTimelineBackend } from "./memory-backend.js";
|
||||
import { planProviderHistory } from "./provider-history.js";
|
||||
|
||||
export type {
|
||||
TimelineChild,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelineRewindIntent,
|
||||
@@ -40,6 +43,8 @@ export interface TimelineRetirementCommit {
|
||||
retirement: TimelineRetirement;
|
||||
/** The rows that left the active conversation. They stay stored. */
|
||||
retiredRows: ProjectedTimelineRow[];
|
||||
/** The provider children the retired rows spawned, and their descendants. They stay stored. */
|
||||
retiredChildIds: string[];
|
||||
epoch: string;
|
||||
retirementCount: number;
|
||||
}
|
||||
@@ -211,10 +216,62 @@ export class AgentTimeline {
|
||||
});
|
||||
}
|
||||
|
||||
/** The agent's provider children, retired ones included. */
|
||||
listChildren(agentId: string): Promise<TimelineChild[]> {
|
||||
return this.run({ agentId }, () => this.backend.children(agentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the descriptor `update` derives from the child's current one, creating the child when
|
||||
* absent. A retired child is left as it is and resolves null.
|
||||
*/
|
||||
recordChild(
|
||||
scope: TimelineScope,
|
||||
update: (previous: ProviderSubagentDescriptor | null) => ProviderSubagentDescriptor,
|
||||
): Promise<ProviderSubagentDescriptor | null> {
|
||||
return this.run(scope, async () => {
|
||||
const child = await this.backend.child(scope);
|
||||
if (child?.retired) return null;
|
||||
const descriptor = update(child?.descriptor ?? null);
|
||||
await this.backend.setChild(scope, descriptor);
|
||||
return descriptor;
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends to a child's history, creating the child when absent. A retired child resolves null. */
|
||||
recordChildEntries(
|
||||
scope: TimelineScope,
|
||||
entries: readonly TimelineEntry[],
|
||||
): Promise<TimelineCommit[] | null> {
|
||||
return this.run(scope, async () => {
|
||||
const child = await this.backend.child(scope);
|
||||
if (child?.retired) return null;
|
||||
if (!child) this.track(scope, await this.backend.create(scope, {}));
|
||||
return entries.length === 0 ? [] : await this.commit(scope, entries);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a child's history. A retired child stays stored and resolves false. */
|
||||
removeChild(scope: TimelineScope): Promise<boolean> {
|
||||
return this.run(scope, async () => {
|
||||
const child = await this.backend.child(scope);
|
||||
if (child?.retired) return false;
|
||||
await this.backend.delete(scope);
|
||||
this.sizes.delete(sizeKey(scope));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Deleting an agent's root history deletes all of its children's too. */
|
||||
delete(scope: TimelineScope): Promise<void> {
|
||||
return this.run(scope, async () => {
|
||||
await this.backend.delete(scope);
|
||||
this.sizes.delete(sizeKey(scope));
|
||||
if (scope.subagentId !== undefined) return;
|
||||
const childPrefix = sizeKey({ agentId: scope.agentId, subagentId: "" });
|
||||
for (const key of this.sizes.keys()) {
|
||||
if (key.startsWith(childPrefix)) this.sizes.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -379,11 +436,15 @@ export class AgentTimeline {
|
||||
if (!intent.cut) throw new Error("Cannot complete a rewind before its provider outcome");
|
||||
const retireFromSeq = resolveRetireFromSeq(intent, await this.backend.prompts(scope));
|
||||
if (retireFromSeq === null) throw new TimelineRewindBoundaryError(intent);
|
||||
const result = await this.backend.retire(scope, { retireFromSeq });
|
||||
const result = await this.backend.retire(scope, {
|
||||
retireFromSeq,
|
||||
selectChildren: childrenSpawnedBy,
|
||||
});
|
||||
this.track(scope, result.head);
|
||||
const commit: TimelineRetirementCommit = {
|
||||
retirement: result.retirement,
|
||||
retiredRows: result.retiredRows,
|
||||
retiredChildIds: result.retiredChildIds,
|
||||
epoch: result.head.epoch,
|
||||
retirementCount: result.head.retirementCount,
|
||||
};
|
||||
@@ -523,6 +584,35 @@ function judgeRewindEvidence(
|
||||
};
|
||||
}
|
||||
|
||||
/** The listed children that retired tool calls spawned, and their descendants. */
|
||||
function childrenSpawnedBy(
|
||||
retiredRows: readonly ProjectedTimelineRow[],
|
||||
children: readonly TimelineChild[],
|
||||
): string[] {
|
||||
const toolCallIds = new Set(
|
||||
retiredRows.flatMap((row) => (row.item.type === "tool_call" ? [row.item.callId] : [])),
|
||||
);
|
||||
const listed = children.flatMap((child) =>
|
||||
!child.retired && child.descriptor ? [child.descriptor] : [],
|
||||
);
|
||||
const retired = new Set(
|
||||
listed
|
||||
.filter((child) => child.toolCallId !== null && toolCallIds.has(child.toolCallId))
|
||||
.map((child) => child.id),
|
||||
);
|
||||
let grew = retired.size > 0;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const child of listed) {
|
||||
if (retired.has(child.id) || child.parentSubagentId === null) continue;
|
||||
if (!retired.has(child.parentSubagentId)) continue;
|
||||
retired.add(child.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
return [...retired];
|
||||
}
|
||||
|
||||
function sizeKey(scope: TimelineScope): string {
|
||||
return scope.subagentId === undefined ? scope.agentId : `${scope.agentId}\0${scope.subagentId}`;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "../../agent-timeline-store-types.js";
|
||||
import type { ConversationRewindCut } from "../../agent-sdk-types.js";
|
||||
import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js";
|
||||
import type { ProviderSubagentDescriptor } from "../../provider-subagents/store.js";
|
||||
|
||||
/** Root history uses `agentId` alone; provider-child history adds the child's id. */
|
||||
export interface TimelineScope {
|
||||
@@ -63,6 +64,21 @@ export interface TimelinePrompt {
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
/** A provider child of an agent: its listing, stored with the child's scope. */
|
||||
export interface TimelineChild {
|
||||
subagentId: string;
|
||||
/** Null until the provider describes the child; such a child is not listed. */
|
||||
descriptor: ProviderSubagentDescriptor | null;
|
||||
/** Spawned by a rewound turn. Its rows stay stored; it is no longer listed. */
|
||||
retired: boolean;
|
||||
}
|
||||
|
||||
/** Picks the children a retirement takes with it, from the rows it retires. */
|
||||
export type RetiredChildSelector = (
|
||||
retiredRows: readonly ProjectedTimelineRow[],
|
||||
children: readonly TimelineChild[],
|
||||
) => readonly string[];
|
||||
|
||||
export interface TimelineBackendAppendResult {
|
||||
rows: AgentTimelineRow[];
|
||||
head: TimelineHead;
|
||||
@@ -72,10 +88,12 @@ export interface TimelineBackendRetireResult {
|
||||
retirement: TimelineRetirement;
|
||||
/** The rows that left the active conversation. They stay stored. */
|
||||
retiredRows: ProjectedTimelineRow[];
|
||||
/** The children the retirement marked retired. */
|
||||
retiredChildIds: string[];
|
||||
head: TimelineHead;
|
||||
}
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
/**
|
||||
* Storage for the timeline owner. Each mutating call is one atomic commit: it either applies
|
||||
@@ -85,6 +103,7 @@ type MaybePromise<T> = T | Promise<T>;
|
||||
*/
|
||||
export interface AgentTimelineBackend {
|
||||
create(scope: TimelineScope, seed: TimelineSeed): MaybePromise<TimelineHead>;
|
||||
/** Deleting a root scope deletes every child scope of the agent too, retired ones included. */
|
||||
delete(scope: TimelineScope): MaybePromise<void>;
|
||||
head(scope: TimelineScope): MaybePromise<TimelineHead | null>;
|
||||
/** Allocates the next sequence numbers and folds the entries into the projection. */
|
||||
@@ -99,12 +118,19 @@ export interface AgentTimelineBackend {
|
||||
setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): MaybePromise<TimelineHead>;
|
||||
/**
|
||||
* Allocates the next sequence number for a retirement of the active rows anchored at or after
|
||||
* `retireFromSeq`, and clears the rewind intent, in one commit.
|
||||
* `retireFromSeq`, marks the children `selectChildren` picks retired, and clears the rewind
|
||||
* intent, in one commit. `selectChildren` is pure.
|
||||
*/
|
||||
retire(
|
||||
scope: TimelineScope,
|
||||
input: { retireFromSeq: number },
|
||||
input: { retireFromSeq: number; selectChildren: RetiredChildSelector },
|
||||
): MaybePromise<TimelineBackendRetireResult>;
|
||||
/** The agent's children, retired ones included, in no particular order. */
|
||||
children(agentId: string): MaybePromise<TimelineChild[]>;
|
||||
/** The child of a child scope; null when the scope does not exist. */
|
||||
child(scope: TimelineScope): MaybePromise<TimelineChild | null>;
|
||||
/** Stores a child's descriptor, creating its scope when absent. */
|
||||
setChild(scope: TimelineScope, descriptor: ProviderSubagentDescriptor): MaybePromise<void>;
|
||||
attachProviderMessageId(
|
||||
scope: TimelineScope,
|
||||
clientMessageId: string,
|
||||
|
||||
@@ -7,10 +7,13 @@ import type {
|
||||
AgentTimelineRow,
|
||||
} from "../../agent-timeline-store-types.js";
|
||||
import { TimelineProjection, type ProjectedTimelineRow } from "../../timeline-projection.js";
|
||||
import type { ProviderSubagentDescriptor } from "../../provider-subagents/store.js";
|
||||
import type {
|
||||
AgentTimelineBackend,
|
||||
TimelineBackendAppendResult,
|
||||
RetiredChildSelector,
|
||||
TimelineBackendRetireResult,
|
||||
TimelineChild,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelinePrompt,
|
||||
@@ -29,12 +32,21 @@ interface MemoryTimelineState {
|
||||
retired: ProjectedTimelineRow[];
|
||||
retirementCount: number;
|
||||
rewind: TimelineRewindIntent | null;
|
||||
/** Child scopes only. */
|
||||
descriptor: ProviderSubagentDescriptor | null;
|
||||
childRetired: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
|
||||
function scopeKey(scope: TimelineScope): string {
|
||||
return scope.subagentId === undefined ? scope.agentId : `${scope.agentId}\0${scope.subagentId}`;
|
||||
return scope.subagentId === undefined
|
||||
? scope.agentId
|
||||
: childKeyPrefix(scope.agentId) + scope.subagentId;
|
||||
}
|
||||
|
||||
function childKeyPrefix(agentId: string): string {
|
||||
return `${agentId}\0`;
|
||||
}
|
||||
|
||||
function cloneRow<T extends AgentTimelineRow>(row: T): T {
|
||||
@@ -71,6 +83,8 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
retired: [],
|
||||
retirementCount: 0,
|
||||
rewind: null,
|
||||
descriptor: null,
|
||||
childRetired: false,
|
||||
};
|
||||
this.states.set(scopeKey(scope), state);
|
||||
return this.describe(state);
|
||||
@@ -78,6 +92,40 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
|
||||
delete(scope: TimelineScope): void {
|
||||
this.states.delete(scopeKey(scope));
|
||||
if (scope.subagentId !== undefined) return;
|
||||
const prefix = childKeyPrefix(scope.agentId);
|
||||
for (const key of this.states.keys()) {
|
||||
if (key.startsWith(prefix)) this.states.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
children(agentId: string): TimelineChild[] {
|
||||
const prefix = childKeyPrefix(agentId);
|
||||
const children: TimelineChild[] = [];
|
||||
for (const [key, state] of this.states) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
children.push({
|
||||
subagentId: key.slice(prefix.length),
|
||||
descriptor: state.descriptor ? { ...state.descriptor } : null,
|
||||
retired: state.childRetired,
|
||||
});
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
child(scope: TimelineScope): TimelineChild | null {
|
||||
const state = this.states.get(scopeKey(scope));
|
||||
if (!state || scope.subagentId === undefined) return null;
|
||||
return {
|
||||
subagentId: scope.subagentId,
|
||||
descriptor: state.descriptor ? { ...state.descriptor } : null,
|
||||
retired: state.childRetired,
|
||||
};
|
||||
}
|
||||
|
||||
setChild(scope: TimelineScope, descriptor: ProviderSubagentDescriptor): void {
|
||||
if (!this.states.has(scopeKey(scope))) this.create(scope, {});
|
||||
this.requireState(scope).descriptor = { ...descriptor };
|
||||
}
|
||||
|
||||
head(scope: TimelineScope): TimelineHead | null {
|
||||
@@ -121,16 +169,29 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
|
||||
return this.describe(state);
|
||||
}
|
||||
|
||||
retire(scope: TimelineScope, input: { retireFromSeq: number }): TimelineBackendRetireResult {
|
||||
retire(
|
||||
scope: TimelineScope,
|
||||
input: { retireFromSeq: number; selectChildren: RetiredChildSelector },
|
||||
): TimelineBackendRetireResult {
|
||||
const state = this.requireState(scope);
|
||||
const retirement = { seq: state.nextSeq, retireFromSeq: input.retireFromSeq };
|
||||
const retiredRows = state.projection.retire(retirement);
|
||||
const retiredChildIds = [...input.selectChildren(retiredRows, this.children(scope.agentId))];
|
||||
for (const subagentId of retiredChildIds) {
|
||||
const child = this.states.get(scopeKey({ agentId: scope.agentId, subagentId }));
|
||||
if (child) child.childRetired = true;
|
||||
}
|
||||
state.retired.push(...retiredRows);
|
||||
state.nextSeq = retirement.seq + 1;
|
||||
if (state.minSeq === 0) state.minSeq = retirement.seq;
|
||||
state.retirementCount += 1;
|
||||
state.rewind = null;
|
||||
return { retirement, retiredRows: retiredRows.map(cloneRow), head: this.describe(state) };
|
||||
return {
|
||||
retirement,
|
||||
retiredRows: retiredRows.map(cloneRow),
|
||||
retiredChildIds,
|
||||
head: this.describe(state),
|
||||
};
|
||||
}
|
||||
|
||||
/** Retired rows, oldest retirement first. Not part of the backend contract; tests read it. */
|
||||
|
||||
@@ -6,10 +6,14 @@ import type {
|
||||
AgentTimelineRow,
|
||||
} from "../agent-timeline-store-types.js";
|
||||
import type { ProjectedTimelineRow } from "../timeline-projection.js";
|
||||
import type { ProviderSubagentDescriptor } from "../provider-subagents/store.js";
|
||||
import type {
|
||||
AgentTimelineBackend,
|
||||
MaybePromise,
|
||||
RetiredChildSelector,
|
||||
TimelineBackendAppendResult,
|
||||
TimelineBackendRetireResult,
|
||||
TimelineChild,
|
||||
TimelineEntry,
|
||||
TimelineHead,
|
||||
TimelinePrompt,
|
||||
@@ -21,69 +25,96 @@ import { MemoryTimelineBackend } from "./internal/memory-backend.js";
|
||||
|
||||
/**
|
||||
* Testing entry point of the timeline module. Production code imports `./index.js`.
|
||||
*
|
||||
* Defers every call by a turn of the event loop, the shape of an I/O-backed store, and records
|
||||
* the committed appends and whole-history reads.
|
||||
*/
|
||||
|
||||
/** A backend tests can also ask for the rows rewinds retired, which stay stored. */
|
||||
export interface InspectableTimelineBackend extends AgentTimelineBackend {
|
||||
retiredRows(scope: TimelineScope): ProjectedTimelineRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The backends the timeline contract suite (`agent-timeline.test.ts`) runs against. Every
|
||||
* production backend is listed here.
|
||||
*/
|
||||
export const TIMELINE_CONTRACT_BACKENDS: ReadonlyArray<{
|
||||
name: string;
|
||||
create: () => InspectableTimelineBackend;
|
||||
}> = [{ name: "memory", create: () => new MemoryTimelineBackend() }];
|
||||
|
||||
/**
|
||||
* Wraps a backend: defers every call by a turn of the event loop, the shape of an I/O-backed
|
||||
* store, records the committed appends and whole-history reads, and fails the next append or
|
||||
* retirement on request.
|
||||
*/
|
||||
export class DeferredTimelineBackend implements AgentTimelineBackend {
|
||||
private readonly memory = new MemoryTimelineBackend();
|
||||
readonly appends: TimelineEntry[][] = [];
|
||||
readRowsCalls = 0;
|
||||
failNextAppend = false;
|
||||
failNextRetire = false;
|
||||
|
||||
private async defer<T>(call: () => T): Promise<T> {
|
||||
constructor(private readonly inner: InspectableTimelineBackend = new MemoryTimelineBackend()) {}
|
||||
|
||||
private async defer<T>(call: () => MaybePromise<T>): Promise<T> {
|
||||
await nextTurn();
|
||||
return call();
|
||||
return await call();
|
||||
}
|
||||
|
||||
create(scope: TimelineScope, seed: TimelineSeed): Promise<TimelineHead> {
|
||||
return this.defer(() => this.memory.create(scope, seed));
|
||||
return this.defer(() => this.inner.create(scope, seed));
|
||||
}
|
||||
delete(scope: TimelineScope): Promise<void> {
|
||||
return this.defer(() => this.memory.delete(scope));
|
||||
return this.defer(() => this.inner.delete(scope));
|
||||
}
|
||||
head(scope: TimelineScope): Promise<TimelineHead | null> {
|
||||
return this.defer(() => this.memory.head(scope));
|
||||
return this.defer(() => this.inner.head(scope));
|
||||
}
|
||||
append(
|
||||
scope: TimelineScope,
|
||||
entries: readonly TimelineEntry[],
|
||||
): Promise<TimelineBackendAppendResult> {
|
||||
return this.defer(() => {
|
||||
return this.defer(async () => {
|
||||
if (this.failNextAppend) {
|
||||
this.failNextAppend = false;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
const result = this.memory.append(scope, entries);
|
||||
const result = await this.inner.append(scope, entries);
|
||||
this.appends.push([...entries]);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
isRetired(scope: TimelineScope, entry: TimelineEntry): Promise<boolean> {
|
||||
return this.defer(() => this.memory.isRetired(scope, entry));
|
||||
return this.defer(() => this.inner.isRetired(scope, entry));
|
||||
}
|
||||
prompts(scope: TimelineScope): Promise<TimelinePrompt[]> {
|
||||
return this.defer(() => this.memory.prompts(scope));
|
||||
return this.defer(() => this.inner.prompts(scope));
|
||||
}
|
||||
setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): Promise<TimelineHead> {
|
||||
return this.defer(() => this.memory.setRewind(scope, rewind));
|
||||
return this.defer(() => this.inner.setRewind(scope, rewind));
|
||||
}
|
||||
children(agentId: string): Promise<TimelineChild[]> {
|
||||
return this.defer(() => this.inner.children(agentId));
|
||||
}
|
||||
child(scope: TimelineScope): Promise<TimelineChild | null> {
|
||||
return this.defer(() => this.inner.child(scope));
|
||||
}
|
||||
setChild(scope: TimelineScope, descriptor: ProviderSubagentDescriptor): Promise<void> {
|
||||
return this.defer(() => this.inner.setChild(scope, descriptor));
|
||||
}
|
||||
retire(
|
||||
scope: TimelineScope,
|
||||
input: { retireFromSeq: number },
|
||||
input: { retireFromSeq: number; selectChildren: RetiredChildSelector },
|
||||
): Promise<TimelineBackendRetireResult> {
|
||||
return this.defer(() => {
|
||||
if (this.failNextRetire) {
|
||||
this.failNextRetire = false;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
return this.memory.retire(scope, input);
|
||||
return this.inner.retire(scope, input);
|
||||
});
|
||||
}
|
||||
/** Rows rewinds retired; they stay stored. */
|
||||
retiredRows(scope: TimelineScope): ProjectedTimelineRow[] {
|
||||
return this.memory.retiredRows(scope);
|
||||
return this.inner.retiredRows(scope);
|
||||
}
|
||||
attachProviderMessageId(
|
||||
scope: TimelineScope,
|
||||
@@ -91,29 +122,29 @@ export class DeferredTimelineBackend implements AgentTimelineBackend {
|
||||
providerMessageId: string,
|
||||
): Promise<AgentTimelineRow | null> {
|
||||
return this.defer(() =>
|
||||
this.memory.attachProviderMessageId(scope, clientMessageId, providerMessageId),
|
||||
this.inner.attachProviderMessageId(scope, clientMessageId, providerMessageId),
|
||||
);
|
||||
}
|
||||
findSubmittedPrompt(
|
||||
scope: TimelineScope,
|
||||
clientMessageId: string,
|
||||
): Promise<AgentTimelineRow | null> {
|
||||
return this.defer(() => this.memory.findSubmittedPrompt(scope, clientMessageId));
|
||||
return this.defer(() => this.inner.findSubmittedPrompt(scope, clientMessageId));
|
||||
}
|
||||
fetch(
|
||||
scope: TimelineScope,
|
||||
options: AgentTimelineFetchOptions,
|
||||
): Promise<AgentTimelineFetchResult> {
|
||||
return this.defer(() => this.memory.fetch(scope, options));
|
||||
return this.defer(() => this.inner.fetch(scope, options));
|
||||
}
|
||||
readRows(scope: TimelineScope): Promise<ProjectedTimelineRow[]> {
|
||||
this.readRowsCalls += 1;
|
||||
return this.defer(() => this.memory.readRows(scope));
|
||||
return this.defer(() => this.inner.readRows(scope));
|
||||
}
|
||||
lastItem(scope: TimelineScope): Promise<AgentTimelineItem | null> {
|
||||
return this.defer(() => this.memory.lastItem(scope));
|
||||
return this.defer(() => this.inner.lastItem(scope));
|
||||
}
|
||||
lastAssistantMessage(scope: TimelineScope): Promise<string | null> {
|
||||
return this.defer(() => this.memory.lastAssistantMessage(scope));
|
||||
return this.defer(() => this.inner.lastAssistantMessage(scope));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8158,7 +8158,7 @@ export class Session {
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
parentAgentId: msg.parentAgentId,
|
||||
subagents: this.agentManager.listProviderSubagents(msg.parentAgentId),
|
||||
subagents: await this.agentManager.listProviderSubagents(msg.parentAgentId),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
@@ -8182,7 +8182,10 @@ export class Session {
|
||||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
||||
try {
|
||||
await this.loadProviderSubagentParent(msg.parentAgentId);
|
||||
const descriptor = this.agentManager.getProviderSubagent(msg.parentAgentId, msg.subagentId);
|
||||
const descriptor = await this.agentManager.getProviderSubagent(
|
||||
msg.parentAgentId,
|
||||
msg.subagentId,
|
||||
);
|
||||
if (!descriptor) {
|
||||
throw new Error("Provider subagent not found");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user