Compare commits

...
Author SHA1 Message Date
Kit Langton e342333eea fix(core): close background shutdown races 2026-08-24 11:43:50 -04:00
Kit Langton 40f11b9956 fix(core): notify background jobs on shutdown 2026-08-24 10:51:23 -04:00
7 changed files with 489 additions and 152 deletions
+194 -24
View File
@@ -19,10 +19,17 @@ export type Info = {
metadata?: Record<string, unknown>
}
export type SettledInfo = Info & {
status: Exclude<Status, "running">
completed_at: number
}
type Active = {
info: Info
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
onBackgroundSettled?: (info: SettledInfo) => Effect.Effect<void>
backgroundNotification?: Deferred.Deferred<void>
scope: Scope.Closeable
token: object
blockingSessions: Map<SessionSchema.ID, number>
@@ -31,18 +38,29 @@ type Active = {
type State = {
jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
notifications: Set<Deferred.Deferred<void>>
scope: Scope.Scope
shuttingDown: boolean
}
type Notification = {
jobID: string
effect: Effect.Effect<void>
done: Deferred.Deferred<void>
}
type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
notify?: Notification
scope?: Scope.Closeable
}
type BackgroundResult = {
info?: Info
backgrounded?: Deferred.Deferred<Info>
notify?: Notification
cancel?: { id: string; token: object }
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
@@ -64,6 +82,7 @@ export type StartInput = {
title?: string
metadata?: Record<string, unknown>
run: Effect.Effect<string, unknown>
onBackgroundSettled?: (info: SettledInfo) => Effect.Effect<void>
}
export type WaitInput = {
@@ -96,6 +115,8 @@ export interface Interface {
readonly background: (id: string) => Effect.Effect<Info | undefined>
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
/** Cancels detached work and awaits its terminal callbacks before application teardown. */
readonly shutdown: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
@@ -125,6 +146,23 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
return next
}
function clearNotification(job: Active) {
if (!job.onBackgroundSettled && !job.backgroundNotification) return job
return { ...job, onBackgroundSettled: undefined, backgroundNotification: undefined }
}
function claimNotification(job: Active, info: SettledInfo) {
if (!job.isBackgrounded || !job.onBackgroundSettled || !job.backgroundNotification) return { job }
return {
job: clearNotification(job),
notify: {
jobID: info.id,
effect: job.onBackgroundSettled(info),
done: job.backgroundNotification,
},
}
}
/**
* Makes one scoped, process-local registry. Entries are intentionally not
* durable: process restart or owner-scope closure loses status and interrupts
@@ -135,9 +173,28 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
export const make = Effect.gen(function* () {
const state: State = {
jobs: yield* SynchronizedRef.make(new Map()),
notifications: new Set(),
scope: yield* Scope.Scope,
shuttingDown: false,
}
const notify = Effect.fnUntraced(function* (notification: Notification) {
yield* notification.effect.pipe(
Effect.catchCause((cause) =>
Effect.logError("Failed to notify background Job settlement", { jobID: notification.jobID, cause }),
),
Effect.ensuring(
Effect.sync(() => state.notifications.delete(notification.done)).pipe(
Effect.andThen(Deferred.succeed(notification.done, undefined)),
),
),
)
})
const launchNotification = Effect.fnUntraced(function* (notification: Notification) {
yield* notify(notification).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
@@ -150,22 +207,37 @@ export const make = Effect.gen(function* () {
: Cause.hasInterruptsOnly(exit.cause)
? "cancelled"
: "error"
const info = {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
} satisfies SettledInfo
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
info,
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
const notification = claimNotification(next, info)
return [
{
info,
done: job.done,
notify: notification.notify,
scope: job.scope,
},
new Map(jobs).set(id, notification.job),
]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.notify) yield* launchNotification(result.notify)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
yield* Scope.close(result.scope, Exit.void).pipe(
Effect.catchCause((cause) => Effect.logError("Failed to close settled Job scope", { id, cause })),
Effect.forkIn(state.scope, { startImmediately: true }),
)
}
return result.info
})
@@ -197,15 +269,43 @@ export const make = Effect.gen(function* () {
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const backgrounded = yield* Deferred.make<Info>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
if (state.shuttingDown)
return [
{
info: {
id,
type: input.type,
title: input.title,
status: "cancelled",
started_at,
completed_at: started_at,
metadata: input.metadata,
},
},
jobs,
] as readonly [StartResult, Map<string, Active>]
const existing = jobs.get(id)
if (existing?.info.status === "running") {
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
if (existing.onBackgroundSettled || !input.onBackgroundSettled)
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
const backgroundNotification = yield* Deferred.make<void>()
const adopted = {
...existing,
onBackgroundSettled: input.onBackgroundSettled,
backgroundNotification,
}
if (adopted.isBackgrounded) state.notifications.add(backgroundNotification)
return [{ info: snapshot(adopted) }, new Map(jobs).set(id, adopted)] as readonly [
StartResult,
Map<string, Active>,
]
}
const done = yield* Deferred.make<Info>()
const backgrounded = yield* Deferred.make<Info>()
const backgroundNotification = input.onBackgroundSettled ? yield* Deferred.make<void>() : undefined
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
const job = {
@@ -219,6 +319,8 @@ export const make = Effect.gen(function* () {
},
done,
backgrounded,
onBackgroundSettled: input.onBackgroundSettled,
backgroundNotification,
scope,
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -250,7 +352,11 @@ export const make = Effect.gen(function* () {
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
yield* SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
if (!job || job.isBackgrounded) return jobs
if (job.info.status !== "running") {
if (!job.onBackgroundSettled) return jobs
return new Map(jobs).set(input.id, clearNotification(job))
}
return new Map(jobs).set(input.id, {
...job,
blockingSessions: decrementSession(job.blockingSessions, input.sessionID),
@@ -262,7 +368,11 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.info.status !== "running")
return [
{ type: "finished", info: snapshot(job) },
job.onBackgroundSettled ? new Map(jobs).set(input.id, clearNotification(job)) : jobs,
]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
@@ -286,16 +396,38 @@ export const make = Effect.gen(function* () {
state.jobs,
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job || job.info.status !== "running") return [{}, jobs]
if (!job) return [{}, jobs]
if (state.shuttingDown) {
if (job.info.status === "running") return [{ info: snapshot(job), cancel: { id, token: job.token } }, jobs]
return [
{ info: snapshot(job) },
job.onBackgroundSettled ? new Map(jobs).set(id, clearNotification(job)) : jobs,
]
}
if (job.info.status !== "running") {
if (!job.onBackgroundSettled || !job.backgroundNotification) return [{}, jobs]
const info = {
...snapshot(job),
status: job.info.status,
completed_at: job.info.completed_at ?? job.info.started_at,
} satisfies SettledInfo
const next = { ...job, info, isBackgrounded: true }
state.notifications.add(job.backgroundNotification)
const notification = claimNotification(next, info)
return [{ info, notify: notification.notify }, new Map(jobs).set(id, notification.job)]
}
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
if (job.backgroundNotification) state.notifications.add(job.backgroundNotification)
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
},
)
if (result.cancel) return yield* cancelGeneration(result.cancel.id, result.cancel.token)
if (result.notify) yield* launchNotification(result.notify)
if (result.info && result.backgrounded)
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
return result.info
@@ -305,6 +437,7 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
if (state.shuttingDown) return [[], jobs]
const results: BackgroundResult[] = []
const next = new Map(jobs)
for (const [id, job] of jobs) {
@@ -317,6 +450,7 @@ export const make = Effect.gen(function* () {
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
if (job.backgroundNotification) state.notifications.add(job.backgroundNotification)
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
next.set(id, updated)
}
@@ -331,29 +465,65 @@ export const make = Effect.gen(function* () {
return result.flatMap((item) => (item.info ? [item.info] : []))
})
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
const cancelGeneration = Effect.fnUntraced(function* (id: string, token?: object) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (token && job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const info = {
...job.info,
status: "cancelled" as const,
completed_at,
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
} satisfies SettledInfo
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status: "cancelled" as const,
completed_at,
},
info,
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
const notification = claimNotification(next, info)
return [
{
info,
done: job.done,
notify: notification.notify,
scope: job.scope,
},
new Map(jobs).set(id, notification.notify ? notification.job : clearNotification(notification.job)),
]
})
if (result.scope)
yield* Scope.close(result.scope, Exit.void).pipe(
Effect.catchCause((cause) => Effect.logError("Failed to close cancelled Job scope", { id, cause })),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.notify) yield* launchNotification(result.notify)
return result.info
})
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")((id) => cancelGeneration(id))
const shutdown: Interface["shutdown"] = Effect.gen(function* () {
const drain = yield* SynchronizedRef.modify(state.jobs, (jobs) => {
state.shuttingDown = true
return [
{
running: Array.from(jobs.values()).filter((job) => job.info.status === "running" && job.isBackgrounded),
notifications: Array.from(state.notifications),
},
jobs,
] as const
})
yield* Effect.forEach(drain.running, (job) => cancelGeneration(job.info.id, job.token), {
concurrency: "unbounded",
discard: true,
})
yield* Effect.forEach(drain.notifications, Deferred.await, { concurrency: "unbounded", discard: true })
}).pipe(Effect.withSpan("Job.shutdown"))
return Service.of({ get, start, wait, block, background, backgroundAll, cancel, shutdown })
})
const layer = Layer.effect(Service, make)
+7 -3
View File
@@ -156,9 +156,13 @@ export const providerLayerWithCell = (cell: Cell) =>
}
cell.runtime = runtime
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (cell.runtime === runtime) cell.runtime = undefined
}),
jobs.shutdown.pipe(
Effect.ensuring(
Effect.sync(() => {
if (cell.runtime === runtime) cell.runtime = undefined
}),
),
),
)
}),
)
+52 -53
View File
@@ -3,9 +3,10 @@ export * as ShellTool from "./shell.js"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Deferred, Effect, Schema } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
import { Job } from "../../job.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { PluginRuntime } from "../../plugin/runtime.js"
@@ -105,62 +106,49 @@ export const Plugin = {
id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
const config = yield* Config.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
const notifyWhenSettled = Effect.fn("ShellTool.notifyWhenSettled")(function* (
input: {
sessionID: SessionSchema.ID
id: string
shellID: string
command: string
settled: Deferred.Deferred<Output>
},
info: Job.SettledInfo,
) {
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) =>
Effect.gen(function* () {
const info = result.info
if (!info) return
const state =
info.status === "completed"
? "completed"
: info.status === "error"
? "error"
: info.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: state === "error"
? (info.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const output = info.status === "completed" ? yield* Deferred.await(input.settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: info.status === "error"
? (info.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session
.synthetic({
sessionID: input.sessionID,
text: `<shell id="${input.id}" state="${info.status}" command="${input.command}">\n${text}\n</shell>`,
description: input.command,
metadata: {
source: "shell",
jobID: input.id,
shellID: input.shellID,
state: info.status,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
...(info.status === "cancelled" ? { resume: false } : {}),
})
.pipe(Effect.ignore)
})
yield* ctx.tool
@@ -294,6 +282,13 @@ export const Plugin = {
})
const settled = yield* Deferred.make<Output>()
const notification = {
sessionID: context.sessionID,
id: context.id,
shellID: info.id,
command: info.command,
settled,
}
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
@@ -305,11 +300,16 @@ export const Plugin = {
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
onBackgroundSettled: (result) => notifyWhenSettled(notification, result),
})
if (job.status === "cancelled") {
yield* shell.remove(info.id).pipe(Effect.ignore)
return yield* Effect.fail(new Error("Command cancelled"))
}
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
const background = yield* runtime.job.background(job.id)
if (background?.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return backgroundResult(info.id)
}
@@ -318,7 +318,6 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id)
}
if (result?.info.status === "error")
+33 -53
View File
@@ -2,7 +2,7 @@ export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { PluginRuntime } from "../../plugin/runtime.js"
@@ -57,11 +57,6 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
// One completion observer per job generation. Keyed by child plus start time so a fresh
// continuation job is observable even while a settled generation's observer is finalizing.
const notifications = new Set<string>()
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
@@ -86,44 +81,15 @@ export const Plugin = {
state: "completed" | "error" | "cancelled",
text: string,
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
})
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
startedAt: number,
) {
const key = `${childID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
yield* runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return injectCompletion(
parentID,
childID,
agent,
description,
"error",
result.info.error ?? "Subagent failed",
)
if (result.info?.status === "cancelled")
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
yield* runtime.session
.synthetic({
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
...(state === "cancelled" ? { resume: false } : {}),
})
.pipe(Effect.ignore)
})
yield* ctx.tool
@@ -257,11 +223,32 @@ export const Plugin = {
title: input.description,
metadata: {},
run,
onBackgroundSettled: (result) => {
const text =
result.status === "completed"
? (result.output ?? NO_TEXT)
: result.status === "error"
? (result.error ?? "Subagent failed")
: "Subagent cancelled"
return injectCompletion(
context.sessionID,
child.id,
agent.name,
input.description,
result.status,
text,
)
},
})
if (info.status === "cancelled") {
yield* runtime.session.interrupt(child.id)
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
}
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description, info.started_at)
const result = yield* runtime.job.background(info.id)
if (result?.status === "cancelled")
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
return backgroundResult(child.id)
}
@@ -273,13 +260,6 @@ export const Plugin = {
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(
context.sessionID,
child.id,
agent.name,
input.description,
result.info.started_at,
)
return backgroundResult(child.id)
}
// Failure surfaces keep the sessionID visible so the model can continue the child.
+17 -17
View File
@@ -3,25 +3,25 @@ import { Global } from "@opencode-ai/util/global"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export function globalLayer(root: string) {
const data = path.join(root, "data")
const cache = path.join(root, "cache")
return Global.layerWith({
home: path.join(root, "home"),
data,
cache,
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
})
}
export const tempGlobalLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.map((tmp) => {
const data = path.join(tmp.path, "data")
const cache = path.join(tmp.path, "cache")
return Global.layerWith({
home: path.join(tmp.path, "home"),
data,
cache,
config: path.join(tmp.path, "config"),
state: path.join(tmp.path, "state"),
tmp: path.join(tmp.path, "tmp"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
})
}),
),
).pipe(Effect.map((tmp) => globalLayer(tmp.path))),
)
+105
View File
@@ -145,6 +145,111 @@ describe("Job", () => {
}),
)
it.live("shutdown cancels only background jobs and awaits their terminal callbacks", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const foregroundWork = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const callbackStarted = yield* Deferred.make<Job.SettledInfo>()
const releaseCallback = yield* Deferred.make<void>()
const background = yield* jobs.start({
id: "job_background_shutdown",
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
onBackgroundSettled: (info) =>
Deferred.await(interrupted).pipe(
Effect.andThen(Deferred.succeed(callbackStarted, info)),
Effect.andThen(Deferred.await(releaseCallback)),
),
})
const foreground = yield* jobs.start({
id: "job_foreground_shutdown",
type: "test",
run: Deferred.await(foregroundWork).pipe(Effect.as("foreground")),
})
yield* jobs.background(background.id)
const shutdown = yield* jobs.shutdown.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
expect(yield* Deferred.await(callbackStarted)).toMatchObject({
id: background.id,
status: "cancelled",
})
expect(shutdown.pollUnsafe()).toBeUndefined()
expect(yield* jobs.get(foreground.id)).toMatchObject({ status: "running" })
yield* Deferred.succeed(releaseCallback, undefined)
yield* Fiber.join(shutdown)
expect(yield* jobs.get(background.id)).toMatchObject({ status: "cancelled" })
expect(yield* jobs.get(foreground.id)).toMatchObject({ status: "running" })
expect(yield* jobs.background(foreground.id)).toMatchObject({ status: "cancelled" })
expect(yield* jobs.start({ id: "job_started_during_shutdown", type: "test", run: Effect.never })).toMatchObject({
status: "cancelled",
})
expect(yield* jobs.get("job_started_during_shutdown")).toBeUndefined()
}),
)
it.live("shutdown awaits a background callback already running after completion", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const work = yield* Deferred.make<void>()
const callbackStarted = yield* Deferred.make<void>()
const releaseCallback = yield* Deferred.make<void>()
const job = yield* jobs.start({
id: "job_completed_before_shutdown",
type: "test",
run: Deferred.await(work).pipe(Effect.as("done")),
onBackgroundSettled: () =>
Deferred.succeed(callbackStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseCallback))),
})
yield* jobs.background(job.id)
yield* Deferred.succeed(work, undefined)
yield* Deferred.await(callbackStarted)
expect(yield* jobs.get(job.id)).toMatchObject({ status: "completed" })
const shutdown = yield* jobs.shutdown.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
yield* Effect.yieldNow
expect(shutdown.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(releaseCallback, undefined)
yield* Fiber.join(shutdown)
}),
)
it.live("shutdown rejects late background notification registration", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const callback = yield* Deferred.make<void>()
const job = yield* jobs.start({
id: "job_completed_before_background_shutdown",
type: "test",
run: Effect.succeed("done"),
onBackgroundSettled: () => Deferred.succeed(callback, undefined),
})
expect(yield* jobs.wait({ id: job.id })).toMatchObject({ info: { status: "completed" } })
yield* jobs.shutdown
expect(yield* jobs.background(job.id)).toMatchObject({ status: "completed" })
expect((yield* Deferred.poll(callback))._tag).toBe("None")
}),
)
it.live("shutdown completes when a background callback defects", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({
id: "job_defective_shutdown_callback",
type: "test",
run: Effect.never,
onBackgroundSettled: () => Effect.die("callback defect"),
})
yield* jobs.background(job.id)
yield* jobs.shutdown.pipe(Effect.timeout("1 second"))
expect(yield* jobs.get(job.id)).toMatchObject({ status: "cancelled" })
}),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
+81 -2
View File
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -26,6 +26,7 @@ import { Job } from "@opencode-ai/core/job"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Permission } from "@opencode-ai/core/permission"
@@ -37,7 +38,7 @@ import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { globalLayer, tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
@@ -159,6 +160,7 @@ const replacements = [
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const lifecycleIt = testEffect(Layer.empty)
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
sessionID,
@@ -777,6 +779,83 @@ describe("ShellTool", () => {
),
)
lifecycleIt.live("notifies the session when application shutdown cancels a background command", () =>
Effect.acquireUseRelease(
Effect.all([Effect.promise(() => tmpdir()), Scope.make()]),
([tmp, applicationScope]) =>
Effect.gen(function* () {
reset()
const databasePath = path.join(tmp.path, "opencode.sqlite")
const testGlobalLayer = globalLayer(tmp.path)
const context = yield* Layer.buildWithScope(
AppNodeBuilder.build(nodes, [
[SessionExecution.node, executionNode],
[Permission.node, permission],
[PluginSupervisor.node, shellPluginSupervisor],
[Database.node, Database.configured({ path: databasePath })],
[Bus.node, Bus.configured({ persist: true })],
[Global.node, testGlobalLayer],
]),
applicationScope,
)
const sessions = Context.get(context, Session.Service)
const location = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
yield* sessions.create({
id: sessionID,
title: "shell shutdown test",
location,
model: sessionModel,
})
const locations = Context.get(context, LocationServiceMap.Service)
const settled = yield* Effect.gen(function* () {
yield* (yield* PluginSupervisor.Service).flush
return yield* executeTool(
yield* Tool.Service,
call({ command: idleCommand, background: true }, "call-shutdown-background"),
)
}).pipe(Effect.provide(locations.get(location)))
expect(settled.metadata).toMatchObject({ status: "running" })
yield* Scope.close(applicationScope, Exit.void)
const pending = yield* Layer.build(
AppNodeBuilder.build(Database.node, [
[Database.node, Database.configured({ path: databasePath })],
[Global.node, testGlobalLayer],
]),
).pipe(
Effect.flatMap((verification) =>
SessionInbox.list(Context.get(verification, Database.Service).db, sessionID),
),
Effect.scoped,
)
const cancellation = pending.find(
(item) =>
item.type === "synthetic" &&
item.payload.metadata?.source === "shell" &&
item.payload.metadata.jobID === "call-shutdown-background",
)
expect(cancellation).toMatchObject({
type: "synthetic",
payload: {
text: expect.stringContaining("Command cancelled"),
description: idleCommand,
metadata: {
source: "shell",
jobID: "call-shutdown-background",
shellID: settled.metadata?.shellID,
state: "cancelled",
},
},
})
}),
([tmp, applicationScope]) =>
Scope.close(applicationScope, Exit.void).pipe(
Effect.andThen(Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined))),
),
),
)
it.live("preserves a background command's non-zero exit", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),