Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andkb 1294c1973e fix(muse): finish child refreshes and stop retries on failure
A final child update must read history newer than an in-flight retry. Serialize fresh reads instead of joining an older snapshot. Stop retry scheduling when the native runtime fails, including requests that reject during shutdown.

Co-authored-by: kb <kb@wardbitz.com>
2026-11-03 12:41:53 +00:00
paseo-bot[bot]andkb 030b471dba fix(muse): retry child history after the parent finishes
Finishing the parent clears its silence timer, so a failed final child read had no future retry. Keep retries with the child projection, share concurrent reads, and cancel pending retries when its session closes.

Co-authored-by: kb <kb@wardbitz.com>
2026-11-03 12:29:48 +00:00
kb 0ca959c518 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>
2026-11-03 12:09:04 +00:00
kb 75bbd9ac42 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.
2026-11-03 12:03:18 +00:00
kb 2e81768969 test(muse): drive the failed-repair retry test on the fake clock
The test polled the fake's request log and then slept 50 ms before advancing
the clock again, so a slow host could advance time before the plugin had
handled the failed page and re-armed its watchdog. The fake now never answers
the first page, so the plugin's own request timeout fails the repair on the
fake clock, and the test steps through the timeout and the re-armed watchdog
one timer at a time. Waiting for the first request moves into a harness
helper.
2026-11-03 12:03:17 +00:00
kb 8a349c6045 fix(muse): keep subagent runs alive, finished, and on the configured model
Muse projects a subagent's approvals onto the parent with the child's view
cursor. The plugin stored that cursor as the parent's recovery anchor, so the
two-minute silence repair paged the parent from a cursor Muse rejects
("unknown cursor anchor") and the failed repair failed the whole session.
Projected events no longer move the parent's cursor, and a failed silence
repair now logs and retries at the next silence.

Muse sends no turn events for a subagent and leaves its parent item
inProgress after it finishes, reporting the end in controlStatus. Finished
subagents therefore stayed "running" in the Subagents track and on their
timeline card until the parent's session closed. The plugin now ends the
child's turn, and settles the card, when the item reports resultReady or
closed, or a terminal status.

