Skip to content

Commit 03bcf58

Browse files
randommJanni Turunen
andauthored
fix(taskctl): clear assignee on developing→reviewing transition and add happy path integration test (#257) (#258)
Co-authored-by: Janni Turunen <janni@Jannis-MacBook-Air.local>
1 parent 57155fd commit 03bcf58

File tree

2 files changed

+158
-4
lines changed

2 files changed

+158
-4
lines changed

packages/opencode/src/tasks/pulse.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ function isPidAlive(pid: number): boolean {
237237
}
238238
}
239239

240-
export { isPidAlive, writeLockFile, removeLockFile, readLockPid, processAdversarialVerdicts, spawnAdversarial }
240+
export { isPidAlive, writeLockFile, removeLockFile, readLockPid, processAdversarialVerdicts, spawnAdversarial, scheduleReadyTasks, heartbeatActiveAgents }
241241

242242
async function scheduleReadyTasks(jobId: string, projectId: string, pmSessionId: string): Promise<void> {
243243
const job = await Store.getJob(projectId, jobId)
@@ -399,12 +399,14 @@ async function heartbeatActiveAgents(jobId: string, projectId: string): Promise<
399399
if (!alive) {
400400
log.info("developer session ended, transitioning to review stage", { taskId: task.id })
401401
await Store.updateTask(projectId, task.id, {
402+
assignee: null,
403+
assignee_pid: null,
402404
pipeline: { ...updated.pipeline, stage: "reviewing", last_activity: now },
403-
})
405+
}, true)
404406
} else {
405407
await Store.updateTask(projectId, task.id, {
406408
pipeline: { ...updated.pipeline, last_activity: now },
407-
})
409+
}, true)
408410
}
409411
}
410412
}
@@ -862,7 +864,7 @@ async function spawnAdversarial(task: Task, jobId: string, projectId: string, pm
862864

863865
await Store.updateTask(projectId, task.id, {
864866
pipeline: { ...task.pipeline, stage: "adversarial-running", last_activity: new Date().toISOString() },
865-
})
867+
}, true)
866868

