refactor(core): replace background job service (#34559)

This commit is contained in:
Kit Langton
2026-06-29 23:53:35 -04:00
committed by GitHub
parent 6846542115
commit 461a1c3ab4
24 changed files with 593 additions and 673 deletions
@@ -1,8 +1,9 @@
export * as BackgroundJob from "./background-job" export * as Job from "./job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id"
import { makeGlobalNode } from "./effect/app-node" import { makeGlobalNode } from "./effect/app-node"
import { Identifier } from "./id/id"
import { SessionSchema } from "./session/schema"
export type Status = "running" | "completed" | "error" | "cancelled" export type Status = "running" | "completed" | "error" | "cancelled"
@@ -21,14 +22,11 @@ export type Info = {
type Active = { type Active = {
info: Info info: Info
done: Deferred.Deferred<Info> done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable scope: Scope.Closeable
token: object token: object
pending: number blockingSessions: Map<SessionSchema.ID, number>
next: number isBackgrounded: boolean
output?: { sequence: number; text: string }
tail: Deferred.Deferred<void>
promoted: Deferred.Deferred<Info>
onPromote?: Effect.Effect<void>
} }
type State = { type State = {
@@ -42,36 +40,29 @@ type FinishResult = {
scope?: Scope.Closeable scope?: Scope.Closeable
} }
type PromoteResult = { type BackgroundResult = {
info?: Info info?: Info
promoted?: Deferred.Deferred<Info> backgrounded?: Deferred.Deferred<Info>
onPromote?: Effect.Effect<void>
} }
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object } type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type ExtendResult = type BlockWait = {
| { extended: false } done: Deferred.Deferred<Info>
| { backgrounded: Deferred.Deferred<Info>
extended: true
previous: Deferred.Deferred<void>
scope: Scope.Closeable
tail: Deferred.Deferred<void>
token: object
sequence: number
} }
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
export type StartInput = { export type StartInput = {
id?: string id?: string
type: string type: string
title?: string title?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
onPromote?: Effect.Effect<void>
run: Effect.Effect<string, unknown>
}
export type ExtendInput = {
id: string
run: Effect.Effect<string, unknown> run: Effect.Effect<string, unknown>
} }
@@ -85,18 +76,30 @@ export type WaitResult = {
timedOut: boolean timedOut: boolean
} }
export type BlockInput = {
id: string
sessionID: SessionSchema.ID
}
export type BlockResult = { type: "finished"; info: Info } | { type: "backgrounded"; info: Info }
export type BackgroundAllInput = {
sessionID: SessionSchema.ID
type?: string
}
export interface Interface { export interface Interface {
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly get: (id: string) => Effect.Effect<Info | undefined> readonly get: (id: string) => Effect.Effect<Info | undefined>
readonly start: (input: StartInput) => Effect.Effect<Info> readonly start: (input: StartInput) => Effect.Effect<Info>
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult> readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly waitForPromotion: (id: string) => Effect.Effect<Info> readonly block: (input: BlockInput) => Effect.Effect<BlockResult | undefined>
readonly promote: (id: string) => Effect.Effect<Info | undefined> readonly background: (id: string) => Effect.Effect<Info | undefined>
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined> readonly cancel: (id: string) => Effect.Effect<Info | undefined>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
function snapshot(job: Active): Info { function snapshot(job: Active): Info {
return { return {
@@ -110,6 +113,19 @@ function errorText(error: unknown) {
return String(error) return String(error)
} }
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
return new Map(input).set(sessionID, (input.get(sessionID) ?? 0) + 1)
}
function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
const count = input.get(sessionID)
if (count === undefined) return input
const next = new Map(input)
if (count <= 1) next.delete(sessionID)
else next.set(sessionID, count - 1)
return next
}
/** /**
* Makes one scoped, process-local registry. Entries are intentionally not * Makes one scoped, process-local registry. Entries are intentionally not
* durable: process restart or owner-scope closure loses status and interrupts * durable: process restart or owner-scope closure loses status and interrupts
@@ -123,26 +139,13 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope, scope: yield* Scope.Scope,
} }
const settle = Effect.fn("BackgroundJob.settle")(function* ( const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
id: string,
token: object,
sequence: number,
exit: Exit.Exit<string, unknown>,
) {
const completed_at = yield* Clock.currentTimeMillis const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => { const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id) const job = jobs.get(id)
if (!job) return [{}, jobs] if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs] if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const pending = job.pending - 1
const output =
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
? { sequence, text: exit.value }
: job.output
if (Exit.isSuccess(exit) && pending > 0) {
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
}
const status: Exclude<Status, "running"> = Exit.isSuccess(exit) const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed" ? "completed"
: Cause.hasInterruptsOnly(exit.cause) : Cause.hasInterruptsOnly(exit.cause)
@@ -150,14 +153,12 @@ export const make = Effect.gen(function* () {
: "error" : "error"
const next = { const next = {
...job, ...job,
onPromote: undefined, blockingSessions: new Map<SessionSchema.ID, number>(),
pending: 0,
output,
info: { info: {
...job.info, ...job.info,
status, status,
completed_at, completed_at,
...(output ? { output: output.text } : {}), ...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}), ...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
}, },
} }
@@ -170,43 +171,41 @@ export const make = Effect.gen(function* () {
return result.info return result.info
}) })
const fork = Effect.fn("BackgroundJob.fork")(function* ( const fork = Effect.fn("Job.fork")(function* (
scope: Scope.Scope, scope: Scope.Scope,
id: string, id: string,
token: object, token: object,
sequence: number,
run: Effect.Effect<string, unknown>, run: Effect.Effect<string, unknown>,
) { ) {
return yield* run.pipe( return yield* run.pipe(
Effect.matchCauseEffect({ Effect.matchCauseEffect({
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)), onSuccess: (output) => settle(id, token, Exit.succeed(output)),
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)), onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
}), }),
Effect.asVoid, Effect.asVoid,
Effect.forkIn(scope, { startImmediately: true }), Effect.forkIn(scope, { startImmediately: true }),
) )
}) })
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () { const list: Interface["list"] = Effect.fn("Job.list")(function* () {
return Array.from((yield* SynchronizedRef.get(state.jobs)).values()) return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
.map(snapshot) .map(snapshot)
.toSorted((a, b) => a.started_at - b.started_at) .toSorted((a, b) => a.started_at - b.started_at)
}) })
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) { const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id) const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job) return if (!job) return undefined
return snapshot(job) return snapshot(job)
}) })
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) { const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) => return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () { Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job") const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>() const done = yield* Deferred.make<Info>()
const promoted = yield* Deferred.make<Info>() const backgrounded = yield* Deferred.make<Info>()
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modifyEffect( const result = yield* SynchronizedRef.modifyEffect(
state.jobs, state.jobs,
Effect.fnUntraced(function* (jobs) { Effect.fnUntraced(function* (jobs) {
@@ -226,13 +225,11 @@ export const make = Effect.gen(function* () {
metadata: input.metadata, metadata: input.metadata,
}, },
done, done,
backgrounded,
scope, scope,
token, token,
pending: 1, blockingSessions: new Map<SessionSchema.ID, number>(),
next: 1, isBackgrounded: false,
tail,
promoted,
onPromote: input.onPromote,
} }
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [ return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
StartResult, StartResult,
@@ -240,56 +237,13 @@ export const make = Effect.gen(function* () {
] ]
}), }),
) )
if ("scope" in result) if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
yield* fork(
result.scope,
id,
result.token,
0,
restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
)
return result.info return result.info
}), }),
) )
}) })
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) { const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [ExtendResult, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
return [
{ extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
new Map(jobs).set(input.id, {
...job,
pending: job.pending + 1,
next: job.next + 1,
tail,
}),
]
},
)
if (!result.extended) return false
yield* fork(
result.scope,
input.id,
result.token,
result.sequence,
Deferred.await(result.previous).pipe(
Effect.andThen(restore(input.run)),
Effect.ensuring(Deferred.succeed(result.tail, undefined)),
),
)
return true
}),
)
})
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id) const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false } if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false } if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
@@ -300,41 +254,91 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true } return { info: snapshot(job), timedOut: true }
}) })
const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) { const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id) yield* SynchronizedRef.update(state.jobs, (jobs) => {
if (!job || job.info.status !== "running") return yield* Effect.never const job = jobs.get(input.id)
if (job.info.metadata?.background === true) return snapshot(job) if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
return yield* Deferred.await(job.promoted) return new Map(jobs).set(input.id, {
...job,
blockingSessions: decrementSession(job.blockingSessions, input.sessionID),
})
})
}) })
const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) { const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
const result = yield* SynchronizedRef.modifyEffect( 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.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
}),
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(Effect.ensuring(removeBlock(input)))
})
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
const result = yield* SynchronizedRef.modify(
state.jobs, state.jobs,
Effect.fnUntraced(function* (jobs) { (jobs): readonly [BackgroundResult, Map<string, Active>] => {
const job = jobs.get(id) const job = jobs.get(id)
if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>] if (!job || job.info.status !== "running") return [{}, jobs]
if (job.info.metadata?.background === true) if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
const next = { const next = {
...job, ...job,
onPromote: undefined, isBackgrounded: true,
info: { blockingSessions: new Map<SessionSchema.ID, number>(),
...job.info,
metadata: { ...job.info.metadata, background: true },
},
} }
return [ return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
{ info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted }, },
new Map(jobs).set(id, next),
] as readonly [PromoteResult, Map<string, Active>]
}),
) )
if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore) if (result.info && result.backgrounded)
if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore) yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
return result.info return result.info
}) })
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) { const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
const results: BackgroundResult[] = []
const next = new Map(jobs)
for (const [id, job] of jobs) {
if (job.info.status !== "running") continue
if (job.isBackgrounded) continue
if (input.type !== undefined && job.info.type !== input.type) continue
if (!job.blockingSessions.has(input.sessionID)) continue
const updated = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
next.set(id, updated)
}
return [results, next]
},
)
yield* Effect.forEach(
result,
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
{ discard: true },
)
return result.flatMap((item) => (item.info ? [item.info] : []))
})
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => { const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id) const job = jobs.get(id)
@@ -342,8 +346,7 @@ export const make = Effect.gen(function* () {
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = { const next = {
...job, ...job,
onPromote: undefined, blockingSessions: new Map<SessionSchema.ID, number>(),
pending: 0,
info: { info: {
...job.info, ...job.info,
status: "cancelled" as const, status: "cancelled" as const,
@@ -357,7 +360,7 @@ export const make = Effect.gen(function* () {
return result.info return result.info
}) })
return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel }) return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel })
}) })
export const layer = Layer.effect(Service, make) export const layer = Layer.effect(Service, make)
+14 -14
View File
@@ -3,8 +3,8 @@ export * as ShellTool from "./shell"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema, Scope } from "effect" import { Effect, Layer, Schema, Scope } from "effect"
import { BackgroundJob } from "../background-job"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Job } from "../job"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { LocationServiceMap } from "../location-service-map" import { LocationServiceMap } from "../location-service-map"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
@@ -74,8 +74,8 @@ const modelOutput = (output: Output): string | undefined => {
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows. // TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired. // TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
// TODO: Persist background job status and define restart recovery before exposing remote observation. // TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined. // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only. // TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview. // TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
@@ -98,12 +98,12 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const tools = yield* ApplicationTools.Service const tools = yield* ApplicationTools.Service
const sessions = yield* SessionV2.Service const sessions = yield* SessionV2.Service
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service const fsUtil = yield* FSUtil.Service
const injectWhenDone = Effect.fn("ShellTool.injectWhenDone")(function* ( const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
callID: string, callID: string,
command: string, command: string,
@@ -121,9 +121,9 @@ export const layer = Layer.effectDiscard(
if (state === undefined) return Effect.void if (state === undefined) return Effect.void
const text = const text =
state === "completed" state === "completed"
? result.info!.output ?? "" ? (result.info!.output ?? "")
: state === "error" : state === "error"
? result.info!.error ?? "Command failed" ? (result.info!.error ?? "Command failed")
: "Command cancelled" : "Command cancelled"
return sessions.synthetic({ return sessions.synthetic({
sessionID, sessionID,
@@ -156,9 +156,7 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const parent = yield* sessions const parent = yield* sessions
.get(context.sessionID) .get(context.sessionID)
.pipe( .pipe(Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })))
Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })),
)
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
@@ -203,6 +201,7 @@ export const layer = Layer.effectDiscard(
timeout, timeout,
metadata: { sessionID: context.sessionID }, metadata: { sessionID: context.sessionID },
}) })
return yield* Effect.gen(function* () {
const final = yield* shell.wait(info.id) const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
@@ -213,6 +212,7 @@ export const layer = Layer.effectDiscard(
const body = page.output || "(no output)" const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return `${body}${notice}` return `${body}${notice}`
}).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)))
}) })
const info = yield* jobs.start({ const info = yield* jobs.start({
@@ -220,10 +220,10 @@ export const layer = Layer.effectDiscard(
type: name, type: name,
title: input.command, title: input.command,
metadata: { sessionID: context.sessionID }, metadata: { sessionID: context.sessionID },
onPromote: injectWhenDone(context.sessionID, context.toolCallID, input.command),
run: run(), run: run(),
}) })
yield* injectWhenDone(context.sessionID, context.toolCallID, input.command) yield* jobs.background(info.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return { return {
output: BACKGROUND_STARTED, output: BACKGROUND_STARTED,
truncated: false, truncated: false,
@@ -262,7 +262,7 @@ export const layer = Layer.effectDiscard(
status: "completed" as const, status: "completed" as const,
...(warnings.length ? { warnings } : {}), ...(warnings.length ? { warnings } : {}),
} }
}).pipe(Effect.provide(locations.get(parent.location))) as Effect.Effect<Schema.Schema.Type<typeof Output>, ToolFailure> }).pipe(Effect.provide(locations.get(parent.location)))
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}), }),
}) })
@@ -273,5 +273,5 @@ export const layer = Layer.effectDiscard(
export const node = makeGlobalNode({ export const node = makeGlobalNode({
name: "shell-tool", name: "shell-tool",
layer, layer,
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node, FSUtil.node], deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node, FSUtil.node],
}) })
+17 -17
View File
@@ -3,7 +3,7 @@ export * as SubagentTool from "./subagent"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema, Scope } from "effect" import { Effect, Layer, Schema, Scope } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { BackgroundJob } from "../background-job" import { Job } from "../job"
import { LocationServiceMap } from "../location-service-map" import { LocationServiceMap } from "../location-service-map"
import { SessionV2 } from "../session" import { SessionV2 } from "../session"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
@@ -44,7 +44,7 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const tools = yield* ApplicationTools.Service const tools = yield* ApplicationTools.Service
const sessions = yield* SessionV2.Service const sessions = yield* SessionV2.Service
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
@@ -77,7 +77,7 @@ export const layer = Layer.effectDiscard(
}) })
}) })
const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* ( const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID, parentID: SessionSchema.ID,
childID: SessionSchema.ID, childID: SessionSchema.ID,
description: string, description: string,
@@ -138,37 +138,37 @@ export const layer = Layer.effectDiscard(
yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
yield* sessions.resume(child.id) yield* sessions.resume(child.id)
return yield* latestAssistantText(child.id) return yield* latestAssistantText(child.id)
}) }).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id)))
const info = yield* jobs.start({ const info = yield* jobs.start({
id: child.id, id: child.id,
type: name, type: name,
title: input.description, title: input.description,
metadata: {}, metadata: {},
onPromote: injectWhenDone(context.sessionID, child.id, input.description),
run, run,
}) })
if (background) { if (background) {
if ((yield* jobs.promote(info.id)) === undefined) yield* jobs.background(info.id)
yield* injectWhenDone(context.sessionID, child.id, input.description) yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
} }
const result = yield* Effect.raceFirst( const result = yield* jobs
jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)), .block({ id: child.id, sessionID: context.sessionID })
jobs.waitForPromotion(child.id), .pipe(
).pipe(
Effect.onInterrupt(() => Effect.onInterrupt(() =>
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }), Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
), ),
) )
if (result?.metadata?.background === true) if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
if (result?.status === "error") }
return yield* new ToolFailure({ message: result.error ?? "Subagent failed" }) if (result?.info.status === "error")
if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT } if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}), }),
}), }),
}) })
@@ -182,5 +182,5 @@ export const layer = Layer.effectDiscard(
export const node = makeGlobalNode({ export const node = makeGlobalNode({
name: "subagent-tool", name: "subagent-tool",
layer, layer,
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node], deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node],
}) })
-103
View File
@@ -1,103 +0,0 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { Deferred, Effect, Exit, Scope } from "effect"
import { it } from "./lib/effect"
describe("BackgroundJob", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { durable: false },
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
timedOut: true,
info: { status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("publishes jobs before starting immediately settling work", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
const id = `job_immediate_start_${index}`
return Effect.gen(function* () {
const job = yield* jobs.start({
id,
type: "test",
run: jobs
.get(id)
.pipe(
Effect.flatMap((info) =>
info?.status === "running"
? Effect.succeed(`done-${index}`)
: Effect.fail("job started before publish"),
),
),
})
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `done-${index}` },
})
})
})
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("increments pending work before starting immediately settling extensions", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) =>
Effect.gen(function* () {
const first = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as(`first-${index}`)),
})
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed(`second-${index}`) })).toBe(true)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(first, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `second-${index}` },
})
}),
)
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const interrupted = yield* Deferred.make<void>()
const jobs = yield* BackgroundJob.make.pipe(Scope.provide(scope))
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* Scope.close(scope, Exit.void)
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
// The abandoned in-memory registry is not a durable observation channel.
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)
})
+164
View File
@@ -0,0 +1,164 @@
import { describe, expect } from "bun:test"
import { Job } from "@opencode-ai/core/job"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(Job.layer)
describe("Job", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { durable: false },
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
timedOut: true,
info: { status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}),
)
it.live("publishes jobs before starting immediately settling work", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
const id = `job_immediate_start_${index}`
return Effect.gen(function* () {
const job = yield* jobs.start({
id,
type: "test",
run: jobs
.get(id)
.pipe(
Effect.flatMap((info) =>
info?.status === "running"
? Effect.succeed(`done-${index}`)
: Effect.fail("job started before publish"),
),
),
})
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `done-${index}` },
})
})
})
}),
)
it.live("returns finished from a blocking wait when completion wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
yield* Deferred.succeed(latch, undefined)
expect(yield* Fiber.join(waiting)).toMatchObject({
type: "finished",
info: { status: "completed", output: "done" },
})
expect(yield* jobs.background(job.id)).toBeUndefined()
}),
)
it.live("returns backgrounded from a blocking wait when background wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" })
expect(yield* Fiber.join(waiting)).toMatchObject({
type: "backgrounded",
info: { id: job.id, status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}),
)
it.live("backgrounds only jobs actively blocking a session", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const parent = SessionSchema.ID.make("ses_parent")
const other = SessionSchema.ID.make("ses_other")
const latch = yield* Deferred.make<void>()
const first = yield* jobs.start({
id: "job_first",
type: "test",
run: Deferred.await(latch).pipe(Effect.as("first")),
})
const second = yield* jobs.start({
id: "job_second",
type: "test",
run: Deferred.await(latch).pipe(Effect.as("second")),
})
const third = yield* jobs.start({
id: "job_third",
type: "other",
run: Deferred.await(latch).pipe(Effect.as("third")),
})
const scope = yield* Scope.Scope
const firstWait = yield* jobs
.block({ id: first.id, sessionID: parent })
.pipe(Effect.forkIn(scope, { startImmediately: true }))
const secondWait = yield* jobs
.block({ id: second.id, sessionID: other })
.pipe(Effect.forkIn(scope, { startImmediately: true }))
const thirdWait = yield* jobs
.block({ id: third.id, sessionID: parent })
.pipe(Effect.forkIn(scope, { startImmediately: true }))
expect(yield* jobs.backgroundAll({ sessionID: parent, type: "test" })).toMatchObject([{ id: first.id }])
expect(yield* Fiber.join(firstWait)).toMatchObject({ type: "backgrounded", info: { id: first.id } })
yield* Deferred.succeed(latch, undefined)
expect(yield* Fiber.join(secondWait)).toMatchObject({ type: "finished", info: { id: second.id } })
expect(yield* Fiber.join(thirdWait)).toMatchObject({ type: "finished", info: { id: third.id } })
}),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const interrupted = yield* Deferred.make<void>()
const jobs = yield* Job.make.pipe(Scope.provide(scope))
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* Scope.close(scope, Exit.void)
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
// The abandoned in-memory registry is not a durable observation channel.
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)
})
+7 -17
View File
@@ -17,12 +17,11 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { BackgroundJob } from "@opencode-ai/core/background-job" import { Job } from "@opencode-ai/core/job"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { ShellTool } from "@opencode-ai/core/tool/shell" import { ShellTool } from "@opencode-ai/core/tool/shell"
@@ -120,7 +119,7 @@ const layer = AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([
Database.node, Database.node,
EventV2.node, EventV2.node,
BackgroundJob.node, Job.node,
ToolOutputStore.cleanupNode, ToolOutputStore.cleanupNode,
SessionV2.node, SessionV2.node,
ShellTool.node, ShellTool.node,
@@ -155,10 +154,7 @@ const overflowCommand = (bytes: number) =>
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const withSession = <A, E, R>( const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () { Effect.gen(function* () {
const sessions = yield* SessionV2.Service const sessions = yield* SessionV2.Service
const location = Location.Ref.make({ directory: AbsolutePath.make(directory) }) const location = Location.Ref.make({ directory: AbsolutePath.make(directory) })
@@ -214,9 +210,7 @@ describe("ShellTool", () => {
reset() reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen( Effect.andThen(
withSession(tmp.path, (registry) => withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
settleTool(registry, call({ command: cwdCommand, workdir: "src" })),
),
), ),
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => Effect.sync(() =>
@@ -247,9 +241,7 @@ describe("ShellTool", () => {
: Effect.void : Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe( return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen( Effect.andThen(
withSession(tmp.path, (registry) => withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
executeTool(registry, call({ command: cwdCommand, workdir: "src" })),
),
), ),
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))), Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
) )
@@ -314,9 +306,7 @@ describe("ShellTool", () => {
reset() reset()
denyAction = "external_directory" denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt") const target = path.join(outside.path, "secret.txt")
return withSession(active.path, (registry) => return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
settleTool(registry, call({ command: `cat ${target}` })),
).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["shell"]) expect(assertions.map((item) => item.action)).toEqual(["shell"])
@@ -417,7 +407,7 @@ test("keeps locked deferred parity TODOs visible", async () => {
"Restore PowerShell and cmd-specific invocation/path handling on Windows.", "Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.", "Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", "Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist background job status and define restart recovery before exposing remote observation.", "Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.", "Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.", "Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
+3 -5
View File
@@ -10,7 +10,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { BackgroundJob } from "@opencode-ai/core/background-job" import { Job } from "@opencode-ai/core/job"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -95,7 +95,7 @@ const layer = AppNodeBuilder.build(
LayerNode.group([ LayerNode.group([
Database.node, Database.node,
EventV2.node, EventV2.node,
BackgroundJob.node, Job.node,
ToolOutputStore.cleanupNode, ToolOutputStore.cleanupNode,
SessionV2.node, SessionV2.node,
SubagentTool.node, SubagentTool.node,
@@ -242,7 +242,7 @@ describe("SubagentTool", () => {
), ),
) )
it.live("promotes background work and injects one synthetic parent completion", () => it.live("notifies once when background work completes", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -251,7 +251,6 @@ describe("SubagentTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* SessionV2.Service const sessions = yield* SessionV2.Service
const jobs = yield* BackgroundJob.Service
const parent = yield* sessions.create({ location }) const parent = yield* sessions.create({ location })
yield* withSubagent(parent.location) yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
@@ -270,7 +269,6 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured) const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" }) expect(settled.output?.structured).toMatchObject({ status: "running" })
yield* jobs.promote(childID)
yield* Effect.yieldNow yield* Effect.yieldNow
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1) expect(synthetic).toHaveLength(1)
+2 -2
View File
@@ -48,7 +48,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map" import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
@@ -74,7 +74,7 @@ export const AppLayer = Layer.mergeAll(
Todo.defaultLayer, Todo.defaultLayer,
Session.defaultLayer, Session.defaultLayer,
SessionStatus.defaultLayer, SessionStatus.defaultLayer,
BackgroundJob.defaultLayer, Job.defaultLayer,
RuntimeFlags.defaultLayer, RuntimeFlags.defaultLayer,
EventV2Bridge.defaultLayer, EventV2Bridge.defaultLayer,
SessionRunState.defaultLayer, SessionRunState.defaultLayer,
@@ -1,32 +1,34 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job" import { Service, make } from "@opencode-ai/core/job"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
export { export {
Service, Service,
type ExtendInput, type BackgroundAllInput,
type BlockInput,
type BlockResult,
type Info, type Info,
type Interface, type Interface,
type StartInput, type StartInput,
type Status, type Status,
type WaitInput, type WaitInput,
type WaitResult, type WaitResult,
} from "@opencode-ai/core/background-job" } from "@opencode-ai/core/job"
/** Keeps the legacy service instance-scoped while sharing the core registry engine. */ /** Keeps the legacy service instance-scoped while sharing the core registry engine. */
export const layer = Layer.effect( export const layer = Layer.effect(
CoreBackgroundJob.Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make(() => CoreBackgroundJob.make) const state = yield* InstanceState.make(() => make)
return CoreBackgroundJob.Service.of({ return Service.of({
list: () => InstanceState.useEffect(state, (jobs) => jobs.list()), list: () => InstanceState.useEffect(state, (jobs) => jobs.list()),
get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)), get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)),
start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)), start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)),
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)), wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)), block: (input) => InstanceState.useEffect(state, (jobs) => jobs.block(input)),
promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)), background: (id) => InstanceState.useEffect(state, (jobs) => jobs.background(id)),
backgroundAll: (input) => InstanceState.useEffect(state, (jobs) => jobs.backgroundAll(input)),
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)), cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
}) })
}), }),
@@ -34,6 +36,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer export const defaultLayer = layer
export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] }) export const node = LayerNode.make({ service: Service, layer, deps: [] })
export * as BackgroundJob from "./job" export * as Job from "./job"
@@ -1,6 +1,6 @@
import { Account } from "@/account/account" import { Account } from "@/account/account"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -33,7 +33,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service const worktreeSvc = yield* Worktree.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const background = yield* BackgroundJob.Service const jobs = yield* Job.Service
const flags = yield* RuntimeFlags.Service const flags = yield* RuntimeFlags.Service
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
@@ -159,15 +159,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
params: { sessionID: SessionID } params: { sessionID: SessionID }
}) { }) {
if (!flags.experimentalBackgroundSubagents) return false if (!flags.experimentalBackgroundSubagents) return false
const jobs = (yield* background.list()).filter( return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0
(job) =>
job.type === "task" &&
job.status === "running" &&
job.metadata?.parentSessionId === ctx.params.sessionID &&
job.metadata.background !== true,
)
const promoted = yield* Effect.forEach(jobs, (job) => background.promote(job.id), { concurrency: "unbounded" })
return promoted.some((job) => job !== undefined)
}) })
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
@@ -7,7 +7,7 @@ import * as Observability from "@opencode-ai/core/observability"
import { Account } from "@/account/account" import { Account } from "@/account/account"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { Command } from "@/command" import { Command } from "@/command"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
@@ -233,7 +233,7 @@ const app = LayerNode.group([
Session.node, Session.node,
SessionProjector.node, SessionProjector.node,
SessionStatus.node, SessionStatus.node,
BackgroundJob.node, Job.node,
RuntimeFlags.node, RuntimeFlags.node,
EventV2Bridge.node, EventV2Bridge.node,
SessionRunState.node, SessionRunState.node,
+11 -17
View File
@@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Runner } from "@/effect/runner" import { Runner } from "@/effect/runner"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { Effect, Latch, Layer, Scope, Context } from "effect" import { Effect, Latch, Layer, Scope, Context } from "effect"
import { Session } from "./session" import { Session } from "./session"
import { SessionID } from "./schema" import { SessionID } from "./schema"
@@ -29,7 +29,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const background = yield* BackgroundJob.Service const jobs = yield* Job.Service
const status = yield* SessionStatus.Service const status = yield* SessionStatus.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
@@ -75,7 +75,7 @@ export const layer = Layer.effect(
}) })
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
yield* cancelBackgroundJobs(background, sessionID) yield* cancelJobs(jobs, sessionID)
const data = yield* InstanceState.get(state) const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID) const existing = data.runners.get(sessionID)
if (!existing) { if (!existing) {
@@ -108,31 +108,25 @@ export const layer = Layer.effect(
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(Layer.provide(Job.defaultLayer), Layer.provide(SessionStatus.defaultLayer))
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
)
const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* ( const cancelJobs = Effect.fn("SessionRunState.cancelJobs")(function* (jobs: Job.Interface, sessionID: SessionID) {
background: BackgroundJob.Interface, const running = yield* jobs.list()
sessionID: SessionID,
) {
const jobs = yield* background.list()
const pending = new Set<string>([sessionID]) const pending = new Set<string>([sessionID])
const cancelled = new Set<string>() const cancelled = new Set<string>()
const matches = (job: BackgroundJob.Info) => { const matches = (job: Job.Info) => {
if (job.status !== "running") return false if (job.status !== "running") return false
if (cancelled.has(job.id)) return false if (cancelled.has(job.id)) return false
if (pending.has(job.id)) return true if (pending.has(job.id)) return true
if (typeof job.metadata?.sessionId === "string" && pending.has(job.metadata.sessionId)) return true if (typeof job.metadata?.sessionId === "string" && pending.has(job.metadata.sessionId)) return true
return typeof job.metadata?.parentSessionId === "string" && pending.has(job.metadata.parentSessionId) return typeof job.metadata?.parentSessionId === "string" && pending.has(job.metadata.parentSessionId)
} }
let batch = jobs.filter(matches) let batch = running.filter(matches)
while (batch.length > 0) { while (batch.length > 0) {
yield* Effect.forEach( yield* Effect.forEach(
batch, batch,
(job) => (job) =>
background.cancel(job.id).pipe( jobs.cancel(job.id).pipe(
Effect.tap(() => Effect.tap(() =>
Effect.sync(() => { Effect.sync(() => {
cancelled.add(job.id) cancelled.add(job.id)
@@ -143,7 +137,7 @@ const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(f
), ),
{ concurrency: "unbounded", discard: true }, { concurrency: "unbounded", discard: true },
) )
batch = jobs.filter(matches) batch = running.filter(matches)
} }
}) })
@@ -151,6 +145,6 @@ function busyError(sessionID: SessionID) {
return new Session.BusyError({ sessionID }) return new Session.BusyError({ sessionID })
} }
export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [Job.node, SessionStatus.node] })
export * as SessionRunState from "./run-state" export * as SessionRunState from "./run-state"
+10 -13
View File
@@ -4,7 +4,7 @@ import { Slug } from "@opencode-ai/core/util/slug"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { serviceUse } from "@opencode-ai/core/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path" import path from "path"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -491,13 +491,13 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
export const layer: Layer.Layer< export const layer: Layer.Layer<
Service, Service,
never, never,
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service Job.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const database = yield* Database.Service const database = yield* Database.Service
const background = yield* BackgroundJob.Service const jobs = yield* Job.Service
const events = yield* EventV2Bridge.Service const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service const flags = yield* RuntimeFlags.Service
@@ -618,7 +618,7 @@ export const layer: Layer.Layer<
Effect.catchCause(() => Effect.succeed(false)), Effect.catchCause(() => Effect.succeed(false)),
) )
if (hasInstance) yield* cancelBackgroundJobs(background, sessionID) if (hasInstance) yield* cancelJobs(jobs, sessionID)
const kids = yield* children(sessionID) const kids = yield* children(sessionID)
for (const child of kids) { for (const child of kids) {
yield* remove(child.id) yield* remove(child.id)
@@ -941,7 +941,7 @@ export const layer: Layer.Layer<
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer), Layer.provide(Job.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide( Layer.provide(
@@ -953,19 +953,16 @@ export const defaultLayer = layer.pipe(
Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer),
) )
const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ( const cancelJobs = Effect.fn("Session.cancelJobs")(function* (jobs: Job.Interface, sessionID: SessionID) {
background: BackgroundJob.Interface, const running = yield* jobs.list()
sessionID: SessionID,
) {
const jobs = yield* background.list()
yield* Effect.forEach( yield* Effect.forEach(
jobs.filter((job) => { running.filter((job) => {
if (job.status !== "running") return false if (job.status !== "running") return false
if (job.id === sessionID) return true if (job.id === sessionID) return true
if (job.metadata?.sessionId === sessionID) return true if (job.metadata?.sessionId === sessionID) return true
return job.metadata?.parentSessionId === sessionID return job.metadata?.parentSessionId === sessionID
}), }),
(job) => background.cancel(job.id), (job) => jobs.cancel(job.id),
{ concurrency: "unbounded", discard: true }, { concurrency: "unbounded", discard: true },
) )
}) })
@@ -1098,7 +1095,7 @@ export function* listGlobal(input?: {
export const node = LayerNode.make({ export const node = LayerNode.make({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], deps: [Job.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
}) })
export * as Session from "./session" export * as Session from "./session"
+3 -3
View File
@@ -48,7 +48,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@@ -325,7 +325,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(BackgroundJob.defaultLayer), Layer.provide(Job.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
@@ -426,7 +426,7 @@ export const node = LayerNode.make({
Agent.node, Agent.node,
Skill.node, Skill.node,
Session.node, Session.node,
BackgroundJob.node, Job.node,
Provider.node, Provider.node,
LSP.node, LSP.node,
Instruction.node, Instruction.node,
+26 -27
View File
@@ -2,7 +2,7 @@ import * as Tool from "./tool"
import DESCRIPTION from "./task.txt" import DESCRIPTION from "./task.txt"
import { ToolJsonSchema } from "./json-schema" import { ToolJsonSchema } from "./json-schema"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
@@ -33,11 +33,11 @@ const BACKGROUND_STARTED = [
"DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.", "Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n") ].join("\n")
const BACKGROUND_UPDATED = [ const BACKGROUND_ALREADY_RUNNING = [
"Additional context sent to the running background task.", "The task is already working in the background.",
"The task is still working in the background. You will be notified automatically when it finishes.", "The task is still working in the background. You will be notified automatically when it finishes.",
"DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.", "Work on non-overlapping tasks, or briefly tell the user it is still running and end your response.",
].join("\n") ].join("\n")
const BaseParameterFields = { const BaseParameterFields = {
@@ -82,7 +82,7 @@ export const TaskTool = Tool.define(
id, id,
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
const background = yield* BackgroundJob.Service const jobs = yield* Job.Service
const config = yield* Config.Service const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
@@ -229,7 +229,7 @@ export const TaskTool = Tool.define(
}) })
const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) { const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) {
yield* background.wait({ id: jobID }).pipe( yield* jobs.wait({ id: jobID }).pipe(
Effect.flatMap((result) => { Effect.flatMap((result) => {
if (result.info?.status === "completed") return inject("completed", result.info.output ?? "") if (result.info?.status === "completed") return inject("completed", result.info.output ?? "")
if (result.info?.status === "error") return inject("error", result.info.error ?? "") if (result.info?.status === "error") return inject("error", result.info.error ?? "")
@@ -239,7 +239,8 @@ export const TaskTool = Tool.define(
) )
}) })
if (yield* background.extend({ id: nextSession.id, run: runTask() })) { const existing = yield* jobs.get(nextSession.id)
if (existing?.status === "running") {
return { return {
title: params.description, title: params.description,
metadata: { metadata: {
@@ -250,24 +251,17 @@ export const TaskTool = Tool.define(
output: renderOutput({ output: renderOutput({
sessionID: nextSession.id, sessionID: nextSession.id,
state: "running", state: "running",
summary: "Background task updated", summary: "Background task already running",
text: BACKGROUND_UPDATED, text: BACKGROUND_ALREADY_RUNNING,
}), }),
} }
} }
const info = yield* background.start({ const info = yield* jobs.start({
id: nextSession.id, id: nextSession.id,
type: id, type: id,
title: params.description, title: params.description,
metadata, metadata,
onPromote: Effect.all([
ctx.metadata({
title: params.description,
metadata: { ...metadata, background: true, jobId: nextSession.id },
}),
notify(nextSession.id),
]),
run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))), run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
}) })
@@ -289,6 +283,7 @@ export const TaskTool = Tool.define(
} }
if (runInBackground) { if (runInBackground) {
yield* jobs.background(info.id)
yield* notify(info.id) yield* notify(info.id)
return backgroundResult() return backgroundResult()
} }
@@ -306,23 +301,27 @@ export const TaskTool = Tool.define(
}), }),
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const result = yield* Effect.raceFirst( const result = yield* jobs.block({ id: nextSession.id, sessionID: ctx.sessionID })
background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), if (result?.type === "backgrounded") {
background.waitForPromotion(nextSession.id), yield* ctx.metadata({
) title: params.description,
if (result?.metadata?.background === true) return backgroundResult() metadata: { ...metadata, background: true, jobId: nextSession.id },
if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) })
if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) yield* notify(nextSession.id)
return backgroundResult()
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Task failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled"))
return { return {
title: params.description, title: params.description,
metadata, metadata,
output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.info.output ?? "" }),
} }
}), }),
(_, exit) => (_, exit) =>
Effect.gen(function* () { Effect.gen(function* () {
if (Exit.hasInterrupts(exit)) if (Exit.hasInterrupts(exit)) yield* Effect.all([cancel, jobs.cancel(nextSession.id)], { discard: true })
yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true })
}).pipe( }).pipe(
Effect.ensuring( Effect.ensuring(
Effect.sync(() => { Effect.sync(() => {
+1 -1
View File
@@ -172,7 +172,7 @@ Wait on a **published readiness signal**, not wall-clock time. Available afforda
- `awaitWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — wrap any effect with `Effect.timeoutOrElse` and a custom error message. - `awaitWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — wrap any effect with `Effect.timeoutOrElse` and a custom error message.
- `llm.wait(n)` from `test/lib/llm-server.ts` — wait until the mock LLM has received `n` HTTP calls. - `llm.wait(n)` from `test/lib/llm-server.ts` — wait until the mock LLM has received `n` HTTP calls.
- `SessionStatus.Service` `.get(sessionID)` — observable per-session state (`{ type: "busy" | "idle" | ... }`). - `SessionStatus.Service` `.get(sessionID)` — observable per-session state (`{ type: "busy" | "idle" | ... }`).
- `BackgroundJob.wait({ id, timeout })` from `src/background/job.ts` — wait for a background job to complete. - `Job.wait({ id, timeout })` from `src/job.ts` — wait for a job to complete.
- Bus subscriptions — fork `Stream.runForEach(bus.subscribe(Event), ...)` and open a `Latch` inside the callback to signal first-event readiness. - Bus subscriptions — fork `Stream.runForEach(bus.subscribe(Event), ...)` and open a `Latch` inside the callback to signal first-event readiness.
- `Deferred.await(deferred).pipe(Effect.timeoutOrElse(...))` for one-shot signals. - `Deferred.await(deferred).pipe(Effect.timeoutOrElse(...))` for one-shot signals.
@@ -1,243 +0,0 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect } from "effect"
import { BackgroundJob } from "@/background/job"
import { testEffect } from "../lib/effect"
const it = testEffect(BackgroundJob.defaultLayer)
describe("background.job", () => {
it.instance("tracks started jobs through completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
title: "test job",
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job.id.startsWith("job_")).toBe(true)
expect(job.status).toBe("running")
expect(job.title).toBe("test job")
yield* Deferred.succeed(latch, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.timedOut).toBe(false)
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("done")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id])
}),
)
it.instance("returns a running snapshot when wait times out", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
run: Effect.never,
})
const result = yield* jobs.wait({ id: job.id, timeout: 1 })
expect(result.timedOut).toBe(true)
expect(result.info?.status).toBe("running")
}),
)
it.instance("deduplicates concurrent starts for a running id", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const started = yield* Deferred.make<void>()
const id = "job_test"
const [first, second] = yield* Effect.all(
[
jobs.start({
id,
type: "test",
run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
jobs.start({
id,
type: "test",
run: Effect.fail(new Error("duplicate started")),
}),
],
{ concurrency: "unbounded" },
)
yield* Deferred.await(started)
expect(first.id).toBe(id)
expect(second.id).toBe(id)
expect(first.status).toBe("running")
expect(second.status).toBe("running")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([id])
yield* jobs.cancel(id)
}),
)
it.instance("waits for extensions before completing a running job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const second = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as("first")),
})
expect(yield* jobs.extend({ id: job.id, run: Deferred.await(second).pipe(Effect.as("second")) })).toBe(true)
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(second, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("second")
}),
)
it.instance("runs extensions after earlier work completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const order: string[] = []
const job = yield* jobs.start({
type: "test",
run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
})
expect(
yield* jobs.extend({
id: job.id,
run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
}),
).toBe(true)
yield* Effect.yieldNow
expect(order).toEqual(["start"])
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
expect(order).toEqual(["start", "extend"])
}),
)
it.instance("rejects extensions after a job completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({ type: "test", run: Effect.succeed("done") })
yield* jobs.wait({ id: job.id })
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("late") })).toBe(false)
expect((yield* jobs.get(job.id))?.output).toBe("done")
}),
)
it.instance("records failed jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
run: Effect.fail(new Error("boom")),
})
const result = yield* jobs.wait({ id: job.id })
expect(result.info?.status).toBe("error")
expect(result.info?.error).toBe("boom")
}),
)
it.instance("ignores stale settlements after restarting a failed job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const fail = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const id = "job_test"
yield* jobs.start({
id,
type: "test",
run: Deferred.await(fail).pipe(Effect.andThen(Effect.fail(new Error("boom")))),
})
yield* jobs.extend({
id,
run: Effect.never.pipe(
Effect.ensuring(Deferred.succeed(interrupted, undefined).pipe(Effect.andThen(Deferred.await(release)))),
),
})
yield* Deferred.succeed(fail, undefined)
expect((yield* jobs.wait({ id })).info?.status).toBe("error")
yield* Deferred.await(interrupted)
yield* jobs.start({ id, type: "test", run: Effect.never })
yield* Deferred.succeed(release, undefined)
yield* Effect.yieldNow
expect((yield* jobs.get(id))?.status).toBe("running")
yield* jobs.cancel(id)
}),
)
it.instance("can cancel running jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* jobs.extend({
id: job.id,
run: Effect.never,
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("promotes running jobs without interrupting them", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const promoted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { parentSessionId: "parent" },
onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
run: Deferred.await(latch).pipe(Effect.as("done")),
})
const info = yield* jobs.promote(job.id)
expect(info?.status).toBe("running")
expect(info?.metadata?.background).toBe(true)
yield* Deferred.await(promoted)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
metadata: { value: "initial" },
run: Effect.succeed("done"),
})
if (job.metadata) job.metadata.value = "changed"
expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial")
}),
)
})
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber } from "effect"
import { Job } from "@/job"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(Job.defaultLayer)
describe("job", () => {
it.instance("tracks started jobs through completion", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
title: "test job",
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job.id.startsWith("job_")).toBe(true)
expect(job.status).toBe("running")
expect(job.title).toBe("test job")
yield* Deferred.succeed(latch, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.timedOut).toBe(false)
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("done")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id])
}),
)
it.instance("returns a running snapshot when wait times out", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "test", run: Effect.never })
const result = yield* jobs.wait({ id: job.id, timeout: 1 })
expect(result.timedOut).toBe(true)
expect(result.info?.status).toBe("running")
}),
)
it.instance("deduplicates concurrent starts for a running id", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const started = yield* Deferred.make<void>()
const id = "job_test"
const [first, second] = yield* Effect.all(
[
jobs.start({
id,
type: "test",
run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
jobs.start({ id, type: "test", run: Effect.fail(new Error("duplicate started")) }),
],
{ concurrency: "unbounded" },
)
yield* Deferred.await(started)
expect(first.id).toBe(id)
expect(second.id).toBe(id)
expect(first.status).toBe("running")
expect(second.status).toBe("running")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([id])
yield* jobs.cancel(id)
}),
)
it.instance("records failed jobs", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "test", run: Effect.fail(new Error("boom")) })
const result = yield* jobs.wait({ id: job.id })
expect(result.info?.status).toBe("error")
expect(result.info?.error).toBe("boom")
}),
)
it.instance("can cancel running jobs", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("releases blocking waits when backgrounded", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
.pipe(Effect.forkChild)
expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" })
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "test", metadata: { value: "initial" }, run: Effect.succeed("done") })
if (job.metadata) job.metadata.value = "changed"
expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial")
}),
)
})
@@ -12,7 +12,7 @@ import { testEffect } from "../lib/effect"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
const layer = (experimentalWorkspaces: boolean) => const layer = (experimentalWorkspaces: boolean) =>
Layer.mergeAll( Layer.mergeAll(
@@ -24,7 +24,7 @@ const layer = (experimentalWorkspaces: boolean) =>
Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer), Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(BackgroundJob.defaultLayer), Layer.provide(Job.defaultLayer),
), ),
) )
const it = testEffect(layer(false)) const it = testEffect(layer(false))
@@ -11,7 +11,7 @@ import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { Command } from "../../src/command" import { Command } from "../../src/command"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
@@ -183,7 +183,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces
lsp, lsp,
makeMcp(input?.mcpInstructions), makeMcp(input?.mcpInstructions),
FSUtil.defaultLayer, FSUtil.defaultLayer,
BackgroundJob.defaultLayer, Job.defaultLayer,
status, status,
Database.defaultLayer, Database.defaultLayer,
EventV2Bridge.defaultLayer, EventV2Bridge.defaultLayer,
@@ -12,7 +12,7 @@ import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixtur
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
@@ -24,7 +24,7 @@ const it = testEffect(
Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer), Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer), Layer.provide(Job.defaultLayer),
), ),
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
testInstanceStoreLayer, testInstanceStoreLayer,
+27 -31
View File
@@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job" import { Job } from "@/job"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -19,7 +19,7 @@ import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture" import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { pollWithTimeout, testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@@ -35,7 +35,7 @@ const ref = {
const layer = (flags: Partial<RuntimeFlags.Info> = {}) => const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll( Layer.mergeAll(
Agent.defaultLayer, Agent.defaultLayer,
BackgroundJob.defaultLayer, Job.defaultLayer,
EventV2Bridge.defaultLayer, EventV2Bridge.defaultLayer,
Config.defaultLayer, Config.defaultLayer,
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
@@ -480,9 +480,9 @@ describe("tool.task", () => {
}), }),
) )
it.instance("promotes a running foreground task without restarting it", () => it.instance("backgrounds a running foreground task without restarting it", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* tool.init() const def = yield* tool.init()
@@ -531,7 +531,12 @@ describe("tool.task", () => {
expect(job).toBeDefined() expect(job).toBeDefined()
if (!job) throw new Error("task job not found") if (!job) throw new Error("task job not found")
expect(job.metadata?.parentSessionId).toBe(chat.id) expect(job.metadata?.parentSessionId).toBe(chat.id)
yield* jobs.promote(job.id) yield* pollWithTimeout(
jobs
.backgroundAll({ sessionID: chat.id, type: "task" })
.pipe(Effect.map((backgrounded) => (backgrounded.length > 0 ? backgrounded : undefined))),
"task never blocked the parent session",
)
const result = yield* Fiber.join(fiber) const result = yield* Fiber.join(fiber)
expect(result.metadata.background).toBe(true) expect(result.metadata.background).toBe(true)
@@ -548,7 +553,7 @@ describe("tool.task", () => {
background.instance("execute launches background tasks without waiting for completion", () => background.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* tool.init() const def = yield* tool.init()
@@ -584,15 +589,13 @@ describe("tool.task", () => {
}), }),
) )
background.instance("background task completion waits for running updates", () => background.instance("running task_id reports the existing background task", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* tool.init() const def = yield* tool.init()
const first = defer<void>() const first = defer<void>()
const second = defer<void>()
const updated = defer<SessionPrompt.PromptInput>()
const injected = defer<SessionPrompt.PromptInput>() const injected = defer<SessionPrompt.PromptInput>()
let prompts = 0 let prompts = 0
const promptOps: TaskPromptOps = { const promptOps: TaskPromptOps = {
@@ -603,9 +606,7 @@ describe("tool.task", () => {
return Effect.succeed(reply(input, "done")) return Effect.succeed(reply(input, "done"))
} }
prompts++ prompts++
if (prompts === 1) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done"))) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
updated.resolve(input)
return Effect.promise(() => second.promise).pipe(Effect.as(reply(input, "second done")))
}, },
} }
const context = { const context = {
@@ -640,27 +641,22 @@ describe("tool.task", () => {
expect(result.metadata.sessionId).toBe(started.metadata.sessionId) expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
expect(result.metadata.background).toBe(true) expect(result.metadata.background).toBe(true)
expect(result.output).toContain("Background task updated") expect(result.output).toContain("Background task already running")
expect(prompts).toBe(1)
first.resolve() first.resolve()
expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
second.resolve()
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 }) const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
expect(waited.info?.status).toBe("completed") expect(waited.info?.status).toBe("completed")
expect(waited.info?.output).toBe("second done") expect(waited.info?.output).toBe("first done")
const notification = yield* Effect.promise(() => injected.promise) const notification = yield* Effect.promise(() => injected.promise)
expect(notification.variant).toBe("xhigh") expect(notification.variant).toBe("xhigh")
expect(notification.parts[0]?.type).toBe("text") expect(notification.parts[0]?.type).toBe("text")
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done") if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("first done")
}), }),
) )
background.instance("background tasks complete through the background job service", () => background.instance("background tasks complete through the job service", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* tool.init() const def = yield* tool.init()
@@ -693,7 +689,7 @@ describe("tool.task", () => {
background.instance("background task completion does not wait for the parent async prompt", () => background.instance("background task completion does not wait for the parent async prompt", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* tool.init() const def = yield* tool.init()
@@ -731,7 +727,7 @@ describe("tool.task", () => {
background.instance("removing the parent session cancels running background tasks", () => background.instance("removing the parent session cancels running background tasks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
@@ -770,7 +766,7 @@ describe("tool.task", () => {
background.instance("removing the child task session cancels its running background task", () => background.instance("removing the child task session cancels its running background task", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
@@ -809,7 +805,7 @@ describe("tool.task", () => {
background.instance("cancelling the parent run cancels running background tasks", () => background.instance("cancelling the parent run cancels running background tasks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service const runState = yield* SessionRunState.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
@@ -848,7 +844,7 @@ describe("tool.task", () => {
it.instance("cancelling a child run cancels its own pre-runner task job", () => it.instance("cancelling a child run cancels its own pre-runner task job", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat } = yield* seed() const { chat } = yield* seed()
@@ -869,7 +865,7 @@ describe("tool.task", () => {
it.instance("cancelling a parent run recursively cancels descendant background tasks", () => it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat } = yield* seed() const { chat } = yield* seed()
+1 -1
View File
@@ -690,7 +690,7 @@ Affected schema:
Change: Change:
- Remove the optional `background` bash parameter and process-local background settlement shape from the shipped tool. - Remove the optional `background` bash parameter and process-local background settlement shape from the shipped tool.
- Retain the internal `BackgroundJob` prototype for a later integration slice. - Retain the internal `Job` prototype for a later integration slice.
Reason: Reason:
+1 -1
View File
@@ -47,7 +47,7 @@ Next reviewed slices:
remaining one-turn native-adapter use with a narrow typed dispatcher remaining one-turn native-adapter use with a narrow typed dispatcher
- batch streamed deltas and add covering context indexes - batch streamed deltas and add covering context indexes
- expose replayable Session event cursors over HTTP and the generated SDK where remote consumers need them - expose replayable Session event cursors over HTTP and the generated SDK where remote consumers need them
- integrate the new BackgroundJob service with V2 tool execution: support background - integrate the new Job service with V2 tool execution: support background
bash jobs and background agent dispatch with durable status observation, bash jobs and background agent dispatch with durable status observation,
completion delivery, and explicit cancellation / continuation semantics completion delivery, and explicit cancellation / continuation semantics
- add durable/clustered interruption, retries, and stale-owner fencing only as - add durable/clustered interruption, retries, and stale-owner fencing only as