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