From 344ea8e973e6e55c5b73a7bf1b7576c844cbb9db Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 07:42:09 -0500 Subject: [PATCH 1/6] test(muse): resume on a fresh host when Muse replays queued work Muse Code 1.4.12 does not start session MCP servers from session/resume config when the resumed session replays queued work, so a Muse root loses T3's tools for that host's life. This test fails until the adapter relaunches the host on a replaying resume ack. Co-Authored-By: Claude Opus 5.5 --- .../provider-muse/src/server/adapter.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 63e0a1779d205..c302aed9a0edf 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -410,10 +410,50 @@ describe("MuseAdapterV2", () => { }, }, }); + assert.strictEqual(fake.closeCount(), 0); } }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("resumes on a fresh host when Muse replays queued work instead of starting MCP", () => + Effect.gen(function* () { + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("test-environment"), + threadId: THREAD_ID, + providerSessionId: "test-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43210/mcp", + authorizationHeader: "Bearer thread-scoped-test-token", + browserToolsAvailable: true, + }); + const fake = yield* makeFakeMuse("first"); + const recovery = yield* makeFakeMuse("second"); + for (const muse of [fake, recovery]) + muse.host.initializeResult.grantedCapabilities.push("sessionMcp"); + const ack = pendingPromise>(); + fake.queueResponse("session/resume", ack.promise); + const opening = yield* makeHarness(fake, INSTANCE_ID, "saved-session", recovery).pipe( + Effect.forkScoped, + ); + const first = yield* fake.takeCall("session/resume"); + // Muse announces the replayed turn before it acknowledges the resume. + yield* fake.emit("turn/started", { turnId: "replayed-turn" }); + yield* Effect.yieldNow; + ack.resolve({ + session: { sessionId: "saved-session", modelId: MODEL, activeTurnId: "replayed-turn" }, + history: { items: [] }, + }); + const harness = yield* Fiber.join(opening); + assert.strictEqual(fake.closeCount(), 1); + const again = yield* recovery.takeCall("session/resume"); + assert.strictEqual(again.params.sessionId, "saved-session"); + assert.deepStrictEqual(again.params.config, first.params.config); + assert.isFalse(yield* harness.runtime.hasPendingBackgroundWork!); + const { nativeId } = yield* startConversation(harness, recovery); + assert.match(nativeId, /^second-/); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("selects the runtime mode's permission profile on native start and resume", () => Effect.gen(function* () { for (const [runtimeMode, profile, approvalMode] of [ From 4769c26de25f4083696ec8498e46dacb1c6298b9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 07:42:49 -0500 Subject: [PATCH 2/6] fix(muse): a resume that replays queued work no longer drops T3 tools Muse Code 1.4.12 (and the 1.4.13 canary) never starts the session MCP servers from session/resume config when the resumed session replays queued work, a queued T3 turn or a background-terminal delivery. The root then runs without mcp__t3_code__ tools for that host's life, and MSP has no method to reload them. The resume ack says so: session.activeTurnId is set. When T3 resumed with MCP config and the ack names an active turn, the adapter now drops the replaying host's events, closes it (Muse cancels the replayed item), and resumes on a new host, up to five times. Each relaunch drains one queued item, and an idle resume starts MCP. Co-Authored-By: Claude Opus 5.5 --- packages/provider-muse/src/server/adapter.ts | 72 +++++++++++++------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 4ea37a2cc9eba..95b298a00dcf7 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -1840,30 +1840,54 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( : undefined; const startModel = yield* resolveModel(args.modelSelection); const permissionProfile = musePermissionProfile(args.runtimePolicy.runtimeMode); - const result = yield* request( - requestedId ? "session/resume" : "session/start", - requestedId - ? { excludeItems: true, ...(config ? { config } : {}), permissionProfile } - : { - workspaceRoot: cwd, - ...(config ? { config } : {}), - ...(startModel ? { modelId: startModel } : {}), - providerId: "meta", - approvalMode: museApprovalMode(args.runtimePolicy.runtimeMode), - permissionProfile, - }, - ).pipe( - Effect.catch((error) => { - missingNativeSession = - requestedId !== undefined && - error.payload instanceof MspError && - (error.payload.kind === "sessionNotFound" || error.payload.kind === "notFound"); - return Effect.fail(error); - }), - Effect.flatMap((value) => decode(MuseSessionResult, value)), - ); - if (result.session.sessionId !== nativeSessionId) - return yield* protocolError("Muse returned a different session identity"); + const open = Effect.gen(function* () { + const result = yield* request( + requestedId ? "session/resume" : "session/start", + requestedId + ? { excludeItems: true, ...(config ? { config } : {}), permissionProfile } + : { + workspaceRoot: cwd, + ...(config ? { config } : {}), + ...(startModel ? { modelId: startModel } : {}), + providerId: "meta", + approvalMode: museApprovalMode(args.runtimePolicy.runtimeMode), + permissionProfile, + }, + ).pipe( + Effect.catch((error) => { + missingNativeSession = + requestedId !== undefined && + error.payload instanceof MspError && + (error.payload.kind === "sessionNotFound" || error.payload.kind === "notFound"); + return Effect.fail(error); + }), + Effect.flatMap((value) => decode(MuseSessionResult, value)), + ); + if (result.session.sessionId !== nativeSessionId) + return yield* protocolError("Muse returned a different session identity"); + return result; + }); + let result = yield* open; + // Muse Code 1.4.12 never starts session MCP servers on a host whose resume replays + // queued work, and MSP cannot reload them. Closing the host cancels one replayed + // item, so resume on fresh hosts until one comes back idle. + for (let relaunch = 0; config && result.session.activeTurnId; relaunch++) { + if (relaunch === 5) { + yield* Effect.logWarning("Muse kept replaying queued work; T3 tools are unavailable"); + break; + } + yield* Effect.logWarning("Muse replayed queued work without T3 tools; relaunching"); + const replaying = host; + yield* eventPermit.withPermits(1)( + Effect.sync(() => { + hostEpoch++; + wake = undefined; + }), + ); + yield* Effect.tryPromise(() => replaying.close()).pipe(Effect.ignore); + yield* launchHost(); + result = yield* open; + } if (requestedId) yield* request("session/setApprovalMode", { mode: museApprovalMode(args.runtimePolicy.runtimeMode), From 35681e960b6a744f1d1f9641942c84426d5fb718 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 12:14:10 -0500 Subject: [PATCH 3/6] fix(muse): check for MCP config once, outside the relaunch loop The loop condition read config on every pass, which lint reports as no-unmodified-loop-condition. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/provider-muse/src/server/adapter.ts | 35 +++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 95b298a00dcf7..9b28a60f204f8 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -1871,23 +1871,26 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( // Muse Code 1.4.12 never starts session MCP servers on a host whose resume replays // queued work, and MSP cannot reload them. Closing the host cancels one replayed // item, so resume on fresh hosts until one comes back idle. - for (let relaunch = 0; config && result.session.activeTurnId; relaunch++) { - if (relaunch === 5) { - yield* Effect.logWarning("Muse kept replaying queued work; T3 tools are unavailable"); - break; + if (config) + for (let relaunch = 0; result.session.activeTurnId; relaunch++) { + if (relaunch === 5) { + yield* Effect.logWarning( + "Muse kept replaying queued work; T3 tools are unavailable", + ); + break; + } + yield* Effect.logWarning("Muse replayed queued work without T3 tools; relaunching"); + const replaying = host; + yield* eventPermit.withPermits(1)( + Effect.sync(() => { + hostEpoch++; + wake = undefined; + }), + ); + yield* Effect.tryPromise(() => replaying.close()).pipe(Effect.ignore); + yield* launchHost(); + result = yield* open; } - yield* Effect.logWarning("Muse replayed queued work without T3 tools; relaunching"); - const replaying = host; - yield* eventPermit.withPermits(1)( - Effect.sync(() => { - hostEpoch++; - wake = undefined; - }), - ); - yield* Effect.tryPromise(() => replaying.close()).pipe(Effect.ignore); - yield* launchHost(); - result = yield* open; - } if (requestedId) yield* request("session/setApprovalMode", { mode: museApprovalMode(args.runtimePolicy.runtimeMode), From 1ae22ea8824093da553750484f944efec5d1455c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 16:42:40 -0500 Subject: [PATCH 4/6] fix(muse): a replaying host's stuck close no longer wedges the session The resume relaunch awaited the replaying host's close() with no limit, inside register under the command permit. A replayed background command that inherits Muse's pipes keeps that promise pending, so ensureThread and interruptTurn hung for good. Each host now owns a child scope, and the relaunch closes that scope. This runs the host's existing release finalizer: close bounded at 13 s, then the AgentScope unit reap. The 13 s bound now applies with or without a unit; the SDK has sent SIGKILL by 12 s, so the session lease is free. The finalizer ends the session only for the current host epoch. Co-Authored-By: Claude Opus 5.5 --- .../provider-muse/src/server/adapter.test.ts | 71 +++++++++++++++++++ packages/provider-muse/src/server/adapter.ts | 34 +++++---- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index c302aed9a0edf..70028b538c72a 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -454,6 +454,77 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect.each(["no scope unit", "a scope unit"] as const)( + "relaunches past a replaying host whose close never settles, with %s", + (scopeUnit) => + Effect.gen(function* () { + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("test-environment"), + threadId: THREAD_ID, + providerSessionId: "test-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43210/mcp", + authorizationHeader: "Bearer thread-scoped-test-token", + browserToolsAvailable: true, + }); + const fake = yield* makeFakeMuse("first"); + const recovery = yield* makeFakeMuse("second"); + for (const muse of [fake, recovery]) + muse.host.initializeResult.grantedCapabilities.push("sessionMcp"); + // A replayed background command inherits Muse's pipes and outlives it. + fake.holdClose(new Promise(() => {})); + fake.queueResponse("session/resume", { + session: { sessionId: "saved-session", modelId: MODEL, activeTurnId: "replayed-turn" }, + history: { items: [] }, + }); + const stopped: Array<{ unit: string; resumes: number }> = []; + yield* Effect.gen(function* () { + const opening = yield* makeHarness(fake, INSTANCE_ID, "saved-session", recovery).pipe( + Effect.forkScoped, + ); + yield* Effect.promise(() => fake.closing); + yield* TestClock.adjust("13 seconds"); + const harness = yield* Fiber.join(opening); + assert.deepStrictEqual( + stopped, + scopeUnit === "a scope unit" ? [{ unit: "t3code-muse-replay.scope", resumes: 0 }] : [], + ); + assert.strictEqual( + (yield* recovery.takeCall("session/resume")).params.sessionId, + "saved-session", + ); + + const { nativeId, providerTurn } = yield* startConversation(harness, recovery); + const stopping = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: providerTurn.id, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + assert.strictEqual((yield* recovery.takeCall("turn/interrupt")).params.turnId, nativeId); + yield* recovery.emit("turn/completed", { turnId: nativeId, terminal: "cancelled" }); + yield* Fiber.join(stopping); + assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted"); + assert.strictEqual(fake.closeCount(), 1); + }).pipe( + Effect.scoped, + Effect.provideService(AgentScope, { + ...AgentScope.defaultValue(), + ...(scopeUnit === "a scope unit" + ? { + wrap: ({ command, args }) => + Effect.succeed({ command, args, unit: "t3code-muse-replay.scope" }), + stop: (unit) => + Effect.sync(() => { + stopped.push({ unit, resumes: recovery.calls.length }); + }), + } + : {}), + }), + ); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("selects the runtime mode's permission profile on native start and resume", () => Effect.gen(function* () { for (const [runtimeMode, profile, approvalMode] of [ diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 9b28a60f204f8..066cd9b3134f0 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -30,6 +30,7 @@ import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; import * as Queue from "effect/Queue"; @@ -322,6 +323,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( const commands = yield* Semaphore.make(1); const eventPermit = yield* Semaphore.make(1); let host: MuseSdkHost; + let hostScope: Scope.Closeable; let hostEpoch = 0; let closed = false; let broken = false; @@ -1666,6 +1668,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ); const launchHost = Effect.fnUntraced(function* () { const epoch = ++hostEpoch; + const owned = yield* Scope.fork(scope); const mcpSession = yield* mcpSessions.read(input.threadId); const environment = McpProviderSession.withAgentDeviceEnvironment( options.environment, @@ -1682,10 +1685,10 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( const scopeUnit = launch.unit; const stopScope = scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); - yield* Scope.addFinalizer(scope, stopScope); + yield* Scope.addFinalizer(owned, stopScope); const processExited = yield* Deferred.make(); // Observe raw exit before acquisition: inherited stdout can also block startup cleanup. - yield* Deferred.await(processExited).pipe(Effect.andThen(stopScope), Effect.forkIn(scope)); + yield* Deferred.await(processExited).pipe(Effect.andThen(stopScope), Effect.forkIn(owned)); const created = yield* Effect.acquireRelease( createMuseSdkHostEffect( { @@ -1711,22 +1714,23 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ), (created) => Effect.gen(function* () { - if (host === created) { + if (epoch === hostEpoch) { closed = true; hostEpoch++; } - const close = Effect.tryPromise(() => created.close()); // SDK shutdown sends SIGKILL after 10s + 2s, but inherited pipes can - // keep its close promise pending. Let the scope reap those holders. - yield* ( - scopeUnit === undefined - ? close - : close.pipe(Effect.interruptible, Effect.timeoutOption("13 seconds")) - ).pipe(Effect.ignore); + // keep its close promise pending. Stop waiting then; the scope unit, + // when there is one, reaps those holders. + yield* Effect.tryPromise(() => created.close()).pipe( + Effect.interruptible, + Effect.timeoutOption("13 seconds"), + Effect.ignore, + ); }), { interruptible: true }, - ).pipe(Effect.provideService(Scope.Scope, scope)); + ).pipe(Effect.provideService(Scope.Scope, owned)); host = created; + hostScope = owned; created.connection.onNotification((notification) => { Queue.offerUnsafe(inbox, { type: "notification", @@ -1769,7 +1773,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( void created.exited.then(reportExit); yield* Deferred.await(processExited).pipe( Effect.tap((exit) => Effect.sync(() => reportExit(exit))), - Effect.forkIn(scope), + Effect.forkIn(owned), ); }); yield* Effect.addFinalizer(() => @@ -1880,14 +1884,16 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( break; } yield* Effect.logWarning("Muse replayed queued work without T3 tools; relaunching"); - const replaying = host; + const replaying = hostScope; yield* eventPermit.withPermits(1)( Effect.sync(() => { hostEpoch++; wake = undefined; }), ); - yield* Effect.tryPromise(() => replaying.close()).pipe(Effect.ignore); + // Muse leases a session to one live host, so the replacement waits for + // this host's bounded shutdown before it resumes. + yield* Scope.close(replaying, Exit.void); yield* launchHost(); result = yield* open; } From 64269818ee105458012a58accc8f41d656605812 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 05:17:30 +0000 Subject: [PATCH 5/6] test(muse): await replay notification before resume acknowledgement --- .../provider-muse/src/server/adapter.test.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index e98524e1ed87c..9f553ffc9d0ca 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -433,13 +433,32 @@ describe("MuseAdapterV2", () => { muse.host.initializeResult.grantedCapabilities.push("sessionMcp"); const ack = pendingPromise>(); fake.queueResponse("session/resume", ack.promise); - const opening = yield* makeHarness(fake, INSTANCE_ID, "saved-session", recovery).pipe( - Effect.forkScoped, - ); + const replayed = yield* Deferred.make(); + const opening = yield* makeHarness( + fake, + INSTANCE_ID, + "saved-session", + recovery, + undefined, + undefined, + { + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => + typeof event === "object" && + event !== null && + "method" in event && + event.method === "turn/started" + ? Deferred.succeed(replayed, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }, + ).pipe(Effect.forkScoped); const first = yield* fake.takeCall("session/resume"); // Muse announces the replayed turn before it acknowledges the resume. yield* fake.emit("turn/started", { turnId: "replayed-turn" }); - yield* Effect.yieldNow; + yield* Deferred.await(replayed); ack.resolve({ session: { sessionId: "saved-session", modelId: MODEL, activeTurnId: "replayed-turn" }, history: { items: [] }, From 0259bb455cca4fed71e68094d0ab70277d1ab4a4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 13:31:29 +0000 Subject: [PATCH 6/6] test(muse): a replaying resume with no live view fails without relaunching Co-Authored-By: Claude Opus 5.5 --- .../provider-muse/src/server/adapter.test.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 7005598fe0bc9..121738480144d 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -987,6 +987,7 @@ describe("MuseAdapterV2", () => { sessionId: "unexpected-session", initialNativeThreadId: undefined, history: { items: [] }, + activeTurnId: null, }, { reason: "an unavailable projection", @@ -994,20 +995,45 @@ describe("MuseAdapterV2", () => { sessionId: "saved-session", initialNativeThreadId: "saved-session", history: { items: null, mode: "none", noneReason: "projectionUnavailable" }, + activeTurnId: null, + }, + { + reason: "an unavailable projection while replaying queued work", + method: "session/resume", + sessionId: "saved-session", + initialNativeThreadId: "saved-session", + history: { items: null, mode: "none", noneReason: "projectionUnavailable" }, + activeTurnId: "replayed-turn", }, ])( "closes a newly opened host when native session registration returns $reason", - ({ method, sessionId, initialNativeThreadId, history }) => + ({ method, sessionId, initialNativeThreadId, history, activeTurnId }) => Effect.gen(function* () { const fake = yield* makeFakeMuse(); + const recovery = yield* makeFakeMuse("second"); + if (activeTurnId) { + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("test-environment"), + threadId: THREAD_ID, + providerSessionId: "test-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43210/mcp", + authorizationHeader: "Bearer thread-scoped-test-token", + browserToolsAvailable: true, + }); + for (const muse of [fake, recovery]) + muse.host.initializeResult.grantedCapabilities.push("sessionMcp"); + } fake.queueResponse(method, { - session: { sessionId, modelId: MODEL, activeTurnId: null }, + session: { sessionId, modelId: MODEL, activeTurnId }, history, }); - const result = yield* makeHarness(fake, INSTANCE_ID, initialNativeThreadId).pipe( + const result = yield* makeHarness(fake, INSTANCE_ID, initialNativeThreadId, recovery).pipe( Effect.exit, ); assert.strictEqual(result._tag, "Failure"); + // A session Muse cannot stream fails before a relaunch cancels its replayed work. + assert.deepStrictEqual(recovery.calls, []); yield* Effect.promise(() => fake.host.exited); assert.strictEqual(fake.closeCount(), 1); assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 0);