From 0ca959c51844593f3a549f9cad49a6e8492ab631 Mon Sep 17 00:00:00 2001 From: kb Date: Wed, 14 Oct 2026 12:45:18 -0400 Subject: [PATCH] fix(muse): open a child whose session/read fails When session/read failed for a new child, Children.update() returned without saving it. refresh() only visits saved children, and a replayed item revision doesn't reach update() again, so a child whose last revision hit the failure (a finished child replayed on resume, for example) never appeared. The child now opens without its title and workspace, so later item updates and refresh() read it like any other. Co-authored-by: kb --- plugins/muse-provider/server/children.ts | 62 +++++++++++---------- plugins/muse-provider/test/provider.test.ts | 59 ++++++++++++-------- 2 files changed, 69 insertions(+), 52 deletions(-) diff --git a/plugins/muse-provider/server/children.ts b/plugins/muse-provider/server/children.ts index f14670fd96..e969f6d460 100644 --- a/plugins/muse-provider/server/children.ts +++ b/plugins/muse-provider/server/children.ts @@ -36,40 +36,42 @@ export class Children { ) return; const id = item.childSessionId; - if (!this.children.has(id)) { - let session; - try { - ({ session } = await this.host.request( - "session/read", - { sessionId: id }, - childSessionSchema, - )); - } catch (error) { - this.readFailed(id, error); - return; - } - this.emit({ - type: "session.opened", - sessionId: id, - parentSessionId: this.parentId, - toolCallId: item.callId ?? item.itemId, - capabilities: [], - restoration: "parent", - cwd: session.workspaceRoot ?? this.cwd, - title: session.title, - }); - this.children.set(id, { - timeline: new Timeline(this.host, id, id, this.emit), - cursor: undefined, - ended: false, - }); - } + if (!this.children.has(id)) await this.open(id, item); await this.read(id); this.end(id, item); } // A child's transcript is a projection: Muse 1.4.7 can refuse to read a subagent's view right - // after creating it, and that must not fail the parent. The next item update or refresh retries - // from the same cursor. + // after creating it, and that must not fail the parent. A child whose session/read fails still + // opens, without its title and workspace, so a later item update or refresh() can read it: the + // item may be the child's last revision, which a replay would not deliver again. + private async open(id: string, item: WireItem): Promise { + let session; + try { + ({ session } = await this.host.request( + "session/read", + { sessionId: id }, + childSessionSchema, + )); + } catch (error) { + this.readFailed(id, error); + } + this.emit({ + type: "session.opened", + sessionId: id, + parentSessionId: this.parentId, + toolCallId: item.callId ?? item.itemId, + capabilities: [], + restoration: "parent", + cwd: session?.workspaceRoot ?? this.cwd, + title: session?.title, + }); + this.children.set(id, { + timeline: new Timeline(this.host, id, id, this.emit), + cursor: undefined, + ended: false, + }); + } + // The next item update or refresh() reads a failed child again from the same cursor. private async read(id: string): Promise { try { await this.page(id); diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 09f6eef1d6..c7f0e28208 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1274,31 +1274,28 @@ for (const outcome of ["ready", "failed"] as const) { ).toHaveLength(1); }); } -for (const [read, failure] of [ - ["view/page", "once"], - ["view/page", "always"], - ["session/read", "once"], -] as const) { - test(`a child ${read} that fails ${failure} leaves the parent session running`, async () => { - const h = await harness("subagent", { - MUSE_TEST_CHILD: "dedicated", - [read === "view/page" ? "MUSE_TEST_CHILD_PAGE_FAIL" : "MUSE_TEST_CHILD_READ_FAIL"]: failure, - }); - await h.open(); - await h.prompt(); - await h.wait( - (e) => - (e.type === "session.turn" && e.sessionId === "paseo-session" && e.state !== "started") || - e.type === "session.runtime_failed", - ); - expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]); - expect( - h.events.filter((e) => e.type === "session.turn" && e.sessionId === "paseo-session"), - ).toContainEqual(expect.objectContaining({ state: "completed" })); +async function childReadHarness(env: Record) { + const h = await harness("subagent", { MUSE_TEST_CHILD: "dedicated", ...env }); + await h.open(); + await h.prompt(); + await h.wait( + (e) => + (e.type === "session.turn" && e.sessionId === "paseo-session" && e.state !== "started") || + e.type === "session.runtime_failed", + ); + expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]); + expect( + h.events.filter((e) => e.type === "session.turn" && e.sessionId === "paseo-session"), + ).toContainEqual(expect.objectContaining({ state: "completed" })); + return h; +} +for (const failure of ["once", "always"] as const) { + test(`a child view/page that fails ${failure} leaves the parent session running`, async () => { + const h = await childReadHarness({ MUSE_TEST_CHILD_PAGE_FAIL: failure }); // The next update of the subagent's item reads the child again. expect( (await h.recorded()).filter( - (f) => f.method === read && f.params.sessionId === "fixture-child", + (f) => f.method === "view/page" && f.params.sessionId === "fixture-child", ).length, ).toBeGreaterThan(1); expect( @@ -1318,6 +1315,24 @@ for (const [read, failure] of [ ).toEqual([expect.objectContaining({ state: "completed" })]); }); } +test("a child whose session/read always fails still opens, shows its transcript, and ends", async () => { + const h = await childReadHarness({ MUSE_TEST_CHILD_READ_FAIL: "always" }); + // Without its session it opens in the parent's workspace and untitled. + expect( + h.events.filter((e) => e.type === "session.opened" && e.sessionId === "fixture-child"), + ).toEqual([expect.objectContaining({ cwd: h.root, title: undefined })]); + expect( + h.events.find( + (e) => + e.type === "timeline.item" && + e.sessionId === "fixture-child" && + e.item.type === "assistant_message", + ), + ).toMatchObject({ item: { text: "SUBAGENT_PHASE0_OK" } }); + expect( + h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"), + ).toEqual([expect.objectContaining({ state: "completed" })]); +}); test("gap backfill repairs a missing completion without duplicating item revisions", async () => { const h = await harness("text-reasoning", { MUSE_TEST_GAP: "1" }); await h.open();