返回 DeepSeek-Reasonix
topic_archive_test.go
根目录 / desktop / topic_archive_test.go
1 package main
2
3 import (
4 "bufio"
5 "bytes"
6 "errors"
7 "log/slog"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/config"
17 "reasonix/internal/control"
18 "reasonix/internal/provider"
19 )
20
21 type snapshotErrorSessionController struct {
22 control.SessionAPI
23 err error
24 }
25
26 func (c *snapshotErrorSessionController) Snapshot() error { return c.err }
27
28 func TestTrashTopicSnapshotFailureKeepsRuntimeAndFiles(t *testing.T) {
29 isolateDesktopUserDirs(t)
30 projectRoot := t.TempDir()
31 topicID := "topic_snapshot_failure"
32 if err := addProject(projectRoot, ""); err != nil {
33 t.Fatalf("add project: %v", err)
34 }
35 if err := setTopicTitle(projectRoot, topicID, "Snapshot failure"); err != nil {
36 t.Fatalf("set topic title: %v", err)
37 }
38 dir := config.SessionDir()
39 if err := os.MkdirAll(dir, 0o755); err != nil {
40 t.Fatalf("mkdir sessions: %v", err)
41 }
42 sessionPath := writeTopicSessionWithPrompt(t, dir, "snapshot-failure.jsonl", topicID, "Snapshot failure", projectRoot, "preserve me", time.Now())
43 base := controllerWithContent(t, sessionPath)
44 defer base.Close()
45 snapshotErr := errors.New("snapshot blocked")
46 ctrl := &snapshotErrorSessionController{SessionAPI: base, err: snapshotErr}
47 tab := &WorkspaceTab{ID: "snapshot-failure", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
48 TopicTitle: "Snapshot failure", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
49 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
50 if err := app.TrashTopic(topicID); !errors.Is(err, snapshotErr) {
51 t.Fatalf("TrashTopic snapshot error = %v, want %v", err, snapshotErr)
52 }
53 if got := app.tabs[tab.ID]; got != tab || tab.removed {
54 t.Fatalf("snapshot failure changed runtime binding: got=%p removed=%v", got, tab.removed)
55 }
56 if got := ctrl.SessionPath(); !sameDesktopPath(got, sessionPath) {
57 t.Fatalf("snapshot failure session path = %q, want %q", got, sessionPath)
58 }
59 if agent.IsCleanupPending(sessionPath) {
60 t.Fatal("snapshot failure must not publish a cleanup-pending marker")
61 }
62 if _, err := os.Stat(sessionPath); err != nil {
63 t.Fatalf("snapshot failure removed the session file: %v", err)
64 }
65 if got := loadTopicTitle(projectRoot, topicID); got != "Snapshot failure" {
66 t.Fatalf("snapshot failure topic title = %q", got)
67 }
68 }
69
70 func TestTrashTopicRejectsConcurrentRuntimeMutationWithoutWaiting(t *testing.T) {
71 isolateDesktopUserDirs(t)
72 topicID := "topic_runtime_mutation_busy"
73 if err := setTopicTitle("", topicID, "Runtime mutation busy"); err != nil {
74 t.Fatalf("set topic title: %v", err)
75 }
76 app := &App{}
77 app.runtimeRebuildMu.Lock()
78 err := app.TrashTopic(topicID)
79 app.runtimeRebuildMu.Unlock()
80 if !errors.Is(err, errTopicArchiveBusy) {
81 t.Fatalf("TrashTopic error = %v, want %v", err, errTopicArchiveBusy)
82 }
83 // The rebuild mutex was held until after TrashTopic returned, so errTopicArchiveBusy
84 // is a deterministic nonblocking proof without a shared-runner wall-clock limit.
85 if got := loadTopicTitle("", topicID); got != "Runtime mutation busy" {
86 t.Fatalf("busy archive changed topic title to %q", got)
87 }
88 }
89
90 func TestTrashTopicForeignLeaseFailsBeforeCleanupCommit(t *testing.T) {
91 isolateDesktopUserDirs(t)
92 projectRoot := t.TempDir()
93 topicID := "topic_foreign_lease"
94 if err := addProject(projectRoot, ""); err != nil {
95 t.Fatalf("add project: %v", err)
96 }
97 if err := setTopicTitle(projectRoot, topicID, "Foreign lease"); err != nil {
98 t.Fatalf("set topic title: %v", err)
99 }
100 dir := config.SessionDir()
101 if err := os.MkdirAll(dir, 0o755); err != nil {
102 t.Fatalf("mkdir sessions: %v", err)
103 }
104 sessionPath := writeTopicSessionWithPrompt(t, dir, "foreign-lease.jsonl", topicID, "Foreign lease", projectRoot, "preserve me", time.Now())
105
106 cmd := exec.Command(os.Args[0], "-test.run=^TestTrashTopicForeignLeaseHelper$")
107 cmd.Env = append(os.Environ(),
108 "REASONIX_TOPIC_ARCHIVE_LEASE_HELPER=1",
109 "REASONIX_TOPIC_ARCHIVE_LEASE_PATH="+sessionPath,
110 )
111 stdin, err := cmd.StdinPipe()
112 if err != nil {
113 t.Fatalf("lease helper stdin: %v", err)
114 }
115 stdout, err := cmd.StdoutPipe()
116 if err != nil {
117 t.Fatalf("lease helper stdout: %v", err)
118 }
119 var stderr bytes.Buffer
120 cmd.Stderr = &stderr
121 if err := cmd.Start(); err != nil {
122 t.Fatalf("start lease helper: %v", err)
123 }
124 released := false
125 release := func() {
126 if released {
127 return
128 }
129 released = true
130 _ = stdin.Close()
131 if err := cmd.Wait(); err != nil {
132 t.Errorf("lease helper exit: %v (%s)", err, stderr.String())
133 }
134 }
135 defer release()
136 ready, err := bufio.NewReader(stdout).ReadString('\n')
137 if err != nil || strings.TrimSpace(ready) != "ready" {
138 t.Fatalf("lease helper readiness = %q, err=%v (%s)", ready, err, stderr.String())
139 }
140
141 app := NewApp()
142 if err := app.TrashTopic(topicID); !errors.Is(err, errSessionBusyElsewhere) {
143 t.Fatalf("TrashTopic foreign lease error = %v, want %v", err, errSessionBusyElsewhere)
144 }
145 if agent.IsCleanupPending(sessionPath) {
146 t.Fatal("rejected archive published a cleanup-pending marker")
147 }
148 if got := loadTopicTitle(projectRoot, topicID); got != "Foreign lease" {
149 t.Fatalf("rejected archive topic title = %q", got)
150 }
151 if err := reconcileDesktopCleanupPending(dir); err != nil {
152 t.Fatalf("reconcile without marker: %v", err)
153 }
154 if _, err := os.Stat(sessionPath); err != nil {
155 t.Fatalf("rejected archive removed the live session: %v", err)
156 }
157
158 release()
159 if err := reconcileDesktopCleanupPending(dir); err != nil {
160 t.Fatalf("reconcile after foreign release: %v", err)
161 }
162 if _, err := os.Stat(sessionPath); err != nil {
163 t.Fatalf("failed archive scheduled a later deletion: %v", err)
164 }
165 }
166
167 func TestTrashTopicForeignLeaseHelper(t *testing.T) {
168 if os.Getenv("REASONIX_TOPIC_ARCHIVE_LEASE_HELPER") != "1" {
169 return
170 }
171 lease, err := agent.TryAcquireSessionLease(os.Getenv("REASONIX_TOPIC_ARCHIVE_LEASE_PATH"))
172 if err != nil {
173 t.Fatalf("TryAcquireSessionLease: %v", err)
174 }
175 if _, err := os.Stdout.WriteString("ready\n"); err != nil {
176 lease.Release()
177 t.Fatalf("write readiness: %v", err)
178 }
179 var release [1]byte
180 _, _ = os.Stdin.Read(release[:])
181 lease.Release()
182 }
183
184 func TestSnapshotTopicRuntimeConflictLogOmitsSessionPath(t *testing.T) {
185 privatePath := filepath.Join(t.TempDir(), "private-session.jsonl")
186 base := control.New(control.Options{})
187 defer base.Close()
188 ctrl := &snapshotErrorSessionController{SessionAPI: base, err: &agent.SessionSnapshotConflictError{
189 Path: privatePath,
190 Kind: agent.SessionSnapshotConflictDiverged,
191 }}
192 var output bytes.Buffer
193 previous := slog.Default()
194 slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
195 defer slog.SetDefault(previous)
196
197 app := NewApp()
198 if err := app.snapshotTopicRuntimeBindings([]removedSessionRuntime{{ctrl: ctrl}}); err != nil {
199 t.Fatalf("snapshotTopicRuntimeBindings: %v", err)
200 }
201 logged := output.String()
202 if strings.Contains(logged, privatePath) {
203 t.Fatalf("snapshot conflict log exposed the session path: %s", logged)
204 }
205 if !strings.Contains(logged, string(agent.SessionSnapshotConflictDiverged)) {
206 t.Fatalf("snapshot conflict log omitted the safe conflict kind: %s", logged)
207 }
208 }
209
210 func TestTrashTopicConvertsLocalLeaseWithoutUnlockWindow(t *testing.T) {
211 isolateDesktopUserDirs(t)
212 projectRoot := t.TempDir()
213 topicID := "topic_local_lease"
214 if err := addProject(projectRoot, ""); err != nil {
215 t.Fatalf("add project: %v", err)
216 }
217 if err := setTopicTitle(projectRoot, topicID, "Local lease"); err != nil {
218 t.Fatalf("set topic title: %v", err)
219 }
220 dir := config.SessionDir()
221 if err := os.MkdirAll(dir, 0o755); err != nil {
222 t.Fatalf("mkdir sessions: %v", err)
223 }
224 sessionPath := writeTopicSessionWithPrompt(t, dir, "local-lease.jsonl", topicID, "Local lease", projectRoot, "preserve me", time.Now())
225 ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
226 defer ctrl.Close()
227 tab := &WorkspaceTab{ID: "local", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
228 TopicTitle: "Local lease", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
229 if err := tab.ensureSessionLease(sessionPath); err != nil {
230 t.Fatalf("ensureSessionLease: %v", err)
231 }
232 keep := &WorkspaceTab{ID: "keep", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "keep", Ready: true}
233 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab, keep.ID: keep}, tabOrder: []string{tab.ID, keep.ID}, activeTabID: tab.ID}
234
235 pinDesktopSessionRoot(t, app)
236 if err := app.TrashTopic(" " + topicID + " "); err != nil {
237 t.Fatalf("TrashTopic: %v", err)
238 }
239 if agent.IsCleanupPending(sessionPath) {
240 t.Fatal("completed archive left cleanup pending")
241 }
242 assertLegacyLifecycle(t, app, sessionPath, "archived")
243 if lease, err := agent.TryAcquireSessionLease(sessionPath); err != nil {
244 t.Fatalf("lease was not released: %v", err)
245 } else {
246 lease.Release()
247 }
248 }
249
250 func TestLegacyTrashTopicCommittedCleanupFailureReconcilesWithoutFailureResponse(t *testing.T) {
251 isolateDesktopUserDirs(t)
252 projectRoot := t.TempDir()
253 topicID := "topic_committed_cleanup"
254 if err := addProject(projectRoot, ""); err != nil {
255 t.Fatalf("add project: %v", err)
256 }
257 if err := setTopicTitle(projectRoot, topicID, "Committed cleanup"); err != nil {
258 t.Fatalf("set topic title: %v", err)
259 }
260 dir := config.SessionDir()
261 if err := os.MkdirAll(dir, 0o755); err != nil {
262 t.Fatalf("mkdir sessions: %v", err)
263 }
264 sessionPath := writeTopicSessionWithPrompt(t, dir, "committed-cleanup.jsonl", topicID, "Committed cleanup", projectRoot, "preserve me", time.Now())
265 topicArchiveCleanupHookForTest = func() error { return errors.New("injected cleanup failure") }
266 defer func() { topicArchiveCleanupHookForTest = nil }()
267
268 app := NewApp()
269 if err := app.trashTopic(topicID); err != nil {
270 t.Fatalf("committed TrashTopic returned a failure: %v", err)
271 }
272 if !agent.IsCleanupPending(sessionPath) {
273 t.Fatal("committed cleanup failure did not retain its durable marker")
274 }
275 if got := loadTopicTitle(projectRoot, topicID); got != "" {
276 t.Fatalf("committed archive retained topic title %q", got)
277 }
278 if _, err := os.Stat(sessionPath); err != nil {
279 t.Fatalf("deferred session disappeared before reconciliation: %v", err)
280 }
281
282 topicArchiveCleanupHookForTest = nil
283 if err := reconcileDesktopCleanupPending(dir); err != nil {
284 t.Fatalf("reconcile committed cleanup: %v", err)
285 }
286 if agent.IsCleanupPending(sessionPath) {
287 t.Fatal("reconciliation retained the cleanup marker")
288 }
289 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
290 t.Fatalf("reconciled session still exists, err=%v", err)
291 }
292 }
293
294 func TestLegacyTrashTopicLegacyMirrorFailureStaysCommittedAndRepairs(t *testing.T) {
295 isolateDesktopUserDirs(t)
296 projectRoot := t.TempDir()
297 seedLegacyTopicBridge(t, projectRoot)
298 topicID := "topic_metadata_retry"
299 if err := addProject(projectRoot, ""); err != nil {
300 t.Fatalf("add project: %v", err)
301 }
302 if err := setTopicTitle(projectRoot, topicID, "Metadata retry"); err != nil {
303 t.Fatalf("set topic title: %v", err)
304 }
305 dir := config.SessionDir()
306 if err := os.MkdirAll(dir, 0o755); err != nil {
307 t.Fatalf("mkdir sessions: %v", err)
308 }
309 writeTopicSessionWithPrompt(t, dir, "metadata-retry.jsonl", topicID, "Metadata retry", projectRoot, "preserve me", time.Now())
310 topicLegacyWriteHookForTest = func(path string) error {
311 if path == topicTitleSourcesPath(projectRoot) {
312 return errors.New("injected legacy mirror failure")
313 }
314 return nil
315 }
316 t.Cleanup(func() { topicLegacyWriteHookForTest = nil })
317
318 app := NewApp()
319 if err := app.trashTopic(topicID); err != nil {
320 t.Fatalf("committed TrashTopic returned a failure: %v", err)
321 }
322 if got := loadTopicTitle(projectRoot, topicID); got != "" {
323 t.Fatalf("authoritative deleted title = %q, want empty", got)
324 }
325 if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
326 t.Fatalf("SQLite-committed delete should not create archive retry intent: %v", err)
327 }
328
329 topicLegacyWriteHookForTest = nil
330 if err := setTopicCreatedAt(projectRoot, "topic-live", 1234); err != nil {
331 t.Fatalf("trigger pending mirror repair: %v", err)
332 }
333 legacyTitles, err := loadLegacyStringMap(topicTitlesPath(projectRoot))
334 if err != nil {
335 t.Fatal(err)
336 }
337 if _, ok := legacyTitles[topicID]; ok {
338 t.Fatalf("repaired legacy mirror resurrected deleted topic: %#v", legacyTitles)
339 }
340 if got := loadTopicTitle(projectRoot, topicID); got != "" {
341 t.Fatalf("reconciled archive retained topic title %q", got)
342 }
343 if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
344 t.Fatalf("reconciled metadata marker still exists, err=%v", err)
345 }
346 projects := loadProjectsFile()
347 for _, project := range projects.Projects {
348 if containsDesktopString(project.Topics, topicID) || containsDesktopString(project.PinnedTopics, topicID) {
349 t.Fatalf("reconciled archive retained topic in project index: %+v", project)
350 }
351 }
352 }
353
354 func TestTopicArchiveIntentCompletesSessionsAndMetadataAfterRestart(t *testing.T) {
355 isolateDesktopUserDirs(t)
356 projectRoot := t.TempDir()
357 topicID := "topic_restart_commit"
358 if err := addProject(projectRoot, ""); err != nil {
359 t.Fatalf("add project: %v", err)
360 }
361 if err := setTopicTitle(projectRoot, topicID, "Restart commit"); err != nil {
362 t.Fatalf("set topic title: %v", err)
363 }
364 dir := config.SessionDir()
365 if err := os.MkdirAll(dir, 0o755); err != nil {
366 t.Fatalf("mkdir sessions: %v", err)
367 }
368 sessionPath := writeTopicSessionWithPrompt(t, dir, "restart-commit.jsonl", topicID, "Restart commit", projectRoot, "preserve me", time.Now())
369 targets := []topicTrashTarget{{dir: dir, sessionPath: sessionPath, key: filepath.Base(sessionPath)}}
370 if err := markTopicArchiveMetadataPending(topicID, targets); err != nil {
371 t.Fatalf("mark archive intent: %v", err)
372 }
373
374 app := NewApp()
375 if err := reconcileTopicArchiveMetadataPending(app.deleteTopic); err != nil {
376 t.Fatalf("reconcile archive intent: %v", err)
377 }
378 if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
379 t.Fatalf("reconciled live session still exists, err=%v", err)
380 }
381 trashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath), filepath.Base(sessionPath))
382 if _, err := os.Stat(trashPath); err != nil {
383 t.Fatalf("reconciled session was not preserved in trash: %v", err)
384 }
385 if got := loadTopicTitle(projectRoot, topicID); got != "" {
386 t.Fatalf("reconciled archive retained topic title %q", got)
387 }
388 if agent.IsCleanupPending(sessionPath) {
389 t.Fatal("reconciled archive retained session cleanup marker")
390 }
391 if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
392 t.Fatalf("reconciled archive retained topic marker, err=%v", err)
393 }
394 }
395
396 func TestTrashTopicPreservesDivergedExistingTrash(t *testing.T) {
397 isolateDesktopUserDirs(t)
398 projectRoot := t.TempDir()
399 topicID := "topic_diverged_trash"
400 if err := addProject(projectRoot, ""); err != nil {
401 t.Fatalf("add project: %v", err)
402 }
403 if err := setTopicTitle(projectRoot, topicID, "Diverged trash"); err != nil {
404 t.Fatalf("set topic title: %v", err)
405 }
406 dir := config.SessionDir()
407 if err := os.MkdirAll(dir, 0o755); err != nil {
408 t.Fatalf("mkdir sessions: %v", err)
409 }
410 sessionPath := writeTopicSessionWithPrompt(t, dir, "diverged-trash.jsonl", topicID, "Diverged trash", projectRoot, "new live history", time.Now())
411 existingTrashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath), filepath.Base(sessionPath))
412 existing := agent.NewSession("system")
413 existing.Add(provider.Message{Role: provider.RoleUser, Content: "older trash history"})
414 if err := existing.SaveSnapshot(existingTrashPath); err != nil {
415 t.Fatalf("write existing trash: %v", err)
416 }
417
418 app := NewApp()
419 if err := app.TrashTopic(topicID); err != nil {
420 t.Fatalf("TrashTopic: %v", err)
421 }
422 ref := assertLegacyLifecycle(t, app, sessionPath, "archived")
423 old, err := agent.LoadSession(existingTrashPath)
424 if err != nil {
425 t.Fatal(err)
426 }
427 found := false
428 for _, message := range old.Snapshot() {
429 found = found || message.Content == "older trash history"
430 }
431 if !found {
432 t.Fatal("older trash history was overwritten")
433 }
434 page, err := app.ReadSessionHistory(ref, "", 32)
435 if err != nil || !hasHistoryContent(page.Messages, "new live history") {
436 t.Fatalf("new archived history=%+v %v", page, err)
437 }
438 }
439
440 func TestTopicArchiveOwnershipRollbackRestoresLocalLease(t *testing.T) {
441 isolateDesktopUserDirs(t)
442 dir := config.SessionDir()
443 if err := os.MkdirAll(dir, 0o755); err != nil {
444 t.Fatalf("mkdir sessions: %v", err)
445 }
446 sessionPath := filepath.Join(dir, "rollback-lease.jsonl")
447 if err := os.WriteFile(sessionPath, []byte("{}\n"), 0o644); err != nil {
448 t.Fatalf("write session: %v", err)
449 }
450 tab := &WorkspaceTab{ID: "rollback", SessionPath: sessionPath, Ready: true}
451 if err := tab.ensureSessionLease(sessionPath); err != nil {
452 t.Fatalf("ensureSessionLease: %v", err)
453 }
454 defer tab.releaseSessionLease()
455 removed := []removedSessionRuntime{{tab: tab, sessionDir: dir, sessionPath: sessionPath}}
456 targets := []topicTrashTarget{{dir: dir, sessionPath: sessionPath, key: filepath.Base(sessionPath)}}
457
458 batch, err := acquireTopicArchiveOwnership(targets, removed)
459 if err != nil {
460 t.Fatalf("acquireTopicArchiveOwnership: %v", err)
461 }
462 if tab.sessionLeaseRuntimeKey() != "" {
463 batch.rollback()
464 t.Fatal("converted ownership remained attached to the runtime tab")
465 }
466 batch.rollback()
467 if got := tab.sessionLeaseRuntimeKey(); got != sessionRuntimeKey(sessionPath) {
468 t.Fatalf("rolled back lease key = %q, want %q", got, sessionRuntimeKey(sessionPath))
469 }
470 if next, err := agent.TryAcquireSessionLease(sessionPath); !errors.Is(err, agent.ErrSessionLeaseHeld) {
471 if next != nil {
472 next.Release()
473 }
474 t.Fatalf("competing lease after rollback err = %v, want ErrSessionLeaseHeld", err)
475 }
476 }
477
478 func TestUnusedTransientBlankSessionOnlyMatchesZeroByteFiles(t *testing.T) {
479 isolateDesktopUserDirs(t)
480 dir := t.TempDir()
481 zero := filepath.Join(dir, "zero.jsonl")
482 writeZeroByteSession(t, zero)
483 if !unusedTransientBlankSession(dir, zero) {
484 t.Fatal("zero-byte unindexed session should be an unused transient blank")
485 }
486 nonzero := filepath.Join(dir, "nonzero.jsonl")
487 if err := os.WriteFile(nonzero, []byte(`{"role":"system","content":"identity"}`+"\n"), 0o600); err != nil {
488 t.Fatalf("write non-empty session: %v", err)
489 }
490 if unusedTransientBlankSession(dir, nonzero) {
491 t.Fatal("non-empty session must not be classified as an unused transient blank")
492 }
493 }
494
495 func TestTransientBlankCleanupPreservesTopicWithSiblingInAnotherGlobalDirectory(t *testing.T) {
496 isolateDesktopUserDirs(t)
497 app := NewApp()
498 ghostDir := desktopSessionDir(globalWorkspaceRoot())
499 legacyDir := config.SessionDir()
500 for _, dir := range []string{ghostDir, legacyDir} {
501 if err := os.MkdirAll(dir, 0o755); err != nil {
502 t.Fatal(err)
503 }
504 }
505 topicID := "shared-global-topic"
506 ghost := filepath.Join(ghostDir, "ghost.jsonl")
507 sibling := filepath.Join(legacyDir, "sibling.jsonl")
508 writeZeroByteSession(t, ghost)
509 if err := pinNewEmptySessionBranchMeta(ghost, "global", "", topicID, defaultTopicTitle); err != nil {
510 t.Fatal(err)
511 }
512 if err := os.WriteFile(sibling, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o600); err != nil {
513 t.Fatal(err)
514 }
515 if err := pinNewEmptySessionBranchMeta(sibling, "global", "", topicID, "Keep"); err != nil {
516 t.Fatal(err)
517 }
518 if err := prependTopicsInProjectsFile("", []string{topicID}, false); err != nil {
519 t.Fatal(err)
520 }
521 app.discardUnusedTransientBlankSessions([]string{ghostDir}, "")
522 if _, err := os.Stat(ghost); !os.IsNotExist(err) {
523 t.Fatalf("transient ghost should be removed, stat err = %v", err)
524 }
525 if _, err := os.Stat(sibling); err != nil {
526 t.Fatalf("sibling session should remain: %v", err)
527 }
528 if !topicIndexedInRegistry("global", "", topicID) {
529 t.Fatal("sibling topic registration was removed with a ghost from another directory")
530 }
531 }
532
533 func writeZeroByteSession(t *testing.T, path string) {
534 t.Helper()
535 if err := os.WriteFile(path, nil, 0o600); err != nil {
536 t.Fatalf("write zero-byte session %s: %v", path, err)
537 }
538 }
539
540 func TestTrashTopicArchivesFailedRuntimeWithStaleWriteAuthority(t *testing.T) {
541 isolateDesktopUserDirs(t)
542
543 projectRoot := t.TempDir()
544 topicID := "topic_failed_authority"
545 if err := addProject(projectRoot, ""); err != nil {
546 t.Fatalf("add project: %v", err)
547 }
548 if err := setTopicTitle(projectRoot, topicID, "Failed authority"); err != nil {
549 t.Fatalf("set topic title: %v", err)
550 }
551 dir := config.SessionDir()
552 if err := os.MkdirAll(dir, 0o755); err != nil {
553 t.Fatalf("mkdir sessions: %v", err)
554 }
555 sessionPath := writeTopicSession(t, dir, "failed-authority.jsonl", topicID, "Failed authority", projectRoot)
556 ctrl := controllerWithContent(t, sessionPath)
557 defer ctrl.Close()
558 lease, err := agent.TryAcquireSessionLease(sessionPath)
559 if err != nil {
560 t.Fatalf("TryAcquireSessionLease: %v", err)
561 }
562 if err := ctrl.BindSessionWriteAuthority(lease); err != nil {
563 lease.Release()
564 t.Fatalf("BindSessionWriteAuthority: %v", err)
565 }
566 lease.Release()
567 if err := ctrl.Snapshot(); !errors.Is(err, agent.ErrSessionWriteAuthorityStale) {
568 t.Fatalf("Snapshot error = %v, want %v", err, agent.ErrSessionWriteAuthorityStale)
569 }
570
571 failed := &WorkspaceTab{ID: "failed", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
572 TopicTitle: "Failed authority", SessionPath: sessionPath, Ctrl: ctrl, disabledMCP: map[string]ServerView{}}
573 keep := &WorkspaceTab{ID: "keep", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "keep", Ready: true}
574 app := &App{tabs: map[string]*WorkspaceTab{failed.ID: failed, keep.ID: keep}, tabOrder: []string{failed.ID, keep.ID}, activeTabID: failed.ID}
575 app.desktopSessionService(dir)
576 t.Cleanup(app.closeSessionServices)
577 app.mu.Lock()
578 app.newSessionRuntimeLocked(failed, sessionRuntimeKey(sessionPath))
579 _, save := app.markTabStartupFailureLocked(failed, agent.ErrSessionWriteAuthorityStale, suppressStartupRestore)
580 app.mu.Unlock()
581 app.writeTabsSaveRequest(save)
582
583 if err := app.TrashTopic(topicID); err != nil {
584 t.Fatalf("TrashTopic: %v", err)
585 }
586 assertLegacyLifecycle(t, app, sessionPath, "archived")
587 if app.tabs[keep.ID] != keep || app.tabs[failed.ID] != nil {
588 t.Fatalf("runtime bindings after archive = %#v", app.tabs)
589 }
590 }
591
592 func TestTopicArchiveRejectsFailedRuntimeThatStartedRetrying(t *testing.T) {
593 topicID := "topic_retrying_failure"
594 tab := &WorkspaceTab{ID: "failed", Scope: "global", TopicID: topicID, SessionPath: "retrying.jsonl"}
595 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
596 app.mu.Lock()
597 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.SessionPath))
598 app.markTabStartupFailureLocked(tab, errors.New("restore failed"), suppressStartupRestore)
599 app.mu.Unlock()
600 captured := app.captureTopicRuntimeBindings(topicID)
601
602 app.mu.Lock()
603 app.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
604 app.mu.Unlock()
605 if _, unchanged := app.removeTopicRuntimeBindingsIfUnchanged(topicID, captured); unchanged {
606 t.Fatal("archive detached a failed runtime after its retry changed generation state")
607 }
608 if app.tabs[tab.ID] != tab || tab.removed {
609 t.Fatal("rejected stale archive changed the retrying tab")
610 }
611 }
612
612 lines GO