diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 16318b55a7..89ebf1f280 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -35,6 +35,7 @@ type Active = { done: Deferred.Deferred timeoutFiber?: Fiber.Fiber timeout?: (duration: number) => Effect.Effect + kill?: () => Effect.Effect } /** @@ -59,6 +60,8 @@ export interface Interface { readonly wait: (id: Shell.ID) => Effect.Effect // Replaces the running command's timeout from now; zero clears it. readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect + // Stops a running command while retaining its terminal state and output. + readonly kill: (id: Shell.ID) => Effect.Effect readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect readonly remove: (id: Shell.ID) => Effect.Effect } @@ -120,6 +123,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( yield* removeSession(id) }) + const kill = Effect.fn("Shell.kill")(function* (id: Shell.ID) { + const session = yield* require(id) + if (session.kill) yield* session.kill() + return yield* Deferred.await(session.done) + }) + const list = Effect.fn("Shell.list")(function* () { return Array.from(sessions.values()) .filter((session) => session.info.status === "running") @@ -313,6 +322,8 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( ) }) + session.kill = () => finish("exited", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))) + yield* session.timeout(invocation.timeout) runFork( @@ -335,7 +346,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect( return session.info }) - return Service.of({ name, create, list, get, wait, timeout, output, remove }) + return Service.of({ name, create, list, get, wait, timeout, kill, output, remove }) }), ) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index b663140688..d40b06dca3 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -224,7 +224,7 @@ export const Plugin = { const run = settleShell().pipe( Effect.tap((output) => Deferred.succeed(settled, output)), Effect.map((output) => output.output), - Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)), + Effect.onInterrupt(() => shell.kill(info.id).pipe(Effect.ignore)), ) const job = yield* runtime.job.start({ id: context.callID, diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 2b540d7538..45d80a677d 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -480,6 +480,44 @@ describe("ShellTool", () => { ), ) + it.live("retains partial output when a foreground command is interrupted", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const jobs = yield* Job.Service + const shell = yield* Shell.Service + const scope = yield* Scope.Scope + const waiting = yield* executeTool( + registry, + call({ command: steadyProgressCommand }, "call-interrupt-output"), + ).pipe(Effect.forkIn(scope, { startImmediately: true })) + + const waitForShell = (remaining = 1000): Effect.Effect => + Effect.gen(function* () { + const job = yield* jobs.get("call-interrupt-output") + const shellID = job?.metadata?.shellID + if (typeof shellID === "string") return ShellSchema.ID.make(shellID) + if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* waitForShell(remaining - 1) + }) + const id = yield* waitForShell() + yield* Effect.sleep(Duration.millis(100)) + yield* Fiber.interrupt(waiting) + + expect((yield* shell.get(id)).status).toBe("exited") + expect((yield* shell.output(id)).output).toContain("steady") + yield* shell.remove(id) + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + it.live("returns the shell id for a background command", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),