fix(coding-agent): single projection for RLM child snapshots (#1845)
* fix(coding-agent): single projection for RLM child snapshots * fix(coding-agent): project live follow-up activity for session-only children A rehydrated child registered without an RlmChildRun always snapshotted as status done with no activity, so an attach mid-follow-up showed it idle. Derive activity from live session state, mirroring the run projection's done-plus-activity convention for follow-ups.
1- Fixed reattached sessions omitting queued child agents or showing the wrong child activity.16 unmodified lines1718192021212223242593 unmodified lines119120121122123123124125126127394 unmodified linesExpand all5225235245255265275285295305315325332 unmodified lines5365375385395405415425435445455465475485495505515528 unmodified lines56156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160294 unmodified lines69769869970065470170265670370470570670783 unmodified lines79179279379479579679779879980080180280380480580689 unmodified lines8968978988999009019029039049059061346 unmodified linesExpand all22532254225522562257225822592260226122622263226422652203226622672268220622692270227122722273227422752276227722782279228022812282228322842285228622871930 unmodified linesExpand all16 unmodified lines type AgentSessionMessageController, createAgentSessionMessage, isAgentSessionMessage,} from "../src/core/agent-messages.js";import { AgentSession } from "../src/core/agent-session.js";import { AgentSession, type RlmChildAgentSnapshot } from "../src/core/agent-session.js";import { AuthStorage } from "../src/core/auth-storage.js";import type { LoadExtensionsResult } from "../src/core/extensions/index.js";import { type HostRequestHandlers, ReplKernelManager } from "../src/core/kernel/index.js";import { convertToLlm } from "../src/core/messages.js";93 unmodified lines promise: Promise<unknown>; } >; _rlmChildCleanupFailures: Map<string, Awaited<ReturnType<AgentSession["listRlmSubagents"]>>["subagents"][number]>; _rlmChildSessions: Map<string, AgentSession>; _rlmChildSessions: Map<string, { session: AgentSession; run?: InspectableRlmRun }>; _rlmChildUnsubscribes: Map<string, () => void>; _deletedRlmChildIds: Set<string>; _rlmQuiescenceWaitAborts: Set<AbortController>; _createKernelHostHandlers(): HostRequestHandlers;394 unmodified linesExpand all const childDir = join(tempDir, childId); mkdirSync(childDir, { recursive: true }); const child = createSession({ rlmSessionDir: childDir }); child.setSessionName("restored-worker"); const restoredAnswer = assistantMessage("restored answer", usage(7, 3)); restoredAnswer.content.push({ type: "toolCall", id: "tool-1", name: "ipython", arguments: {} }); child.agent.state.messages.push(restoredAnswer); child.setCurrentRecap("restored recap"); const disposeChild = vi.spyOn(child, "disposeAsync"); const root = createSession(); const childStatuses: string[] = []; root.subscribe((event) => {2 unmodified lines } });
expect(root.registerRlmChildSession(childId, child)).toBe(true); expect(root.getRlmChildSnapshots()).toEqual([ expect.objectContaining({ id: childId, answerPreview: "restored answer", toolUseCount: 1, tokenCount: 10, recap: "restored recap", }), ]); expect((await root.listRlmSubagents()).subagents).toEqual([ expect.objectContaining({ rlm_child_id: childId, session_name: "restored-worker",8 unmodified lines expect(await root.listRlmSubagents()).toEqual({ subagents: [] }); expect(childStatuses).toEqual(["cancelled"]); });
it("projects live follow-up activity for a restored session-only child", async () => { const childId = "restored-followup-child"; const childDir = join(tempDir, childId); mkdirSync(childDir, { recursive: true }); const followUpGate = deferred<void>(); let followUpStarted = false; const child = createSession({ rlmSessionDir: childDir, streamFn: () => { const stream = createAssistantMessageEventStream(); followUpStarted = true; void followUpGate.promise.then(() => { stream.push({ type: "done", reason: "stop", message: assistantMessage("follow-up answer") }); }); return stream; }, }); const root = createSession(); expect(root.registerRlmChildSession(childId, child)).toBe(true);
const followUp = child.prompt("follow-up work"); await waitFor(() => followUpStarted); const busy = root.getRlmChildSnapshots(); expect(busy).toEqual([expect.objectContaining({ id: childId, status: "done" })]); expect(busy[0]?.activity).toBeDefined();
followUpGate.resolve(); await followUp; await child.waitForIdle(); const idle = root.getRlmChildSnapshots(); expect(idle).toEqual([expect.objectContaining({ id: childId, status: "done" })]); expect(idle[0]?.activity).toBeUndefined(); });
it("retries and releases failed retained child cleanup on the next compaction", async () => { const childId = "retained-retry-child"; const childDir = join(tempDir, childId); mkdirSync(childDir, { recursive: true });94 unmodified lines session: parent, }); // The same child can be visible in both lifecycle registries while // deletion settles; it must be traversed exactly once and remain hidden. rootInternals._rlmChildSessions.set(id, parent); rootInternals._rlmChildSessions.set(id, { session: parent }); } else { rootInternals._rlmChildSessions.set(id, parent); rootInternals._rlmChildSessions.set(id, { session: parent }); if (hiding === "deleted") { rootInternals._deletedRlmChildIds.add(id); } else { rootInternals._deletingRlmChildren.set(id, {83 unmodified lines expect(childUpdates[0]?.label).toBe("summarize shard 1"); await waitFor(() => childUpdates.some((update) => update.status === "done")); const doneUpdate = [...childUpdates].reverse().find((update) => update.status === "done"); expect(doneUpdate?.answerPreview).toBe("child answer: summarize shard 1"); expect(root.getRlmChildSnapshots()).toEqual([ expect.objectContaining({ id: result.rlm_child_id, status: "done", answerPreview: doneUpdate?.answerPreview, durationMs: expect.any(Number), }), ]); const child = root.getRlmChildSession(result.rlm_child_id); expect(child?.messages[0]).toMatchObject({ role: "custom", customType: "agent_message",89 unmodified lines deleteRlmSubagentRuntime: async (_id, child) => child?.disposeAsync(), }, }); const spawned = await root.runRlmChild("pending task", { name: "pending-child" }); expect(root.getRlmChildSnapshots()).toEqual([ expect.objectContaining({ id: spawned.rlm_child_id, status: "queued" }), ]); const handlers = (root as unknown as InspectableRlmSession)._createKernelHostHandlers(); const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler");
1346 unmodified linesExpand all const child = root.getRlmChildSession(childId); if (!child) { throw new Error("Missing retained child session"); } const rootInternals = root as unknown as InspectableRlmSession; await waitFor(() => !rootInternals._activeRlmChildRuns.has(childId)); const completeRelease = root.releaseRlmChildSession(childId, child); if (!completeRelease) throw new Error("Failed to release retained child");
child.setCurrentRecap("retained recap"); child.setSessionName("renamed-worker");
const childUpdates = events.filter( (event): event is { type: "rlm_child_update"; child: { sessionName?: string } } => (event): event is { type: "rlm_child_update"; child: RlmChildAgentSnapshot } => typeof event === "object" && event !== null && (event as { type?: string }).type === "rlm_child_update", ); expect(childUpdates.at(-1)?.child.sessionName).toBe("renamed-worker"); expect(childUpdates.at(-1)?.child).toMatchObject({ sessionName: "renamed-worker", durationMs: expect.any(Number), tokenCount: 10, recap: "retained recap", repliedSinceTask: false, }); expect(root.getRlmChildSnapshots()).toEqual([ expect.objectContaining({ sessionName: "renamed-worker", durationMs: expect.any(Number), tokenCount: 10, recap: "retained recap", }), ]); });
it("surfaces a child's recap on its snapshot once the summarizer sets it", async () => { let releaseChild: () => void = () => {};1930 unmodified linesExpand all