返回 DeepSeek-Reasonix
session_runtime_test.go
根目录 / desktop / session_runtime_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 "reasonix/internal/tool"
13 )
14
15 func TestSessionParentLive(t *testing.T) {
16 app := NewApp()
17 path := filepath.Join(t.TempDir(), "parent.jsonl")
18 otherPath := filepath.Join(filepath.Dir(path), "other.jsonl")
19 if app.sessionParentLive(path) {
20 t.Fatal("session without a tab or runtime reported live")
21 }
22
23 building := &WorkspaceTab{ID: "building", SessionPath: path}
24 app.tabs[building.ID] = building
25 if !app.sessionParentLive(path) {
26 t.Fatal("building tab session did not report live")
27 }
28 if app.subagentParentProbeForBuild(building)(path) {
29 t.Fatal("initial build treated its own unbound session as a competing live parent")
30 }
31 if !app.subagentParentProbeForBuild(&WorkspaceTab{ID: "other-build"})(path) {
32 t.Fatal("concurrent build did not see the other building tab as live")
33 }
34 if app.sessionParentLive(otherPath) {
35 t.Fatal("unrelated session reported live")
36 }
37 delete(app.tabs, building.ID)
38
39 ctrl := control.New(control.Options{SessionDir: filepath.Dir(path), SessionPath: path, Label: "ready"})
40 t.Cleanup(ctrl.Close)
41 ready := &WorkspaceTab{ID: "ready", Ctrl: ctrl, Ready: true}
42 app.tabs[ready.ID] = ready
43 if !app.sessionParentLive(path) {
44 t.Fatal("ready controller session did not report live")
45 }
46 delete(app.tabs, ready.ID)
47
48 detached := &WorkspaceTab{ID: "detached"}
49 app.detachedSessions["detached"] = detached
50 app.mu.Lock()
51 app.newSessionRuntimeLocked(detached, sessionRuntimeKey(path))
52 app.mu.Unlock()
53 if !app.sessionParentLive(path) {
54 t.Fatal("detached runtime session did not report live")
55 }
56 }
57
58 func TestCleanupStaleRunningWithDesktopParentProbe(t *testing.T) {
59 sessionDir := t.TempDir()
60 parentSession := "parent"
61 parentPath := filepath.Join(sessionDir, parentSession+".jsonl")
62 app := NewApp()
63 building := &WorkspaceTab{ID: "building", SessionPath: parentPath}
64 app.tabs[building.ID] = building
65 sweeper := &WorkspaceTab{ID: "concurrent-build"}
66
67 store := agent.NewSubagentStore(filepath.Join(sessionDir, "subagents"))
68 prepareRunning := func() string {
69 run, err := store.PrepareFresh(agent.SubagentSpec{
70 Kind: "task",
71 Name: "task",
72 WorkspaceRoot: t.TempDir(),
73 ParentSession: parentSession,
74 SystemPrompt: "sys",
75 Registry: tool.NewRegistry(),
76 Model: "model",
77 })
78 if err != nil {
79 t.Fatalf("PrepareFresh: %v", err)
80 }
81 if err := store.MarkRunning(run); err != nil {
82 t.Fatalf("MarkRunning: %v", err)
83 }
84 ref := run.Ref
85 run.Release()
86 return ref
87 }
88 requireStatus := func(ref string, want agent.SubagentStatus) {
89 meta, err := store.LoadMeta(ref)
90 if err != nil {
91 t.Fatalf("LoadMeta(%s): %v", ref, err)
92 }
93 if meta.Status != want {
94 t.Fatalf("status(%s) = %q, want %q", ref, meta.Status, want)
95 }
96 }
97
98 crashRef := prepareRunning()
99 cleaned, err := store.WithParentSessionProbe(app.subagentParentProbeForBuild(building)).CleanupStaleRunning()
100 if err != nil {
101 t.Fatalf("CleanupStaleRunning for initial owner build: %v", err)
102 }
103 if cleaned != 1 {
104 t.Fatalf("cleaned for initial owner build = %d, want 1", cleaned)
105 }
106 requireStatus(crashRef, agent.SubagentInterrupted)
107
108 liveRef := prepareRunning()
109 cleaned, err = store.WithParentSessionProbe(app.subagentParentProbeForBuild(sweeper)).CleanupStaleRunning()
110 if err != nil {
111 t.Fatalf("CleanupStaleRunning with live parent: %v", err)
112 }
113 if cleaned != 0 {
114 t.Fatalf("cleaned with live parent = %d, want 0", cleaned)
115 }
116 requireStatus(liveRef, agent.SubagentRunning)
117
118 delete(app.tabs, building.ID)
119 laterRef := prepareRunning()
120 cleaned, err = agent.NewSubagentStore(filepath.Join(sessionDir, "subagents")).
121 WithParentSessionProbe(app.subagentParentProbeForBuild(sweeper)).
122 CleanupStaleRunning()
123 if err != nil {
124 t.Fatalf("CleanupStaleRunning after parent exit: %v", err)
125 }
126 if cleaned != 2 {
127 t.Fatalf("cleaned after parent exit = %d, want 2", cleaned)
128 }
129 requireStatus(liveRef, agent.SubagentInterrupted)
130 requireStatus(laterRef, agent.SubagentInterrupted)
131 }
132
133 func TestMetaNeverReportsReadyWithoutController(t *testing.T) {
134 app := NewApp()
135 tab := &WorkspaceTab{
136 ID: "tab_broken_ready",
137 Scope: "global",
138 Ready: true, // compatibility field from an older persisted/runtime path
139 StartupErr: "startup failed",
140 }
141 app.tabs[tab.ID] = tab
142 app.tabOrder = []string{tab.ID}
143 app.activeTabID = tab.ID
144
145 meta := app.MetaForTab(tab.ID)
146 if meta.Ready {
147 t.Fatal("Meta reported ready with a nil controller")
148 }
149 if meta.Runtime.Phase != sessionRuntimeFailed {
150 t.Fatalf("runtime phase = %q, want %q", meta.Runtime.Phase, sessionRuntimeFailed)
151 }
152 }
153
154 func TestDeferredStartupReusesSameProcessTabLease(t *testing.T) {
155 isolateDesktopUserDirs(t)
156 dir := desktopSessionDir(globalTabWorkspaceRoot())
157 if err := os.MkdirAll(dir, 0o755); err != nil {
158 t.Fatal(err)
159 }
160 path := filepath.Join(dir, "self-owned-startup.jsonl")
161 if err := os.WriteFile(path, nil, 0o644); err != nil {
162 t.Fatal(err)
163 }
164
165 app := NewApp()
166 app.ctx = context.Background()
167 app.readyHook = func() {}
168 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "self_owned")
169 tab.SessionPath = path
170 tab.StartupErr = (&sessionLeaseBusyError{}).Error()
171 tab.StartupErrLeaseHeld = true
172 tab.Ready = false
173 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
174 installNoopRuntimeEvents(app, tab.sink)
175 app.tabs[tab.ID] = tab
176 app.tabOrder = []string{tab.ID}
177 app.activeTabID = tab.ID
178 if err := tab.ensureSessionLease(path); err != nil {
179 t.Fatalf("pre-acquire tab lease: %v", err)
180 }
181 t.Cleanup(func() {
182 if ctrl := app.controllerForTab(tab); ctrl != nil {
183 ctrl.Close()
184 }
185 tab.releaseSessionLease()
186 })
187 app.mu.Lock()
188 app.bindSessionRuntimeKeyLocked(tab, path)
189 app.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, &sessionLeaseBusyError{})
190 app.mu.Unlock()
191
192 before := tab.buildGeneration
193 app.retryDeferredStartupBuild(tab.ID, tab)
194 if tab.Ctrl == nil {
195 t.Fatal("same-process owner was not rebuilt using its existing lease")
196 }
197 if tab.buildGeneration != before+1 {
198 t.Fatalf("build generation = %d, want exactly one retry from %d", tab.buildGeneration, before)
199 }
200 app.mu.RLock()
201 view := app.sessionRuntimeViewLocked(tab)
202 runtimeCount := len(app.runtimeBySessionKey)
203 app.mu.RUnlock()
204 if view.Phase != sessionRuntimeReady {
205 t.Fatalf("runtime phase = %q, want ready", view.Phase)
206 }
207 if runtimeCount != 1 {
208 t.Fatalf("runtime key count = %d, want one", runtimeCount)
209 }
210 }
211
212 func TestStartingRuntimePlaceholderIsNotOverwrittenByDuplicateTab(t *testing.T) {
213 isolateDesktopUserDirs(t)
214 app := NewApp()
215 path := filepath.Join(t.TempDir(), "same-session.jsonl")
216 first := &WorkspaceTab{
217 ID: "first",
218 SessionPath: path,
219 sink: &tabEventSink{tabID: "first", app: app},
220 }
221 second := &WorkspaceTab{
222 ID: "second",
223 SessionPath: path,
224 sink: &tabEventSink{tabID: "second", app: app},
225 }
226 app.tabs[first.ID] = first
227 app.tabs[second.ID] = second
228
229 app.mu.Lock()
230 app.setSessionRuntimePhaseLocked(first, sessionRuntimeStarting, nil)
231 app.setSessionRuntimePhaseLocked(second, sessionRuntimeStarting, nil)
232 key := sessionRuntimeKey(path)
233 owner := app.runtimeBySessionKey[key]
234 runtimeCount := len(app.runtimeBySessionKey)
235 secondRuntimeID := second.runtimeID
236 app.mu.Unlock()
237
238 if owner == nil || owner.Owner != first {
239 t.Fatalf("starting placeholder owner = %#v, want first tab", owner)
240 }
241 if runtimeCount != 1 {
242 t.Fatalf("runtime key count = %d, want one", runtimeCount)
243 }
244 if secondRuntimeID != "" {
245 t.Fatalf("duplicate tab created private runtime %q before claim", secondRuntimeID)
246 }
247
248 // A starting placeholder is not an attachable runtime: its controller and
249 // lease are still private to the owner build. Attaching it would remove the
250 // owner tab and cause both candidate builds to retire.
251 if app.attachExistingSessionRuntime(second, path, context.Background()) {
252 t.Fatal("starting runtime attached before its controller was published")
253 }
254 if app.tabs[first.ID] != first || app.tabs[second.ID] != second {
255 t.Fatal("starting runtime attach removed one of the restoring tabs")
256 }
257 if first.Ctrl != nil || second.Ctrl != nil {
258 t.Fatal("starting runtime attach published a controller")
259 }
260
261 // Once the owner publishes its controller and ready phase, the same claim
262 // attaches that runtime and retires only the duplicate visible tab.
263 ctrl := control.New(control.Options{
264 SessionDir: filepath.Dir(path),
265 SessionPath: path,
266 Label: "ready",
267 })
268 t.Cleanup(ctrl.Close)
269 app.mu.Lock()
270 first.Ctrl = ctrl
271 first.Ready = true
272 app.advanceSessionRuntimeEpochLocked(first)
273 app.mu.Unlock()
274 if !app.claimSessionRuntime(second, path, context.Background()) {
275 t.Fatal("ready runtime was not attached by the duplicate claim")
276 }
277 app.mu.RLock()
278 readyOwner := app.runtimeBySessionKey[key]
279 firstStillVisible := app.tabs[first.ID]
280 secondStillVisible := app.tabs[second.ID]
281 view := app.sessionRuntimeViewLocked(second)
282 app.mu.RUnlock()
283 if readyOwner == nil || readyOwner.Owner != second || second.Ctrl != ctrl {
284 t.Fatalf("ready runtime owner/controller = %#v/%p, want second/%p", readyOwner, second.Ctrl, ctrl)
285 }
286 if firstStillVisible != nil || secondStillVisible != second {
287 t.Fatalf("visible tabs after attach = first %#v second %#v", firstStillVisible, secondStillVisible)
288 }
289 if view.Phase != sessionRuntimeReady {
290 t.Fatalf("attached runtime phase = %q, want ready", view.Phase)
291 }
292 }
293
294 func TestBackendRejectsSubmitWhenRuntimeIsNotReady(t *testing.T) {
295 app := NewApp()
296 ctrl := &runtimeStatusSessionController{}
297 tab := &WorkspaceTab{ID: "blocked", Ctrl: ctrl, Ready: true}
298 app.tabs[tab.ID] = tab
299 app.activeTabID = tab.ID
300 app.mu.Lock()
301 app.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, &sessionLeaseBusyError{})
302 app.mu.Unlock()
303
304 err := app.SubmitToTab(tab.ID, "must not send")
305 if err == nil || !strings.Contains(err.Error(), "workspace failed to start") {
306 t.Fatalf("SubmitToTab error = %v, want runtime readiness failure", err)
307 }
308 if status := ctrl.RuntimeStatus(); status != (control.RuntimeStatus{}) {
309 t.Fatalf("controller status changed after rejected submit: %+v", status)
310 }
311 }
312
312 lines GO