job.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import { describe, expect } from "bun:test"
  2. import { Job } from "@opencode-ai/core/job"
  3. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  4. import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
  5. import { SessionSchema } from "@opencode-ai/core/session/schema"
  6. import { testEffect } from "./lib/effect"
  7. const it = testEffect(AppNodeBuilder.build(Job.node))
  8. describe("Job", () => {
  9. it.live("tracks process-local work through explicit observation", () =>
  10. Effect.gen(function* () {
  11. const jobs = yield* Job.Service
  12. const latch = yield* Deferred.make<void>()
  13. const job = yield* jobs.start({
  14. type: "test",
  15. metadata: { durable: false },
  16. run: Deferred.await(latch).pipe(Effect.as("done")),
  17. })
  18. expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
  19. expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
  20. timedOut: true,
  21. info: { status: "running" },
  22. })
  23. yield* Deferred.succeed(latch, undefined)
  24. expect(yield* jobs.wait({ id: job.id })).toMatchObject({
  25. timedOut: false,
  26. info: { status: "completed", output: "done" },
  27. })
  28. }),
  29. )
  30. it.live("publishes jobs before starting immediately settling work", () =>
  31. Effect.gen(function* () {
  32. const jobs = yield* Job.Service
  33. yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
  34. const id = `job_immediate_start_${index}`
  35. return Effect.gen(function* () {
  36. const job = yield* jobs.start({
  37. id,
  38. type: "test",
  39. run: jobs
  40. .get(id)
  41. .pipe(
  42. Effect.flatMap((info) =>
  43. info?.status === "running"
  44. ? Effect.succeed(`done-${index}`)
  45. : Effect.fail("job started before publish"),
  46. ),
  47. ),
  48. })
  49. expect(yield* jobs.wait({ id: job.id })).toMatchObject({
  50. timedOut: false,
  51. info: { status: "completed", output: `done-${index}` },
  52. })
  53. })
  54. })
  55. }),
  56. )
  57. it.live("returns finished from a blocking wait when completion wins", () =>
  58. Effect.gen(function* () {
  59. const jobs = yield* Job.Service
  60. const latch = yield* Deferred.make<void>()
  61. const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
  62. const waiting = yield* jobs
  63. .block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
  64. .pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
  65. yield* Deferred.succeed(latch, undefined)
  66. expect(yield* Fiber.join(waiting)).toMatchObject({
  67. type: "finished",
  68. info: { status: "completed", output: "done" },
  69. })
  70. expect(yield* jobs.background(job.id)).toBeUndefined()
  71. }),
  72. )
  73. it.live("returns backgrounded from a blocking wait when background wins", () =>
  74. Effect.gen(function* () {
  75. const jobs = yield* Job.Service
  76. const latch = yield* Deferred.make<void>()
  77. const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
  78. const waiting = yield* jobs
  79. .block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
  80. .pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
  81. expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" })
  82. expect(yield* Fiber.join(waiting)).toMatchObject({
  83. type: "backgrounded",
  84. info: { id: job.id, status: "running" },
  85. })
  86. yield* Deferred.succeed(latch, undefined)
  87. expect(yield* jobs.wait({ id: job.id })).toMatchObject({
  88. timedOut: false,
  89. info: { status: "completed", output: "done" },
  90. })
  91. }),
  92. )
  93. it.live("backgrounds only jobs actively blocking a session", () =>
  94. Effect.gen(function* () {
  95. const jobs = yield* Job.Service
  96. const parent = SessionSchema.ID.make("ses_parent")
  97. const other = SessionSchema.ID.make("ses_other")
  98. const latch = yield* Deferred.make<void>()
  99. const first = yield* jobs.start({
  100. id: "job_first",
  101. type: "test",
  102. run: Deferred.await(latch).pipe(Effect.as("first")),
  103. })
  104. const second = yield* jobs.start({
  105. id: "job_second",
  106. type: "test",
  107. run: Deferred.await(latch).pipe(Effect.as("second")),
  108. })
  109. const third = yield* jobs.start({
  110. id: "job_third",
  111. type: "other",
  112. run: Deferred.await(latch).pipe(Effect.as("third")),
  113. })
  114. const scope = yield* Scope.Scope
  115. const firstWait = yield* jobs
  116. .block({ id: first.id, sessionID: parent })
  117. .pipe(Effect.forkIn(scope, { startImmediately: true }))
  118. const secondWait = yield* jobs
  119. .block({ id: second.id, sessionID: other })
  120. .pipe(Effect.forkIn(scope, { startImmediately: true }))
  121. const thirdWait = yield* jobs
  122. .block({ id: third.id, sessionID: parent })
  123. .pipe(Effect.forkIn(scope, { startImmediately: true }))
  124. expect(yield* jobs.backgroundAll({ sessionID: parent, type: "test" })).toMatchObject([{ id: first.id }])
  125. expect(yield* Fiber.join(firstWait)).toMatchObject({ type: "backgrounded", info: { id: first.id } })
  126. yield* Deferred.succeed(latch, undefined)
  127. expect(yield* Fiber.join(secondWait)).toMatchObject({ type: "finished", info: { id: second.id } })
  128. expect(yield* Fiber.join(thirdWait)).toMatchObject({ type: "finished", info: { id: third.id } })
  129. }),
  130. )
  131. it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
  132. Effect.gen(function* () {
  133. const scope = yield* Scope.make()
  134. const interrupted = yield* Deferred.make<void>()
  135. const jobs = yield* Job.make.pipe(Scope.provide(scope))
  136. const job = yield* jobs.start({
  137. type: "test",
  138. run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
  139. })
  140. yield* Scope.close(scope, Exit.void)
  141. yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
  142. // The abandoned in-memory registry is not a durable observation channel.
  143. expect((yield* jobs.get(job.id))?.status).toBe("running")
  144. }),
  145. )
  146. })