Bläddra i källkod

fix(task): handle running background resumes

Shoubhit Dash 3 månader sedan
förälder
incheckning
45360e5e0b
2 ändrade filer med 189 tillägg och 16 borttagningar
  1. 19 5
      packages/opencode/src/tool/task.ts
  2. 170 11
      packages/opencode/test/tool/task.test.ts

+ 19 - 5
packages/opencode/src/tool/task.ts

@@ -8,7 +8,7 @@ import { Agent } from "../agent/agent"
 import type { SessionPrompt } from "../session/prompt"
 import type { SessionPrompt } from "../session/prompt"
 import { SessionStatus } from "@/session/status"
 import { SessionStatus } from "@/session/status"
 import { TuiEvent } from "@/cli/cmd/tui/event"
 import { TuiEvent } from "@/cli/cmd/tui/event"
-import { Cause, Effect, Option, Schema } from "effect"
+import { Cause, Effect, Option, Schema, Scope, Stream } from "effect"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { BackgroundJob } from "@/background/job"
 import { BackgroundJob } from "@/background/job"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -82,6 +82,7 @@ export const TaskTool = Tool.define(
     const sessions = yield* Session.Service
     const sessions = yield* Session.Service
     const status = yield* SessionStatus.Service
     const status = yield* SessionStatus.Service
     const jobs = yield* BackgroundJob.Service
     const jobs = yield* BackgroundJob.Service
+    const scope = yield* Scope.Scope
 
 
     const run = Effect.fn(
     const run = Effect.fn(
       "TaskTool.execute",
       "TaskTool.execute",
@@ -163,6 +164,9 @@ export const TaskTool = Tool.define(
       if (background && !Flag.OPENCODE_EXPERIMENTAL) {
       if (background && !Flag.OPENCODE_EXPERIMENTAL) {
         return yield* Effect.fail(new Error("Background tasks require OPENCODE_EXPERIMENTAL=true"))
         return yield* Effect.fail(new Error("Background tasks require OPENCODE_EXPERIMENTAL=true"))
       }
       }
+      if ((yield* jobs.get(nextSession.id))?.status === "running") {
+        return yield* Effect.fail(new Error(`Task ${nextSession.id} is already running`))
+      }
 
 
       const metadata = {
       const metadata = {
         sessionId: nextSession.id,
         sessionId: nextSession.id,
@@ -198,11 +202,21 @@ export const TaskTool = Tool.define(
         return result.parts.findLast((item) => item.type === "text")?.text ?? ""
         return result.parts.findLast((item) => item.type === "text")?.text ?? ""
       })
       })
 
 
-      const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: {
+      const resumeParent: (input: {
         userID: MessageID
         userID: MessageID
         state: "completed" | "error"
         state: "completed" | "error"
-      }) {
-        if ((yield* status.get(ctx.sessionID)).type !== "idle") return
+        attempts?: number
+      }) => Effect.Effect<void> = Effect.fn("TaskTool.resumeParent")(function* (input) {
+        if ((yield* status.get(ctx.sessionID)).type !== "idle") {
+          if ((input.attempts ?? 0) >= 60) return
+          yield* bus.subscribe(SessionStatus.Event.Idle).pipe(
+            Stream.filter((event) => event.properties.sessionID === ctx.sessionID),
+            Stream.take(1),
+            Stream.runDrain,
+            Effect.timeoutOption("1 second"),
+          )
+          return yield* resumeParent({ ...input, attempts: (input.attempts ?? 0) + 1 })
+        }
         const latest = yield* sessions.findMessage(ctx.sessionID, (item) => item.info.role === "user")
         const latest = yield* sessions.findMessage(ctx.sessionID, (item) => item.info.role === "user")
         if (Option.isNone(latest)) return
         if (Option.isNone(latest)) return
         if (latest.value.info.id !== input.userID) return
         if (latest.value.info.id !== input.userID) return
@@ -238,7 +252,7 @@ export const TaskTool = Tool.define(
               },
               },
             ],
             ],
           })
           })
