refactor(core): replace background job service (#34559)
This commit is contained in:
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -17,12 +17,11 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
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 { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
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 { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||
@@ -120,7 +119,7 @@ const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
BackgroundJob.node,
|
||||
Job.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
ShellTool.node,
|
||||
@@ -155,10 +154,7 @@ const overflowCommand = (bytes: number) =>
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
|
||||
const withSession = <A, E, R>(
|
||||
directory: string,
|
||||
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
@@ -214,9 +210,7 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
||||
Effect.andThen(
|
||||
withSession(tmp.path, (registry) =>
|
||||
settleTool(registry, call({ command: cwdCommand, workdir: "src" })),
|
||||
),
|
||||
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() =>
|
||||
@@ -247,9 +241,7 @@ describe("ShellTool", () => {
|
||||
: Effect.void
|
||||
return Effect.promise(() => fs.mkdir(workdir)).pipe(
|
||||
Effect.andThen(
|
||||
withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: "src" })),
|
||||
),
|
||||
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
|
||||
),
|
||||
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
|
||||
)
|
||||
@@ -314,9 +306,7 @@ describe("ShellTool", () => {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
const target = path.join(outside.path, "secret.txt")
|
||||
return withSession(active.path, (registry) =>
|
||||
settleTool(registry, call({ command: `cat ${target}` })),
|
||||
).pipe(
|
||||
return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
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.",
|
||||
"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.",
|
||||
"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 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.",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
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 { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
@@ -95,7 +95,7 @@ const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
BackgroundJob.node,
|
||||
Job.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.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.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -251,7 +251,6 @@ describe("SubagentTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const parent = yield* sessions.create({ location })
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
@@ -270,7 +269,6 @@ describe("SubagentTool", () => {
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||
|
||||
yield* jobs.promote(childID)
|
||||
yield* Effect.yieldNow
|
||||
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(synthetic).toHaveLength(1)
|
||||
|
||||
Reference in New Issue
Block a user