From 81f75f57c8687bef3b87f23745a8ee7b7cc9f74b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:18:57 +0000 Subject: [PATCH] fix(opencode): clear deleted subagents from the track Handle provider deletion events before fetching the missing session, and reconcile removals after complete parent listings so reconnects also clear stale rows. Keep descendant cleanup scoped to the deleted child. --- .../providers/opencode/v2/children.test.ts | 128 ++++++++++++++++++ .../agent/providers/opencode/v2/children.ts | 64 ++++++--- 2 files changed, 173 insertions(+), 19 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.test.ts b/packages/server/src/server/agent/providers/opencode/v2/children.test.ts index 21e2520f80..02d9b26567 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "vitest"; import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; import { V2Harness } from "../test-utils/v2-harness.js"; import { SessionChildren } from "./children.js"; +import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; function setup() { const harness = new V2Harness(); @@ -35,7 +36,134 @@ function setup() { return { children, child, upserts }; } +function setupTree() { + const harness = new V2Harness(); + const sessions = new Map( + [ + { ...harness.info, id: "first", parentID: "session" }, + { ...harness.info, id: "second", parentID: "session" }, + { ...harness.info, id: "grandchild", parentID: "first" }, + ].map((info) => [info.id, info]), + ); + harness.api.session.list = async (input) => ({ + data: [...sessions.values()].filter((info) => info.parentID === input?.parentID), + cursor: {}, + }); + harness.api.session.get = async ({ sessionID }) => { + const info = sessions.get(sessionID); + if (!info) throw { _tag: "SessionNotFoundError", sessionID }; + return info; + }; + const store = new ProviderSubagentStore(); + const children = new SessionChildren({ + client: () => harness.api, + id: "session", + emit: (event) => { + if (event.type === "provider_subagent") store.apply("parent", "opencode", event.event); + }, + reconcilePermissions: async () => {}, + }); + const ids = () => + store + .list("parent") + .map((child) => child.id) + .sort(); + return { harness, sessions, children, ids }; +} + describe("OpenCode v2 child presentation", () => { + test.each(["deletion event", "reconnect"])("removes a deleted child on %s", async (trigger) => { + const harness = new V2Harness(); + const child: SessionInfo = { ...harness.info, id: "child", parentID: "session" }; + let exists = true; + harness.api.session.list = async (input) => ({ + data: exists && input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + harness.api.session.get = async () => { + if (!exists) throw { _tag: "SessionNotFoundError", sessionID: child.id }; + return child; + }; + harness.api.session.active = async () => (exists ? { child: { type: "running" } } : {}); + const store = new ProviderSubagentStore(); + const children = new SessionChildren({ + client: () => harness.api, + id: "session", + emit: (event) => { + if (event.type === "provider_subagent") store.apply("parent", "opencode", event.event); + }, + reconcilePermissions: async () => {}, + }); + await children.reconcile("session"); + expect(store.list("parent")).toMatchObject([{ id: "child", status: "running" }]); + exists = false; + if (trigger === "reconnect") await children.reconcile("session"); + else + await children.observe({ + id: "deleted", + created: 2, + type: "session.deleted", + durable: { aggregateID: "child", seq: 1, version: 1 }, + data: { sessionID: "child" }, + }); + expect(store.list("parent")).toEqual([]); + }); + + test.each(["deletion event", "reconnect"])( + "removes a deleted child's descendants on %s without removing its sibling", + async (trigger) => { + const { sessions, children, ids } = setupTree(); + await children.reconcile("session"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + sessions.delete("first"); + sessions.delete("grandchild"); + if (trigger === "reconnect") await children.reconcile("session"); + else + await children.observe({ + id: "deleted", + created: 2, + type: "session.deleted", + durable: { aggregateID: "first", seq: 1, version: 1 }, + data: { sessionID: "first" }, + }); + expect(ids()).toEqual(["second"]); + }, + ); + + test("retains children on later pages and when reconciling only another subtree", async () => { + const { harness, sessions, children, ids } = setupTree(); + await children.reconcile("session"); + const list = harness.api.session.list; + harness.api.session.list = async (input) => { + if (input?.cursor === "second-page") return { data: [sessions.get("second")!], cursor: {} }; + if (input?.parentID === "session") + return { data: [sessions.get("first")!], cursor: { next: "second-page" } }; + return list(input); + }; + await children.reconcile("session"); + await children.reconcile("first"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + }); + + test("keeps missing children when a listing fails before its last page", async () => { + const { harness, sessions, children, ids } = setupTree(); + await children.reconcile("session"); + sessions.delete("first"); + sessions.delete("grandchild"); + const list = harness.api.session.list; + harness.api.session.list = async (input) => { + if (input?.cursor) throw new Error("Provider disconnected"); + if (input?.parentID === "session") + return { data: [sessions.get("second")!], cursor: { next: "second-page" } }; + return list(input); + }; + await expect(children.reconcile("session")).rejects.toThrow("Provider disconnected"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + harness.api.session.list = list; + await children.reconcile("session"); + expect(ids()).toEqual(["second"]); + }); + test("restores agent, model, variant and all token categories in the child subtitle", async () => { const { children, upserts } = setup(); await children.reconcile("session"); diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts index 08af88dcb0..6488139b50 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -17,11 +17,19 @@ interface ChildrenOptions { reconcilePermissions(id: string): Promise; bindChild?: (id: string) => void; } +interface Child { + parentID: string; + timeline: V2Timeline; + signature?: string; +} export class SessionChildren { - private readonly children = new Map(); - private readonly childStates = new Map(); + private readonly children = new Map(); constructor(private readonly options: ChildrenOptions) {} async observe(event: import("@opencode/client").OpenCodeEvent) { + if (event.type === "session.deleted") { + this.remove(event.data.sessionID); + return; + } if ( event.type === "session.created" && event.data.parentID && @@ -29,17 +37,18 @@ export class SessionChildren { ) { await this.reconcile(event.data.parentID); } - if ( - "sessionID" in event.data && - typeof event.data.sessionID === "string" && - this.children.has(event.data.sessionID) - ) { - await this.reconcileChild( - await this.options.client().session.get({ sessionID: event.data.sessionID }), - ); + if ("sessionID" in event.data && typeof event.data.sessionID === "string") { + const child = this.children.get(event.data.sessionID); + if (child) + await this.reconcileChild( + await this.options.client().session.get({ sessionID: event.data.sessionID }), + child.parentID, + ); } } async reconcile(parentID: string) { + const previous = [...this.children].filter(([, child]) => child.parentID === parentID); + const seen = new Set(); let cursor: string | undefined; do { const page = await this.options.client().session.list({ @@ -47,18 +56,35 @@ export class SessionChildren { limit: 100, }); for (const child of page.data) { - await this.reconcileChild(child); + seen.add(child.id); + await this.reconcileChild(child, parentID); await this.reconcile(child.id); } cursor = page.cursor.next ?? undefined; } while (cursor); + // A complete parent listing is authoritative. Do not prune on a partial or failed page. + for (const [id] of previous) { + if (!seen.has(id)) this.remove(id); + } } - private async reconcileChild(info: SessionInfo) { + private remove(id: string) { + if (!this.children.delete(id)) return; + // OpenCode deletes a session's descendants with it. + for (const [childID, child] of this.children) { + if (child.parentID === id) this.remove(childID); + } + this.options.emit({ + type: "provider_subagent", + provider: "opencode", + event: { type: "remove", id }, + }); + } + private async reconcileChild(info: SessionInfo, parentID: string) { await this.options.reconcilePermissions(info.id); - let timeline = this.children.get(info.id); - if (!timeline) { - timeline = new V2Timeline(); - this.children.set(info.id, timeline); + let child = this.children.get(info.id); + if (!child) { + child = { parentID, timeline: new V2Timeline() }; + this.children.set(info.id, child); this.options.bindChild?.(info.id); } const active = await this.options.client().session.active(); @@ -88,11 +114,11 @@ export class SessionChildren { timestamp: new Date(info.time.updated).toISOString(), }; const signature = JSON.stringify(presentation); - if (this.childStates.get(info.id) !== signature) { - this.childStates.set(info.id, signature); + if (child.signature !== signature) { + child.signature = signature; this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); } - await timeline.messages(await messages(this.options.client(), info.id), (event) => { + await child.timeline.messages(await messages(this.options.client(), info.id), (event) => { if (event.type === "timeline") this.options.emit({ type: "provider_subagent",