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..0bab3a98a537 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1165,14 +1165,36 @@ 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. + 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))); + .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)).pipe(Effect.ignore) + : Effect.void, + ), + ), + ); }); const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) =>