mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user