Skip to content

Commit b070652

Browse files
Apply PR #24174: feat(core): add background subagent support
2 parents b04570c + a84edc2 commit b070652

18 files changed

Lines changed: 1119 additions & 70 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { InstanceState } from "@/effect/instance-state"
2+
import { Identifier } from "@/id/id"
3+
import { Cause, Deferred, Effect, Fiber, Layer, Scope, Context } from "effect"
4+
5+
export type Status = "running" | "completed" | "error" | "cancelled"
6+
7+
export type Info = {
8+
id: string
9+
type: string
10+
title?: string
11+
status: Status
12+
started_at: number
13+
completed_at?: number
14+
output?: string
15+
error?: string
16+
metadata?: Record<string, unknown>
17+
}
18+
19+
type Active = {
20+
info: Info
21+
done: Deferred.Deferred<Info>
22+
fiber?: Fiber.Fiber<void, unknown>
23+
}
24+
25+
type State = {
26+
jobs: Map<string, Active>
27+
scope: Scope.Scope
28+
}
29+
30+
export type StartInput = {
31+
id?: string
32+
type: string
33+
title?: string
34+
metadata?: Record<string, unknown>
35+
run: Effect.Effect<string, unknown>
36+
}
37+
38+
export type WaitInput = {
39+
id: string
40+
timeout?: number
41+
}
42+
43+
export type WaitResult = {
44+
info?: Info
45+
timedOut: boolean
46+
}
47+
48+
export interface Interface {
49+
readonly list: () => Effect.Effect<Info[]>
50+
readonly get: (id: string) => Effect.Effect<Info | undefined>
51+
readonly start: (input: StartInput) => Effect.Effect<Info>
52+
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
53+
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
54+
}
55+
56+
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
57+
58+
function snapshot(job: Active): Info {
59+
return {
60+
...job.info,
61+
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
62+
}
63+
}
64+
65+
function errorText(error: unknown) {
66+
if (error instanceof Error) return error.message
67+
return String(error)
68+
}
69+
70+
export const layer = Layer.effect(
71+
Service,
72+
Effect.gen(function* () {
73+
const state = yield* InstanceState.make<State>(
74+
Effect.fn("BackgroundJob.state")(function* () {
75+
return {
76+
jobs: new Map(),
77+
scope: yield* Scope.Scope,
78+
}
79+
}),
80+
)
81+
82+
const finish = Effect.fn("BackgroundJob.finish")(function* (
83+
job: Active,
84+
status: Exclude<Status, "running">,
85+
data?: { output?: string; error?: string },
86+
) {
87+
if (job.info.status !== "running") return snapshot(job)
88+
job.info.status = status
89+
job.info.completed_at = Date.now()
90+
if (data?.output !== undefined) job.info.output = data.output
91+
if (data?.error !== undefined) job.info.error = data.error
92+
job.fiber = undefined
93+
const info = snapshot(job)
94+
yield* Deferred.succeed(job.done, info).pipe(Effect.ignore)
95+
return info
96+
})
97+
98+
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
99+
const s = yield* InstanceState.get(state)
100+
return Array.from(s.jobs.values())
101+
.map(snapshot)
102+
.toSorted((a, b) => a.started_at - b.started_at)
103+
})
104+
105+
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
106+
const s = yield* InstanceState.get(state)
107+
const job = s.jobs.get(id)
108+
if (!job) return
109+
return snapshot(job)
110+
})
111+
112+
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
113+
const s = yield* InstanceState.get(state)
114+
const id = input.id ?? Identifier.ascending("job")
115+
const existing = s.jobs.get(id)
116+
if (existing?.info.status === "running") return snapshot(existing)
117+
118+
const job: Active = {
119+
info: {
120+
id,
121+
type: input.type,
122+
title: input.title,
123+
status: "running",
124+
started_at: Date.now(),
125+
metadata: input.metadata,
126+
},
127+
done: yield* Deferred.make<Info>(),
128+
}
129+
s.jobs.set(id, job)
130+
job.fiber = yield* input.run.pipe(
131+
Effect.matchCauseEffect({
132+
onSuccess: (output) => finish(job, "completed", { output }),
133+
onFailure: (cause) =>
134+
finish(job, Cause.hasInterruptsOnly(cause) ? "cancelled" : "error", {
135+
error: errorText(Cause.squash(cause)),
136+
}),
137+
}),
138+
Effect.asVoid,
139+
Effect.forkIn(s.scope),
140+
)
141+
return snapshot(job)
142+
})
143+
144+
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
145+
const s = yield* InstanceState.get(state)
146+
const job = s.jobs.get(input.id)
147+
if (!job) return { timedOut: false }
148+
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
149+
if (!input.timeout) return { info: yield* Deferred.await(job.done), timedOut: false }
150+
return yield* Effect.raceAll([
151+
Deferred.await(job.done).pipe(Effect.map((info) => ({ info, timedOut: false }))),
152+
Effect.sleep(input.timeout).pipe(Effect.as({ info: snapshot(job), timedOut: true })),
153+
])
154+
})
155+
156+
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
157+
const s = yield* InstanceState.get(state)
158+
const job = s.jobs.get(id)
159+
if (!job) return
160+
if (job.info.status !== "running") return snapshot(job)
161+
const fiber = job.fiber
162+
const info = yield* finish(job, "cancelled")
163+
if (fiber) yield* Fiber.interrupt(fiber).pipe(Effect.ignore)
164+
return info
165+
})
166+
167+
return Service.of({ list, get, start, wait, cancel })
168+
}),
169+
)
170+
171+
export const defaultLayer = layer
172+
173+
export * as BackgroundJob from "./job"

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,12 +1967,15 @@ function Task(props: ToolProps<typeof TaskTool>) {
19671967
const { navigate } = useRoute()
19681968
const sync = useSync()
19691969

1970-
onMount(() => {
1971-
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
1972-
void sync.session.sync(props.metadata.sessionId)
1970+
createEffect(() => {
1971+
const sessionID = props.metadata.sessionId
1972+
if (!sessionID) return
1973+
if (sync.data.message[sessionID]?.length) return
1974+
void sync.session.sync(sessionID)
19731975
})
19741976

1975-
const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])
1977+
const childSessionID = createMemo(() => props.metadata.sessionId)
1978+
const messages = createMemo(() => sync.data.message[childSessionID() ?? ""] ?? [])
19761979

19771980
const tools = createMemo(() => {
19781981
return messages().flatMap((msg) =>
@@ -1986,7 +1989,16 @@ function Task(props: ToolProps<typeof TaskTool>) {
19861989
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
19871990
)
19881991

1989-
const isRunning = createMemo(() => props.part.state.status === "running")
1992+
const isBackground = createMemo(() => props.metadata.background === true)
1993+
const isBackgroundRunning = createMemo(() => {
1994+
const sessionID = childSessionID()
1995+
if (!isBackground() || !sessionID) return false
1996+
const status = sync.data.session_status[sessionID]?.type
1997+
if (status === "busy" || status === "retry") return true
1998+
if (status === "idle") return false
1999+
return !messages().some((x) => x.role === "assistant" && x.time.completed)
2000+
})
2001+
const isRunning = createMemo(() => props.part.state.status === "running" || isBackgroundRunning())
19902002

19912003
const duration = createMemo(() => {
19922004
const first = messages().find((x) => x.role === "user")?.time.created
@@ -1997,7 +2009,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
19972009

19982010
const content = createMemo(() => {
19992011
if (!props.input.description) return ""
2000-
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
2012+
const description = isBackground() ? `${props.input.description} (background)` : props.input.description
2013+
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
20012014

20022015
if (isRunning() && tools().length > 0) {
20032016
// content[0] += ` · ${tools().length} toolcalls`
@@ -2008,7 +2021,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
20082021
} else content.push(`↳ ${tools().length} toolcalls`)
20092022
}
20102023

2011-
if (props.part.state.status === "completed") {
2024+
if (!isRunning() && props.part.state.status === "completed") {
20122025
content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}`)
20132026
}
20142027

@@ -2023,8 +2036,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
20232036
pending="Delegating..."
20242037
part={props.part}
20252038
onClick={() => {
2026-
if (props.metadata.sessionId) {
2027-
navigate({ type: "session", sessionID: props.metadata.sessionId })
2039+
const sessionID = childSessionID()
2040+
if (sessionID) {
2041+
navigate({ type: "session", sessionID })
20282042
}
20292043
}}
20302044
>

packages/opencode/src/effect/app-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { SessionShare } from "@/share/session"
5151
import { SyncEvent } from "@/sync"
5252
import { Npm } from "@opencode-ai/core/npm"
5353
import { memoMap } from "@opencode-ai/core/effect/memo-map"
54+
import { BackgroundJob } from "@/background/job"
5455

5556
// Adjusts the default Config layer to ensure that plugins are always initialised before
5657
// any other layers read the current config
@@ -93,6 +94,7 @@ export const AppLayer = Layer.mergeAll(
9394
Todo.defaultLayer,
9495
Session.defaultLayer,
9596
SessionStatus.defaultLayer,
97+
BackgroundJob.defaultLayer,
9698
SessionRunState.defaultLayer,
9799
SessionProcessor.defaultLayer,
98100
SessionCompaction.defaultLayer,

packages/opencode/src/id/id.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import z from "zod"
22
import { randomBytes } from "crypto"
33

44
const prefixes = {
5+
job: "job",
56
event: "evt",
67
session: "ses",
78
message: "msg",

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export const layer = Layer.effect(
118118
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
119119
resolvePromptParts: (template: string) => resolvePromptParts(template),
120120
prompt: (input: PromptInput) => prompt(input),
121+
loop: (input: LoopInput) => loop(input),
121122
} satisfies TaskPromptOps
122123
})
123124

packages/opencode/src/tool/registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GlobTool } from "./glob"
77
import { GrepTool } from "./grep"
88
import { ReadTool } from "./read"
99
import { TaskTool } from "./task"
10+
import { TaskStatusTool } from "./task_status"
1011
import { TodoWriteTool } from "./todo"
1112
import { WebFetchTool } from "./webfetch"
1213
import { WriteTool } from "./write"
@@ -50,6 +51,8 @@ import { Agent } from "../agent/agent"
5051
import { Git } from "@/git"
5152
import { Skill } from "../skill"
5253
import { Permission } from "@/permission"
54+
import { SessionStatus } from "@/session/status"
55+
import { BackgroundJob } from "@/background/job"
5356

5457
const log = Log.create({ service: "tool.registry" })
5558

@@ -82,12 +85,14 @@ export const layer: Layer.Layer<
8285
| Agent.Service
8386
| Skill.Service
8487
| Session.Service
88+
| SessionStatus.Service
8589
| Provider.Service
8690
| Git.Service
8791
| LSP.Service
8892
| Instruction.Service
8993
| AppFileSystem.Service
9094
| Bus.Service
95+
| BackgroundJob.Service
9196
| HttpClient.HttpClient
9297
| ChildProcessSpawner
9398
| Ripgrep.Service
@@ -121,6 +126,7 @@ export const layer: Layer.Layer<
121126
const greptool = yield* GrepTool
122127
const patchtool = yield* ApplyPatchTool
123128
const skilltool = yield* SkillTool
129+
const taskstatus = yield* TaskStatusTool
124130
const agent = yield* Agent.Service
125131

126132
const state = yield* InstanceState.make<State>(
@@ -201,6 +207,7 @@ export const layer: Layer.Layer<
201207
edit: Tool.init(edit),
202208
write: Tool.init(writetool),
203209
task: Tool.init(task),
210+
taskstatus: Tool.init(taskstatus),
204211
fetch: Tool.init(webfetch),
205212
todo: Tool.init(todo),
206213
search: Tool.init(websearch),
@@ -226,6 +233,7 @@ export const layer: Layer.Layer<
226233
tool.edit,
227234
tool.write,
228235
tool.task,
236+
tool.taskstatus,
229237
tool.fetch,
230238
tool.todo,
231239
tool.search,
@@ -345,12 +353,14 @@ export const defaultLayer = Layer.suspend(() =>
345353
Layer.provide(Skill.defaultLayer),
346354
Layer.provide(Agent.defaultLayer),
347355
Layer.provide(Session.defaultLayer),
356+
Layer.provide(SessionStatus.defaultLayer),
348357
Layer.provide(Provider.defaultLayer),
349358
Layer.provide(Git.defaultLayer),
350359
Layer.provide(LSP.defaultLayer),
351360
Layer.provide(Instruction.defaultLayer),
352361
Layer.provide(AppFileSystem.defaultLayer),
353362
Layer.provide(Bus.layer),
363+
Layer.provide(BackgroundJob.defaultLayer),
354364
Layer.provide(FetchHttpClient.layer),
355365
Layer.provide(Format.defaultLayer),
356366
Layer.provide(CrossSpawnSpawner.defaultLayer),

0 commit comments

Comments
 (0)