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();