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:
Mohamed Boudra
2026-11-10 13:09:38 +01:00
parent dc85c2bfbc
commit 80879eaf7c
22 changed files with 1386 additions and 631 deletions
+1
View File
@@ -53,6 +53,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
| [docs/timeline-sync.md](docs/timeline-sync.md) | Owned agent history, rewind retirements, live stream vs authoritative fetch, paged catch-up, client replica |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations |
+6 -3
View File
@@ -38,8 +38,8 @@ An unarchived agent may be `closed` without being deleted or archived. Closing r
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline, processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already same Paseo agent ID. Resume does not read provider history when the agent already has a timeline;
primed. see [owned history](timeline-sync.md#owned-history).
Reload releases the old runtime before resuming its durable session: an idle provider process can Reload releases the old runtime before resuming its durable session: an idle provider process can
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
@@ -213,7 +213,8 @@ lifecycle is separate from public workspace visibility and is not a public creat
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children. children. The timeline and the provider children stay, so an archived chat reads them without its
provider; deleting the agent removes them.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone. `create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
@@ -285,6 +286,8 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
Only the parent's current runtime makes a provider child running. A child's stored status is the last report about it, and that report can come from provider history or from a runtime an earlier daemon ran, so a stored `running` child does not make the workspace running or keep the parent's runtime alive. It counts again once the live session reports it running. Closing a runtime cancels the children that runtime reported running; children it never reported keep their stored status.
A finished workspace can be marked unread after it has been reviewed. The daemon restores A finished workspace can be marked unread after it has been reviewed. The daemon restores
`finished` attention on its newest eligible workspace-root agent without sending a new completion `finished` attention on its newest eligible workspace-root agent without sending a new completion
notification. Opening the workspace clears that attention through the normal focus flow. notification. Opening the workspace clears that attention through the normal focus flow.
+1
View File
@@ -94,6 +94,7 @@ workspace for a directory clears it. The store lives in memory and resets on dae
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents | | `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions | | `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | | `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/timeline/` | Owned agent history, provider children, rewind retirements, behind a backend |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | | `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | | `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK | | `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
+8
View File
@@ -175,6 +175,14 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
--- ---
## Runtime-only Agent Timelines
Agent timelines are not written to `$PASEO_HOME`. `AgentTimeline` owns each agent's history behind
a backend, and the only backend is in memory, so a daemon restart loses it and the next resume
imports provider history. See [owned history](timeline-sync.md#owned-history).
---
## 2. Daemon Configuration ## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json` **Path:** `$PASEO_HOME/config.json`
+62 -11
View File
@@ -5,15 +5,9 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates. 1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas. 2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon retains projected items in memory. Each source event advances the stream sequence, Pagination bounds each read, not total resident history. Streaming overhead must remain
then replaces the previous tool state or merges into the current text item. Intermediate payloads proportional to the retained transcript. Cumulative text snapshots must be released after their
are never retained for history or catch-up. Provider history is the durable transcript authority deltas have been consumed.
and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
The invariants are: The invariants are:
@@ -32,6 +26,44 @@ bounded item is used for runtime timeline rows and live stream events. Provider
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
again to assistant and reasoning rows the projection merges from many chunks. again to assistant and reasoning rows the projection merges from many chunks.
## Owned history
The daemon owns each agent's history in `AgentTimeline`
(`packages/server/src/server/agent/timeline/`): one ordered history per agent, with each provider
child's descriptor and history in a child scope of the same agent. Each source event advances the
stream sequence, then replaces the previous tool state or merges into the current text item.
Intermediate payloads are never retained for history or catch-up.
History is append-only: rewind, import, reload and archive never delete or rewrite a row. Only
deleting the agent, or a provider removing one of its own children, deletes history.
- **Rewind** retires a range of rows. They leave the conversation and stay stored, in the same
epoch. See [Rewind retires rows](#rewind-retires-rows).
- **Provider history** is imported only into an agent that has no history. After that, resume
does not read it. Reload agent and Unarchive append only the rows the provider has after the
owned conversation, as ordinary live rows. Any other difference, including an answer Paseo saw only part of or a prompt sent with
attachments, changes nothing and is reported as diverged. A provider child Paseo already holds,
listed or retired, is never imported again.
- **Archive and reload** keep the history and the provider children. An archived chat reads them
without its provider. Deleting the agent deletes its history and every child's, retired ones
included.
`epoch` is the stored history's identity, minted once when the history is created.
`AgentTimeline` serializes every operation of one agent, child scopes included, and keeps its
state in an `AgentTimelineBackend`: the sequence, the rows and retirements, prompt identities, the
open rewind, and the children. The backend contract suite is `agent-timeline.test.ts`, run over
`TIMELINE_CONTRACT_BACKENDS` in `timeline/testing.ts`. The only backend keeps everything in
memory, so a daemon restart loses the owned history: the next resume imports provider history into
a new epoch, and clients take the destructive reset below. Rewinds retired before the restart come
back if the provider history still holds them. Retained history costs memory for every loaded,
closed or archived agent until the daemon exits or the agent is deleted.
An open rewind is stored with the history. Resuming an agent whose history holds one settles it
before the agent takes prompts: a recorded provider cut is committed, and an unknown outcome is
judged from the resumed provider history. When that history proves nothing, the rewind stays open
and the agent refuses prompts with the unknown-outcome error.
## Presence is not delivery ## Presence is not delivery
Client heartbeat reports presence: Client heartbeat reports presence:
@@ -126,7 +158,7 @@ or retry backoff. This cannot keep a mobile socket alive after the operating sys
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport. bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than - When the page overlaps or is adjacent to the local end cursor, only projected items newer than
that cursor are applied. Already-covered rows are not replayed. that cursor are applied. Already-covered rows are not replayed.
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the - A true middle gap or epoch change atomically replaces stale canonical history with the
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
local submissions; it never retains two discontiguous canonical ranges. local submissions; it never retains two discontiguous canonical ranges.
@@ -139,6 +171,24 @@ history replay. The pending approval UI can hide that tool from presentation, bu
must retain its position. Creating a new history card on rejection places it after the prompt that must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt. rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Rewind retires rows
A conversation rewind commits a retirement `{ seq, retireFromSeq }`: active rows anchored in
`[retireFromSeq, seq)` leave the conversation, and `seq` is the retirement's own position in the
sequence. The provider children spawned by retired tool calls, and their descendants, are retired
in the same commit. They leave the listing, keep their history, and drop late provider events.
Clients learn of a retirement live through `agent.timeline.rewind`, and on pages through
`retirements`, which lists the retirements whose `seq` lies inside the page's span. The client
drops the rows it holds in that range; rows without a timeline position are unechoed local sends
and stay. Retired ranges and the retirement's `seq` count as covered, so forward cursors advance
through them. Tail, before and prompt-jump pages are served from active rows, so one served before
a retirement the client already applied is stale and is refetched (`viewed-timeline-sync.ts`).
Clients without `timeline_rewind_operations` cannot drop retired rows. For them the session derives
a wire epoch that changes with each retirement, so they take the destructive replacement they
always took on rewind. The shim is tagged `COMPAT(timelineRewindOperations)`.
## Provider child history ## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client Child transcripts use the same projected-page reconciliation as the main conversation. The client
@@ -303,7 +353,7 @@ the draft-create handoff is the one boundary that also permits the legacy canoni
arrived first. Generic reducers and consumers do not reimplement message identity matching. arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and rows because a provider may never echo them. A known epoch change replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold
reset without an existing epoch is destructive because the client has no continuity anchor. reset without an existing epoch is destructive because the client has no continuity anchor.
@@ -319,6 +369,7 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in
## Relevant code ## Relevant code
- Server owned history: `packages/server/src/server/agent/timeline/`
- Server live stream forwarding: `packages/server/src/server/session.ts` - Server live stream forwarding: `packages/server/src/server/session.ts`
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts` - App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts` - App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
@@ -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 () => { 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-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-reload-fail-"));
let providerHistory: AgentTimelineItem[] = [ 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. */ /** 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) { function resumeWithProviderHistory(manager: AgentManager, cwd: string) {
return manager.resumeAgentFromPersistence( return manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "provider-history" }, { 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.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }),
]); ]);
expect((await manager.fetchProviderSubagentTimeline(imported.id, "thread-child")).rows).toEqual([ 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", provider: "codex",
event: { type: "upsert", id: "stale-child", title: "Stale child", status: "running" }, 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); 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" }), expect.objectContaining({ id: "stale-child", status: "canceled" }),
]); ]);
}); });
@@ -4924,11 +5024,13 @@ test("reloadAgentSession terminalizes running provider children when preserving
provider: "codex", provider: "codex",
event: { type: "upsert", id: "running-child", title: "Running child", status: "running" }, 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); 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" }), 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); const storage = new AgentStorage(join(workdir, "agents"), logger);
class ProviderChildHistorySession extends TestAgentSession { class ProviderChildHistorySession extends TestAgentSession {
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> { override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
// The history's last report on this child is that it started.
yield { yield {
type: "provider_subagent", type: "provider_subagent",
provider: "codex", provider: "codex",
@@ -4945,7 +5048,7 @@ test("resuming without stored history restores provider children from the provid
type: "upsert", type: "upsert",
id: "restored-child", id: "restored-child",
title: "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); const snapshot = await resumeWithProviderHistory(manager, workdir);
expect(manager.listProviderSubagents(snapshot.id)).toEqual([ expect(await manager.listProviderSubagents(snapshot.id)).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "restored-child", id: "restored-child",
parentAgentId: snapshot.id, parentAgentId: snapshot.id,
title: "Restored child", title: "Restored child",
status: "completed", status: "running",
}),
]);
expect(manager.listProviderSubagentActivity()).toEqual([
expect.objectContaining({
id: "restored-child",
parentAgentId: snapshot.id,
}), }),
]); ]);
// 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 () => { 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}'`, `Unknown agent '${internalAgentId}'`,
); );
expect(() => manager.getProviderSubagent(internalAgentId, "hidden-child")).toThrow( await expect(manager.getProviderSubagent(internalAgentId, "hidden-child")).rejects.toThrow(
`Unknown agent '${internalAgentId}'`, `Unknown agent '${internalAgentId}'`,
); );
await expect( await expect(
manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child"), manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child"),
).rejects.toThrow(`Unknown agent '${internalAgentId}'`); ).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 () => { 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, logger,
}); });
expect(manager.getProviderSubagent(parent.id, "provider-child-running")?.status).toBe( expect((await manager.getProviderSubagent(parent.id, "provider-child-running"))?.status).toBe(
"canceled", "canceled",
); );
expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe( expect((await manager.getProviderSubagent(parent.id, "provider-child-finishing"))?.status).toBe(
"completed", "completed",
); );
} finally { } 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 () => { 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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger); const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -804,6 +804,12 @@ export class AgentManager {
private readonly agents = new Map<string, LiveManagedAgent>(); private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timeline: AgentTimeline; private readonly timeline: AgentTimeline;
private readonly providerSubagents: ProviderSubagentStore; 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 agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>(); private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>(); private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
@@ -1359,28 +1365,30 @@ export class AgentManager {
return await this.timeline.fetch({ agentId: id }, options); return await this.timeline.fetch({ agentId: id }, options);
} }
listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] { async listProviderSubagents(parentAgentId: string): Promise<ProviderSubagentDescriptor[]> {
this.requirePublicParent(parentAgentId); this.requirePublicParent(parentAgentId);
return this.providerSubagents.list(parentAgentId); return await this.providerSubagents.list(parentAgentId);
} }
listProviderSubagentActivity(): ProviderSubagentDescriptor[] { /** The children each loaded public agent's current runtime reports running. */
const publicParentIds = new Set( async listProviderSubagentActivity(): Promise<ProviderSubagentDescriptor[]> {
Array.from(this.agents.values()) const publicParents = Array.from(this.agents.values()).filter((agent) => !agent.internal);
.filter((agent) => !agent.internal) const children = await Promise.all(
.map((agent) => agent.id), 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 return children.flat();
.listAll()
.filter((subagent) => publicParentIds.has(subagent.parentAgentId));
} }
getProviderSubagent( async getProviderSubagent(
parentAgentId: string, parentAgentId: string,
subagentId: string, subagentId: string,
): ProviderSubagentDescriptor | null { ): Promise<ProviderSubagentDescriptor | null> {
this.requirePublicParent(parentAgentId); this.requirePublicParent(parentAgentId);
return this.providerSubagents.get(parentAgentId, subagentId); return await this.providerSubagents.get(parentAgentId, subagentId);
} }
async fetchProviderSubagentTimeline( async fetchProviderSubagentTimeline(
@@ -1574,13 +1582,14 @@ export class AgentManager {
currentResumeOptions, currentResumeOptions,
); );
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, { const registered = await this.registerSession(session, storedConfig, resolvedAgentId, {
...options, ...options,
launchEnv, launchEnv,
persistence: handle, persistence: handle,
restoring: true, restoring: true,
importMissingHistory: true, importMissingHistory: true,
}); });
return await this.settleStoredRewind(registered);
} }
importProviderSession(input: { importProviderSession(input: {
@@ -1659,7 +1668,7 @@ export class AgentManager {
}); });
for (const event of imported.providerSubagentEvents ?? []) { for (const event of imported.providerSubagentEvents ?? []) {
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event); 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; return agent;
} finally { } finally {
@@ -1772,7 +1781,7 @@ export class AgentManager {
restoring: true, restoring: true,
}); });
return settleRewind return settleRewind
? registered.then((snapshot) => this.settleRewindAfterReload(snapshot)) ? registered.then((snapshot) => this.settleRewindAfterResume(snapshot))
: registered.then((agent) => ({ agent, rewind: "none" as const })); : registered.then((agent) => ({ agent, rewind: "none" as const }));
} catch (error) { } catch (error) {
if (closedExisting) { if (closedExisting) {
@@ -1981,7 +1990,7 @@ export class AgentManager {
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) && (this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
!this.foregroundMutationTails.has(agent.id) && !this.foregroundMutationTails.has(agent.id) &&
!this.sessionEventTails.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; 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> { private async cancelRunningProviderSubagents(parentAgentId: string): Promise<void> {
for (const subagent of this.providerSubagents.list(parentAgentId)) { const running = this.runningProviderSubagents.get(parentAgentId);
if (subagent.status !== "running") { this.runningProviderSubagents.delete(parentAgentId);
continue; for (const subagent of await this.providerSubagents.list(parentAgentId)) {
} if (!running?.has(subagent.id)) continue;
const event = await this.providerSubagents.apply(parentAgentId, subagent.provider, { const event = await this.providerSubagents.apply(parentAgentId, subagent.provider, {
type: "upsert", type: "upsert",
id: subagent.id, id: subagent.id,
status: "canceled", status: "canceled",
}); });
if (event) this.dispatch({ type: "provider_subagent", event }); if (event) this.publishProviderSubagent(event);
} }
} }
@@ -3708,8 +3721,17 @@ export class AgentManager {
return "open"; return "open";
} }
/** The reload itself succeeded; a rewind that still cannot settle stays open and is reported. */ /** Stored history can hold a rewind an earlier runtime, or an earlier daemon, left open. */
private async settleRewindAfterReload( 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, snapshot: ManagedAgent,
): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> { ): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> {
let rewind: RewindSettlement = "open"; let rewind: RewindSettlement = "open";
@@ -4174,9 +4196,8 @@ export class AgentManager {
events: readonly ProviderSubagentStreamEvent[], events: readonly ProviderSubagentStreamEvent[],
options: { dispatch: boolean }, options: { dispatch: boolean },
): Promise<void> { ): Promise<void> {
for (const event of events) { for (const update of await this.providerSubagents.applyHistory(agentId, events)) {
const update = await this.providerSubagents.apply(agentId, event.provider, event.event); if (options.dispatch) this.dispatch({ type: "provider_subagent", event: update });
if (update && options.dispatch) this.dispatch({ type: "provider_subagent", event: update });
} }
} }
@@ -4279,12 +4300,10 @@ export class AgentManager {
} }
private async discardRetainedAgentState(agentId: string): Promise<void> { private async discardRetainedAgentState(agentId: string): Promise<void> {
await this.timeline.delete({ agentId }); const removed = await this.providerSubagents.deleteParent(agentId);
this.paseoToolPolicies.delete(agentId); this.paseoToolPolicies.delete(agentId);
this.detachPluginTools(agentId); this.detachPluginTools(agentId);
for (const event of await this.providerSubagents.deleteParent(agentId)) { for (const event of removed) this.publishProviderSubagent(event);
this.dispatch({ type: "provider_subagent", event });
}
} }
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
@@ -4391,7 +4410,11 @@ export class AgentManager {
): Promise<void> { ): Promise<void> {
if (event.type === "provider_subagent") { if (event.type === "provider_subagent") {
const update = await this.providerSubagents.apply(agent.id, event.provider, event.event); 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; return;
} }
const turnId = getAgentStreamEventTurnId(event); 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. */ /** Children spawned by retired tool calls leave with them, before clients learn of the retirement. */
private publishRetirement(agentId: string, commit: TimelineRetirementCommit): void { private publishRetirement(agentId: string, commit: TimelineRetirementCommit): void {
const toolCallIds = new Set( for (const subagentId of commit.retiredChildIds) {
commit.retiredRows.flatMap((row) => (row.item.type === "tool_call" ? [row.item.callId] : [])), this.publishProviderSubagent({ type: "remove", parentAgentId: agentId, subagentId });
);
for (const event of this.providerSubagents.retireSpawnedBy(agentId, toolCallIds)) {
this.dispatch({ type: "provider_subagent", event });
} }
this.dispatch({ this.dispatch({
type: "timeline_retirement", type: "timeline_retirement",
@@ -31,7 +31,7 @@ import {
StaleProviderSessionError, StaleProviderSessionError,
} from "./stale-provider-session-error.js"; } 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 fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
const output = execFileSync( const output = execFileSync(
process.execPath, process.execPath,
@@ -41,8 +41,12 @@ test("long concurrent plugin and child streams retain text-sized history and rel
timeout: 20_000, 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); 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); expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
}); });
@@ -642,7 +646,7 @@ describe("PluginAgentClientRegistry", () => {
state: "completed", state: "completed",
}); });
await nextTurn(); await nextTurn();
const [child] = manager.listProviderSubagents(agent.id); const [child] = await manager.listProviderSubagents(agent.id);
expect(child).toMatchObject({ expect(child).toMatchObject({
id: "reusable-child", id: "reusable-child",
title: "Plugin child", title: "Plugin child",
@@ -656,7 +660,7 @@ describe("PluginAgentClientRegistry", () => {
state: "started", state: "started",
}); });
await nextTurn(); await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([ expect(await manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: "running" }, { id: child.id, title: child.title, status: "running" },
]); ]);
@@ -667,7 +671,7 @@ describe("PluginAgentClientRegistry", () => {
state: outcome, state: outcome,
}); });
await nextTurn(); await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([ expect(await manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome }, { id: child.id, title: child.title, status: outcome },
]); ]);
} finally { } finally {
@@ -60,7 +60,9 @@ test("projects Codex child history and confines old-client degradation to the ch
); );
} }
await expect await expect
.poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length) .poll(
async () => (await ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id)).length,
)
.toBe(1); .toBe(1);
await expect await expect
.poll( .poll(
@@ -1,7 +1,19 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent-sdk-types.js";
import { AgentTimeline } from "../timeline/index.js"; import { AgentTimeline } from "../timeline/index.js";
import { ProviderSubagentStore } from "./store.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", () => { describe("ProviderSubagentStore", () => {
test("keeps provider children and their timelines scoped to the parent agent", async () => { test("keeps provider children and their timelines scoped to the parent agent", async () => {
const subagents = new ProviderSubagentStore(new AgentTimeline()); const subagents = new ProviderSubagentStore(new AgentTimeline());
@@ -34,7 +46,7 @@ describe("ProviderSubagentStore", () => {
timestamp: "2026-07-12T10:00:03.000Z", timestamp: "2026-07-12T10:00:03.000Z",
}); });
expect(subagents.list("parent-a")).toEqual([ expect(await subagents.list("parent-a")).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "child-1", id: "child-1",
parentAgentId: "parent-a", parentAgentId: "parent-a",
@@ -53,12 +65,15 @@ describe("ProviderSubagentStore", () => {
item: { type: "assistant_message", text: "Found it." }, 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([ expect(await subagents.deleteParent("parent-a")).toEqual([
{ type: "remove", parentAgentId: "parent-a", subagentId: "child-1" }, { type: "remove", parentAgentId: "parent-a", subagentId: "child-1" },
]); ]);
expect(subagents.list("parent-a")).toEqual([]); expect(await subagents.list("parent-a")).toEqual([]);
expect(subagents.list("parent-b")).toHaveLength(1); expect(await subagents.list("parent-b")).toHaveLength(1);
}); });
test("limits oversized provider child tool output before storage", async () => { 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 () => { 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 }) => const upsert = (id: string, fields: { toolCallId?: string; parentSubagentId?: string }) =>
subagents.apply("parent-a", "claude", { subagents.apply("parent-a", "claude", {
type: "upsert", type: "upsert",
@@ -104,11 +128,13 @@ describe("ProviderSubagentStore", () => {
item: { type: "assistant_message", text: "Working on B." }, item: { type: "assistant_message", text: "Working on B." },
}); });
expect(subagents.retireSpawnedBy("parent-a", new Set(["call-b"]))).toEqual([ const intent = await timeline.beginRewind(parent, "b");
{ type: "remove", parentAgentId: "parent-a", subagentId: "rewound" }, await timeline.recordRewindCut(parent, intent.id, { kind: "at_target" });
{ type: "remove", parentAgentId: "parent-a", subagentId: "grandchild" }, expect(await timeline.completeRewind(parent, intent.id)).toMatchObject({
]); kind: "retired",
expect(subagents.list("parent-a").map((subagent) => subagent.id)).toEqual(["kept"]); 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. // The retired child's history stays stored; a late event neither revives nor extends it.
expect( expect(
@@ -119,7 +145,10 @@ describe("ProviderSubagentStore", () => {
}), }),
).toBeNull(); ).toBeNull();
expect(await upsert("rewound", { toolCallId: "call-b" })).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( expect(
(await subagents.fetchTimeline("parent-a", "rewound")).rows.map((row) => row.item), (await subagents.fetchTimeline("parent-a", "rewound")).rows.map((row) => row.item),
).toEqual([{ type: "assistant_message", text: "Working on B." }]); ).toEqual([{ type: "assistant_message", text: "Working on B." }]);
@@ -61,10 +61,6 @@ export type ProviderSubagentStoreEvent =
} }
| { type: "remove"; parentAgentId: string; subagentId: string }; | { type: "remove"; parentAgentId: string; subagentId: string };
function storeKey(parentAgentId: string, subagentId: string): string {
return `${parentAgentId}\0${subagentId}`;
}
function childScope(parentAgentId: string, subagentId: string): TimelineScope { function childScope(parentAgentId: string, subagentId: string): TimelineScope {
return { agentId: parentAgentId, subagentId }; 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; 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 { 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) {} constructor(private readonly timeline: AgentTimeline) {}
/** Resolves null when the event belongs to a retired child; nothing is recorded. */ /** Resolves null when the event belongs to a retired child; nothing is recorded. */
@@ -91,20 +87,18 @@ export class ProviderSubagentStore {
provider: AgentProvider, provider: AgentProvider,
event: ProviderSubagentInputEvent, event: ProviderSubagentInputEvent,
): Promise<ProviderSubagentStoreEvent | null> { ): Promise<ProviderSubagentStoreEvent | null> {
const key = storeKey(parentAgentId, event.id);
const scope = childScope(parentAgentId, event.id); const scope = childScope(parentAgentId, event.id);
if (this.retired.has(key)) return null;
if (event.type === "remove") { if (event.type === "remove") {
this.descriptors.delete(key); if (!(await this.timeline.removeChild(scope))) return null;
await this.timeline.delete(scope);
return { type: "remove", parentAgentId, subagentId: event.id }; return { type: "remove", parentAgentId, subagentId: event.id };
} }
if (event.type === "timeline") { if (event.type === "timeline") {
await this.timeline.open(scope); const commits = await this.timeline.recordChildEntries(scope, [
const { { item: event.item, timestamp: event.timestamp },
commits: [{ row, epoch }], ]);
} = await this.timeline.append(scope, [{ item: event.item, timestamp: event.timestamp }]); if (!commits) return null;
const [{ row, epoch }] = commits;
return { return {
type: "timeline", type: "timeline",
parentAgentId, parentAgentId,
@@ -115,9 +109,9 @@ export class ProviderSubagentStore {
}; };
} }
const previous = this.descriptors.get(key); const subagent = await this.timeline.recordChild(scope, (previous) => {
const timestamp = event.timestamp ?? new Date().toISOString(); const timestamp = event.timestamp ?? new Date().toISOString();
const subagent: ProviderSubagentDescriptor = { return {
id: event.id, id: event.id,
parentAgentId, parentAgentId,
provider, provider,
@@ -131,23 +125,39 @@ export class ProviderSubagentStore {
subtitle: stickyField(event.subtitle, previous?.subtitle), subtitle: stickyField(event.subtitle, previous?.subtitle),
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId), parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
}; };
this.descriptors.set(key, subagent); });
await this.timeline.open(scope); return subagent ? { type: "upsert", subagent } : null;
return { type: "upsert", subagent };
} }
list(parentAgentId: string): ProviderSubagentDescriptor[] { /**
return [...this.descriptors.values()] * Applies the child events of a provider history read. Children the parent already holds,
.filter((subagent) => subagent.parentAgentId === parentAgentId) * 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)); .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
} }
listAll(): ProviderSubagentDescriptor[] { async get(parentAgentId: string, subagentId: string): Promise<ProviderSubagentDescriptor | null> {
return [...this.descriptors.values()]; return (await this.list(parentAgentId)).find((child) => child.id === subagentId) ?? null;
}
get(parentAgentId: string, subagentId: string): ProviderSubagentDescriptor | null {
return this.descriptors.get(storeKey(parentAgentId, subagentId)) ?? null;
} }
fetchTimeline( fetchTimeline(
@@ -158,53 +168,10 @@ export class ProviderSubagentStore {
return this.timeline.fetch(childScope(parentAgentId, subagentId), options); return this.timeline.fetch(childScope(parentAgentId, subagentId), options);
} }
/** /** Deletes the parent's history and every child's, retired children included. */
* 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 };
});
}
async deleteParent(parentAgentId: string): Promise<ProviderSubagentStoreEvent[]> { async deleteParent(parentAgentId: string): Promise<ProviderSubagentStoreEvent[]> {
const prefix = storeKey(parentAgentId, ""); const listed = await this.list(parentAgentId);
const retired = [...this.retired] await this.timeline.delete({ agentId: parentAgentId });
.filter((key) => key.startsWith(prefix)) return listed.map((child) => ({ type: "remove", parentAgentId, subagentId: child.id }));
.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 }));
} }
} }
@@ -2155,7 +2155,7 @@ describe("Codex app-server provider", () => {
); );
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"])); expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"]));
expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" }); 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", toolCallId: "spawn-child",
status: "completed", status: "completed",
}); });
@@ -344,6 +344,115 @@ describe("AgentManager conversation rewind", () => {
expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); 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 () => { test("reloading settles an unknown outcome the provider history proves unchanged", async () => {
const harness = await createConversationHarness(); const harness = await createConversationHarness();
const { manager, agentId, session } = harness; const { manager, agentId, session } = harness;
@@ -544,14 +653,16 @@ describe("AgentManager conversation rewind", () => {
await harness.prompt("A"); await harness.prompt("A");
await harness.prompt("B"); await harness.prompt("B");
await manager.flush(); 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-A",
"child-of-B", "child-of-B",
]); ]);
await manager.rewind(agentId, "B", "conversation"); 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( expect(
harness.events.flatMap((event) => harness.events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "remove" ? [event.event] : [], event.type === "provider_subagent" && event.event.type === "remove" ? [event.event] : [],
@@ -58,6 +58,8 @@ export class FakeRewindSession implements AgentSession {
nextRewind: FakeRewindOutcome = { kind: "applied" }; nextRewind: FakeRewindOutcome = { kind: "applied" };
/** Prompts whose turn spawns a provider subagent through a tool call. */ /** Prompts whose turn spawns a provider subagent through a tool call. */
readonly subagentPrompts = new Set<string>(); readonly subagentPrompts = new Set<string>();
/** Provider child events the provider's history replays after its conversation. */
historySubagentEvents: AgentStreamEvent[] = [];
private promptCount = 0; private promptCount = 0;
private subscribers = new Set<(event: AgentStreamEvent) => void>(); private subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -118,6 +120,15 @@ export class FakeRewindSession implements AgentSession {
provider: this.provider, provider: this.provider,
event: { type: "upsert", id: `child-of-${text}`, toolCallId: callId, status: "running" }, 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.activeTurnId = null;
this.emit({ type: "turn_completed", provider: this.provider, turnId }); this.emit({ type: "turn_completed", provider: this.provider, turnId });
@@ -136,6 +147,7 @@ export class FakeRewindSession implements AgentSession {
for (const item of this.history) { for (const item of this.history) {
yield { type: "timeline", provider: this.provider, item }; yield { type: "timeline", provider: this.provider, item };
} }
yield* this.historySubagentEvents;
} }
async getRuntimeInfo() { async getRuntimeInfo() {
@@ -149,7 +149,7 @@ for (let index = 0; index < sessions.length; index++) {
const item = items[step * 2]!; const item = items[step * 2]!;
assert.equal(item.type === "reasoning" && item.text, expected.get(`${index}:${step}`)); 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); assert.equal(children.length, 1);
let rootItems = 0; let rootItems = 0;
let childItems = 0; let childItems = 0;
@@ -160,17 +160,32 @@ for (let index = 0; index < sessions.length; index++) {
assert.equal(rootItems, steps * 2); assert.equal(rootItems, steps * 2);
assert.equal(childItems, steps * 2); assert.equal(childItems, steps * 2);
await manager.archiveAgent(agents[index]!.id); await manager.archiveAgent(agents[index]!.id);
assert.equal(await timeline.describe({ agentId: agents[index]!.id }), null);
assert.equal( assert.equal(
manager.listAgents().some((agent) => agent.id === agents[index]!.id), manager.listAgents().some((agent) => agent.id === agents[index]!.id),
false, 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 plugins.shutdown();
await manager.flushForShutdown(); await manager.flushForShutdown();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
global.gc(); global.gc();
const closed = process.memoryUsage().heapUsed - before; 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); assert.equal(sessions.length, 3);
process.stdout.write(JSON.stringify({ retained, closed })); process.stdout.write(JSON.stringify({ retained, archived, closed }));
@@ -3,9 +3,10 @@ import { promisify } from "node:util";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentTimelineItem, ConversationRewindCut } from "../agent-sdk-types.js"; import type { AgentTimelineItem, ConversationRewindCut } from "../agent-sdk-types.js";
import type { ProviderSubagentDescriptor } from "../provider-subagents/store.js";
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js"; import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
import type { AgentTimelineFetchResult, AgentTimelineRow } from "../agent-timeline-store-types.js"; import type { AgentTimelineFetchResult, AgentTimelineRow } from "../agent-timeline-store-types.js";
import { DeferredTimelineBackend } from "./testing.js"; import { DeferredTimelineBackend, TIMELINE_CONTRACT_BACKENDS } from "./testing.js";
import { selectReferenceProjectedTimelinePage } from "../test-utils/timeline-page-reference.js"; import { selectReferenceProjectedTimelinePage } from "../test-utils/timeline-page-reference.js";
import { import {
AgentTimeline, AgentTimeline,
@@ -33,10 +34,16 @@ async function append(
return commit.row; return commit.row;
} }
describe.each([ describe.each(
["memory", () => new AgentTimeline()], TIMELINE_CONTRACT_BACKENDS.flatMap(({ name, create }) => [
["deferred", () => new AgentTimeline(new DeferredTimelineBackend())], { name, create, createTimeline: () => new AgentTimeline(create()) },
])("AgentTimeline over a %s backend", (_name, createTimeline) => { {
name: `deferred ${name}`,
create,
createTimeline: () => new AgentTimeline(new DeferredTimelineBackend(create())),
},
]),
)("AgentTimeline over a $name backend", ({ create, createTimeline }) => {
it("lets a read observe writes called before it without awaiting them", async () => { it("lets a read observe writes called before it without awaiting them", async () => {
const timeline = createTimeline(); const timeline = createTimeline();
void timeline.create(root("a"), { epoch: "e" }); void timeline.create(root("a"), { epoch: "e" });
@@ -77,7 +84,7 @@ describe.each([
}); });
it("rejects a failed write without publishing it or consuming a sequence number", async () => { it("rejects a failed write without publishing it or consuming a sequence number", async () => {
const backend = new DeferredTimelineBackend(); const backend = new DeferredTimelineBackend(create());
const timeline = new AgentTimeline(backend); const timeline = new AgentTimeline(backend);
await timeline.create(root("a"), { epoch: "e" }); await timeline.create(root("a"), { epoch: "e" });
const published: TimelineCommit[] = []; const published: TimelineCommit[] = [];
@@ -230,9 +237,9 @@ describe.each([
}); });
}); });
describe("history paging", () => { describe.each(TIMELINE_CONTRACT_BACKENDS)("history paging over a $name backend", ({ create }) => {
it("resets stale forward cursors to complete available turns", async () => { it("resets stale forward cursors to complete available turns", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline(create());
await timeline.create(root("agent"), { epoch: "current" }); await timeline.create(root("agent"), { epoch: "current" });
for (let index = 0; index < 60; index++) for (let index = 0; index < 60; index++)
await append( await append(
@@ -284,7 +291,7 @@ describe("history paging", () => {
]; ];
it("clamps an overshooting before cursor into the bounded tail window", async () => { it("clamps an overshooting before cursor into the bounded tail window", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline(create());
await timeline.create(root("agent-1"), { epoch: "epoch-1", nextSeq: 8, rows: seededRows }); await timeline.create(root("agent-1"), { epoch: "epoch-1", nextSeq: 8, rows: seededRows });
const result = await timeline.fetch(root("agent-1"), { const result = await timeline.fetch(root("agent-1"), {
@@ -318,7 +325,7 @@ describe("history paging", () => {
}); });
it("returns a bounded reset window when an after cursor is behind retained history", async () => { it("returns a bounded reset window when an after cursor is behind retained history", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline(create());
await timeline.create(root("agent-1"), { epoch: "epoch-1", nextSeq: 8, rows: seededRows }); await timeline.create(root("agent-1"), { epoch: "epoch-1", nextSeq: 8, rows: seededRows });
const result = await timeline.fetch(root("agent-1"), { const result = await timeline.fetch(root("agent-1"), {
@@ -408,9 +415,11 @@ describe("projected timeline retention", () => {
}); });
}); });
describe("projected sequence ownership", () => { describe.each(TIMELINE_CONTRACT_BACKENDS)(
"projected sequence ownership over a $name backend",
({ create }) => {
it("preserves fetched coverage when another source chunk arrives", async () => { it("preserves fetched coverage when another source chunk arrives", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline(create());
await timeline.create(root("a")); await timeline.create(root("a"));
await append(timeline, "a", { type: "assistant_message", text: "A" }); await append(timeline, "a", { type: "assistant_message", text: "A" });
const before = await timeline.fetch(root("a")); const before = await timeline.fetch(root("a"));
@@ -428,7 +437,7 @@ describe("projected sequence ownership", () => {
}); });
it("preserves source positions when seeding a projected history whose last update is an earlier tool", async () => { it("preserves source positions when seeding a projected history whose last update is an earlier tool", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline(create());
await timeline.create(root("a")); await timeline.create(root("a"));
const tool = { const tool = {
type: "tool_call" as const, type: "tool_call" as const,
@@ -455,7 +464,7 @@ describe("projected sequence ownership", () => {
}); });
it("keeps differently identified assistant messages as separate rows", async () => { it("keeps differently identified assistant messages as separate rows", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline(create());
await timeline.create(root("a")); await timeline.create(root("a"));
await append(timeline, "a", { type: "assistant_message", messageId: "first", text: "First" }); await append(timeline, "a", { type: "assistant_message", messageId: "first", text: "First" });
await append(timeline, "a", { type: "assistant_message", messageId: "second", text: "Sec" }); await append(timeline, "a", { type: "assistant_message", messageId: "second", text: "Sec" });
@@ -465,7 +474,8 @@ describe("projected sequence ownership", () => {
{ type: "assistant_message", messageId: "second", text: "Second" }, { type: "assistant_message", messageId: "second", text: "Second" },
]); ]);
}); });
}); },
);
it("includes transitive tool updates in a contiguous projected tail", async () => { it("includes transitive tool updates in a contiguous projected tail", async () => {
const timeline = new AgentTimeline(); const timeline = new AgentTimeline();
@@ -673,9 +683,11 @@ it("continues a scroll across appends and lifecycle collapse without losing sour
expect(last.hasNewer).toBe(false); expect(last.hasNewer).toBe(false);
}); });
describe("conversation rewind", () => { describe.each(TIMELINE_CONTRACT_BACKENDS)(
"conversation rewind over a $name backend",
({ create }) => {
const scope = root("agent"); const scope = root("agent");
const createBackend = () => new DeferredTimelineBackend(); const createBackend = () => new DeferredTimelineBackend(create());
function prompt(messageId: string, turnId: string): TimelineEntry { function prompt(messageId: string, turnId: string): TimelineEntry {
return { item: { type: "user_message", text: messageId, messageId }, turnId }; return { item: { type: "user_message", text: messageId, messageId }, turnId };
@@ -746,7 +758,9 @@ describe("conversation rewind", () => {
epoch: "e", epoch: "e",
retirementCount: 1, retirementCount: 1,
}); });
expect(published.map((commit) => texts(commit.retiredRows))).toEqual([["b", "tool_call", "B"]]); expect(published.map((commit) => texts(commit.retiredRows))).toEqual([
["b", "tool_call", "B"],
]);
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "c", "C"]); expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "c", "C"]);
expect(texts(backend.retiredRows(scope))).toEqual(["b", "tool_call", "B"]); expect(texts(backend.retiredRows(scope))).toEqual(["b", "tool_call", "B"]);
expect(await timeline.describe(scope)).toEqual({ expect(await timeline.describe(scope)).toEqual({
@@ -1033,9 +1047,114 @@ describe("conversation rewind", () => {
// Each page covers 50 prompts and their 50 retirements. // Each page covers 50 prompts and their 50 retirements.
expect(pages).toBe(20); expect(pages).toBe(20);
}); });
});
describe("provider history", () => { const child = (subagentId: string): TimelineScope => ({ agentId: "agent", subagentId });
function descriptor(
id: string,
fields: { toolCallId?: string; parentSubagentId?: string },
): ProviderSubagentDescriptor {
return {
id,
parentAgentId: "agent",
parentSubagentId: fields.parentSubagentId ?? null,
provider: "claude",
title: null,
description: null,
status: "running",
createdAt: "2026-07-12T10:00:00.000Z",
updatedAt: "2026-07-12T10:00:00.000Z",
toolCallId: fields.toolCallId ?? null,
cwd: null,
subtitle: null,
};
}
async function conversationWithChildren(): Promise<{
backend: DeferredTimelineBackend;
timeline: AgentTimeline;
}> {
const { backend, timeline } = await conversation();
await timeline.recordChild(child("kept"), () => descriptor("kept", {}));
await timeline.recordChild(child("rewound"), () =>
descriptor("rewound", { toolCallId: "call-b" }),
);
await timeline.recordChild(child("grandchild"), () =>
descriptor("grandchild", { parentSubagentId: "rewound" }),
);
await timeline.recordChildEntries(child("rewound"), [reply("working", "t2")]);
return { backend, timeline };
}
async function children(timeline: AgentTimeline): Promise<[string, boolean][]> {
return (await timeline.listChildren("agent"))
.map((stored): [string, boolean] => [stored.subagentId, stored.retired])
.sort(([left], [right]) => left.localeCompare(right));
}
it("retires the children the rewound turn spawned, and their descendants, with the turn", async () => {
const { backend, timeline } = await conversationWithChildren();
expect(await rewind(timeline, "b")).toMatchObject({
kind: "retired",
retiredChildIds: ["rewound", "grandchild"],
});
const stored: [string, boolean][] = [
["grandchild", true],
["kept", false],
["rewound", true],
];
expect(await children(timeline)).toEqual(stored);
// A retired child keeps its history, and a late provider event changes nothing.
expect(texts((await timeline.fetch(child("rewound"))).rows)).toEqual(["working"]);
expect(
await timeline.recordChild(child("rewound"), () => descriptor("rewound", {})),
).toBeNull();
expect(await timeline.recordChildEntries(child("rewound"), [reply("late", "t2")])).toBeNull();
expect(await timeline.removeChild(child("rewound"))).toBe(false);
expect(texts((await timeline.fetch(child("rewound"))).rows)).toEqual(["working"]);
// The next owner over the same backend reads the same children.
expect(await children(new AgentTimeline(backend))).toEqual(stored);
});
it("leaves the conversation and its children as they were when the retirement fails", async () => {
const { backend, timeline } = await conversationWithChildren();
const intent = await timeline.beginRewind(scope, "b");
await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" });
backend.failNextRetire = true;
await expect(timeline.completeRewind(scope, intent.id)).rejects.toThrow("disk full");
expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]);
expect(await children(timeline)).toEqual([
["grandchild", false],
["kept", false],
["rewound", false],
]);
expect((await timeline.describe(scope))?.rewind?.id).toBe(intent.id);
expect(await timeline.completeRewind(scope, intent.id)).toMatchObject({
kind: "retired",
retiredChildIds: ["rewound", "grandchild"],
});
});
it("deletes every child history with the agent's, retired ones included", async () => {
const { backend, timeline } = await conversationWithChildren();
await rewind(timeline, "b");
await timeline.delete(scope);
const next = new AgentTimeline(backend);
expect(await next.listChildren("agent")).toEqual([]);
for (const subagentId of ["kept", "rewound", "grandchild"]) {
expect(await next.describe(child(subagentId))).toBeNull();
}
});
},
);
describe.each(TIMELINE_CONTRACT_BACKENDS)("provider history over a $name backend", ({ create }) => {
const scope = root("agent"); const scope = root("agent");
function prompt(messageId: string): TimelineEntry { function prompt(messageId: string): TimelineEntry {
@@ -1069,7 +1188,7 @@ describe("provider history", () => {
backend: DeferredTimelineBackend; backend: DeferredTimelineBackend;
timeline: AgentTimeline; timeline: AgentTimeline;
}> { }> {
const backend = new DeferredTimelineBackend(); const backend = new DeferredTimelineBackend(create());
const timeline = new AgentTimeline(backend); const timeline = new AgentTimeline(backend);
await timeline.create(scope, { epoch: "e" }); await timeline.create(scope, { epoch: "e" });
await timeline.append(scope, entries); await timeline.append(scope, entries);
@@ -1077,7 +1196,7 @@ describe("provider history", () => {
} }
it("imports history into a scope that has none, once", async () => { it("imports history into a scope that has none, once", async () => {
const timeline = new AgentTimeline(new DeferredTimelineBackend()); const timeline = new AgentTimeline(new DeferredTimelineBackend(create()));
const history = [prompt("a"), reply("A"), prompt("b"), reply("B")]; const history = [prompt("a"), reply("A"), prompt("b"), reply("B")];
const first = await timeline.importHistory(scope, history); const first = await timeline.importHistory(scope, history);
@@ -4,6 +4,7 @@ export {
TimelineRewindPendingError, TimelineRewindPendingError,
TimelineRewindTargetError, TimelineRewindTargetError,
type TimelineAppendResult, type TimelineAppendResult,
type TimelineChild,
type TimelineCommit, type TimelineCommit,
type TimelineEntry, type TimelineEntry,
type TimelineHead, type TimelineHead,
@@ -7,8 +7,10 @@ import type {
AgentTimelineRow, AgentTimelineRow,
} from "../../agent-timeline-store-types.js"; } from "../../agent-timeline-store-types.js";
import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js"; import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js";
import type { ProviderSubagentDescriptor } from "../../provider-subagents/store.js";
import type { import type {
AgentTimelineBackend, AgentTimelineBackend,
TimelineChild,
TimelineEntry, TimelineEntry,
TimelineHead, TimelineHead,
TimelinePrompt, TimelinePrompt,
@@ -21,6 +23,7 @@ import { MemoryTimelineBackend } from "./memory-backend.js";
import { planProviderHistory } from "./provider-history.js"; import { planProviderHistory } from "./provider-history.js";
export type { export type {
TimelineChild,
TimelineEntry, TimelineEntry,
TimelineHead, TimelineHead,
TimelineRewindIntent, TimelineRewindIntent,
@@ -40,6 +43,8 @@ export interface TimelineRetirementCommit {
retirement: TimelineRetirement; retirement: TimelineRetirement;
/** The rows that left the active conversation. They stay stored. */ /** The rows that left the active conversation. They stay stored. */
retiredRows: ProjectedTimelineRow[]; retiredRows: ProjectedTimelineRow[];
/** The provider children the retired rows spawned, and their descendants. They stay stored. */
retiredChildIds: string[];
epoch: string; epoch: string;
retirementCount: number; 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> { delete(scope: TimelineScope): Promise<void> {
return this.run(scope, async () => { return this.run(scope, async () => {
await this.backend.delete(scope); await this.backend.delete(scope);
this.sizes.delete(sizeKey(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"); if (!intent.cut) throw new Error("Cannot complete a rewind before its provider outcome");
const retireFromSeq = resolveRetireFromSeq(intent, await this.backend.prompts(scope)); const retireFromSeq = resolveRetireFromSeq(intent, await this.backend.prompts(scope));
if (retireFromSeq === null) throw new TimelineRewindBoundaryError(intent); 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); this.track(scope, result.head);
const commit: TimelineRetirementCommit = { const commit: TimelineRetirementCommit = {
retirement: result.retirement, retirement: result.retirement,
retiredRows: result.retiredRows, retiredRows: result.retiredRows,
retiredChildIds: result.retiredChildIds,
epoch: result.head.epoch, epoch: result.head.epoch,
retirementCount: result.head.retirementCount, 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 { function sizeKey(scope: TimelineScope): string {
return scope.subagentId === undefined ? scope.agentId : `${scope.agentId}\0${scope.subagentId}`; return scope.subagentId === undefined ? scope.agentId : `${scope.agentId}\0${scope.subagentId}`;
} }
@@ -6,6 +6,7 @@ import type {
} from "../../agent-timeline-store-types.js"; } from "../../agent-timeline-store-types.js";
import type { ConversationRewindCut } from "../../agent-sdk-types.js"; import type { ConversationRewindCut } from "../../agent-sdk-types.js";
import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.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. */ /** Root history uses `agentId` alone; provider-child history adds the child's id. */
export interface TimelineScope { export interface TimelineScope {
@@ -63,6 +64,21 @@ export interface TimelinePrompt {
submitted: boolean; 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 { export interface TimelineBackendAppendResult {
rows: AgentTimelineRow[]; rows: AgentTimelineRow[];
head: TimelineHead; head: TimelineHead;
@@ -72,10 +88,12 @@ export interface TimelineBackendRetireResult {
retirement: TimelineRetirement; retirement: TimelineRetirement;
/** The rows that left the active conversation. They stay stored. */ /** The rows that left the active conversation. They stay stored. */
retiredRows: ProjectedTimelineRow[]; retiredRows: ProjectedTimelineRow[];
/** The children the retirement marked retired. */
retiredChildIds: string[];
head: TimelineHead; 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 * 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 { export interface AgentTimelineBackend {
create(scope: TimelineScope, seed: TimelineSeed): MaybePromise<TimelineHead>; 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>; delete(scope: TimelineScope): MaybePromise<void>;
head(scope: TimelineScope): MaybePromise<TimelineHead | null>; head(scope: TimelineScope): MaybePromise<TimelineHead | null>;
/** Allocates the next sequence numbers and folds the entries into the projection. */ /** 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>; setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): MaybePromise<TimelineHead>;
/** /**
* Allocates the next sequence number for a retirement of the active rows anchored at or after * 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( retire(
scope: TimelineScope, scope: TimelineScope,
input: { retireFromSeq: number }, input: { retireFromSeq: number; selectChildren: RetiredChildSelector },
): MaybePromise<TimelineBackendRetireResult>; ): 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( attachProviderMessageId(
scope: TimelineScope, scope: TimelineScope,
clientMessageId: string, clientMessageId: string,
@@ -7,10 +7,13 @@ import type {
AgentTimelineRow, AgentTimelineRow,
} from "../../agent-timeline-store-types.js"; } from "../../agent-timeline-store-types.js";
import { TimelineProjection, type ProjectedTimelineRow } from "../../timeline-projection.js"; import { TimelineProjection, type ProjectedTimelineRow } from "../../timeline-projection.js";
import type { ProviderSubagentDescriptor } from "../../provider-subagents/store.js";
import type { import type {
AgentTimelineBackend, AgentTimelineBackend,
TimelineBackendAppendResult, TimelineBackendAppendResult,
RetiredChildSelector,
TimelineBackendRetireResult, TimelineBackendRetireResult,
TimelineChild,
TimelineEntry, TimelineEntry,
TimelineHead, TimelineHead,
TimelinePrompt, TimelinePrompt,
@@ -29,12 +32,21 @@ interface MemoryTimelineState {
retired: ProjectedTimelineRow[]; retired: ProjectedTimelineRow[];
retirementCount: number; retirementCount: number;
rewind: TimelineRewindIntent | null; rewind: TimelineRewindIntent | null;
/** Child scopes only. */
descriptor: ProviderSubagentDescriptor | null;
childRetired: boolean;
} }
const DEFAULT_TIMELINE_FETCH_LIMIT = 200; const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function scopeKey(scope: TimelineScope): string { 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 { function cloneRow<T extends AgentTimelineRow>(row: T): T {
@@ -71,6 +83,8 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
retired: [], retired: [],
retirementCount: 0, retirementCount: 0,
rewind: null, rewind: null,
descriptor: null,
childRetired: false,
}; };
this.states.set(scopeKey(scope), state); this.states.set(scopeKey(scope), state);
return this.describe(state); return this.describe(state);
@@ -78,6 +92,40 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
delete(scope: TimelineScope): void { delete(scope: TimelineScope): void {
this.states.delete(scopeKey(scope)); 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 { head(scope: TimelineScope): TimelineHead | null {
@@ -121,16 +169,29 @@ export class MemoryTimelineBackend implements AgentTimelineBackend {
return this.describe(state); 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 state = this.requireState(scope);
const retirement = { seq: state.nextSeq, retireFromSeq: input.retireFromSeq }; const retirement = { seq: state.nextSeq, retireFromSeq: input.retireFromSeq };
const retiredRows = state.projection.retire(retirement); 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.retired.push(...retiredRows);
state.nextSeq = retirement.seq + 1; state.nextSeq = retirement.seq + 1;
if (state.minSeq === 0) state.minSeq = retirement.seq; if (state.minSeq === 0) state.minSeq = retirement.seq;
state.retirementCount += 1; state.retirementCount += 1;
state.rewind = null; 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. */ /** Retired rows, oldest retirement first. Not part of the backend contract; tests read it. */
@@ -6,10 +6,14 @@ import type {
AgentTimelineRow, AgentTimelineRow,
} from "../agent-timeline-store-types.js"; } from "../agent-timeline-store-types.js";
import type { ProjectedTimelineRow } from "../timeline-projection.js"; import type { ProjectedTimelineRow } from "../timeline-projection.js";
import type { ProviderSubagentDescriptor } from "../provider-subagents/store.js";
import type { import type {
AgentTimelineBackend, AgentTimelineBackend,
MaybePromise,
RetiredChildSelector,
TimelineBackendAppendResult, TimelineBackendAppendResult,
TimelineBackendRetireResult, TimelineBackendRetireResult,
TimelineChild,
TimelineEntry, TimelineEntry,
TimelineHead, TimelineHead,
TimelinePrompt, TimelinePrompt,
@@ -21,69 +25,96 @@ import { MemoryTimelineBackend } from "./internal/memory-backend.js";
/** /**
* Testing entry point of the timeline module. Production code imports `./index.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 { export class DeferredTimelineBackend implements AgentTimelineBackend {
private readonly memory = new MemoryTimelineBackend();
readonly appends: TimelineEntry[][] = []; readonly appends: TimelineEntry[][] = [];
readRowsCalls = 0; readRowsCalls = 0;
failNextAppend = false; failNextAppend = false;
failNextRetire = 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(); await nextTurn();
return call(); return await call();
} }
create(scope: TimelineScope, seed: TimelineSeed): Promise<TimelineHead> { 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> { 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> { head(scope: TimelineScope): Promise<TimelineHead | null> {
return this.defer(() => this.memory.head(scope)); return this.defer(() => this.inner.head(scope));
} }
append( append(
scope: TimelineScope, scope: TimelineScope,
entries: readonly TimelineEntry[], entries: readonly TimelineEntry[],
): Promise<TimelineBackendAppendResult> { ): Promise<TimelineBackendAppendResult> {
return this.defer(() => { return this.defer(async () => {
if (this.failNextAppend) { if (this.failNextAppend) {
this.failNextAppend = false; this.failNextAppend = false;
throw new Error("disk full"); throw new Error("disk full");
} }
const result = this.memory.append(scope, entries); const result = await this.inner.append(scope, entries);
this.appends.push([...entries]); this.appends.push([...entries]);
return result; return result;
}); });
} }
isRetired(scope: TimelineScope, entry: TimelineEntry): Promise<boolean> { 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[]> { 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> { 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( retire(
scope: TimelineScope, scope: TimelineScope,
input: { retireFromSeq: number }, input: { retireFromSeq: number; selectChildren: RetiredChildSelector },
): Promise<TimelineBackendRetireResult> { ): Promise<TimelineBackendRetireResult> {
return this.defer(() => { return this.defer(() => {
if (this.failNextRetire) { if (this.failNextRetire) {
this.failNextRetire = false; this.failNextRetire = false;
throw new Error("disk full"); throw new Error("disk full");
} }
return this.memory.retire(scope, input); return this.inner.retire(scope, input);
}); });
} }
/** Rows rewinds retired; they stay stored. */ /** Rows rewinds retired; they stay stored. */
retiredRows(scope: TimelineScope): ProjectedTimelineRow[] { retiredRows(scope: TimelineScope): ProjectedTimelineRow[] {
return this.memory.retiredRows(scope); return this.inner.retiredRows(scope);
} }
attachProviderMessageId( attachProviderMessageId(
scope: TimelineScope, scope: TimelineScope,
@@ -91,29 +122,29 @@ export class DeferredTimelineBackend implements AgentTimelineBackend {
providerMessageId: string, providerMessageId: string,
): Promise<AgentTimelineRow | null> { ): Promise<AgentTimelineRow | null> {
return this.defer(() => return this.defer(() =>
this.memory.attachProviderMessageId(scope, clientMessageId, providerMessageId), this.inner.attachProviderMessageId(scope, clientMessageId, providerMessageId),
); );
} }
findSubmittedPrompt( findSubmittedPrompt(
scope: TimelineScope, scope: TimelineScope,
clientMessageId: string, clientMessageId: string,
): Promise<AgentTimelineRow | null> { ): Promise<AgentTimelineRow | null> {
return this.defer(() => this.memory.findSubmittedPrompt(scope, clientMessageId)); return this.defer(() => this.inner.findSubmittedPrompt(scope, clientMessageId));
} }
fetch( fetch(
scope: TimelineScope, scope: TimelineScope,
options: AgentTimelineFetchOptions, options: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult> { ): Promise<AgentTimelineFetchResult> {
return this.defer(() => this.memory.fetch(scope, options)); return this.defer(() => this.inner.fetch(scope, options));
} }
readRows(scope: TimelineScope): Promise<ProjectedTimelineRow[]> { readRows(scope: TimelineScope): Promise<ProjectedTimelineRow[]> {
this.readRowsCalls += 1; this.readRowsCalls += 1;
return this.defer(() => this.memory.readRows(scope)); return this.defer(() => this.inner.readRows(scope));
} }
lastItem(scope: TimelineScope): Promise<AgentTimelineItem | null> { 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> { lastAssistantMessage(scope: TimelineScope): Promise<string | null> {
return this.defer(() => this.memory.lastAssistantMessage(scope)); return this.defer(() => this.inner.lastAssistantMessage(scope));
} }
} }
+5 -2
View File
@@ -8158,7 +8158,7 @@ export class Session {
payload: { payload: {
requestId: msg.requestId, requestId: msg.requestId,
parentAgentId: msg.parentAgentId, parentAgentId: msg.parentAgentId,
subagents: this.agentManager.listProviderSubagents(msg.parentAgentId), subagents: await this.agentManager.listProviderSubagents(msg.parentAgentId),
error: null, error: null,
}, },
}); });
@@ -8182,7 +8182,10 @@ export class Session {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
try { try {
await this.loadProviderSubagentParent(msg.parentAgentId); 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) { if (!descriptor) {
throw new Error("Provider subagent not found"); throw new Error("Provider subagent not found");
} }