返回 DeepSeek-Reasonix
session_lease_keeper_test.go
根目录 / internal / control / session_lease_keeper_test.go
1 package control
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/event"
14 "reasonix/internal/store"
15 )
16
17 func TestSessionLeaseKeeperRebindMovesLease(t *testing.T) {
18 dir := t.TempDir()
19 a := filepath.Join(dir, "a.jsonl")
20 b := filepath.Join(dir, "b.jsonl")
21
22 k := NewSessionLeaseKeeper()
23 defer k.Release()
24
25 if err := k.Rebind(a); err != nil {
26 t.Fatalf("Rebind(a): %v", err)
27 }
28 if got, want := k.HeldPath(), agent.CanonicalSessionPath(a); got != want {
29 t.Fatalf("HeldPath = %q, want %q", got, want)
30 }
31 if info, err := agent.LoadSessionLeaseInfo(agent.CanonicalSessionPath(a)); err != nil || info == nil {
32 t.Fatalf("lease info for a missing: %v", err)
33 }
34 // a is held: an outside acquire must fail.
35 if _, err := agent.TryAcquireSessionLease(a); !errors.Is(err, agent.ErrSessionLeaseHeld) {
36 t.Fatalf("TryAcquireSessionLease(a) while kept = %v, want ErrSessionLeaseHeld", err)
37 }
38
39 if err := k.Rebind(b); err != nil {
40 t.Fatalf("Rebind(b): %v", err)
41 }
42 if got, want := k.HeldPath(), agent.CanonicalSessionPath(b); got != want {
43 t.Fatalf("HeldPath after rebind = %q, want %q", got, want)
44 }
45 // The old lease is released: a is acquirable again and its owner info
46 // (published inside .lease.lock) is gone with the lock file.
47 if _, err := agent.LoadSessionLeaseInfo(agent.CanonicalSessionPath(a)); !os.IsNotExist(err) {
48 t.Fatalf("lease info for a after rebind err = %v, want not exist", err)
49 }
50 lease, err := agent.TryAcquireSessionLease(a)
51 if err != nil {
52 t.Fatalf("TryAcquireSessionLease(a) after rebind: %v", err)
53 }
54 lease.Release()
55 }
56
57 func TestSessionLeaseKeeperRebindSamePathIsNoop(t *testing.T) {
58 dir := t.TempDir()
59 a := filepath.Join(dir, "a.jsonl")
60
61 k := NewSessionLeaseKeeper()
62 defer k.Release()
63 if err := k.Rebind(a); err != nil {
64 t.Fatalf("Rebind(a): %v", err)
65 }
66 // Same canonical path again must not trip over the keeper's own lease.
67 if err := k.Rebind(a); err != nil {
68 t.Fatalf("Rebind(a) again: %v", err)
69 }
70 if got, want := k.HeldPath(), agent.CanonicalSessionPath(a); got != want {
71 t.Fatalf("HeldPath = %q, want %q", got, want)
72 }
73 }
74
75 func TestSessionLeaseKeeperRefusesHeldPathAndKeepsCurrent(t *testing.T) {
76 dir := t.TempDir()
77 a := filepath.Join(dir, "a.jsonl")
78 b := filepath.Join(dir, "b.jsonl")
79
80 holder, err := agent.TryAcquireSessionLease(b)
81 if err != nil {
82 t.Fatalf("holder acquire: %v", err)
83 }
84 defer holder.Release()
85
86 k := NewSessionLeaseKeeper()
87 defer k.Release()
88 if err := k.Rebind(a); err != nil {
89 t.Fatalf("Rebind(a): %v", err)
90 }
91 err = k.Rebind(b)
92 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
93 t.Fatalf("Rebind(held b) = %v, want ErrSessionLeaseHeld", err)
94 }
95 // Failure leaves the keeper on its previous session.
96 if got, want := k.HeldPath(), agent.CanonicalSessionPath(a); got != want {
97 t.Fatalf("HeldPath after refused rebind = %q, want %q", got, want)
98 }
99 }
100
101 func TestSessionLeaseKeeperEmptyPathReleases(t *testing.T) {
102 dir := t.TempDir()
103 a := filepath.Join(dir, "a.jsonl")
104
105 k := NewSessionLeaseKeeper()
106 defer k.Release()
107 if err := k.Rebind(a); err != nil {
108 t.Fatalf("Rebind(a): %v", err)
109 }
110 if err := k.Rebind(""); err != nil {
111 t.Fatalf("Rebind(empty): %v", err)
112 }
113 if got := k.HeldPath(); got != "" {
114 t.Fatalf("HeldPath after empty rebind = %q, want empty", got)
115 }
116 lease, err := agent.TryAcquireSessionLease(a)
117 if err != nil {
118 t.Fatalf("TryAcquireSessionLease(a) after empty rebind: %v", err)
119 }
120 lease.Release()
121 }
122
123 func TestSessionLeaseKeeperRebindReturningCurrentIsFailureAtomic(t *testing.T) {
124 dir := t.TempDir()
125 current := filepath.Join(dir, "current.jsonl")
126 target := filepath.Join(dir, "target.jsonl")
127 blocked := filepath.Join(dir, "blocked.jsonl")
128 k := NewSessionLeaseKeeper()
129 defer k.Release()
130 if err := k.Rebind(current); err != nil {
131 t.Fatal(err)
132 }
133 blocker, err := agent.TryAcquireSessionLease(blocked)
134 if err != nil {
135 t.Fatal(err)
136 }
137 defer blocker.Release()
138 if err := k.RebindReturningCurrent(blocked, "serve-writer", "return-1"); !errors.Is(err, agent.ErrSessionLeaseHeld) {
139 t.Fatalf("blocked rebind = %v, want held", err)
140 }
141 if got := k.HeldPath(); got != agent.CanonicalSessionPath(current) {
142 t.Fatalf("held path after failure = %q, want %q", got, current)
143 }
144 if err := k.RebindReturningCurrent(target, "serve-writer", "return-1"); err != nil {
145 t.Fatal(err)
146 }
147 if got := k.HeldPath(); got != agent.CanonicalSessionPath(target) {
148 t.Fatalf("held path = %q, want target", got)
149 }
150 info, err := agent.LoadSessionLeaseInfo(current)
151 if err != nil {
152 t.Fatal(err)
153 }
154 if info == nil || info.HandoffTo != "serve-writer" || info.HandoffID != "return-1" {
155 t.Fatalf("reverse reservation = %+v", info)
156 }
157 }
158
159 func TestSessionLeaseKeeperConsumesForwardHandoffBeforeReturningCurrent(t *testing.T) {
160 dir := t.TempDir()
161 current := filepath.Join(dir, "current.jsonl")
162 target := filepath.Join(dir, "target.jsonl")
163 targetSource, err := agent.TryAcquireSessionLease(target)
164 if err != nil {
165 t.Fatal(err)
166 }
167 if err := targetSource.ReleaseForHandoff(agent.SessionWriterID(), "forward-1"); err != nil {
168 t.Fatal(err)
169 }
170 k := NewSessionLeaseKeeper()
171 defer k.Release()
172 if err := k.Rebind(current); err != nil {
173 t.Fatal(err)
174 }
175 pending, err := k.RebindWithHandoffReturningCurrent(
176 target, agent.SessionWriterID(), "forward-1", "return-target", "serve-writer", "return-1",
177 )
178 if err != nil || pending != nil {
179 t.Fatal(err)
180 }
181 if got := k.HeldPath(); got != agent.CanonicalSessionPath(target) {
182 t.Fatalf("held path = %q, want target", got)
183 }
184 info, err := agent.LoadSessionLeaseInfo(current)
185 if err != nil {
186 t.Fatal(err)
187 }
188 if info == nil || info.HandoffTo != "serve-writer" || info.HandoffID != "return-1" {
189 t.Fatalf("current reverse reservation = %+v", info)
190 }
191 }
192
193 func TestSessionLeaseKeeperTwoSidedRollbackRetainsTargetAfterBothReservationsFail(t *testing.T) {
194 dir := t.TempDir()
195 source := filepath.Join(dir, "source.jsonl")
196 target := filepath.Join(dir, "target.jsonl")
197 keeper := NewSessionLeaseKeeper()
198 defer keeper.Release()
199 if err := keeper.Rebind(source); err != nil {
200 t.Fatal(err)
201 }
202 wantSourceErr := errors.New("injected source reservation failure")
203 wantTargetErr := errors.New("injected target reservation failure")
204 pending, err := keeper.rebindWithHandoffReturningCurrentWith(
205 target,
206 agent.TryAcquireSessionLease,
207 func(*SessionLeaseKeeper) error { return wantSourceErr },
208 func(*agent.SessionLease) error { return wantTargetErr },
209 )
210 if !errors.Is(err, wantSourceErr) || !errors.Is(err, wantTargetErr) || pending == nil {
211 t.Fatalf("rollback = (%v, %v), want pending keeper and both errors", pending, err)
212 }
213 defer pending.Release()
214 if got, want := keeper.HeldPath(), agent.CanonicalSessionPath(source); got != want {
215 t.Fatalf("restored source = %q, want %q", got, want)
216 }
217 if got, want := pending.HeldPath(), agent.CanonicalSessionPath(target); got != want {
218 t.Fatalf("pending target = %q, want %q", got, want)
219 }
220 if third, acquireErr := agent.TryAcquireSessionLease(target); !errors.Is(acquireErr, agent.ErrSessionLeaseHeld) {
221 if third != nil {
222 third.Release()
223 }
224 t.Fatalf("third writer acquired pending target: %v", acquireErr)
225 }
226 if err := pending.RetireDetachedForHandoff(agent.SessionWriterID(), "return-target"); err != nil {
227 t.Fatal(err)
228 }
229 }
230
231 func TestSessionLeaseKeeperRetiresDetachedSourceWithoutClearingReplacementAuthority(t *testing.T) {
232 dir := t.TempDir()
233 current := filepath.Join(dir, "current.jsonl")
234 target := filepath.Join(dir, "target.jsonl")
235 currentSession := agent.NewSession("sys")
236 if err := currentSession.Save(current); err != nil {
237 t.Fatal(err)
238 }
239 targetSession := agent.NewSession("sys")
240 if err := targetSession.Save(target); err != nil {
241 t.Fatal(err)
242 }
243 loadedTarget, err := agent.LoadSession(target)
244 if err != nil {
245 t.Fatal(err)
246 }
247 exec := agent.New(nil, nil, currentSession, agent.Options{}, event.Discard)
248 ctrl := newOwnedTestController(t, Options{Executor: exec, SessionPath: current, Sink: event.Discard})
249 defer ctrl.Close()
250 k := NewSessionLeaseKeeper()
251 defer k.Release()
252 if err := k.Rebind(current); err != nil {
253 t.Fatal(err)
254 }
255 if err := k.BindControllerAuthority(ctrl); err != nil {
256 t.Fatal(err)
257 }
258 targetSource, err := agent.TryAcquireSessionLease(target)
259 if err != nil {
260 t.Fatal(err)
261 }
262 if err := targetSource.ReleaseForHandoff(agent.SessionWriterID(), "forward-target"); err != nil {
263 t.Fatal(err)
264 }
265 previous, err := k.RebindDetachingWithHandoff(target, agent.SessionWriterID(), "forward-target")
266 if err != nil {
267 t.Fatal(err)
268 }
269 if err := k.BindSessionAuthority(loadedTarget); err != nil {
270 t.Fatal(err)
271 }
272 if err := previous.RetireDetachedForHandoff("serve-writer", "return-current"); err != nil {
273 t.Fatal(err)
274 }
275 ctrl.Resume(loadedTarget, target)
276 if err := k.BindControllerAuthority(ctrl); err != nil {
277 t.Fatal(err)
278 }
279 if err := ctrl.Snapshot(); err != nil {
280 t.Fatalf("replacement snapshot lost authority: %v", err)
281 }
282 info, err := agent.LoadSessionLeaseInfo(current)
283 if err != nil {
284 t.Fatal(err)
285 }
286 if info == nil || info.HandoffTo != "serve-writer" || info.HandoffID != "return-current" {
287 t.Fatalf("source reverse reservation = %+v", info)
288 }
289 }
290
291 func TestSessionLeaseKeeperReleaseRemovesLeaseInfo(t *testing.T) {
292 dir := t.TempDir()
293 a := filepath.Join(dir, "a.jsonl")
294
295 k := NewSessionLeaseKeeper()
296 if err := k.Rebind(a); err != nil {
297 t.Fatalf("Rebind(a): %v", err)
298 }
299 k.Release()
300 k.Release() // idempotent
301 // Owner info lives inside .lease.lock and dies with the lock file on
302 // release; no legacy .lease.json sidecar is left behind either.
303 if _, err := agent.LoadSessionLeaseInfo(agent.CanonicalSessionPath(a)); !os.IsNotExist(err) {
304 t.Fatalf("lease info after Release err = %v, want not exist", err)
305 }
306 if _, err := os.Stat(store.SessionLeaseInfo(agent.CanonicalSessionPath(a))); !os.IsNotExist(err) {
307 t.Fatalf("legacy lease sidecar after Release stat err = %v, want not exist", err)
308 }
309 if got := k.HeldPath(); got != "" {
310 t.Fatalf("HeldPath after Release = %q, want empty", got)
311 }
312 }
313
314 func TestSessionLeaseKeeperRecoveryRebindsControllerBeforeReturning(t *testing.T) {
315 dir := t.TempDir()
316 a := filepath.Join(dir, "a.jsonl")
317 b := filepath.Join(dir, "b.jsonl")
318 sess := agent.NewSession("sys")
319 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
320 ctrl := newOwnedTestController(t, Options{Executor: exec, SessionPath: a, Sink: event.Discard})
321
322 k := NewSessionLeaseKeeper()
323 defer k.Release()
324 if err := k.Rebind(a); err != nil {
325 t.Fatal(err)
326 }
327 if err := k.BindControllerAuthority(ctrl); err != nil {
328 t.Fatal(err)
329 }
330 releaseSave, err := sess.WriteAuthority().BeginSave(a)
331 if err != nil {
332 t.Fatal(err)
333 }
334 if err := k.HandleSessionRecovered(SessionRecoveryInfo{RecoveryPath: b}); err != nil {
335 t.Fatalf("HandleSessionRecovered: %v", err)
336 }
337 if got := k.HeldPath(); got != agent.CanonicalSessionPath(b) {
338 t.Fatalf("HeldPath = %q, want %q", got, agent.CanonicalSessionPath(b))
339 }
340 if auth := sess.WriteAuthority(); auth == nil || !auth.Covers(b) {
341 t.Fatal("controller was published without authority for recovery path")
342 }
343 releaseSave()
344 }
345
346 func TestSessionLeaseKeeperBindsPrivateResumeCandidate(t *testing.T) {
347 dir := t.TempDir()
348 path := filepath.Join(dir, "target.jsonl")
349 outgoing := agent.NewSession("outgoing")
350 ctrl := newOwnedTestController(t, Options{Executor: agent.New(nil, nil, outgoing, agent.Options{}, event.Discard), SessionPath: path, Sink: event.Discard})
351 keeper := NewSessionLeaseKeeper()
352 defer keeper.Release()
353 if err := keeper.Rebind(path); err != nil {
354 t.Fatal(err)
355 }
356 if err := keeper.BindControllerAuthority(ctrl); err != nil {
357 t.Fatal(err)
358 }
359 oldAuthority := outgoing.WriteAuthority()
360 candidate := agent.NewSession("candidate")
361 if err := keeper.BindSessionAuthority(candidate); err != nil {
362 t.Fatalf("BindSessionAuthority: %v", err)
363 }
364 if auth := candidate.WriteAuthority(); auth == nil || !auth.Covers(path) {
365 t.Fatal("private resume candidate was not bound to the held path")
366 }
367 if oldAuthority == nil || oldAuthority.Valid() {
368 t.Fatal("candidate binding did not retire the outgoing write generation")
369 }
370 ctrl.Resume(candidate, path)
371 if got, want := ctrl.WriteAuthorityGeneration(), candidate.WriteAuthority().Generation(); got != want || got == 0 {
372 t.Fatalf("resumed controller authority generation = %d, want %d", got, want)
373 }
374 }
375
376 func TestSessionLeaseKeeperRebindDetachingTransfersRecoveryCallback(t *testing.T) {
377 dir := t.TempDir()
378 a := filepath.Join(dir, "a.jsonl")
379 b := filepath.Join(dir, "b.jsonl")
380 c := filepath.Join(dir, "c.jsonl")
381 ctrl := newOwnedTestController(t, Options{Executor: agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard), SessionPath: a, Sink: event.Discard})
382 keeper := NewSessionLeaseKeeper()
383 if err := keeper.Rebind(a); err != nil {
384 t.Fatal(err)
385 }
386 if err := keeper.BindControllerAuthority(ctrl); err != nil {
387 t.Fatal(err)
388 }
389 ctrl.SetOnSessionRecovered(keeper.HandleSessionRecovered)
390 detached, err := keeper.RebindDetaching(b)
391 if err != nil {
392 t.Fatal(err)
393 }
394 defer keeper.Release()
395 defer detached.Release()
396 handler := ctrl.sessionRecoveredHandler()
397 if handler == nil {
398 t.Fatal("detached controller lost its recovery callback")
399 }
400 if err := handler(SessionRecoveryInfo{RecoveryPath: c}); err != nil {
401 t.Fatal(err)
402 }
403 if got := detached.HeldPath(); got != agent.CanonicalSessionPath(c) {
404 t.Fatalf("detached recovery moved %q, want %q", got, agent.CanonicalSessionPath(c))
405 }
406 if got := keeper.HeldPath(); got != agent.CanonicalSessionPath(b) {
407 t.Fatalf("foreground keeper moved to %q, want %q", got, agent.CanonicalSessionPath(b))
408 }
409 }
410
411 func TestSessionLeaseKeeperTransitionBindsCandidateBeforeMove(t *testing.T) {
412 dir := t.TempDir()
413 a := filepath.Join(dir, "a.jsonl")
414 b := filepath.Join(dir, "b.jsonl")
415 current := agent.NewSession("sys")
416 exec := agent.New(nil, nil, current, agent.Options{}, event.Discard)
417 ctrl := newOwnedTestController(t, Options{Executor: exec, SessionPath: a, Sink: event.Discard})
418 k := NewSessionLeaseKeeper()
419 defer k.Release()
420 if err := k.Rebind(a); err != nil {
421 t.Fatal(err)
422 }
423 if err := k.BindControllerAuthority(ctrl); err != nil {
424 t.Fatal(err)
425 }
426
427 candidate := agent.NewSession("sys")
428 info := SessionTransitionInfo{OriginalPath: a, TargetPath: b, Reason: "fork", session: candidate}
429 if err := k.HandleSessionTransition(info); err != nil {
430 t.Fatalf("HandleSessionTransition: %v", err)
431 }
432 if got := k.HeldPath(); got != agent.CanonicalSessionPath(b) {
433 t.Fatalf("HeldPath = %q, want %q", got, agent.CanonicalSessionPath(b))
434 }
435 if auth := candidate.WriteAuthority(); auth == nil || !auth.Covers(b) {
436 t.Fatal("candidate was not bound before transition returned")
437 }
438 old, err := agent.TryAcquireSessionLease(a)
439 if err != nil {
440 t.Fatalf("old path remained held: %v", err)
441 }
442 old.Release()
443 }
444
445 func TestSessionLeaseKeeperTransitionFailureKeepsCurrentOwner(t *testing.T) {
446 dir := t.TempDir()
447 a := filepath.Join(dir, "a.jsonl")
448 b := filepath.Join(dir, "b.jsonl")
449 holder, err := agent.TryAcquireSessionLease(b)
450 if err != nil {
451 t.Fatal(err)
452 }
453 defer holder.Release()
454 k := NewSessionLeaseKeeper()
455 defer k.Release()
456 if err := k.Rebind(a); err != nil {
457 t.Fatal(err)
458 }
459 candidate := agent.NewSession("sys")
460 err = k.HandleSessionTransition(SessionTransitionInfo{TargetPath: b, Reason: "switch", session: candidate})
461 if !errors.Is(err, agent.ErrSessionLeaseHeld) && !strings.Contains(err.Error(), "in use") {
462 t.Fatalf("HandleSessionTransition error = %v, want held", err)
463 }
464 if got := k.HeldPath(); got != agent.CanonicalSessionPath(a) {
465 t.Fatalf("failed transition moved keeper to %q", got)
466 }
467 if candidate.WriteAuthority() != nil {
468 t.Fatal("failed transition bound candidate authority")
469 }
470 }
471
472 func TestSessionInUseMessageNamesHolder(t *testing.T) {
473 acquired := time.Date(2026, 7, 6, 3, 4, 0, 0, time.UTC)
474 err := &agent.SessionLeaseError{
475 Path: "/tmp/x.jsonl",
476 Info: &agent.SessionLeaseInfo{
477 SessionPath: "/tmp/x.jsonl",
478 WriterID: "writer-nonce-should-not-appear",
479 PID: 12345,
480 Hostname: "devbox",
481 AcquiredAt: acquired,
482 },
483 }
484 msg := SessionInUseMessage(err)
485 if !strings.Contains(msg, "another Reasonix process") {
486 t.Fatalf("message %q missing holder wording", msg)
487 }
488 if !strings.Contains(msg, "pid 12345") || !strings.Contains(msg, "on devbox") {
489 t.Fatalf("message %q missing pid/host", msg)
490 }
491 if !strings.Contains(msg, "since "+acquired.Local().Format("15:04")) {
492 t.Fatalf("message %q missing local acquire time", msg)
493 }
494 if strings.Contains(msg, "writer-nonce-should-not-appear") {
495 t.Fatalf("message %q leaks the writer id", msg)
496 }
497 if strings.Contains(msg, "/tmp/x.jsonl") {
498 t.Fatalf("message %q leaks the session path", msg)
499 }
500 }
501
502 func TestSessionInUseMessageFallsBackWithoutInfo(t *testing.T) {
503 for name, err := range map[string]error{
504 "nil info": &agent.SessionLeaseError{Path: "/tmp/x.jsonl"},
505 "plain held": agent.ErrSessionLeaseHeld,
506 "zero pid": &agent.SessionLeaseError{Info: &agent.SessionLeaseInfo{PID: 0}},
507 } {
508 msg := SessionInUseMessage(err)
509 if msg != "this session is in use by another Reasonix window or process" {
510 t.Fatalf("%s: message = %q, want generic fallback", name, msg)
511 }
512 if strings.Contains(msg, "pid "+strconv.Itoa(os.Getpid())) {
513 t.Fatalf("%s: fallback should not invent a pid: %q", name, msg)
514 }
515 }
516 }
517
518 func TestSessionLeaseKeeperRebindDetachingIsFailureAtomic(t *testing.T) {
519 dir := t.TempDir()
520 current := filepath.Join(dir, "current.jsonl")
521 target := filepath.Join(dir, "target.jsonl")
522 keeper := NewSessionLeaseKeeper()
523 defer keeper.Release()
524 if err := keeper.Rebind(current); err != nil {
525 t.Fatal(err)
526 }
527 outside, err := agent.TryAcquireSessionLease(target)
528 if err != nil {
529 t.Fatal(err)
530 }
531 defer outside.Release()
532
533 detached, err := keeper.RebindDetaching(target)
534 if !errors.Is(err, agent.ErrSessionLeaseHeld) || detached != nil {
535 t.Fatalf("RebindDetaching = (%v, %v), want held error and nil keeper", detached, err)
536 }
537 if got, want := keeper.HeldPath(), agent.CanonicalSessionPath(current); got != want {
538 t.Fatalf("held path = %q, want unchanged %q", got, want)
539 }
540 }
541
542 func TestSessionLeaseKeeperSplitAndAdopt(t *testing.T) {
543 dir := t.TempDir()
544 current := filepath.Join(dir, "current.jsonl")
545 target := filepath.Join(dir, "target.jsonl")
546 keeper := NewSessionLeaseKeeper()
547 defer keeper.Release()
548 if err := keeper.Rebind(current); err != nil {
549 t.Fatal(err)
550 }
551 detached, err := keeper.RebindDetaching(target)
552 if err != nil {
553 t.Fatal(err)
554 }
555 if got, want := detached.HeldPath(), agent.CanonicalSessionPath(current); got != want {
556 t.Fatalf("detached path = %q, want %q", got, want)
557 }
558 if got, want := keeper.HeldPath(), agent.CanonicalSessionPath(target); got != want {
559 t.Fatalf("foreground path = %q, want %q", got, want)
560 }
561 foreground := keeper.Split()
562 defer foreground.Release()
563 keeper.Adopt(detached)
564 if got, want := keeper.HeldPath(), agent.CanonicalSessionPath(current); got != want {
565 t.Fatalf("adopted path = %q, want %q", got, want)
566 }
567 if got := detached.HeldPath(); got != "" {
568 t.Fatalf("source keeper still holds %q", got)
569 }
570 }
571
572 func TestSessionLeaseKeeperRollbackRetainsTargetWhenReservationWriteFails(t *testing.T) {
573 dir := t.TempDir()
574 source := filepath.Join(dir, "source.jsonl")
575 target := filepath.Join(dir, "target.jsonl")
576 keeper := NewSessionLeaseKeeper()
577 defer keeper.Release()
578 if err := keeper.Rebind(source); err != nil {
579 t.Fatal(err)
580 }
581 previous, err := keeper.RebindDetaching(target)
582 if err != nil {
583 t.Fatal(err)
584 }
585 wantErr := errors.New("injected reservation write failure")
586 pending, err := keeper.restoreDetachedReturningCurrentWith(previous, func(*agent.SessionLease) error {
587 return wantErr
588 })
589 if !errors.Is(err, wantErr) || pending == nil {
590 t.Fatalf("rollback = (%v, %v), want pending keeper and injected error", pending, err)
591 }
592 defer pending.Release()
593 if got, want := keeper.HeldPath(), agent.CanonicalSessionPath(source); got != want {
594 t.Fatalf("restored path = %q, want %q", got, want)
595 }
596 if got, want := pending.HeldPath(), agent.CanonicalSessionPath(target); got != want {
597 t.Fatalf("pending path = %q, want %q", got, want)
598 }
599 if got := previous.HeldPath(); got != "" {
600 t.Fatalf("previous keeper still owns %q", got)
601 }
602 if _, err := agent.TryAcquireSessionLease(target); !errors.Is(err, agent.ErrSessionLeaseHeld) {
603 t.Fatalf("third writer acquired pending target: %v", err)
604 }
605 if err := pending.RetireDetachedForHandoff("serve-writer", "return-target"); err != nil {
606 t.Fatal(err)
607 }
608 info, err := agent.LoadSessionLeaseInfo(target)
609 if err != nil {
610 t.Fatal(err)
611 }
612 if info.HandoffTo != "serve-writer" || info.HandoffID != "return-target" {
613 t.Fatalf("target reservation = %+v", info)
614 }
615 }
616
617 func TestSessionLeaseKeeperRollbackRestoresSourceAfterReservation(t *testing.T) {
618 dir := t.TempDir()
619 source := filepath.Join(dir, "source.jsonl")
620 target := filepath.Join(dir, "target.jsonl")
621 keeper := NewSessionLeaseKeeper()
622 defer keeper.Release()
623 if err := keeper.Rebind(source); err != nil {
624 t.Fatal(err)
625 }
626 previous, err := keeper.RebindDetaching(target)
627 if err != nil {
628 t.Fatal(err)
629 }
630 pending, err := keeper.RestoreDetachedReturningCurrent(previous, "serve-writer", "return-target")
631 if err != nil || pending != nil {
632 t.Fatalf("rollback = (%v, %v), want success", pending, err)
633 }
634 if got, want := keeper.HeldPath(), agent.CanonicalSessionPath(source); got != want {
635 t.Fatalf("restored path = %q, want %q", got, want)
636 }
637 info, err := agent.LoadSessionLeaseInfo(target)
638 if err != nil {
639 t.Fatal(err)
640 }
641 if info.HandoffTo != "serve-writer" || info.HandoffID != "return-target" {
642 t.Fatalf("target reservation = %+v", info)
643 }
644 }
645
645 lines GO