-          yield* continueIfIdle({ userID: message.info.id, state })
+          yield* resumeParent({ userID: message.info.id, state }).pipe(Effect.ignore, Effect.forkIn(scope))
         })
         })
 
 
         yield* jobs.start({
         yield* jobs.start({

+ 170 - 11
packages/opencode/test/tool/task.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect } from "bun:test"
 import { afterEach, describe, expect } from "bun:test"
-import { Deferred, Effect, Layer } from "effect"
+import { Cause, Deferred, Effect, Exit, Layer } from "effect"
 import { Agent } from "../../src/agent/agent"
 import { Agent } from "../../src/agent/agent"
 import { Bus } from "../../src/bus"
 import { Bus } from "../../src/bus"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
@@ -497,6 +497,7 @@ describe("tool.task", () => {
         const tool = yield* TaskTool
         const tool = yield* TaskTool
         const def = yield* tool.init()
         const def = yield* tool.init()
         const loops: string[] = []
         const loops: string[] = []
+        const resumed = yield* Deferred.make<void>()
 
 
         const result = yield* def.execute(
         const result = yield* def.execute(
           {
           {
@@ -515,16 +516,20 @@ describe("tool.task", () => {
                 ...stubOps(sessions, { text: "background done" }),
                 ...stubOps(sessions, { text: "background done" }),
                 loop(input) {
                 loop(input) {
                   loops.push(input.sessionID)
                   loops.push(input.sessionID)
-                  return Effect.sync(() =>
-                    reply(
-                      {
-                        sessionID: input.sessionID,
-                        messageID: MessageID.ascending(),
-                        agent: "build",
-                        model: ref,
-                        parts: [],
-                      },
-                      "looped",
+                  return Deferred.succeed(resumed, undefined).pipe(
+                    Effect.andThen(
+                      Effect.sync(() =>
+                        reply(
+                          {
+                            sessionID: input.sessionID,
+                            messageID: MessageID.ascending(),
+                            agent: "build",
+                            model: ref,
+                            parts: [],
+                          },
+                          "looped",
+                        ),
+                      ),
                     ),
                     ),
                   )
                   )
                 },
                 },
@@ -537,6 +542,7 @@ describe("tool.task", () => {
         )
         )
 
 
         expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.status).toBe("completed")
         expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.status).toBe("completed")
+        yield* Deferred.await(resumed).pipe(Effect.timeout("1 second"))
 
 
         const parent = yield* sessions.findMessage(chat.id, (msg) => msg.info.role === "user")
         const parent = yield* sessions.findMessage(chat.id, (msg) => msg.info.role === "user")
         expect(parent._tag).toBe("Some")
         expect(parent._tag).toBe("Some")
@@ -552,4 +558,157 @@ describe("tool.task", () => {
       }),
       }),
     ),
     ),
   )
   )
+
+  it.live("background task resumes parent after it becomes idle", () =>
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        Flag.OPENCODE_EXPERIMENTAL = true
+        const sessions = yield* Session.Service
+        const status = yield* SessionStatus.Service
+        const jobs = yield* BackgroundJob.Service
+        const { chat, assistant } = yield* seed()
+        const tool = yield* TaskTool
+        const def = yield* tool.init()
+        const loops: string[] = []
+        const resumed = yield* Deferred.make<void>()
+
+        yield* status.set(chat.id, { type: "busy" })
+
+        const result = yield* def.execute(
+          {
+            description: "inspect bug",
+            prompt: "look into the cache key path",
+            subagent_type: "general",
+            background: true,
+          },
+          {
+            sessionID: chat.id,
+            messageID: assistant.id,
+            agent: "build",
+            abort: new AbortController().signal,
+            extra: {
+              promptOps: {
+                ...stubOps(sessions, { text: "background done" }),
+                loop(input) {
+                  loops.push(input.sessionID)
+                  return Effect.sync(() =>
+                    reply(
+                      {
+                        sessionID: input.sessionID,
+                        messageID: MessageID.ascending(),
+                        agent: "build",
+                        model: ref,
+                        parts: [],
+                      },
+                      "looped",
+                    ),
+                  ).pipe(Effect.tap(() => Deferred.succeed(resumed, undefined)))
+                },
+              } satisfies TaskPromptOps,
+            },
+            messages: [],
+            metadata: () => Effect.void,
+            ask: () => Effect.void,
+          },
+        )
+
+        expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.status).toBe("completed")
+        expect(loops).toEqual([])
+        yield* status.set(chat.id, { type: "idle" })
+        yield* Deferred.await(resumed).pipe(Effect.timeout("1 second"))
+        expect(loops).toEqual([chat.id])
+      }),
+    ),
+  )
+
+  it.live("background resume fails while task is already running", () =>
+    provideTmpdirInstance(() =>
+      Effect.gen(function* () {
+        Flag.OPENCODE_EXPERIMENTAL = true
+        const sessions = yield* Session.Service
+        const { chat, assistant } = yield* seed()
+        const tool = yield* TaskTool
+        const def = yield* tool.init()
+        const latch = yield* Deferred.make<void>()
+
+        const result = yield* def.execute(
+          {
+            description: "inspect bug",
+            prompt: "look into the cache key path",
+            subagent_type: "general",
+            background: true,
+          },
+          {
+            sessionID: chat.id,
+            messageID: assistant.id,
+            agent: "build",
+            abort: new AbortController().signal,
+            extra: {
+              promptOps: stubOps(sessions, { wait: Deferred.await(latch) }),
+            },
+            messages: [],
+            metadata: () => Effect.void,
+            ask: () => Effect.void,
+          },
+        )
+
+        const exit = yield* def
+          .execute(
+            {
+              description: "inspect bug again",
+              prompt: "second prompt",
+              subagent_type: "general",
+              task_id: result.metadata.sessionId,
+              background: true,
+            },
+            {
+              sessionID: chat.id,
+              messageID: assistant.id,
+              agent: "build",
+              abort: new AbortController().signal,
+              extra: { promptOps: stubOps(sessions) },
+              messages: [],
+              metadata: () => Effect.void,
+              ask: () => Effect.void,
+            },
+          )
+          .pipe(Effect.exit)
+
+        expect(Exit.isFailure(exit)).toBe(true)
+        if (Exit.isFailure(exit)) {
+          const error = Cause.squash(exit.cause)
+          expect(error instanceof Error ? error.message : String(error)).toContain("is already running")
+        }
+
+        const foregroundExit = yield* def
+          .execute(
+            {
+              description: "inspect bug again",
+              prompt: "second prompt",
+              subagent_type: "general",
+              task_id: result.metadata.sessionId,
+            },
+            {
+              sessionID: chat.id,
+              messageID: assistant.id,
+              agent: "build",
+              abort: new AbortController().signal,
+              extra: { promptOps: stubOps(sessions) },
+              messages: [],
+              metadata: () => Effect.void,
+              ask: () => Effect.void,
+            },
+          )
+          .pipe(Effect.exit)
+
+        expect(Exit.isFailure(foregroundExit)).toBe(true)
+        if (Exit.isFailure(foregroundExit)) {
+          const error = Cause.squash(foregroundExit.cause)
+          expect(error instanceof Error ? error.message : String(error)).toContain("is already running")
+        }
+
+        yield* Deferred.succeed(latch, undefined)
+      }),
+    ),
+  )
 })
 })