mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 14:02:38 +00:00
Persist background Job ownership and terminal results across server restarts. Resume existing subagent Sessions, admit shell cancellation notices without waking idle parents, and preserve explicit cancellation.
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
export * as Shell from "./shell.js"
|
|
|
|
import path from "path"
|
|
import { Context, Deferred, Duration, Effect, Fiber, Latch, Layer, Schema, Schedule, Stream } from "effect"
|
|
import { ChildProcess } from "effect/unstable/process"
|
|
import { produce } from "immer"
|
|
import { Shell } from "@opencode-ai/schema/shell"
|
|
import { AppProcess } from "@opencode-ai/util/process"
|
|
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
import { Bus } from "./bus.js"
|
|
import { Environment } from "./environment/index.js"
|
|
import { FileRetention } from "./file-retention.js"
|
|
import { Location } from "./location.js"
|
|
import { Global } from "@opencode-ai/util/global"
|
|
import { ShellSelect } from "./shell/select.js"
|
|
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
|
import { PluginHooks } from "./plugin/hooks.js"
|
|
import { SessionEnvironment } from "./session/environment.js"
|
|
import { SessionSchema } from "./session/schema.js"
|
|
|
|
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.NotFoundError", {
|
|
id: Shell.ID,
|
|
}) {}
|
|
|
|
// Keep recent exited processes observable in memory, including their file-backed output.
|
|
// The process-local cap complements the time-based sweep, which also cleans files left by restarts.
|
|
const EXITED_LIMIT = 25
|
|
export const RETENTION = Duration.days(7)
|
|
export const DIRECTORY = "shell"
|
|
|
|
type Info = Shell.Info
|
|
type CreateInput = Shell.CreateInput & {
|
|
shell?: string
|
|
}
|
|
|
|
type Active = {
|
|
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
|
|
info: Info
|
|
file: string
|
|
size: number
|
|
// Resolves with the terminal Info once the command exits, times out, or is killed. A wait
|
|
// started after termination resolves immediately from the already-completed deferred.
|
|
done: Deferred.Deferred<Info, NotFoundError>
|
|
timeoutFiber?: Fiber.Fiber<void>
|
|
timeout?: (duration: number) => Effect.Effect<void>
|
|
}
|
|
|
|
/**
|
|
* Location-owned non-interactive shell command process service.
|
|
*
|
|
* Each `create` spawns one shell command, captures combined stdout/stderr to a
|
|
* file, and returns an ID. Clients poll `get` for status and `output` for
|
|
* file-backed output by cursor. No session, message, or permission state lives
|
|
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
|
|
*/
|
|
export interface Interface {
|
|
readonly create: <E = never, R = never>(
|
|
input: CreateInput,
|
|
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
|
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
|
|
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
|
readonly list: () => Effect.Effect<Shell.Info[]>
|
|
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
|
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
|
|
// NotFoundError if the command is unknown or is removed before it terminates.
|
|
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>
|
|
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
|
|
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
|
|
|
export const cleanup = Effect.fn("Shell.cleanup")(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const global = yield* Global.Service
|
|
const directory = path.join(global.data, DIRECTORY)
|
|
const projects = yield* fs.readDirectoryEntries(directory).pipe(
|
|
Effect.map((entries) => entries.filter((entry) => entry.type === "directory")),
|
|
Effect.orElseSucceed(() => []),
|
|
)
|
|
const files = yield* Effect.forEach(
|
|
projects,
|
|
(project) =>
|
|
fs.readDirectoryEntries(path.join(directory, project.name)).pipe(
|
|
Effect.map((entries) =>
|
|
entries.flatMap((entry) =>
|
|
entry.type === "file" && /^sh_[0-9a-f]{12}.*\.out$/.test(entry.name)
|
|
? [path.join(directory, project.name, entry.name)]
|
|
: [],
|
|
),
|
|
),
|
|
Effect.orElseSucceed(() => []),
|
|
),
|
|
{ concurrency: 8 },
|
|
)
|
|
yield* FileRetention.cleanup(fs, files.flat(), RETENTION)
|
|
})
|
|
|
|
const cleanupLayer = Layer.effectDiscard(
|
|
cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped),
|
|
)
|
|
|
|
const cleanupNode = makeGlobalNode({
|
|
name: "shell-output-cleanup",
|
|
layer: cleanupLayer,
|
|
deps: [FSUtil.node, Global.node],
|
|
})
|
|
|
|
const layer = () =>
|
|
Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const location = yield* Location.Service
|
|
const global = yield* Global.Service
|
|
const shell = yield* ShellSelect.Service
|
|
const environment = yield* Environment.Service
|
|
const hooks = yield* PluginHooks.Service
|
|
const environments = yield* SessionEnvironment.Service
|
|
const context = yield* Effect.context()
|
|
const runFork = Effect.runForkWith(context)
|
|
const sessions = new Map<string, Active>()
|
|
const exitOrder: string[] = []
|
|
|
|
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
|
|
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
|
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
|
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
|
|
|
yield* Effect.addFinalizer(() =>
|
|
Effect.gen(function* () {
|
|
for (const session of sessions.values()) {
|
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
// Teardown interrupts pending commands; it is not a terminal command failure.
|
|
yield* Deferred.interrupt(session.done)
|
|
}
|
|
sessions.clear()
|
|
exitOrder.length = 0
|
|
}),
|
|
)
|
|
|
|
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
|
const session = sessions.get(id)
|
|
if (!session) return yield* new NotFoundError({ id })
|
|
return session
|
|
})
|
|
|
|
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
|
const session = sessions.get(id)
|
|
const index = exitOrder.indexOf(id)
|
|
if (index !== -1) exitOrder.splice(index, 1)
|
|
if (!session) return
|
|
sessions.delete(id)
|
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
// Unblock any wait still pending when the command is removed before it terminated.
|
|
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
|
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
|
yield* bus.publish(Shell.Event.Deleted, { id })
|
|
})
|
|
|
|
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
|
yield* require(id)
|
|
yield* removeSession(id)
|
|
})
|
|
|
|
const list = Effect.fn("Shell.list")(function* () {
|
|
return Array.from(sessions.values())
|
|
.filter((session) => session.info.status === "running")
|
|
.map((session) => session.info)
|
|
})
|
|
|
|
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
|
return (yield* require(id)).info
|
|
})
|
|
|
|
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
|
|
return yield* Deferred.await((yield* require(id)).done)
|
|
})
|
|
|
|
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
|
const session = yield* require(id)
|
|
if (session.info.status !== "running" || !session.timeout) return session.info
|
|
yield* session.timeout(duration)
|
|
return session.info
|
|
})
|
|
|
|
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
|
const session = yield* require(id)
|
|
const cursor = input?.cursor ?? 0
|
|
const limit = input?.limit ?? 65536
|
|
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
|
const start = Math.max(0, cursor)
|
|
const length = Math.min(limit, session.size - start)
|
|
const buffer = Buffer.alloc(length)
|
|
const bytesRead = yield* Effect.promise(
|
|
() =>
|
|
new Promise<number>((resolve) => {
|
|
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
|
let offset = 0
|
|
stream.on("data", (chunk: string | Buffer) => {
|
|
const bytes = Buffer.from(chunk)
|
|
bytes.copy(buffer, offset)
|
|
offset += bytes.length
|
|
})
|
|
stream.on("end", () => resolve(offset))
|
|
stream.on("error", () => resolve(0))
|
|
}),
|
|
)
|
|
return {
|
|
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
|
cursor: start + bytesRead,
|
|
size: session.size,
|
|
truncated: false,
|
|
}
|
|
})
|
|
|
|
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
|
input: CreateInput,
|
|
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
|
) {
|
|
const sessionID = input.metadata?.sessionID
|
|
const sessionEnvironment =
|
|
location.workspaceID === undefined && Schema.is(SessionSchema.ID)(sessionID)
|
|
? yield* environments.get(sessionID)
|
|
: undefined
|
|
const invocation: ShellCreateBefore = {
|
|
command: input.command,
|
|
cwd: input.cwd ?? location.directory,
|
|
timeout: input.timeout,
|
|
shell: input.shell ?? (yield* shell.resolve({ priority: "config" })),
|
|
env: {
|
|
...(sessionEnvironment ?? process.env),
|
|
TERM: "xterm-256color",
|
|
OPENCODE_TERMINAL: "1",
|
|
},
|
|
}
|
|
yield* hooks.trigger("shell", "create.before", invocation)
|
|
if (before) yield* before(invocation)
|
|
|
|
const id = Shell.ID.ascending()
|
|
const args = ShellSelect.args(invocation.shell, invocation.command)
|
|
const file = path.join(outputDir, `${id}.out`)
|
|
|
|
const info: Info = {
|
|
id,
|
|
status: "running",
|
|
command: invocation.command,
|
|
cwd: invocation.cwd,
|
|
shell: invocation.shell,
|
|
file,
|
|
metadata: input.metadata ?? {},
|
|
time: { started: Date.now() },
|
|
}
|
|
|
|
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
|
|
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
|
// end). `create` returns once `ready` resolves with the registered session.
|
|
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
|
|
runFork(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const handle = yield* environment.spawner
|
|
.spawn(
|
|
ChildProcess.make(invocation.shell, args, {
|
|
cwd: invocation.cwd,
|
|
env: invocation.env,
|
|
stdin: "ignore",
|
|
detached: process.platform !== "win32",
|
|
forceKillAfter: Duration.seconds(3),
|
|
}),
|
|
)
|
|
.pipe(
|
|
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
|
|
)
|
|
const session: Active = {
|
|
info: produce(info, (draft) => {
|
|
draft.pid = handle.pid
|
|
}),
|
|
file,
|
|
size: 0,
|
|
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
|
}
|
|
sessions.set(id, session)
|
|
|
|
const stream = createWriteStream(file)
|
|
const outputDone = Latch.makeUnsafe()
|
|
const pump = handle.all.pipe(
|
|
Stream.runForEach((chunk: Uint8Array) =>
|
|
Effect.sync(() => {
|
|
stream.write(chunk)
|
|
session.size += chunk.length
|
|
}),
|
|
),
|
|
)
|
|
runFork(
|
|
Effect.gen(function* () {
|
|
yield* pump.pipe(Effect.catch(() => Effect.void))
|
|
yield* Effect.promise(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
stream.end(() => resolve())
|
|
}),
|
|
)
|
|
yield* outputDone.open
|
|
}).pipe(Effect.catch(() => outputDone.open)),
|
|
)
|
|
yield* Effect.promise(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
stream.once("open", () => resolve())
|
|
stream.once("error", () => resolve())
|
|
}),
|
|
)
|
|
|
|
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
|
Effect.gen(function* () {
|
|
if (session.info.status !== "running") return
|
|
session.info = produce(session.info, (draft) => {
|
|
draft.status = status
|
|
if (exit !== undefined) draft.exit = exit
|
|
draft.time.completed = Date.now()
|
|
})
|
|
yield* beforeWait
|
|
yield* outputDone.await
|
|
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
|
// session still reports success rather than the removal NotFoundError. This runs before
|
|
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
|
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
|
yield* Deferred.succeed(session.done, session.info)
|
|
yield* bus.publish(Shell.Event.Exited, {
|
|
id,
|
|
...(exit !== undefined ? { exit } : {}),
|
|
status,
|
|
})
|
|
exitOrder.push(id)
|
|
while (exitOrder.length > EXITED_LIMIT) {
|
|
const oldest = exitOrder[0]
|
|
if (!oldest) break
|
|
yield* removeSession(Shell.ID.make(oldest))
|
|
}
|
|
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
|
// aborting finish when finish itself runs on the timeout fiber.
|
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
})
|
|
|
|
session.timeout = (duration) =>
|
|
Effect.gen(function* () {
|
|
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
|
session.timeoutFiber = undefined
|
|
if (duration === 0 || session.info.status !== "running") return
|
|
session.timeoutFiber = runFork(
|
|
Effect.sleep(Duration.millis(duration)).pipe(
|
|
Effect.flatMap(() =>
|
|
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
|
),
|
|
Effect.catch(() => Effect.void),
|
|
),
|
|
)
|
|
})
|
|
|
|
yield* session.timeout(invocation.timeout)
|
|
|
|
runFork(
|
|
handle.exitCode.pipe(
|
|
Effect.flatMap((code) => finish("exited", code)),
|
|
Effect.catch(() => finish("exited")),
|
|
),
|
|
)
|
|
|
|
yield* bus.publish(Shell.Event.Created, { info })
|
|
yield* Deferred.succeed(ready, session)
|
|
// Hold the handle's scope open until the command terminates; closing it earlier would
|
|
// release (kill) the process before its exit is observed.
|
|
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
|
}),
|
|
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
|
|
)
|
|
|
|
const session = yield* Deferred.await(ready)
|
|
return session.info
|
|
})
|
|
|
|
return Service.of({ create, list, get, wait, timeout, output, remove })
|
|
}),
|
|
)
|
|
|
|
export const node = makeLocationNode({
|
|
service: Service,
|
|
layer: layer(),
|
|
deps: [
|
|
Bus.node,
|
|
Location.node,
|
|
Global.node,
|
|
ShellSelect.node,
|
|
Environment.node,
|
|
PluginHooks.node,
|
|
SessionEnvironment.node,
|
|
cleanupNode,
|
|
],
|
|
})
|