From 75bbd9ac424d8e57180398d949b6072cadc8e2e4 Mon Sep 17 00:00:00 2001 From: kb Date: Wed, 14 Oct 2026 12:20:52 -0400 Subject: [PATCH] fix(muse): keep the parent running when a subagent's view can't be read Muse 1.4.7 sometimes refuses to read a subagent's view ("view range read failed", kind internal), for example right after it creates the subagent. Children.update() let that error reach the session's notification chain, which failed the whole parent session while Muse kept running. Reading a child's transcript is now best effort: a failed session/read or view/page logs to stderr, and the child is read again from the same cursor on its next item update or silence refresh. The parent's item still ends the child's turn either way. --- plugins/muse-provider/server/children.ts | 35 ++++++++++++---- plugins/muse-provider/test/fake-muse.mjs | 16 +++++++- plugins/muse-provider/test/provider.test.ts | 44 +++++++++++++++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/plugins/muse-provider/server/children.ts b/plugins/muse-provider/server/children.ts index 73ba117143..f14670fd96 100644 --- a/plugins/muse-provider/server/children.ts +++ b/plugins/muse-provider/server/children.ts @@ -37,11 +37,17 @@ export class Children { return; const id = item.childSessionId; if (!this.children.has(id)) { - const { session } = await this.host.request( - "session/read", - { sessionId: id }, - childSessionSchema, - ); + 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, @@ -58,9 +64,24 @@ export class Children { ended: false, }); } - await this.page(id); + 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. + private async read(id: string): Promise { + try { + await this.page(id); + } catch (error) { + this.readFailed(id, error); + } + } + private readFailed(id: string, error: unknown): void { + process.stderr.write( + `Muse child ${id} view read failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } // Muse sends no turn events for a child, so the parent's item is the only record that it finished. // A subagent_* tool call completes when the spawn is accepted, so only subagent and workflow items count. private end(id: string, item: WireItem): void { @@ -88,7 +109,7 @@ export class Children { } else if (event.method === "item/delta") child.timeline.delta(deltaSchema.parse(event.params)); } async refresh(): Promise { - for (const id of this.children.keys()) await this.page(id); + for (const id of this.children.keys()) await this.read(id); } private async page(id: string): Promise { const child = this.children.get(id)!; diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index 98b9f7fec3..aa46cca6ef 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -31,6 +31,7 @@ const replacements = new Map(); const counts = new Map(); const receipts = new Set(); let pageHung = false; +const failedChildReads = new Set(); const lines = createInterface({ input: process.stdin }); lines.on("line", receive); function receive(line) { @@ -143,7 +144,20 @@ function scriptedResponse(frame) { respond(frame, { status: "accepted", commandId: frame.params.commandId }); return true; } - return frame.method === "view/page" && failedPage(frame); + return (frame.method === "view/page" && failedPage(frame)) || failedChildRead(frame); +} +const childReadFailures = { + "view/page": "MUSE_TEST_CHILD_PAGE_FAIL", + "session/read": "MUSE_TEST_CHILD_READ_FAIL", +}; +// Muse 1.4.7 can refuse to read a subagent's view right after creating it ("view range read failed"). +function failedChildRead(frame) { + const failure = process.env[childReadFailures[frame.method]]; + if (!failure || frame.params.sessionId !== "fixture-child") return false; + if (failure === "once" && failedChildReads.has(frame.method)) return false; + failedChildReads.add(frame.method); + rpcError(frame, -32603, "internal", "view range read failed"); + return true; } function failedPage(frame) { const anchor = frame.params.cursor; diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index db5837e67a..09f6eef1d6 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1274,6 +1274,50 @@ 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" })); + // 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", + ).length, + ).toBeGreaterThan(1); + expect( + h.events.filter((e) => e.type === "session.opened" && e.sessionId === "fixture-child"), + ).toHaveLength(1); + expect( + h.events.filter( + (e) => + e.type === "timeline.item" && + e.sessionId === "fixture-child" && + e.item.type === "assistant_message", + ), + ).toHaveLength(failure === "once" ? 1 : 0); + // The parent's item still settles the child, transcript or not. + 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();