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 <kb@wardbitz.com>
This commit is contained in:
kb
2026-11-03 12:09:04 +00:00
committed by paseo-bot[bot]
parent 75bbd9ac42
commit 0ca959c518
2 changed files with 69 additions and 52 deletions
+32 -30
View File
@@ -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<void> {
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<void> {
try {
await this.page(id);
+37 -22
View File
@@ -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<string, string>) {
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();