返回 DeepSeek-Reasonix
task_monitor_test.go
根目录 / desktop / task_monitor_test.go
1 package main
2
3 import (
4 "context"
5 "path/filepath"
6 "sync"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 "reasonix/internal/taskcatalog"
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) TaskRuntimeOwnerID() string { return "test-recorder-owner" }
23
24 func (c *taskKillController) CancelJob(id string) bool {
25 c.mu.Lock()
26 defer c.mu.Unlock()
27 c.killed = append(c.killed, id)
28 return true
29 }
30
31 func (c *taskKillController) killCount() int {
32 c.mu.Lock()
33 defer c.mu.Unlock()
34 return len(c.killed)
35 }
36
37 func (c *taskKillController) killedIDs() []string {
38 c.mu.Lock()
39 defer c.mu.Unlock()
40 return append([]string(nil), c.killed...)
41 }
42
43 func TestTaskControlConcurrentInitializationReturnsOneService(t *testing.T) {
44 app := &App{}
45 const callers = 16
46 services := make(chan *taskmonitor.ControlService, callers)
47 var wg sync.WaitGroup
48 for range callers {
49 wg.Go(func() {
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 TestTaskActionProjectResolvesAllowlistWithoutCatalog(t *testing.T) {
129 root := t.TempDir()
130 app := &App{
131 ctx: context.Background(),
132 tabs: map[string]*WorkspaceTab{
133 "active": {ID: "active", Scope: "project", WorkspaceRoot: root},
134 },
135 activeTabID: "active",
136 }
137 key := taskcatalog.ProjectKey(root)
138 project, err := app.taskActionProject(key)
139 if err != nil {
140 t.Fatalf("taskActionProject without catalog: %v", err)
141 }
142 if abs, err := filepath.Abs(root); err == nil {
143 root = abs
144 }
145 if project.Root != root || project.Key != key {
146 t.Fatalf("project=%#v, want root=%q key=%q", project, root, key)
147 }
148 if _, err := app.taskActionProject("not-a-real-project-key"); err == nil {
149 t.Fatal("expected unknown project key error")
150 }
151 }
152
153 func TestStopTaskRoutesMonitorIdentityToRuntimeJob(t *testing.T) {
154 root := t.TempDir()
155 path := agent.NewSessionPath(t.TempDir(), "session")
156 ctrl := &taskKillController{SessionAPI: control.New(control.Options{Label: "session", SessionPath: path})}
157 defer ctrl.Close()
158 app := &App{
159 ctx: context.Background(),
160 tabs: map[string]*WorkspaceTab{
161 "active": {ID: "active", Scope: "project", WorkspaceRoot: root, Ctrl: ctrl},
162 },
163 activeTabID: "active",
164 }
165 sessionID := agent.BranchID(path)
166 monitorID := sessionID + "--task-1"
167 now := time.Now()
168 if err := app.taskStore().SaveTask(app.ctx, root, taskmonitor.TaskSnapshot{
169 SchemaVersion: 1, TaskID: monitorID, JobID: "task-1", SessionID: sessionID,
170 State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive, RuntimeOwnerID: ctrl.TaskRuntimeOwnerID(),
171 Version: 1, CreatedAt: now, UpdatedAt: now,
172 }); err != nil {
173 t.Fatal(err)
174 }
175
176 res, err := app.StopTask(monitorID, 1, "", "desktop-route")
177 if err != nil || !res.Accepted {
178 t.Fatalf("StopTask: result=%+v err=%v", res, err)
179 }
180 ids := ctrl.killedIDs()
181 if len(ids) != 1 || ids[0] != "task-1" {
182 t.Fatalf("runtime killed IDs = %v, want [task-1]", ids)
183 }
184 }
185
186 func TestStopTaskForTabKeepsSourceWorkspaceAfterActiveTabSwitch(t *testing.T) {
187 projectA := t.TempDir()
188 projectB := t.TempDir()
189 pathA := filepath.Join(t.TempDir(), "shared-session.jsonl")
190 pathB := filepath.Join(t.TempDir(), "shared-session.jsonl")
191 ctrlA := &taskKillController{SessionAPI: control.New(control.Options{Label: "a", SessionPath: pathA})}
192 ctrlB := &taskKillController{SessionAPI: control.New(control.Options{Label: "b", SessionPath: pathB})}
193 defer ctrlA.Close()
194 defer ctrlB.Close()
195
196 app := &App{
197 ctx: context.Background(),
198 tabs: map[string]*WorkspaceTab{
199 "tab-a": {ID: "tab-a", Scope: "project", WorkspaceRoot: projectA, SessionPath: pathA, Ctrl: ctrlA},
200 "tab-b": {ID: "tab-b", Scope: "project", WorkspaceRoot: projectB, SessionPath: pathB, Ctrl: ctrlB},
201 },
202 activeTabID: "tab-b",
203 }
204 sessionID := agent.BranchID(pathA)
205 if sessionID != agent.BranchID(pathB) {
206 t.Fatal("test setup must use colliding session IDs")
207 }
208 monitorID := sessionID + "--task-1"
209 now := time.Now()
210 for _, root := range []string{projectA, projectB} {
211 if err := app.taskStore().SaveTask(app.ctx, root, taskmonitor.TaskSnapshot{
212 SchemaVersion: 1, TaskID: monitorID, JobID: "task-1", SessionID: sessionID,
213 State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive, RuntimeOwnerID: ctrlA.TaskRuntimeOwnerID(),
214 Version: 1, CreatedAt: now, UpdatedAt: now,
215 }); err != nil {
216 t.Fatal(err)
217 }
218 }
219
220 res, err := app.StopTaskForTab("tab-a", monitorID, 1, "", "tab-bound-stop")
221 if err != nil || !res.Accepted {
222 t.Fatalf("StopTaskForTab: result=%+v err=%v", res, err)
223 }
224 if ctrlA.killCount() != 1 || ctrlB.killCount() != 0 {
225 t.Fatalf("kill routed away from source tab: projectA=%d projectB=%d", ctrlA.killCount(), ctrlB.killCount())
226 }
227 projectBTask, err := app.taskStore().GetTask(app.ctx, projectB, monitorID)
228 if err != nil || projectBTask == nil || projectBTask.State != taskmonitor.TaskStateRunning {
229 t.Fatalf("project B task mutated by project A control: task=%+v err=%v", projectBTask, err)
230 }
231 }
232
232 lines GO