import { describe, expect } from "bun:test" import { Database } from "@opencode/core/database/database" import jobMigration from "@opencode/core/database/migration/20261030054400_job" import { Job } from "@opencode/core/job" import { JobTable } from "@opencode/core/job/sql" import { KVTable } from "@opencode/core/kv/sql" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Integration } from "@opencode/core/integration" import { LayerNode } from "@opencode/util/effect/layer-node" import { Global } from "@opencode/util/global" import { eq, like } from "drizzle-orm" import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" import { SessionMessage } from "@opencode/core/session/message" import { SessionSchema } from "@opencode/core/session/schema" import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, Database.node, Global.node]))) describe("Job", () => { it.live("tracks process-local work through explicit observation", () => Effect.gen(function* () { const jobs = yield* Job.Service const latch = yield* Deferred.make() const job = yield* jobs.start({ id: "job_metadata", 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("preserves authorization and complete failure details without stacks", () => Effect.gen(function* () { const jobs = yield* Job.Service const job = yield* jobs.start({ id: "job_failure", type: "test", run: Effect.failCause( Cause.combine( Cause.fail( new Integration.AuthorizationError({ cause: new Error("authorization failed", { cause: new Error("token expired") }), }), ), Cause.die({ code: "cleanup_failed" }), ), ), }) expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({ status: "error", error: 'authorization failed\nCaused by: token expired\n{"code":"cleanup_failed"}', }) }), ) it.live("reuses running work when started again with the same ID", () => Effect.gen(function* () { const jobs = yield* Job.Service const output = yield* Deferred.make() const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) }) expect( yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }), ).toEqual(job) yield* Deferred.succeed(output, "original output") expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({ type: "test", status: "completed", output: "original output", }) }), ) it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () => Effect.gen(function* () { const jobs = yield* Job.Service const callback = yield* Deferred.make<() => void>() const output = yield* Deferred.make() const finalized = yield* Deferred.make() const job = yield* jobs.start({ id: "job_replaced", type: "test", run: Effect.callback((resume) => { Deferred.doneUnsafe( callback, Effect.succeed(() => resume(Effect.succeed("obsolete output"))), ) }), }) const complete = yield* Deferred.await(callback) // Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement. const replacement = yield* jobs.wait({ id: job.id }).pipe( Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))), Effect.andThen( jobs.start({ id: job.id, type: "replacement", run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))), }), ), Effect.andThen(Effect.sync(complete)), Effect.forkChild({ startImmediately: true }), ) yield* jobs.cancel(job.id) yield* Fiber.join(replacement) expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" }) expect(yield* Deferred.isDone(finalized)).toBe(false) yield* Deferred.succeed(output, "replacement output") expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({ type: "replacement", status: "completed", output: "replacement output", }) expect(yield* Deferred.isDone(finalized)).toBe(true) }), ) it.live("returns finished from a blocking wait when completion wins", () => Effect.gen(function* () { const jobs = yield* Job.Service const latch = yield* Deferred.make() const job = yield* jobs.start({ id: "job_finished", 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() const job = yield* jobs.start({ id: "job_backgrounded", 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() 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("retains background ownership and terminal output until notification acknowledgment", () => Effect.gen(function* () { const jobs = yield* Job.Service const latch = yield* Deferred.make() const recovery = { kind: "shell" as const, sessionID: SessionSchema.ID.make("ses_background_shell"), shellID: "shell_background", command: "echo done", } const job = yield* jobs.start({ id: recovery.shellID, type: "shell", recovery, run: Deferred.await(latch).pipe(Effect.as("done")), }) expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined() const background = yield* jobs.background(job.id) const running = (yield* jobs.pendingBackground).find((item) => item.id === job.id) expect(running).toMatchObject({ id: job.id, recovery, status: "running" }) expect(running?.notificationID).toStartWith("msg_") expect(background?.notificationID).toBe(running?.notificationID) yield* Deferred.succeed(latch, undefined) yield* jobs.wait({ id: job.id }) const completed = (yield* jobs.pendingBackground).find((item) => item.id === job.id) expect(completed).toMatchObject({ id: job.id, notificationID: running?.notificationID, recovery, status: "completed", output: "done", }) if (!completed) return yield* Effect.die("background marker missing") yield* jobs.completeBackground(completed.notificationID) expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined() }), ) it.live("persists backgroundAll ownership before releasing a blocked subagent", () => Effect.gen(function* () { const jobs = yield* Job.Service const parentSessionID = SessionSchema.ID.make("ses_background_parent") const latch = yield* Deferred.make() const recovery = { kind: "subagent" as const, parentSessionID, childSessionID: SessionSchema.ID.make("ses_background_child"), agent: "explore", description: "Explore background recovery", } const job = yield* jobs.start({ id: recovery.childSessionID, type: "subagent", recovery, run: Deferred.await(latch).pipe(Effect.as("done")), }) const waiting = yield* jobs .block({ id: job.id, sessionID: parentSessionID }) .pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true })) yield* jobs.backgroundAll({ sessionID: parentSessionID }) expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } }) const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id) expect(marker).toMatchObject({ id: job.id, recovery, status: "running" }) if (!marker) return yield* Effect.die("background marker missing") yield* jobs.cancel(job.id) expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toMatchObject({ notificationID: marker.notificationID, status: "cancelled", }) yield* jobs.completeBackground(marker.notificationID) }), ) it.live("retains terminal errors for recovery until notification acknowledgment", () => Effect.gen(function* () { const jobs = yield* Job.Service const latch = yield* Deferred.make() const job = yield* jobs.start({ id: "shell_error", type: "shell", recovery: { kind: "shell", sessionID: SessionSchema.ID.make("ses_background_error"), shellID: "shell_error", command: "exit 1", }, run: Deferred.await(latch).pipe(Effect.andThen(Effect.fail(new Error("shell failed")))), }) yield* jobs.background(job.id) yield* Deferred.succeed(latch, undefined) yield* jobs.wait({ id: job.id }) const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id) expect(marker).toMatchObject({ id: job.id, status: "error", error: "shell failed" }) if (!marker) return yield* Effect.die("background marker missing") yield* jobs.completeBackground(marker.notificationID) }), ) it.live("durably backgrounds recoverable work that has already failed", () => Effect.gen(function* () { const jobs = yield* Job.Service const job = yield* jobs.start({ id: "shell_immediate_error", type: "shell", recovery: { kind: "shell", sessionID: SessionSchema.ID.make("ses_immediate_error"), shellID: "shell_immediate_error", command: "exit 1", }, run: Effect.fail(new Error("shell failed")), }) expect((yield* jobs.wait({ id: job.id })).info?.status).toBe("error") const background = yield* jobs.background(job.id) expect(background?.notificationID).toStartWith("msg_") expect(yield* jobs.pendingBackground).toMatchObject([ { id: job.id, notificationID: background?.notificationID, status: "error", error: "shell failed" }, ]) }), ) it.live("recovers a background marker after its process-local registry closes", () => Effect.gen(function* () { const scope = yield* Scope.make() const previous = yield* Job.make.pipe(Scope.provide(scope)) const job = yield* previous.start({ id: "shell_restart", type: "shell", recovery: { kind: "shell", sessionID: SessionSchema.ID.make("ses_background_restart"), shellID: "shell_restart", command: "sleep 60", }, run: Effect.never, }) yield* previous.background(job.id) yield* Scope.close(scope, Exit.void) const current = yield* Job.make const marker = (yield* current.pendingBackground).find((item) => item.id === job.id) expect(marker).toMatchObject({ id: job.id, status: "running" }) if (!marker) return yield* Effect.die("background marker missing") yield* current.completeBackground(marker.notificationID) }), ) it.live("preserves running background ownership when its work is interrupted", () => Effect.gen(function* () { const jobs = yield* Job.Service const interrupted = yield* Deferred.make() const job = yield* jobs.start({ id: "ses_interrupted_child", type: "subagent", recovery: { kind: "subagent", parentSessionID: SessionSchema.ID.make("ses_interrupted_parent"), childSessionID: SessionSchema.ID.make("ses_interrupted_child"), agent: "explore", description: "Continue after shutdown", }, run: Deferred.await(interrupted).pipe(Effect.andThen(Effect.interrupt)), }) yield* jobs.background(job.id) yield* Deferred.succeed(interrupted, undefined) yield* jobs.wait({ id: job.id }) const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id) expect(marker).toMatchObject({ id: job.id, status: "running" }) if (!marker) return yield* Effect.die("background marker missing") yield* jobs.completeBackground(marker.notificationID) }), ) 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() const jobs = yield* Job.make.pipe(Scope.provide(scope)) const job = yield* jobs.start({ id: "job_abandoned", 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") }), ) it.live("cancels running session jobs and removes unacknowledged SQLite job rows on cancelSession", () => Effect.gen(function* () { const jobs = yield* Job.Service const db = (yield* Database.Service).db const sessionID = SessionSchema.ID.make("ses_cleanup_owner") const otherSessionID = SessionSchema.ID.make("ses_cleanup_other") const interrupted = yield* Deferred.make() const owned = yield* jobs.start({ id: "job_cleanup_owned", type: "shell", recovery: { kind: "shell", sessionID, shellID: "job_cleanup_owned", command: "bun run dev" }, run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), }) const other = yield* jobs.start({ id: "job_cleanup_other", type: "shell", recovery: { kind: "shell", sessionID: otherSessionID, shellID: "job_cleanup_other", command: "bun test" }, run: Effect.succeed("kept"), }) yield* jobs.background(owned.id) yield* jobs.background(other.id) yield* jobs.wait({ id: other.id }) const cancelled = yield* jobs.cancelSession(sessionID) expect(cancelled).toMatchObject([{ id: owned.id, status: "cancelled" }]) yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) expect(yield* db.select().from(JobTable).where(eq(JobTable.session_id, sessionID)).all()).toEqual([]) const remaining = yield* jobs.pendingBackground expect(remaining).toMatchObject([{ id: other.id, status: "completed", output: "kept" }]) if (remaining[0]) yield* jobs.completeBackground(remaining[0].notificationID) }), ) it.live("migrates legacy kv job.background/* markers into the job table", () => Effect.gen(function* () { const db = (yield* Database.Service).db const jobs = yield* Job.Service const notificationID = SessionMessage.ID.create() const sessionID = SessionSchema.ID.make("ses_migrated") yield* db.run("DROP TABLE `job`") yield* db .insert(KVTable) .values({ key: `job.background/${notificationID}`, value: { id: "shell_legacy", notificationID, recovery: { kind: "shell", sessionID, shellID: "shell_legacy", command: "bun test", }, status: "completed", output: "all green", }, }) .run() yield* db.transaction((tx) => jobMigration.up(tx)) expect(yield* db.select().from(KVTable).where(like(KVTable.key, "job.background/%")).all()).toEqual([]) expect(yield* jobs.pendingBackground).toMatchObject([ { id: "shell_legacy", notificationID, status: "completed", output: "all green", recovery: { kind: "shell", sessionID, shellID: "shell_legacy", command: "bun test" }, }, ]) yield* jobs.completeBackground(notificationID) }), ) })