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 -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.
- `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" | ... }`).
- `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.
- `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 { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
const layer = (experimentalWorkspaces: boolean) =>
Layer.mergeAll(
@@ -24,7 +24,7 @@ const layer = (experimentalWorkspaces: boolean) =>
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Job.defaultLayer),
),
)
const it = testEffect(layer(false))
@@ -11,7 +11,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { NamedError } from "@opencode-ai/core/util/error"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Command } from "../../src/command"
import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp"
@@ -183,7 +183,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces
lsp,
makeMcp(input?.mcpInstructions),
FSUtil.defaultLayer,
BackgroundJob.defaultLayer,
Job.defaultLayer,
status,
Database.defaultLayer,
EventV2Bridge.defaultLayer,
@@ -12,7 +12,7 @@ import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixtur
import { testEffect } from "../lib/effect"
import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { GlobalBus } from "@/bus/global"
@@ -24,7 +24,7 @@ const it = testEffect(
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Job.defaultLayer),
),
CrossSpawnSpawner.defaultLayer,
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 { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -19,7 +19,7 @@ import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -35,7 +35,7 @@ const ref = {
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
BackgroundJob.defaultLayer,
Job.defaultLayer,
EventV2Bridge.defaultLayer,
Config.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* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@@ -531,7 +531,12 @@ describe("tool.task", () => {
expect(job).toBeDefined()
if (!job) throw new Error("task job not found")
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)
expect(result.metadata.background).toBe(true)
@@ -548,7 +553,7 @@ describe("tool.task", () => {
background.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
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* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const first = defer<void>()
const second = defer<void>()
const updated = defer<SessionPrompt.PromptInput>()
const injected = defer<SessionPrompt.PromptInput>()
let prompts = 0
const promptOps: TaskPromptOps = {
@@ -603,9 +606,7 @@ describe("tool.task", () => {
return Effect.succeed(reply(input, "done"))
}
prompts++
if (prompts === 1) 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")))
return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
},
}
const context = {
@@ -640,27 +641,22 @@ describe("tool.task", () => {
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
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()
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 })
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)
expect(notification.variant).toBe("xhigh")
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* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
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", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@@ -731,7 +727,7 @@ describe("tool.task", () => {
background.instance("removing the parent session cancels running background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
@@ -770,7 +766,7 @@ describe("tool.task", () => {
background.instance("removing the child task session cancels its running background task", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
@@ -809,7 +805,7 @@ describe("tool.task", () => {
background.instance("cancelling the parent run cancels running background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
@@ -848,7 +844,7 @@ describe("tool.task", () => {
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()
@@ -869,7 +865,7 @@ describe("tool.task", () => {
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()