session/resume takes no model or approval mode, and Muse 1.4.7 reports its
default model after a resume while it keeps running the configured one. The
plugin adopted and persisted the reported model. After a resume it now
re-applies the configured model and approval mode when Muse reports others.
2026-11-03 12:03:17 +00:00
6 changed files with 599 additions and 29 deletions
+94 -20
View File
@@ -1,6 +1,6 @@
import type { ProviderEvent } from "@getpaseo/plugin/server/provider";
import { MspConnection, type Notification } from "./connection.js";
import { Timeline } from "./timeline.js";
import { Timeline, subagentOutcome } from "./timeline.js";
import {
childSessionSchema,
pageSchema,
@@ -10,12 +10,19 @@ import {
type WireItem,
} from "./wire.js";
// Match the existing silence-recovery cadence, including after the parent becomes idle.
const READ_RETRY_MS = 2 * 60 * 1000;
interface Child {
timeline: Timeline;
cursor: string | undefined;
ended: boolean;
reading: Promise<void> | undefined;
retry: ReturnType<typeof setTimeout> | undefined;
}
export class Children {
private readonly children = new Map<string, Child>();
private stopped = false;
constructor(
private readonly host: MspConnection,
private readonly parentId: string,
@@ -35,28 +42,87 @@ export class Children {
)
return;
const id = item.childSessionId;
if (!this.children.has(id)) {
const { session } = await this.host.request(
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. 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,
);
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,
});
));
} catch (error) {
this.readFailed(id, error);
}
await this.page(id);
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,
reading: undefined,
retry: undefined,
});
}
// A failed final read must recover even after the parent finishes and stops its silence timer.
// Each update reads fresh history after any older read, with serialized cursor advancement.
private async read(id: string): Promise<void> {
const child = this.children.get(id);
if (!child) return;
while (child.reading) await child.reading;
if (this.stopped || this.children.get(id) !== child) return;
clearTimeout(child.retry);
child.retry = undefined;
child.reading = this.page(id)
.catch((error: unknown) => {
this.readFailed(id, error);
if (!this.stopped && this.children.get(id) === child)
child.retry = setTimeout(() => {
void this.read(id);
}, READ_RETRY_MS);
})
.finally(() => {
child.reading = undefined;
});
return child.reading;
}
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 {
const child = this.children.get(id)!;
const outcome =
item.kind === "subagent" || item.kind === "workflow" ? subagentOutcome(item) : undefined;
if (!outcome || child.ended) return;
child.ended = true;
this.emit({
type: "session.turn",
sessionId: id,
turnId: item.itemId,
state: outcome,
...(outcome === "failed"
? { error: { message: item.result?.errorKind ?? "Muse subagent failed" } }
: {}),
});
}
async notify(id: string, event: Notification): Promise<void> {
const child = this.children.get(id);
@@ -67,7 +133,7 @@ export class Children {
} else if (event.method === "item/delta") child.timeline.delta(deltaSchema.parse(event.params));
}
async refresh(): Promise<void> {
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<void> {
const child = this.children.get(id)!;
@@ -87,7 +153,15 @@ export class Children {
if (next !== null) child.cursor = next;
} while (next !== null);
}
stop(): void {
this.stopped = true;
for (const child of this.children.values()) {
clearTimeout(child.retry);
child.retry = undefined;
}
}
close(): void {
this.stop();
for (const sessionId of this.children.keys()) this.emit({ type: "session.closed", sessionId });
this.children.clear();
}
+35 -2
View File
@@ -164,16 +164,18 @@ export class Session {
await live.recovery.backfill();
return live.children.refresh();
})
.catch((error) => this.runtimeFailed(error));
.catch((error: unknown) => this.repairFailed(error));
},
),
};
this.providerId = response.session.providerId ?? undefined;
const configured = { model: this.config.model, mode: this.config.mode };
this.config = {
...this.config,
model: response.session.modelId ?? this.config.model,
mode: response.session.approvalMode?.mode ?? this.config.mode ?? "onRequest",
};
if (input.persistence) await this.restoreControls(nativeId, configured);
this.options.emit({
type: "session.opened",
requestId: input.requestId,
@@ -205,6 +207,26 @@ export class Session {
});
}
// session/resume takes no model or approval mode, and Muse can reload a session on its defaults.
private async restoreControls(
sessionId: string,
configured: Pick<ProviderSessionConfig, "model" | "mode">,
): Promise<void> {
if (configured.model && configured.model !== this.config.model) {
await this.host.command(
"session/setModel",
{ sessionId, model: { providerId: this.providerId, modelId: configured.model } },
ackSchema,
);
this.config = { ...this.config, model: configured.model };
}
if (configured.mode && configured.mode !== this.config.mode) {
const mode = approvalModeSchema.parse(configured.mode);
await this.host.command("session/setApprovalMode", { sessionId, mode }, ackSchema);
this.config = { ...this.config, mode };
}
}
private live(): Live {
if (!this.liveState) throw new MuseError("sessionNotOpen", "Muse session is not open");
return this.liveState;
@@ -393,8 +415,17 @@ export class Session {
.then(() => this.notify(notification))
.catch((error: unknown) => this.runtimeFailed(error));
}
// The silence repair is best effort: the live view still delivers, so a failed page waits for the
// next silence instead of failing the session. Host death reaches runtimeFailed through onExit.
private repairFailed(error: unknown): void {
process.stderr.write(
`Muse silence repair failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
this.live().recovery.activity(undefined, this.activeTurnIds().length > 0);
}
private runtimeFailed(error: unknown): void {
this.live().recovery.close();
this.live().children.stop();
this.options.emit({
type: "session.runtime_failed",
sessionId: this.options.id,
@@ -410,7 +441,9 @@ export class Session {
return;
}
await this.processNotification(notification);
this.live().recovery.activity(envelope.viewCursor, this.activeTurnIds().length > 0);
// A projected subagent event carries the child's view cursor, which this session's view rejects.
const cursor = envelope.subagentOrigin === undefined ? envelope.viewCursor : undefined;
this.live().recovery.activity(cursor, this.activeTurnIds().length > 0);
}
private async processNotification(notification: Notification): Promise<void> {
switch (notification.method) {
+22
View File
@@ -144,6 +144,16 @@ function toolCallItem(item: WireItem, patch?: string): ProviderTimelineItem {
}
: toolDetail(item, patch),
} as const;
if (item.kind === "subagent" || item.kind === "workflow") {
const outcome = subagentOutcome(item);
if (outcome === "failed")
return {
...base,
status: outcome,
error: item.result?.errorKind ?? item.failureReason ?? item.status,
};
return { ...base, status: outcome ?? "running", error: null };
}
if (item.status === "inProgress") return { ...base, status: "running", error: null };
if (item.status === "cancelled") return { ...base, status: "canceled", error: null };
if (item.status !== "completed")
@@ -151,6 +161,18 @@ function toolCallItem(item: WireItem, patch?: string): ProviderTimelineItem {
return { ...base, status: "completed", error: null };
}
// Muse 1.4.7 leaves a finished subagent's item `inProgress`; its control status says the child is done.
export function subagentOutcome(item: WireItem): "completed" | "failed" | "canceled" | undefined {
if (item.status === "cancelled") return "canceled";
if (item.status !== "inProgress" && item.status !== "completed") return "failed";
const done =
item.status === "completed" ||
item.controlStatus === "resultReady" ||
item.controlStatus === "closed";
if (!done) return undefined;
return item.result?.errorKind ? "failed" : "completed";
}
export function appendDelta(
previous: WireItem,
delta: z.infer<typeof deltaSchema>,
+4
View File
@@ -28,6 +28,8 @@ export const itemSchema = z.object({
entryId: z.string().optional(),
kind: z.string(),
status: z.string(),
controlStatus: z.string().optional(),
result: z.object({ errorKind: z.string().optional() }).nullish(),
turnId: z.string().nullable().optional(),
commandId: z.string().optional(),
recordedAt: z.string().optional(),
@@ -84,6 +86,8 @@ export type WireApproval = z.infer<typeof approvalSchema>;
export const notificationSchema = z.object({
sessionId: z.string().optional(),
viewCursor: z.string().optional(),
// Marks a subagent's event projected onto the parent's plane; its viewCursor is the child's.
subagentOrigin: z.unknown().optional(),
});
export const frameSchema = z.object({
jsonrpc: z.literal("2.0"),
+155 -6
View File
@@ -1,4 +1,4 @@
import { appendFileSync, readFileSync } from "node:fs";
import { appendFileSync, readFileSync, existsSync } from "node:fs";
import { createInterface } from "node:readline";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -24,12 +24,15 @@ const rows = readFixture(scenario);
if (process.env.MUSE_TEST_REQUESTS)
appendFileSync(
process.env.MUSE_TEST_REQUESTS,
JSON.stringify({ event: "hostStarted", args: process.argv.slice(2) }) + "\n",
JSON.stringify({ event: "hostStarted", pid: process.pid, args: process.argv.slice(2) }) + "\n",
);
const catalogRows = readFixture("catalog-controls");
const replacements = new Map();
const counts = new Map();
const receipts = new Set();
let pageHung = false;
let deferredChildItem;
const failedChildReads = new Set();
const lines = createInterface({ input: process.stdin });
lines.on("line", receive);
function receive(line) {
@@ -78,6 +81,9 @@ function receiveFrame(line) {
for (const row of terminalFirst) emitFixtureMessage(row.msg, recorded, frame);
setTimeout(() => emitFixtureMessage(response.msg, recorded, frame), 50);
} else for (const row of messages) emitFixtureMessage(row.msg, recorded, frame);
// The parent's last live notifications before it waits on the subagent.
if (process.env.MUSE_TEST_CHILD_APPROVAL && frame.method === "turn/start")
projectChildApproval(frame.params.sessionId);
}
function controlResponse(frame) {
if (frame.method === "session/resume" && process.env.MUSE_TEST_HISTORY) {
@@ -110,10 +116,7 @@ function controlResponse(frame) {
return true;
}
if (process.env.MUSE_TEST_HANG === frame.method) return true;
if (frame.method === "session/setReasoningEffort") {
respond(frame, { status: "accepted", commandId: frame.params.commandId });
return true;
}
if (scriptedResponse(frame)) return true;
if (frame.method === "account/read") {
respond(frame, { credentialRequired: true, state: process.env.MUSE_TEST_ACCOUNT || "envKey" });
return true;
@@ -131,6 +134,91 @@ function controlResponse(frame) {
}
return false;
}
// Commands accepted without a recorded exchange, and the view/page failures some tests script.
function scriptedResponse(frame) {
if (
frame.method === "session/setReasoningEffort" ||
(process.env.MUSE_TEST_CHILD_APPROVAL && frame.method === "approval/decide") ||
(process.env.MUSE_TEST_RESUMED_MODEL &&
["session/setModel", "session/setApprovalMode"].includes(frame.method))
) {
respond(frame, { status: "accepted", commandId: frame.params.commandId });
return true;
}
return (
deferredChildPage(frame) ||
(frame.method === "view/page" && failedPage(frame)) ||
failedChildRead(frame)
);
}
// Hold an old child page until the consumer has received the final item update.
function deferredChildPage(frame) {
const releasePath = process.env.MUSE_TEST_CHILD_PAGE_RELEASE;
if (
!releasePath ||
frame.method !== "view/page" ||
frame.params.sessionId !== "fixture-child" ||
!deferredChildItem ||
!failedChildReads.has("view/page")
)
return false;
send(deferredChildItem);
deferredChildItem = undefined;
function release() {
if (!existsSync(releasePath)) {
setTimeout(release, 5);
return;
}
respond(frame, { events: [], nextCursor: null });
}
release();
return true;
}
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;
if (failure === "twice") {
const key = `child-fail:${frame.method}`;
const count = counts.get(key) ?? 0;
if (count >= 2) return false;
counts.set(key, count + 1);
}
failedChildReads.add(frame.method);
rpcError(frame, -32603, "internal", "view range read failed");
return true;
}
function failedPage(frame) {
const anchor = frame.params.cursor;
// Real Muse rejects an anchor from another session's view the same way (live probe, Muse 1.4.7).
if (
process.env.MUSE_TEST_CHILD_APPROVAL &&
anchor &&
anchor.split(":")[1] !== frame.params.sessionId
) {
send({
jsonrpc: "2.0",
id: frame.id,
error: {
code: -32004,
message: "unknown cursor anchor",
data: { kind: "notFound", reason: "missingAnchor", anchor },
},
});
return true;
}
// Never answering lets the plugin's own request timeout fail the repair on the test's fake clock.
if (process.env.MUSE_TEST_PAGE_HANG_ONCE && !pageHung) {
pageHung = true;
return true;
}
return false;
}
function parityResponse(frame) {
if (workflowResponse(frame)) return true;
if (frame.method === "view/page") {
@@ -341,8 +429,25 @@ function applyWireVariants(message) {
message.result.session.modelId = null;
message.result.session.providerId = null;
}
// Muse 1.4.7 can resume a session on its default model and approval mode.
if (message.result?.session && message.result.history && process.env.MUSE_TEST_RESUMED_MODEL) {
message.result.session.modelId = process.env.MUSE_TEST_RESUMED_MODEL;
message.result.session.approvalMode = {
lastCommandId: null,
mode: "onRequest",
source: "replay",
};
}
}
function parityDelivery(message) {
if (
process.env.MUSE_TEST_CHILD_PAGE_RELEASE &&
message.method === "item/completed" &&
message.params.item?.childSessionId === "fixture-child"
) {
deferredChildItem = message;
return true;
}
if (process.env.MUSE_TEST_WORKFLOW && message.method === "turn/completed")
send({
jsonrpc: "2.0",
@@ -379,6 +484,42 @@ function parityDelivery(message) {
});
return false;
}
// A subagent's approval projected onto the parent's plane, shaped like Muse 1.4.7's: the parent's
// sessionId, but a viewCursor and sourceRange from the child's stream.
function projectChildApproval(sessionId) {
const child = "fixture-child";
const subagentOrigin = { subagentId: "subagent-1", childSessionId: child, childRunId: child };
const sourceRange = {
stream: { kind: "session", id: child },
first: { id: "fixture-child-record", sequence: 2 },
last: { id: "fixture-child-record", sequence: 2 },
};
const approvalId = "fixture-child-approval";
send({
jsonrpc: "2.0",
method: "approval/requested",
params: {
sessionId,
approvalId,
itemId: "fixture-child-tool",
toolName: "bash",
subject: { kind: "shell", command: "FOO=1 ls ~" },
currentRequirementId: { approvalId, sourceIndex: 0 },
availableChoices: [
{ choiceId: "approve-once", decision: "approved", scope: "once", label: "Approve once" },
{ choiceId: "deny-once", decision: "denied", scope: "once", label: "Deny" },
],
sourceRange,
subagentOrigin,
viewCursor: `v:${child}:2`,
},
});
send({
jsonrpc: "2.0",
method: "approval/resolved",
params: { sessionId, approvalId, sourceRange, subagentOrigin, viewCursor: `v:${child}:3` },
});
}
function applyTestVariants(message) {
applyParityVariants(message);
if (message.method === "turn/completed" && process.env.MUSE_TEST_INTERRUPT_TERMINAL)
@@ -413,6 +554,14 @@ function applyParityVariants(message) {
message.params.item.role = "worker";
message.params.item.objective = "Child objective";
}
// Muse 1.4.7 leaves a finished subagent `inProgress` and reports the end in controlStatus.
if (process.env.MUSE_TEST_CHILD_OUTCOME && message.method === "item/completed") {
message.params.item.status = "inProgress";
message.params.item.controlStatus = "resultReady";
message.params.item.result = { summary: "Child done", evidenceRefs: [], artifactRefs: [] };
if (process.env.MUSE_TEST_CHILD_OUTCOME === "failed")
message.params.item.result.errorKind = "childFailed";
}
}
if (message.params?.availableChoices && process.env.MUSE_TEST_DENIED)
message.params.availableChoices.push({
+289 -1
View File
@@ -138,6 +138,15 @@ async function harness(
.filter(Boolean)
.map((line) => JSON.parse(line));
}
async function waitForRequests(method: string, count: number) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const sent = (await recorded()).filter((frame) => frame.method === method);
if (sent.length >= count) return sent;
await delay(10);
}
throw new Error(`Muse never received ${count} ${method} request(s)`);
}
return {
provider,
launch,
@@ -148,6 +157,7 @@ async function harness(
open,
prompt,
recorded,
waitForRequests,
root,
usageSource,
};
@@ -406,6 +416,39 @@ for (const cursor of [undefined, "v:old:7"]) {
).not.toHaveProperty("cursor");
});
}
for (const source of ["agent config", "saved session"] as const) {
test(`resume re-applies the ${source} model and approval mode when Muse reports its defaults`, async () => {
const h = await harness("resume-without-cursor", {
MUSE_TEST_RESUMED_MODEL: "meta/muse-default",
});
await h.open(
{ version: 1, data: { sessionId: "saved-session", model: "meta/muse-spark-1.3" } },
"allowAll",
source === "agent config" ? "meta/muse-spark-1.3" : null,
);
const frames = await h.recorded();
expect(frames.find((f) => f.method === "session/setModel")?.params.model).toMatchObject({
modelId: "meta/muse-spark-1.3",
});
expect(frames.find((f) => f.method === "session/setApprovalMode")?.params.mode).toBe(
"allowAll",
);
expect(h.events.findLast((e) => e.type === "session.config")).toMatchObject({
config: { model: "meta/muse-spark-1.3", mode: "allowAll" },
});
expect(h.events.findLast((e) => e.type === "session.persistence")).toMatchObject({
persistence: { data: { model: "meta/muse-spark-1.3" } },
});
});
}
test("resume sends no model or approval change when Muse already matches", async () => {
const h = await harness("resume-without-cursor");
await h.open({ version: 1, data: { sessionId: "saved-session" } });
const frames = await h.recorded();
expect(
frames.filter((f) => ["session/setModel", "session/setApprovalMode"].includes(f.method)),
).toEqual([]);
});
test("steer sends ifBusy steer and joins the running turn", async () => {
const h = await harness("steer");
await h.open();
@@ -1173,10 +1216,17 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
e.item.type === "assistant_message",
),
).toMatchObject({ item: { text: "SUBAGENT_PHASE0_OK" } });
await h.wait((e) => e.type === "session.turn" && e.state === "completed");
await h.wait(
(e) =>
e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
);
expect(
h.events.filter((e) => e.type === "session.opened" && e.sessionId === "fixture-child"),
).toHaveLength(1);
// A completed subagent_spawn tool call only means the spawn was accepted.
expect(
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
).toHaveLength(variant === "tool" ? 0 : 1);
expect(
(await h.recorded())
.filter((f) => f.method === "session/read")
@@ -1192,6 +1242,97 @@ for (const variant of ["tool", "dedicated", "workflow"]) {
).toHaveLength(1);
});
}
for (const outcome of ["ready", "failed"] as const) {
test(`a subagent Muse leaves in progress with a ${outcome} result ends its child and its card`, async () => {
const h = await harness("subagent", {
MUSE_TEST_CHILD: "dedicated",
MUSE_TEST_CHILD_OUTCOME: outcome,
});
await h.open();
await h.prompt();
const state = outcome === "ready" ? "completed" : "failed";
expect(
await h.wait((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
).toMatchObject({
state,
...(outcome === "failed" ? { error: { message: "childFailed" } } : {}),
});
await h.wait(
(e) =>
e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
);
const cards = h.events.filter(
(e) =>
e.type === "timeline.item" &&
e.sessionId === "paseo-session" &&
e.item.type === "tool_call" &&
e.item.name === "subagent",
);
expect(cards.at(-1)).toMatchObject({ item: { status: state } });
expect(
h.events.filter((e) => e.type === "session.turn" && e.sessionId === "fixture-child"),
).toHaveLength(1);
});
}
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 === "view/page" && 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("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();
@@ -1220,6 +1361,54 @@ test("silent active turns page after two minutes and stop polling on recovered t
await vi.advanceTimersByTimeAsync(240000);
expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(1);
});
test("a subagent approval does not move the parent's silence repair onto the child's cursor", async () => {
const h = await harness("text-reasoning", {
MUSE_TEST_SILENT: "1",
MUSE_TEST_CHILD_APPROVAL: "1",
});
await h.open(undefined, "allowAll");
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await h.prompt();
await h.wait((e) => e.type === "session.permission_resolved");
await vi.advanceTimersByTimeAsync(120000);
await h.wait(
(e) =>
(e.type === "session.turn" && e.state === "completed") || e.type === "session.runtime_failed",
);
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
const frames = await h.recorded();
expect(frames.find((f) => f.method === "approval/decide").params.approvalId).toBe(
"fixture-child-approval",
);
const pages = frames.filter((f) => f.method === "view/page");
expect(pages).toHaveLength(1);
expect(pages[0].params.cursor.split(":")[1]).toBe(pages[0].params.sessionId);
});
test("a failed silence repair keeps the session and retries at the next silence", async () => {
const h = await harness("text-reasoning", {
MUSE_TEST_SILENT: "1",
MUSE_TEST_PAGE_HANG_ONCE: "1",
});
await h.open();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await h.prompt();
await h.wait((e) => e.type === "session.prompt_result");
// The first repair's page is never answered. The silence, that page's request timeout and the
// re-armed watchdog all run on the fake clock, one timer per step, whatever the timeout length.
await vi.advanceTimersByTimeAsync(120000);
await h.waitForRequests("view/page", 1);
await vi.advanceTimersToNextTimerAsync();
await vi.advanceTimersToNextTimerAsync();
await h.wait(
(e) =>
(e.type === "session.turn" && e.state === "completed") || e.type === "session.runtime_failed",
);
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(2);
expect(h.events.filter((e) => e.type === "session.turn" && e.state === "completed")).toHaveLength(
1,
);
});
test("session readiness accepts initialization beyond the old event polling window", async () => {
const h = await harness("text-reasoning", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" });
expect(await h.open()).toMatchObject({ type: "session.ready" });
@@ -1743,3 +1932,102 @@ test("Muse reports belong only to the session's account and remain available glo
expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]);
expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds);
});
test("a child history read recovers after the parent finishes", async () => {
const h = await harness("subagent", {
MUSE_TEST_CHILD: "dedicated",
MUSE_TEST_CHILD_PAGE_FAIL: "twice",
});
await h.open();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await h.prompt();
await h.wait(
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
);
expect(h.events.filter((e) => e.type === "session.runtime_failed")).toEqual([]);
expect(
h.events.some(
(e) =>
e.type === "timeline.item" &&
e.sessionId === "fixture-child" &&
e.item.type === "assistant_message",
),
).toBe(false);
await vi.advanceTimersByTimeAsync(120000);
await h.wait(
(e) =>
e.type === "timeline.item" &&
e.sessionId === "fixture-child" &&
e.item.type === "assistant_message",
);
}, 10000);
test("closing a session cancels pending child history retries", async () => {
const h = await harness("subagent", {
MUSE_TEST_CHILD: "dedicated",
MUSE_TEST_CHILD_PAGE_FAIL: "always",
});
await h.open();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await h.prompt();
await h.wait(
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
);
await h.send({ type: "session.close", sessionId: "paseo-session" });
await h.wait((e) => e.type === "session.closed" && e.sessionId === "paseo-session");
expect(vi.getTimerCount()).toBe(0);
});
test("a final child update refreshes history after an older retry read", async () => {
const h = await harness("subagent", {
MUSE_TEST_CHILD: "dedicated",
MUSE_TEST_CHILD_PAGE_FAIL: "once",
});
const release = path.join(h.root, "release-child-page");
h.launch.env.MUSE_TEST_CHILD_PAGE_RELEASE = release;
await h.open();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await h.prompt();
await h.wait(
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
);
await vi.advanceTimersByTimeAsync(120000);
await h.wait(
(e) =>
e.type === "timeline.item" &&
e.sessionId === "paseo-session" &&
e.item.type === "tool_call" &&
e.item.name === "subagent" &&
e.item.status === "completed",
);
await writeFile(release, "release");
await h.wait(
(e) => e.type === "session.turn" && e.sessionId === "fixture-child" && e.state === "completed",
);
expect(
h.events.some(
(e) =>
e.type === "timeline.item" &&
e.sessionId === "fixture-child" &&
e.item.type === "assistant_message" &&
e.item.text === "SUBAGENT_PHASE0_OK",
),
).toBe(true);
}, 10000);
test("native process exit stops child history retry timers", async () => {
const h = await harness("subagent", {
MUSE_TEST_CHILD: "dedicated",
MUSE_TEST_CHILD_PAGE_FAIL: "always",
});
await h.open();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await h.prompt();
await h.wait(
(e) => e.type === "session.turn" && e.sessionId === "paseo-session" && e.state === "completed",
);
const host = (await h.recorded()).find((f) => f.event === "hostStarted");
process.kill(host.pid, "SIGKILL");
await h.wait((e) => e.type === "session.runtime_failed");
expect(vi.getTimerCount()).toBe(0);
});