From cefab493127286163257ff9c3b15c327c901a52c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 01:58:02 -0700 Subject: [PATCH 1/5] fix(antigravity): let Stop end commands that outlived their turn Antigravity keeps a backgrounded command (a watcher, a dev server) open after end_turn, and T3 shows it as Monitoring. The Stop button on that banner sends a turn interrupt, which the adapter turned into session/cancel. With no prompt running that did nothing, so Monitoring stayed up until the command exited on its own or the server restarted (#12325). The agent only kills its background commands when its session closes, so when nothing but promoted commands is running, interruptTurn now stops the session, as Claude's adapter does. That completes each command as stopped; the next turn resumes the conversation. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Layers/AntigravityAdapter.test.ts | 38 +++++++++++++++++++ .../src/provider/Layers/AntigravityAdapter.ts | 7 ++++ 2 files changed, 45 insertions(+) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 4bd0bb3e1010..cd245570d1ad 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -775,6 +775,44 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); + it.effect("stops commands left running after a turn when the idle turn is stopped", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Start a watcher" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* h.emitNative({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "watcher-1", + kind: "execute", + status: "inProgress", + command: "tail -f log", + data: {}, + }, + rawPayload: {}, + }); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(sending); + const started = yield* h.waitForEvent((event) => event.type === "task.started"); + + // Monitoring's Stop reaches the adapter as a turn interrupt. With no + // prompt to cancel, it has to end the session to stop the command. + yield* h.adapter.interruptTurn(threadId); + const stopped = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(stopped.payload).toMatchObject({ taskId: started.payload.taskId, status: "stopped" }); + yield* h.waitForEvent((event) => event.type === "session.exited"); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + expect(h.controls.closed).toBe(1); + }), + ); + it.effect("keeps a launched batch active while child tools continue", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 61a9b3c3a645..94cf9c55d453 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1165,6 +1165,13 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const interruptTurn: Adapter["interruptTurn"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); + // A command that outlived its turn keeps running in the agent, and + // session/cancel only stops a prompt. The agent kills its background + // commands when its session closes, so Stop with nothing else running + // ends the session, as Claude's does. The next turn resumes it. + if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { + return yield* withThreadLock(threadId, stopContext(context)); + } yield* context.promptLock .withPermit( Effect.gen(function* () { From 43073817f3b86458b4f12bda20023c49ab8a18d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 02:10:01 -0700 Subject: [PATCH 2/5] fix(antigravity): decide idle Stop under the prompt lock A turn starting between the idle check and the session stop would have been torn down instead of cancelled. Decide and mark the session stopped while holding the lock sendTurn takes, so a racing turn sees it closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../server/src/provider/Layers/AntigravityAdapter.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 94cf9c55d453..d5ce0b00f61a 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1169,17 +1169,21 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi // session/cancel only stops a prompt. The agent kills its background // commands when its session closes, so Stop with nothing else running // ends the session, as Claude's does. The next turn resumes it. - if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { - return yield* withThreadLock(threadId, stopContext(context)); - } - yield* context.promptLock + const idleWithCommands = yield* context.promptLock .withPermit( Effect.gen(function* () { + // Decided under the prompt lock so a turn cannot start in between. + if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { + context.stopped = true; + return true; + } yield* cancelRequests(context); yield* context.runtime.cancel; + return false; }), ) .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause))); + if (idleWithCommands) yield* withThreadLock(threadId, stopContext(context)); }); const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => From 30dd7a6268bd3925a0e35d992346a5f06b1084c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 05:10:51 -0700 Subject: [PATCH 3/5] fix(antigravity): finish an idle Stop even if its caller is interrupted After marking the session stopped, an interrupted caller left it unreachable with its commands still running. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/Layers/AntigravityAdapter.ts | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index d5ce0b00f61a..fd2ae66ad6ee 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1169,21 +1169,34 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi // session/cancel only stops a prompt. The agent kills its background // commands when its session closes, so Stop with nothing else running // ends the session, as Claude's does. The next turn resumes it. - const idleWithCommands = yield* context.promptLock - .withPermit( - Effect.gen(function* () { - // Decided under the prompt lock so a turn cannot start in between. - if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { - context.stopped = true; - return true; - } - yield* cancelRequests(context); - yield* context.runtime.cancel; - return false; - }), - ) - .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause))); - if (idleWithCommands) yield* withThreadLock(threadId, stopContext(context)); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const idleWithCommands = yield* restore( + context.promptLock + .withPermit( + Effect.gen(function* () { + // Decided under the prompt lock so a turn cannot start in between. + if ( + !context.promptFiber && + [...context.commands.values()].some((c) => c.promoted) + ) { + context.stopped = true; + return true; + } + yield* cancelRequests(context); + yield* context.runtime.cancel; + return false; + }), + ) + .pipe( + Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)), + ), + ); + // Once marked stopped the session must close, or it is left + // unreachable with its commands still running. + if (idleWithCommands) yield* withThreadLock(threadId, stopContext(context)); + }), + ); }); const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => From 280994d325a51076cfda10a44d857f0ce00f7bc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 05:18:12 -0700 Subject: [PATCH 4/5] fix(antigravity): close an idle-stopped session in a finalizer The interruptible permit release between marking and stopping could still strand the session. Close it in ensuring, which always runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/Layers/AntigravityAdapter.ts | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index fd2ae66ad6ee..97443c98eb54 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1169,34 +1169,30 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi // session/cancel only stops a prompt. The agent kills its background // commands when its session closes, so Stop with nothing else running // ends the session, as Claude's does. The next turn resumes it. - yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const idleWithCommands = yield* restore( - context.promptLock - .withPermit( - Effect.gen(function* () { - // Decided under the prompt lock so a turn cannot start in between. - if ( - !context.promptFiber && - [...context.commands.values()].some((c) => c.promoted) - ) { - context.stopped = true; - return true; - } - yield* cancelRequests(context); - yield* context.runtime.cancel; - return false; - }), - ) - .pipe( - Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)), - ), - ); - // Once marked stopped the session must close, or it is left - // unreachable with its commands still running. - if (idleWithCommands) yield* withThreadLock(threadId, stopContext(context)); - }), - ); + let idleWithCommands = false; + yield* context.promptLock + .withPermit( + Effect.gen(function* () { + // Decided under the prompt lock so a turn cannot start in between. + if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { + context.stopped = true; + idleWithCommands = true; + return; + } + yield* cancelRequests(context); + yield* context.runtime.cancel; + }), + ) + .pipe( + Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)), + // Once marked stopped the session must close, even if this call is + // interrupted, or it is left unreachable with its commands running. + Effect.ensuring( + Effect.suspend(() => + idleWithCommands ? withThreadLock(threadId, stopContext(context)) : Effect.void, + ), + ), + ); }); const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => From 8167b0da9c54f2e473173774b394b6268380329f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 05:18:56 -0700 Subject: [PATCH 5/5] fix(antigravity): ignore stop failures in the idle-Stop finalizer Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/Layers/AntigravityAdapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 97443c98eb54..0bab3a98a537 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1189,7 +1189,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi // interrupted, or it is left unreachable with its commands running. Effect.ensuring( Effect.suspend(() => - idleWithCommands ? withThreadLock(threadId, stopContext(context)) : Effect.void, + idleWithCommands + ? withThreadLock(threadId, stopContext(context)).pipe(Effect.ignore) + : Effect.void, ), ), );