返回 DeepSeek-Reasonix
task_monitor_test.go
根目录 / desktop / task_monitor_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/control"
13 "reasonix/internal/taskmonitor"
14 )
15
16 type taskKillController struct {
17 control.SessionAPI
18 mu sync.Mutex
19 killed []string
20 }
21
22 func (c *taskKillController) CancelJob(id string) bool {
23 c.mu.Lock()
24 defer c.mu.Unlock()
25 c.killed = append(c.killed, id)
26 return true
27 }
28
29 func (c *taskKillController) killCount() int {
30 c.mu.Lock()
31 defer c.mu.Unlock()
32 return len(c.killed)
33 }
34
35 func (c *taskKillController) killedIDs() []string {
36 c.mu.Lock()
37 defer c.mu.Unlock()
38 return append([]string(nil), c.killed...)
39 }
40
41 func TestTaskControlConcurrentInitializationReturnsOneService(t *testing.T) {
42 app := &App{}
43 const callers = 16
44 services := make(chan *taskmonitor.ControlService, callers)
45 var wg sync.WaitGroup
46 for i := 0; i < callers; i++ {
47 wg.Add(1)
48 go func() {
49 defer wg.Done()
50 services <- app.taskControl()
51 }()
52 }
53 wg.Wait()
54 close(services)
55
56 var first *taskmonitor.ControlService
57 for service := range services {
58 if first == nil {
59 first = service
60 continue
61 }
62 if service != first {
63 t.Fatal("taskControl returned more than one process-wide service")
64 }
65 }
66 }
67
68 func TestDesktopTaskJobKillerRoutesBySessionNotActiveTab(t *testing.T) {
69 projectA := t.TempDir()
70 projectB := t.TempDir()
71 pathA := filepath.Join(t.TempDir(), "shared-session.jsonl")
72 pathB := filepath.Join(t.TempDir(), "shared-session.jsonl")
73 ctrlA := &taskKillController{SessionAPI: control.New(control.Options{Label: "a", SessionPath: pathA})}
74 ctrlB := &taskKillController{SessionAPI: control.New(control.Options{Label: "b", SessionPath: pathB})}
75 defer ctrlA.Close()
76 defer ctrlB.Close()
77
78 app := &App{
79 tabs: map[string]*WorkspaceTab{
80 "active-a": {ID: "active-a", WorkspaceRoot: projectA, Ctrl: ctrlA},
81 },
82 detachedSessions: map[string]*WorkspaceTab{
83 sessionRuntimeKey(pathB): {ID: "detached-b", WorkspaceRoot: projectB, Ctrl: ctrlB},
84 },
85 activeTabID: "active-a",
86 }
87
88 if agent.BranchID(pathA) != agent.BranchID(pathB) {
89 t.Fatal("test setup must use colliding session IDs")
90 }
91 killer := desktopTaskJobKiller{app: app, projectDir: projectB}
92 if !killer.Kill(agent.BranchID(pathB), "task-1") {
93 t.Fatal("expected detached project B task to be killed")
94 }
95 if ctrlA.killCount() != 0 || ctrlB.killCount() != 1 {
96 t.Fatalf("kill routed incorrectly: active=%d detached=%d", ctrlA.killCount(), ctrlB.killCount())
97 }
98 }
99
100 func TestDesktopTaskJobKillerRefusesLegacyTaskWithoutSession(t *testing.T) {
101 root := t.TempDir()
102 path := agent.NewSessionPath(t.TempDir(), "session")
103 ctrl := &taskKillController{SessionAPI: control.New(control.Options{Label: "session", SessionPath: path})}
104 defer ctrl.Close()
105 app := &App{tabs: map[string]*WorkspaceTab{"active": {ID: "active", WorkspaceRoot: root, Ctrl: ctrl}}}
106
107 if (desktopTaskJobKiller{app: app, projectDir: root}).Kill("", "task-1") {
108 t.Fatal("legacy task without session ID must not be routed by colliding task ID")
109 }
110 if ctrl.killCount() != 0 {
111 t.Fatalf("legacy task unexpectedly killed %d runtime(s)", ctrl.killCount())
112 }
113 }
114
115 func TestTaskMonitorUsesActiveWorkspaceRoot(t *testing.T) {
116 root := t.TempDir()
117 app := &App{
118 tabs: map[string]*WorkspaceTab{
119 "active": {ID: "active", Scope: "project", WorkspaceRoot: root},
120 },
121 activeTabID: "active",
122 }
123 if got := app.projectDir(); got != root {
124 t.Fatalf("projectDir = %q, want active workspace %q", got, root)
125 }
126 }
127
128 func TestStopTaskRoutesMonitorIdentityToRuntimeJob(t *testing.T) {
129 root := t.TempDir()
130 path := agent.NewSessionPath(t.TempDir(), "session")
131 ctrl := &taskKillController{SessionAPI: control.New(control.Options{Label: "session", SessionPath: path})}
132 defer ctrl.Close()
133 app := &App{
134 ctx: context.Background(),
135 tabs: map[string]*WorkspaceTab{
136 "active": {ID: "active", Scope: "project", WorkspaceRoot: root, Ctrl: ctrl},
137 },
138 activeTabID: "active",
139 }
140 sessionID := agent.BranchID(path)
141 monitorID := sessionID + "--task-1"
142 now := time.Now()
143 if err := app.taskStore().SaveTask(app.ctx, root, taskmonitor.TaskSnapshot{
144 SchemaVersion: 1, TaskID: monitorID, JobID: "task-1", SessionID: sessionID,
145 State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive,
146 Version: 1, CreatedAt: now, UpdatedAt: now,
147 }); err != nil {
148 t.Fatal(err)
149 }
150
151 res, err := app.StopTask(monitorID, 1, "", "desktop-route")
152 if err != nil || !res.Accepted {
153 t.Fatalf("StopTask: result=%+v err=%v", res, err)
154 }
155 ids := ctrl.killedIDs()
156 if len(ids) != 1 || ids[0] != "task-1" {
157 t.Fatalf("runtime killed IDs = %v, want [task-1]", ids)
158 }
159 }
160
161 func TestStopTaskForTabKeepsSourceWorkspaceAfterActiveTabSwitch(t *testing.T) {
162 projectA := t.TempDir()
163 projectB := t.TempDir()
164 pathA := filepath.Join(t.TempDir(), "shared-session.jsonl")
165 pathB := filepath.Join(t.TempDir(), "shared-session.jsonl")
166 ctrlA := &taskKillController{SessionAPI: control.New(control.Options{Label: "a", SessionPath: pathA})}
167 ctrlB := &taskKillController{SessionAPI: control.New(control.Options{Label: "b", SessionPath: pathB})}
168 defer ctrlA.Close()
169 defer ctrlB.Close()
170
171 app := &App{
172 ctx: context.Background(),
173 tabs: map[string]*WorkspaceTab{
174 "tab-a": {ID: "tab-a", Scope: "project", WorkspaceRoot: projectA, SessionPath: pathA, Ctrl: ctrlA},
175 "tab-b": {ID: "tab-b", Scope: "project", WorkspaceRoot: projectB, SessionPath: pathB, Ctrl: ctrlB},
176 },
177 activeTabID: "tab-b",
178 }
179 sessionID := agent.BranchID(pathA)
180 if sessionID != agent.BranchID(pathB) {
181 t.Fatal("test setup must use colliding session IDs")
182 }
183 monitorID := sessionID + "--task-1"
184 now := time.Now()
185 for _, root := range []string{projectA, projectB} {
186 if err := app.taskStore().SaveTask(app.ctx, root, taskmonitor.TaskSnapshot{
187 SchemaVersion: 1, TaskID: monitorID, JobID: "task-1", SessionID: sessionID,
188 State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive,
189 Version: 1, CreatedAt: now, UpdatedAt: now,
190 }); err != nil {
191 t.Fatal(err)
192 }
193 }
194
195 res, err := app.StopTaskForTab("tab-a", monitorID, 1, "", "tab-bound-stop")
196 if err != nil || !res.Accepted {
197 t.Fatalf("StopTaskForTab: result=%+v err=%v", res, err)
198 }
199 if ctrlA.killCount() != 1 || ctrlB.killCount() != 0 {
200 t.Fatalf("kill routed away from source tab: projectA=%d projectB=%d", ctrlA.killCount(), ctrlB.killCount())
201 }
202 projectBTask, err := app.taskStore().GetTask(app.ctx, projectB, monitorID)
203 if err != nil || projectBTask == nil || projectBTask.State != taskmonitor.TaskStateRunning {
204 t.Fatalf("project B task mutated by project A control: task=%+v err=%v", projectBTask, err)
205 }
206 }
207
208 func TestListSessionsForTabKeepsSourceDirectoryAfterActiveTabSwitch(t *testing.T) {
209 dirA := t.TempDir()
210 dirB := t.TempDir()
211 pathA := filepath.Join(dirA, "a.jsonl")
212 pathB := filepath.Join(dirB, "b.jsonl")
213 if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}`+"\n"), 0o600); err != nil {
214 t.Fatal(err)
215 }
216 if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}`+"\n"), 0o600); err != nil {
217 t.Fatal(err)
218 }
219 ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "a"})
220 ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "b"})
221 defer ctrlA.Close()
222 defer ctrlB.Close()
223 app := &App{
224 tabs: map[string]*WorkspaceTab{
225 "tab-a": {ID: "tab-a", Scope: "project", WorkspaceRoot: t.TempDir(), SessionPath: pathA, Ctrl: ctrlA},
226 "tab-b": {ID: "tab-b", Scope: "project", WorkspaceRoot: t.TempDir(), SessionPath: pathB, Ctrl: ctrlB},
227 },
228 activeTabID: "tab-b",
229 }
230
231 sessions := app.ListSessionsForTab("tab-a")
232 if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" {
233 t.Fatalf("ListSessionsForTab(tab-a) = %+v", sessions)
234 }
235 }
236
236 lines GO