867869
const prompt = `Review the implementation in worktree at: ${safeWorktree}
868870
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, expect, test, spyOn } from "bun:test"
2+
import { Instance } from "../../src/project/instance"
3+
import { Store } from "../../src/tasks/store"
4+
import type { Task, Job } from "../../src/tasks/types"
5+
import { tmpdir } from "../fixture/fixture"
6+
import { Session } from "../../src/session"
7+
import { SessionPrompt } from "../../src/session/prompt"
8+
import { Worktree } from "../../src/worktree"
9+
import { SessionStatus } from "../../src/session/status"
10+
11+
// Import the tick functions - these need to be exported from pulse.ts
12+
import {
13+
scheduleReadyTasks,
14+
heartbeatActiveAgents,
15+
processAdversarialVerdicts,
16+
} from "../../src/tasks/pulse"
17+
18+
describe("taskctl pulse: full happy path integration test", () => {
19+
test("complete happy path: open → developing → reviewing → adversarial-running → done", async () => {
20+
// Mock SessionPrompt.prompt to return immediately (simulating developer/adversarial completing)
21+
const promptSpy = spyOn(SessionPrompt, "prompt").mockImplementation(() => Promise.resolve())
22+
23+
// Mock Worktree.remove to avoid cleanup noise
24+
const removeSpy = spyOn(Worktree, "remove")
25+
removeSpy.mockImplementation(async () => true)
26+
27+
await using tmp = await tmpdir({ git: true })
28+
await Instance.provide({
29+
directory: tmp.path,
30+
fn: async () => {
31+
const projectId = Instance.project.id
32+
33+
// Create a PM session
34+
const pmSession = await Session.create({
35+
directory: tmp.path,
36+
title: "PM session",
37+
permission: [],
38+
})
39+
40+
// Create a job
41+
const testJob: Job = {
42+
id: `job-${Date.now()}`,
43+
parent_issue: 257,
44+
status: "running",
45+
created_at: new Date().toISOString(),
46+
stopping: false,
47+
pulse_pid: null,
48+
max_workers: 3,
49+
pm_session_id: pmSession.id,
50+
}
51+
52+
// Create a task in open state
53+
const testTask: Task = {
54+
id: `tsk_${Date.now()}${Math.random().toString(36).slice(2, 10)}`,
55+
title: "Implement feature X",
56+
description: "Implement feature X with TDD",
57+
acceptance_criteria: "Tests pass and feature works",
58+
parent_issue: 257,
59+
job_id: testJob.id,
60+
status: "open",
61+
priority: 2,
62+
task_type: "implementation",
63+
labels: ["module:taskctl"],
64+
depends_on: [],
65+
assignee: null,
66+
assignee_pid: null,
67+
worktree: null,
68+
branch: null,
69+
created_at: new Date().toISOString(),
70+
updated_at: new Date().toISOString(),
71+
close_reason: null,
72+
comments: [],
73+
pipeline: {
74+
stage: "idle",
75+
attempt: 0,
76+
last_activity: null,
77+
last_steering: null,
78+
history: [],
79+
adversarial_verdict: null,
80+
},
81+
}
82+
83+
await Store.createJob(projectId, testJob)
84+
await Store.createTask(projectId, testTask)
85+
86+
// Step 1: Schedule ready tasks - should spawn developer
87+
await scheduleReadyTasks(testJob.id, projectId, pmSession.id)
88+
89+
let task = await Store.getTask(projectId, testTask.id)
90+
expect(task?.status).toBe("in_progress")
91+
expect(task?.pipeline.stage).toBe("developing")
92+
expect(task?.assignee).toBeTruthy()
93+
expect(task?.assignee_pid).toBe(process.pid)
94+
expect(task?.worktree).toBeTruthy()
95+
96+
const devSessionId = task?.assignee!
97+
98+
// Step 2: Simulate developer session completing
99+
// In real flow, SessionPrompt sets session to idle via defer(() => cancel())
100+
// Here we manually set it to idle to simulate completion
101+
SessionStatus.set(devSessionId, { type: "idle" })
102+
103+
// Step 3: Heartbeat active agents - should detect idle session and transition to reviewing
104+
await heartbeatActiveAgents(testJob.id, projectId)
105+
106+
task = await Store.getTask(projectId, testTask.id)
107+
expect(task?.status).toBe("in_progress")
108+
expect(task?.pipeline.stage).toBe("reviewing")
109+
110+
// Step 4: Schedule tasks again - should spawn adversarial
111+
await scheduleReadyTasks(testJob.id, projectId, pmSession.id)
112+
113+
task = await Store.getTask(projectId, testTask.id)
114+
expect(task?.pipeline.stage).toBe("adversarial-running")
115+
116+
// Step 5: Simulate adversarial completing and setting APPROVED verdict
117+
const verdict = {
118+
verdict: "APPROVED" as const,
119+
summary: "Code looks good",
120+
issues: [],
121+
created_at: new Date().toISOString(),
122+
}
123+
124+
await Store.updateTask(
125+
projectId,
126+
testTask.id,
127+
{
128+
status: "review",
129+
pipeline: {
130+
...task!.pipeline,
131+
adversarial_verdict: verdict,
132+
},
133+
},
134+
true,
135+
)
136+
137+
// Step 6: Process adversarial verdicts - should commit and close task
138+
await processAdversarialVerdicts(testJob.id, projectId, pmSession.id)
139+
140+
task = await Store.getTask(projectId, testTask.id)
141+
expect(task?.status).toBe("closed")
142+
expect(task?.close_reason).toBe("approved and committed")
143+
expect(task?.pipeline.stage).toBe("done")
144+
expect(task?.pipeline.adversarial_verdict).toBeNull()
145+
},
146+
})
147+
148+
// Clean up mocks
149+
promptSpy.mockRestore()
150+
removeSpy.mockRestore()
151+
})
152+
})

0 commit comments

Comments
 (0)