feat: add experimental background subagents (#27084)

This commit is contained in:
Shoubhit Dash
2026-05-14 22:10:15 +05:30
committed by GitHub
parent bdb0c16a93
commit 22de34c4de
21 changed files with 976 additions and 102 deletions

View File

@@ -214,6 +214,7 @@ export const layer = Layer.effect(
cancel: (sessionID: SessionID) => cancel(sessionID),
resolvePromptParts: (template: string) => resolvePromptParts(template),
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
loop: (input: LoopInput) => loop(input),
} satisfies TaskPromptOps
})

View File

@@ -1,5 +1,6 @@
import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner"
import { BackgroundJob } from "@/background/job"
import { Effect, Latch, Layer, Scope, Context } from "effect"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
@@ -27,6 +28,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const background = yield* BackgroundJob.Service
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
@@ -72,6 +74,7 @@ export const layer = Layer.effect(
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
yield* cancelBackgroundJobs(background, sessionID)
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
@@ -104,7 +107,41 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(BackgroundJob.defaultLayer), Layer.provide(SessionStatus.defaultLayer))
const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* (
background: BackgroundJob.Interface,
sessionID: SessionID,
) {
const jobs = yield* background.list()
const pending = new Set<string>([sessionID])
const cancelled = new Set<string>()
const matches = (job: BackgroundJob.Info) => {
if (job.status !== "running") return false
if (cancelled.has(job.id)) return false
if (pending.has(job.id)) 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)
}
let batch = jobs.filter(matches)
while (batch.length > 0) {
yield* Effect.forEach(
batch,
(job) =>
background.cancel(job.id).pipe(
Effect.tap(() =>
Effect.sync(() => {
cancelled.add(job.id)
pending.add(job.id)
if (typeof job.metadata?.sessionId === "string") pending.add(job.metadata.sessionId)
}),
),
),
{ concurrency: "unbounded", discard: true },
)
batch = jobs.filter(matches)
}
})
function busyError(sessionID: SessionID) {
return new Session.BusyError({ sessionID })

View File

@@ -1,5 +1,6 @@
import { Slug } from "@opencode-ai/core/util/slug"
import path from "path"
import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
@@ -508,10 +509,11 @@ const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D)
export const layer: Layer.Layer<
Service,
never,
Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service
BackgroundJob.Service | Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const background = yield* BackgroundJob.Service
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const sync = yield* SyncEvent.Service
@@ -592,20 +594,19 @@ export const layer: Layer.Layer<
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
const session = yield* get(sessionID)
try {
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
}
// `remove` needs to work in all cases, such as a broken
// sessions that run cleanup. In certain cases these will
// run without any instance state, so we need to turn off
// publishing of events in that case
// `remove` needs to work in all cases, such as broken sessions that
// run cleanup without instance state.
const hasInstance = yield* InstanceState.directory.pipe(
Effect.as(true),
Effect.catchCause(() => Effect.succeed(false)),
)
if (hasInstance) yield* cancelBackgroundJobs(background, sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
}
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
yield* sync.remove(sessionID)
} catch (e) {
@@ -862,12 +863,30 @@ export const layer: Layer.Layer<
)
export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* (
background: BackgroundJob.Interface,
sessionID: SessionID,
) {
const jobs = yield* background.list()
yield* Effect.forEach(
jobs.filter((job) => {
if (job.status !== "running") return false
if (job.id === sessionID) return true
if (job.metadata?.sessionId === sessionID) return true
return job.metadata?.parentSessionId === sessionID
}),
(job) => background.cancel(job.id),
{ concurrency: "unbounded", discard: true },
)
})
function* listByProject(
input: ListInput & {
projectID: ProjectID