From 7594579809c5cc8671d45ca977fc1756f586f20c Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Fri, 20 Feb 2026 13:17:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(taskctl):=20clear=20assignee=20on=20develop?= =?UTF-8?q?ing=E2=86=92reviewing=20transition=20and=20add=20happy=20path?= =?UTF-8?q?=20integration=20test=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/tasks/pulse.ts | 10 +- .../test/tasks/pulse-happy-path.test.ts | 152 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/tasks/pulse-happy-path.test.ts diff --git a/packages/opencode/src/tasks/pulse.ts b/packages/opencode/src/tasks/pulse.ts index 4447058ffd2c..85faa2ca68d6 100644 --- a/packages/opencode/src/tasks/pulse.ts +++ b/packages/opencode/src/tasks/pulse.ts @@ -237,7 +237,7 @@ function isPidAlive(pid: number): boolean { } } -export { isPidAlive, writeLockFile, removeLockFile, readLockPid, processAdversarialVerdicts, spawnAdversarial } +export { isPidAlive, writeLockFile, removeLockFile, readLockPid, processAdversarialVerdicts, spawnAdversarial, scheduleReadyTasks, heartbeatActiveAgents } async function scheduleReadyTasks(jobId: string, projectId: string, pmSessionId: string): Promise { const job = await Store.getJob(projectId, jobId) @@ -399,12 +399,14 @@ async function heartbeatActiveAgents(jobId: string, projectId: string): Promise< if (!alive) { log.info("developer session ended, transitioning to review stage", { taskId: task.id }) await Store.updateTask(projectId, task.id, { + assignee: null, + assignee_pid: null, pipeline: { ...updated.pipeline, stage: "reviewing", last_activity: now }, - }) + }, true) } else { await Store.updateTask(projectId, task.id, { pipeline: { ...updated.pipeline, last_activity: now }, - }) + }, true) } } } @@ -862,7 +864,7 @@ async function spawnAdversarial(task: Task, jobId: string, projectId: string, pm await Store.updateTask(projectId, task.id, { pipeline: { ...task.pipeline, stage: "adversarial-running", last_activity: new Date().toISOString() }, - }) + }, true) const prompt = `Review the implementation in worktree at: ${safeWorktree} diff --git a/packages/opencode/test/tasks/pulse-happy-path.test.ts b/packages/opencode/test/tasks/pulse-happy-path.test.ts new file mode 100644 index 000000000000..409c7eb36e23 --- /dev/null +++ b/packages/opencode/test/tasks/pulse-happy-path.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test, spyOn } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Store } from "../../src/tasks/store" +import type { Task, Job } from "../../src/tasks/types" +import { tmpdir } from "../fixture/fixture" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Worktree } from "../../src/worktree" +import { SessionStatus } from "../../src/session/status" + +// Import the tick functions - these need to be exported from pulse.ts +import { + scheduleReadyTasks, + heartbeatActiveAgents, + processAdversarialVerdicts, +} from "../../src/tasks/pulse" + +describe("taskctl pulse: full happy path integration test", () => { + test("complete happy path: open → developing → reviewing → adversarial-running → done", async () => { + // Mock SessionPrompt.prompt to return immediately (simulating developer/adversarial completing) + const promptSpy = spyOn(SessionPrompt, "prompt").mockImplementation(() => Promise.resolve()) + + // Mock Worktree.remove to avoid cleanup noise + const removeSpy = spyOn(Worktree, "remove") + removeSpy.mockImplementation(async () => true) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const projectId = Instance.project.id + + // Create a PM session + const pmSession = await Session.create({ + directory: tmp.path, + title: "PM session", + permission: [], + }) + + // Create a job + const testJob: Job = { + id: `job-${Date.now()}`, + parent_issue: 257, + status: "running", + created_at: new Date().toISOString(), + stopping: false, + pulse_pid: null, + max_workers: 3, + pm_session_id: pmSession.id, + } + + // Create a task in open state + const testTask: Task = { + id: `tsk_${Date.now()}${Math.random().toString(36).slice(2, 10)}`, + title: "Implement feature X", + description: "Implement feature X with TDD", + acceptance_criteria: "Tests pass and feature works", + parent_issue: 257, + job_id: testJob.id, + status: "open", + priority: 2, + task_type: "implementation", + labels: ["module:taskctl"], + depends_on: [], + assignee: null, + assignee_pid: null, + worktree: null, + branch: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + close_reason: null, + comments: [], + pipeline: { + stage: "idle", + attempt: 0, + last_activity: null, + last_steering: null, + history: [], + adversarial_verdict: null, + }, + } + + await Store.createJob(projectId, testJob) + await Store.createTask(projectId, testTask) + + // Step 1: Schedule ready tasks - should spawn developer + await scheduleReadyTasks(testJob.id, projectId, pmSession.id) + + let task = await Store.getTask(projectId, testTask.id) + expect(task?.status).toBe("in_progress") + expect(task?.pipeline.stage).toBe("developing") + expect(task?.assignee).toBeTruthy() + expect(task?.assignee_pid).toBe(process.pid) + expect(task?.worktree).toBeTruthy() + + const devSessionId = task?.assignee! + + // Step 2: Simulate developer session completing + // In real flow, SessionPrompt sets session to idle via defer(() => cancel()) + // Here we manually set it to idle to simulate completion + SessionStatus.set(devSessionId, { type: "idle" }) + + // Step 3: Heartbeat active agents - should detect idle session and transition to reviewing + await heartbeatActiveAgents(testJob.id, projectId) + + task = await Store.getTask(projectId, testTask.id) + expect(task?.status).toBe("in_progress") + expect(task?.pipeline.stage).toBe("reviewing") + + // Step 4: Schedule tasks again - should spawn adversarial + await scheduleReadyTasks(testJob.id, projectId, pmSession.id) + + task = await Store.getTask(projectId, testTask.id) + expect(task?.pipeline.stage).toBe("adversarial-running") + + // Step 5: Simulate adversarial completing and setting APPROVED verdict + const verdict = { + verdict: "APPROVED" as const, + summary: "Code looks good", + issues: [], + created_at: new Date().toISOString(), + } + + await Store.updateTask( + projectId, + testTask.id, + { + status: "review", + pipeline: { + ...task!.pipeline, + adversarial_verdict: verdict, + }, + }, + true, + ) + + // Step 6: Process adversarial verdicts - should commit and close task + await processAdversarialVerdicts(testJob.id, projectId, pmSession.id) + + task = await Store.getTask(projectId, testTask.id) + expect(task?.status).toBe("closed") + expect(task?.close_reason).toBe("approved and committed") + expect(task?.pipeline.stage).toBe("done") + expect(task?.pipeline.adversarial_verdict).toBeNull() + }, + }) + + // Clean up mocks + promptSpy.mockRestore() + removeSpy.mockRestore() + }) +}) \ No newline at end of file