mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 05:08:58 +00:00
537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
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<void>()
|
|
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<string>()
|
|
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<string>()
|
|
const finalized = yield* Deferred.make<void>()
|
|
const job = yield* jobs.start({
|
|
id: "job_replaced",
|
|
type: "test",
|
|
run: Effect.callback<string>((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<void>()
|
|
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<void>()
|
|
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<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("retains background ownership and terminal output until notification acknowledgment", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* Job.Service
|
|
const latch = yield* Deferred.make<void>()
|
|
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<void>()
|
|
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<void>()
|
|
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<void>()
|
|
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<void>()
|
|
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<void>()
|
|
|
|
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)
|
|
}),
|
|
)
|
|
})
|