job.test.ts 6.1 KB

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