mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e342333eea | ||
|
|
40f11b9956 |
+194
-24
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user