Compare commits

...
Author SHA1 Message Date
Aiden Cline 3e81ed4985 fix(core): retain interrupted shell output 2026-07-29 15:31:43 -05:00
3 changed files with 51 additions and 2 deletions
+12 -1
View File
@@ -35,6 +35,7 @@ type Active = {
done: Deferred.Deferred<Info, NotFoundError>
timeoutFiber?: Fiber.Fiber<void>
timeout?: (duration: number) => Effect.Effect<void>
kill?: () => Effect.Effect<void>
}
/**
@@ -59,6 +60,8 @@ export interface Interface {
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
// Stops a running command while retaining its terminal state and output.
readonly kill: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
}
@@ -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 })
}),
)
+1 -1
View File
@@ -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,
+38
View File
@@ -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<ShellSchema.ID, Error> =>
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()),