detached bc0f858

feat(daemon): preserve session lifecycle identity

(cherry picked from commit f6fe38760903550df52bcc1d66c48b20424dfc3d)
commit
bc0f858d0e96f85455a9223c701be838b9fddb6e
author
brice · · 2026-07-15 06:18 UTC
committer
brice · · 2026-07-15 07:47 UTC
parent
464c9e0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +238 -17
M internal/usecase/daemon/daemon.go +30 -1
@@ -67,7 +67,11 @@ type Daemon struct {
6767 sessions map[domain.SessionID]*session
6868 stopped map[string]stoppedSession
6969 nextID uint64
70- mruSeq atomic.Uint64
70+ // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
71+ // mark. It is guarded by mu and prevents a wall-clock regression from
72+ // reusing a lifecycle identity.
73+ lastAllocatedCreatedAt int64
74+ mruSeq atomic.Uint64
7175 // closing marks that shutdown has irreversibly begun. It is set under mu,
7276 // atomically with the event that makes shutdown inevitable (the registry
7377 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -293,6 +297,22 @@ func WithConfig(cfg domain.Config) Option {
293297 }
294298 }
295299
300+// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
301+// holds d.mu. The persisted high-water mark is loaded by New before any named
302+// session can be resumed or created.
303+func (d *Daemon) allocateLifecycleCreatedAtLocked() int64 {
304+ now := d.nowUnixNano()
305+ if now <= d.lastAllocatedCreatedAt {
306+ now = d.lastAllocatedCreatedAt + 1
307+ }
308+ d.lastAllocatedCreatedAt = now
309+ return now
310+}
311+
312+func (d *Daemon) nowUnixNano() int64 {
313+ return d.clock.Now().UnixNano()
314+}
315+
296316 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
297317 // render debounce, and log receives diagnostics (defaults to slog.Default).
298318 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -357,12 +377,21 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
357377 d.log.Warn("loading persisted sessions failed", "err", err)
358378 } else {
359379 var maxSeq uint64
380+ var maxCreatedAt int64
381+ hasCreatedAt := false
360382 for _, r := range records {
361383 d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
384+ if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
385+ maxCreatedAt = r.CreatedAt
386+ hasCreatedAt = true
387+ }
362388 if r.LastUsedSeq > maxSeq {
363389 maxSeq = r.LastUsedSeq
364390 }
365391 }
392+ if hasCreatedAt {
393+ d.lastAllocatedCreatedAt = maxCreatedAt
394+ }
366395 d.mruSeq.Store(maxSeq)
367396 }
368397 return d
@@ -67,7 +67,11 @@ type Daemon struct {
67 sessions map[domain.SessionID]*session67 sessions map[domain.SessionID]*session
68 stopped map[string]stoppedSession68 stopped map[string]stoppedSession
69 nextID uint6469 nextID uint64
70- mruSeq atomic.Uint6470+ // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
71+ // mark. It is guarded by mu and prevents a wall-clock regression from
72+ // reusing a lifecycle identity.
73+ lastAllocatedCreatedAt int64
74+ mruSeq atomic.Uint64
71 // closing marks that shutdown has irreversibly begun. It is set under mu,75 // closing marks that shutdown has irreversibly begun. It is set under mu,
72 // atomically with the event that makes shutdown inevitable (the registry76 // atomically with the event that makes shutdown inevitable (the registry
73 // emptying in killSession, or shutdownAll starting), and checked by route77 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -293,6 +297,22 @@ func WithConfig(cfg domain.Config) Option {
293 }297 }
294 }298 }
295 299
300+// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
301+// holds d.mu. The persisted high-water mark is loaded by New before any named
302+// session can be resumed or created.
303+func (d *Daemon) allocateLifecycleCreatedAtLocked() int64 {
304+ now := d.nowUnixNano()
305+ if now <= d.lastAllocatedCreatedAt {
306+ now = d.lastAllocatedCreatedAt + 1
307+ }
308+ d.lastAllocatedCreatedAt = now
309+ return now
310+}
311+
312+func (d *Daemon) nowUnixNano() int64 {
313+ return d.clock.Now().UnixNano()
314+}
315+
296 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the316 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
297 // render debounce, and log receives diagnostics (defaults to slog.Default).317 // render debounce, and log receives diagnostics (defaults to slog.Default).
298 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {318 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -357,12 +377,21 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
357 d.log.Warn("loading persisted sessions failed", "err", err)377 d.log.Warn("loading persisted sessions failed", "err", err)
358 } else {378 } else {
359 var maxSeq uint64379 var maxSeq uint64
380+ var maxCreatedAt int64
381+ hasCreatedAt := false
360 for _, r := range records {382 for _, r := range records {
361 d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}383 d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
384+ if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
385+ maxCreatedAt = r.CreatedAt
386+ hasCreatedAt = true
387+ }
362 if r.LastUsedSeq > maxSeq {388 if r.LastUsedSeq > maxSeq {
363 maxSeq = r.LastUsedSeq389 maxSeq = r.LastUsedSeq
364 }390 }
365 }391 }
392+ if hasCreatedAt {
393+ d.lastAllocatedCreatedAt = maxCreatedAt
394+ }
366 d.mruSeq.Store(maxSeq)395 d.mruSeq.Store(maxSeq)
367 }396 }
368 return d397 return d
M internal/usecase/daemon/picker.go +71 -3
@@ -385,19 +385,59 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
385385
386386 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {
387387 if target.Stopped {
388+ if d.resumeStoppedAndSwitch(sess, ac, target) {
389+ return true
390+ }
391+ // The target may have become active after the palette snapshot. Resolve
392+ // it by name, but leave lifecycle validation to the locked hand-off.
393+ if target.Name == "" {
394+ return false
395+ }
396+ d.mu.Lock()
397+ targetSess := d.findByNameLocked(target.Name)
398+ d.mu.Unlock()
399+ if targetSess == nil {
400+ return false
401+ }
402+ target.Session, target.Stopped = targetSess.id, false
403+ if d.switchToActiveTarget(sess, ac, targetSess, target) {
404+ return true
405+ }
406+ // The active target may have stopped between lookup and hand-off.
407+ target.Stopped = true
388408 return d.resumeStoppedAndSwitch(sess, ac, target)
389409 }
410+
390411 targetSess := d.sessionByID(target.Session)
391412 if targetSess == nil {
413+ if target.Name != "" {
414+ // The target may have stopped after the snapshot. The resume path
415+ // checks the expected lifecycle under d.mu before creating anything.
416+ target.Stopped = true
417+ return d.resumeStoppedAndSwitch(sess, ac, target)
418+ }
392419 d.invalidateRender(sess, ac, true, "picker.go")
393420 return false
394421 }
422+ if d.switchToActiveTarget(sess, ac, targetSess, target) {
423+ return true
424+ }
425+ if target.Name != "" {
426+ // The active target may have stopped between lookup and hand-off.
427+ target.Stopped = true
428+ return d.resumeStoppedAndSwitch(sess, ac, target)
429+ }
430+ return false
431+}
432+
433+func (d *Daemon) switchToActiveTarget(sess *session, ac *attachedClient, targetSess *session, target picker.Target) bool {
395434 if targetSess == sess {
396- if sess.switchTab(target.TabIndex) {
435+ switched := d.switchCurrentTarget(sess, target)
436+ if switched {
397437 d.activateTab(sess, sess.activeTab())
398438 }
399439 d.invalidateRender(sess, ac, true, "picker.go")
400- return true
440+ return switched
401441 }
402442 old := d.stealClientForTarget(sess, ac, targetSess, target)
403443 if ac.currentSession() != targetSess {
@@ -414,12 +454,40 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
414454 return true
415455 }
416456
457+// switchCurrentTarget validates and updates a same-session target as one d.mu
458+// commit, matching cross-session hand-off lifecycle semantics.
459+func (d *Daemon) switchCurrentTarget(sess *session, target picker.Target) bool {
460+ d.mu.Lock()
461+ defer d.mu.Unlock()
462+ if d.sessions[target.Session] != sess {
463+ return false
464+ }
465+ sess.mu.Lock()
466+ defer sess.mu.Unlock()
467+ if !targetMatchesLifecycle(target, sess.name, sess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
468+ return false
469+ }
470+ sess.active = target.TabIndex
471+ return true
472+}
473+
474+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
475+ return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
476+}
477+
417478 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
418479 d.mu.Lock()
419480 if d.sessions[target.Session] != targetSess {
420481 d.mu.Unlock()
421482 return nil
422483 }
484+ targetSess.mu.Lock()
485+ matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)
486+ targetSess.mu.Unlock()
487+ if !matches {
488+ d.mu.Unlock()
489+ return nil
490+ }
423491 from.mu.Lock()
424492 if from.client != ac {
425493 from.mu.Unlock()
@@ -458,7 +526,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
458526 func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
459527 d.mu.Lock()
460528 stopped, ok := d.stopped[target.Name]
461- if !ok || stopped.purging {
529+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
462530 d.mu.Unlock()
463531 d.invalidateRender(from, ac, true, "picker.go")
464532 return false
@@ -385,19 +385,59 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
385 385
386 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {386 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {
387 if target.Stopped {387 if target.Stopped {
388+ if d.resumeStoppedAndSwitch(sess, ac, target) {
389+ return true
390+ }
391+ // The target may have become active after the palette snapshot. Resolve
392+ // it by name, but leave lifecycle validation to the locked hand-off.
393+ if target.Name == "" {
394+ return false
395+ }
396+ d.mu.Lock()
397+ targetSess := d.findByNameLocked(target.Name)
398+ d.mu.Unlock()
399+ if targetSess == nil {
400+ return false
401+ }
402+ target.Session, target.Stopped = targetSess.id, false
403+ if d.switchToActiveTarget(sess, ac, targetSess, target) {
404+ return true
405+ }
406+ // The active target may have stopped between lookup and hand-off.
407+ target.Stopped = true
388 return d.resumeStoppedAndSwitch(sess, ac, target)408 return d.resumeStoppedAndSwitch(sess, ac, target)
389 }409 }
410+
390 targetSess := d.sessionByID(target.Session)411 targetSess := d.sessionByID(target.Session)
391 if targetSess == nil {412 if targetSess == nil {
413+ if target.Name != "" {
414+ // The target may have stopped after the snapshot. The resume path
415+ // checks the expected lifecycle under d.mu before creating anything.
416+ target.Stopped = true
417+ return d.resumeStoppedAndSwitch(sess, ac, target)
418+ }
392 d.invalidateRender(sess, ac, true, "picker.go")419 d.invalidateRender(sess, ac, true, "picker.go")
393 return false420 return false
394 }421 }
422+ if d.switchToActiveTarget(sess, ac, targetSess, target) {
423+ return true
424+ }
425+ if target.Name != "" {
426+ // The active target may have stopped between lookup and hand-off.
427+ target.Stopped = true
428+ return d.resumeStoppedAndSwitch(sess, ac, target)
429+ }
430+ return false
431+}
432+
433+func (d *Daemon) switchToActiveTarget(sess *session, ac *attachedClient, targetSess *session, target picker.Target) bool {
395 if targetSess == sess {434 if targetSess == sess {
396- if sess.switchTab(target.TabIndex) {435+ switched := d.switchCurrentTarget(sess, target)
436+ if switched {
397 d.activateTab(sess, sess.activeTab())437 d.activateTab(sess, sess.activeTab())
398 }438 }
399 d.invalidateRender(sess, ac, true, "picker.go")439 d.invalidateRender(sess, ac, true, "picker.go")
400- return true440+ return switched
401 }441 }
402 old := d.stealClientForTarget(sess, ac, targetSess, target)442 old := d.stealClientForTarget(sess, ac, targetSess, target)
403 if ac.currentSession() != targetSess {443 if ac.currentSession() != targetSess {
@@ -414,12 +454,40 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
414 return true454 return true
415 }455 }
416 456
457+// switchCurrentTarget validates and updates a same-session target as one d.mu
458+// commit, matching cross-session hand-off lifecycle semantics.
459+func (d *Daemon) switchCurrentTarget(sess *session, target picker.Target) bool {
460+ d.mu.Lock()
461+ defer d.mu.Unlock()
462+ if d.sessions[target.Session] != sess {
463+ return false
464+ }
465+ sess.mu.Lock()
466+ defer sess.mu.Unlock()
467+ if !targetMatchesLifecycle(target, sess.name, sess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
468+ return false
469+ }
470+ sess.active = target.TabIndex
471+ return true
472+}
473+
474+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
475+ return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
476+}
477+
417 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {478 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
418 d.mu.Lock()479 d.mu.Lock()
419 if d.sessions[target.Session] != targetSess {480 if d.sessions[target.Session] != targetSess {
420 d.mu.Unlock()481 d.mu.Unlock()
421 return nil482 return nil
422 }483 }
484+ targetSess.mu.Lock()
485+ matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)
486+ targetSess.mu.Unlock()
487+ if !matches {
488+ d.mu.Unlock()
489+ return nil
490+ }
423 from.mu.Lock()491 from.mu.Lock()
424 if from.client != ac {492 if from.client != ac {
425 from.mu.Unlock()493 from.mu.Unlock()
@@ -458,7 +526,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
458 func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {526 func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
459 d.mu.Lock()527 d.mu.Lock()
460 stopped, ok := d.stopped[target.Name]528 stopped, ok := d.stopped[target.Name]
461- if !ok || stopped.purging {529+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
462 d.mu.Unlock()530 d.mu.Unlock()
463 d.invalidateRender(from, ac, true, "picker.go")531 d.invalidateRender(from, ac, true, "picker.go")
464 return false532 return false
M internal/usecase/daemon/session.go +14 -13
@@ -159,17 +159,26 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
159159
160160 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
161161 d.nextID++
162- createdAt := time.Now().UnixNano()
162+ stopped, resuming := d.stopped[name]
163+ var createdAt int64
164+ if !ephemeral && resuming {
165+ // A stopped session is the same lifecycle when resumed; it must retain
166+ // the persisted identity rather than receive a fresh timestamp.
167+ createdAt = stopped.createdAt
168+ if createdAt > d.lastAllocatedCreatedAt {
169+ d.lastAllocatedCreatedAt = createdAt
170+ }
171+ } else {
172+ createdAt = d.allocateLifecycleCreatedAtLocked()
173+ }
163174
164175 sctx, cancel := context.WithCancel(d.serveCtx)
165176 for _, tb := range tabs {
166177 tb.ctx, tb.cancel = context.WithCancel(sctx)
167178 }
168179 lastUsedSeq := uint64(0)
169- if !ephemeral {
170- if stopped, ok := d.stopped[name]; ok {
171- lastUsedSeq = stopped.lastUsedSeq
172- }
180+ if !ephemeral && resuming {
181+ lastUsedSeq = stopped.lastUsedSeq
173182 }
174183 sess := &session{
175184 id: id,
@@ -495,10 +504,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
495504 oldName := sess.name
496505 wasEphemeral := sess.ephemeral
497506 createdAt := sess.createdAt
498- if createdAt == 0 {
499- createdAt = time.Now().UnixNano()
500- sess.createdAt = createdAt
501- }
502507 lastUsedSeq := sess.mruAt.Load()
503508 if wasEphemeral || oldName != name {
504509 record := sess.persistRecordLocked(time.Now().UnixNano())
@@ -572,10 +577,6 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
572577
573578 func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
574579 createdAt := s.createdAt
575- if createdAt == 0 {
576- createdAt = updatedAt
577- s.createdAt = createdAt
578- }
579580 tabNames := make([]string, len(s.tabs))
580581 lastCustom := -1
581582 for i, tb := range s.tabs {
@@ -159,17 +159,26 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
159 159
160 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))160 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
161 d.nextID++161 d.nextID++
162- createdAt := time.Now().UnixNano()162+ stopped, resuming := d.stopped[name]
163+ var createdAt int64
164+ if !ephemeral && resuming {
165+ // A stopped session is the same lifecycle when resumed; it must retain
166+ // the persisted identity rather than receive a fresh timestamp.
167+ createdAt = stopped.createdAt
168+ if createdAt > d.lastAllocatedCreatedAt {
169+ d.lastAllocatedCreatedAt = createdAt
170+ }
171+ } else {
172+ createdAt = d.allocateLifecycleCreatedAtLocked()
173+ }
163 174
164 sctx, cancel := context.WithCancel(d.serveCtx)175 sctx, cancel := context.WithCancel(d.serveCtx)
165 for _, tb := range tabs {176 for _, tb := range tabs {
166 tb.ctx, tb.cancel = context.WithCancel(sctx)177 tb.ctx, tb.cancel = context.WithCancel(sctx)
167 }178 }
168 lastUsedSeq := uint64(0)179 lastUsedSeq := uint64(0)
169- if !ephemeral {180+ if !ephemeral && resuming {
170- if stopped, ok := d.stopped[name]; ok {181+ lastUsedSeq = stopped.lastUsedSeq
171- lastUsedSeq = stopped.lastUsedSeq
172- }
173 }182 }
174 sess := &session{183 sess := &session{
175 id: id,184 id: id,
@@ -495,10 +504,6 @@ func (d *Daemon) renameSession(sess *session, name string) error {
495 oldName := sess.name504 oldName := sess.name
496 wasEphemeral := sess.ephemeral505 wasEphemeral := sess.ephemeral
497 createdAt := sess.createdAt506 createdAt := sess.createdAt
498- if createdAt == 0 {
499- createdAt = time.Now().UnixNano()
500- sess.createdAt = createdAt
501- }
502 lastUsedSeq := sess.mruAt.Load()507 lastUsedSeq := sess.mruAt.Load()
503 if wasEphemeral || oldName != name {508 if wasEphemeral || oldName != name {
504 record := sess.persistRecordLocked(time.Now().UnixNano())509 record := sess.persistRecordLocked(time.Now().UnixNano())
@@ -572,10 +577,6 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
572 577
573 func (s *session) persistRecordLocked(updatedAt int64) persist.Record {578 func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
574 createdAt := s.createdAt579 createdAt := s.createdAt
575- if createdAt == 0 {
576- createdAt = updatedAt
577- s.createdAt = createdAt
578- }
579 tabNames := make([]string, len(s.tabs))580 tabNames := make([]string, len(s.tabs))
580 lastCustom := -1581 lastCustom := -1
581 for i, tb := range s.tabs {582 for i, tb := range s.tabs {
M internal/usecase/daemon/session_test.go +119 -0
@@ -1498,3 +1498,122 @@ func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
14981498 })
14991499 }
15001500 }
1501+
1502+type lifecycleClock struct {
1503+ nows []time.Time
1504+ next int
1505+}
1506+
1507+func (c *lifecycleClock) Now() time.Time {
1508+ i := min(c.next, len(c.nows)-1)
1509+ c.next++
1510+ return c.nows[i]
1511+}
1512+func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
1513+
1514+func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) {
1515+ ptys := make([]ports.PTY, 3)
1516+ var releases []func()
1517+ for i := range ptys {
1518+ p, release := newBlockingPTY(t)
1519+ ptys[i] = p
1520+ releases = append(releases, release)
1521+ }
1522+ defer releaseAll(releases)
1523+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}}
1524+ d := newTestDaemon(t, newFactorySeq(t, ptys...), clock)
1525+ sz := domain.Size{Cols: 80, Rows: 24}
1526+
1527+ var got []int64
1528+ for _, name := range []string{"one", "two", "three"} {
1529+ sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
1530+ require.NoError(t, err)
1531+ got = append(got, sess.createdAt)
1532+ }
1533+ require.Equal(t, []int64{100, 101, 102}, got)
1534+}
1535+
1536+func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1537+ store, _ := newMockStore(t)
1538+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1539+ require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1540+
1541+ p, release := newBlockingPTY(t)
1542+ defer release()
1543+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1544+ // Constructing with persistence must establish the lifecycle high-water mark.
1545+ d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1546+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
1547+ t.Cleanup(d.serveCancel)
1548+
1549+ sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1550+ require.NoError(t, err)
1551+ require.Equal(t, int64(901), sess.createdAt)
1552+}
1553+
1554+func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) {
1555+ fromPTY, releaseFrom := newBlockingPTY(t)
1556+ targetPTY, releaseTarget := newBlockingPTY(t)
1557+ defer releaseFrom()
1558+ defer releaseTarget()
1559+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1560+ store, state := newMockStore(t)
1561+ WithStore(store)(d)
1562+ d.ptys = newFactory(t, targetPTY)
1563+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77}
1564+
1565+ require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
1566+ resumed := ac.currentSession()
1567+ require.Equal(t, int64(77), resumed.createdAt)
1568+ require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt)
1569+}
1570+
1571+func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) {
1572+ t.Run("active replacement is rejected", func(t *testing.T) {
1573+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1574+ defer releaseAll(releases)
1575+ target := d.sessions["recent"]
1576+ target.createdAt = 22
1577+ expected := int64(21)
1578+
1579+ require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1580+ require.Same(t, from, ac.currentSession())
1581+ })
1582+
1583+ t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) {
1584+ fromPTY, releaseFrom := newBlockingPTY(t)
1585+ targetPTY, releaseTarget := newBlockingPTY(t)
1586+ defer releaseFrom()
1587+ defer releaseTarget()
1588+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1589+ d.ptys = newFactory(t, targetPTY)
1590+ expected := int64(31)
1591+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
1592+
1593+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1594+ require.Equal(t, "target", ac.currentSession().name)
1595+ require.Equal(t, expected, ac.currentSession().createdAt)
1596+ })
1597+
1598+ t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
1599+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1600+ defer releaseAll(releases)
1601+ target := d.sessions["recent"]
1602+ target.createdAt = 41
1603+ expected := int64(41)
1604+
1605+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1606+ require.Same(t, target, ac.currentSession())
1607+ })
1608+
1609+ t.Run("stopped deletion and recreation is rejected", func(t *testing.T) {
1610+ fromPTY, releaseFrom := newBlockingPTY(t)
1611+ defer releaseFrom()
1612+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1613+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
1614+ expected := int64(51)
1615+
1616+ require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1617+ require.Same(t, from, ac.currentSession())
1618+ })
1619+}
@@ -1498,3 +1498,122 @@ func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
1498 })1498 })
1499 }1499 }
1500 }1500 }
1501+
1502+type lifecycleClock struct {
1503+ nows []time.Time
1504+ next int
1505+}
1506+
1507+func (c *lifecycleClock) Now() time.Time {
1508+ i := min(c.next, len(c.nows)-1)
1509+ c.next++
1510+ return c.nows[i]
1511+}
1512+func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
1513+
1514+func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) {
1515+ ptys := make([]ports.PTY, 3)
1516+ var releases []func()
1517+ for i := range ptys {
1518+ p, release := newBlockingPTY(t)
1519+ ptys[i] = p
1520+ releases = append(releases, release)
1521+ }
1522+ defer releaseAll(releases)
1523+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}}
1524+ d := newTestDaemon(t, newFactorySeq(t, ptys...), clock)
1525+ sz := domain.Size{Cols: 80, Rows: 24}
1526+
1527+ var got []int64
1528+ for _, name := range []string{"one", "two", "three"} {
1529+ sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
1530+ require.NoError(t, err)
1531+ got = append(got, sess.createdAt)
1532+ }
1533+ require.Equal(t, []int64{100, 101, 102}, got)
1534+}
1535+
1536+func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1537+ store, _ := newMockStore(t)
1538+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1539+ require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1540+
1541+ p, release := newBlockingPTY(t)
1542+ defer release()
1543+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1544+ // Constructing with persistence must establish the lifecycle high-water mark.
1545+ d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1546+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
1547+ t.Cleanup(d.serveCancel)
1548+
1549+ sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1550+ require.NoError(t, err)
1551+ require.Equal(t, int64(901), sess.createdAt)
1552+}
1553+
1554+func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) {
1555+ fromPTY, releaseFrom := newBlockingPTY(t)
1556+ targetPTY, releaseTarget := newBlockingPTY(t)
1557+ defer releaseFrom()
1558+ defer releaseTarget()
1559+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1560+ store, state := newMockStore(t)
1561+ WithStore(store)(d)
1562+ d.ptys = newFactory(t, targetPTY)
1563+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77}
1564+
1565+ require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
1566+ resumed := ac.currentSession()
1567+ require.Equal(t, int64(77), resumed.createdAt)
1568+ require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt)
1569+}
1570+
1571+func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) {
1572+ t.Run("active replacement is rejected", func(t *testing.T) {
1573+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1574+ defer releaseAll(releases)
1575+ target := d.sessions["recent"]
1576+ target.createdAt = 22
1577+ expected := int64(21)
1578+
1579+ require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1580+ require.Same(t, from, ac.currentSession())
1581+ })
1582+
1583+ t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) {
1584+ fromPTY, releaseFrom := newBlockingPTY(t)
1585+ targetPTY, releaseTarget := newBlockingPTY(t)
1586+ defer releaseFrom()
1587+ defer releaseTarget()
1588+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1589+ d.ptys = newFactory(t, targetPTY)
1590+ expected := int64(31)
1591+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
1592+
1593+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1594+ require.Equal(t, "target", ac.currentSession().name)
1595+ require.Equal(t, expected, ac.currentSession().createdAt)
1596+ })
1597+
1598+ t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
1599+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1600+ defer releaseAll(releases)
1601+ target := d.sessions["recent"]
1602+ target.createdAt = 41
1603+ expected := int64(41)
1604+
1605+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1606+ require.Same(t, target, ac.currentSession())
1607+ })
1608+
1609+ t.Run("stopped deletion and recreation is rejected", func(t *testing.T) {
1610+ fromPTY, releaseFrom := newBlockingPTY(t)
1611+ defer releaseFrom()
1612+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1613+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
1614+ expected := int64(51)
1615+
1616+ require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1617+ require.Same(t, from, ac.currentSession())
1618+ })
1619+}
M internal/usecase/picker/model.go +4 -0
@@ -58,6 +58,10 @@ type Target struct {
5858 Name string
5959 TabIndex int
6060 Stopped bool
61+ // ExpectedCreatedAt optionally pins this target to a particular named
62+ // session lifecycle. Callers that obtain a snapshot outside the daemon use
63+ // it to reject a same-name replacement at commit time.
64+ ExpectedCreatedAt *int64
6165 }
6266
6367 // Preview is a bounded view of the selected pane's visible frame.
@@ -58,6 +58,10 @@ type Target struct {
58 Name string58 Name string
59 TabIndex int59 TabIndex int
60 Stopped bool60 Stopped bool
61+ // ExpectedCreatedAt optionally pins this target to a particular named
62+ // session lifecycle. Callers that obtain a snapshot outside the daemon use
63+ // it to reject a same-name replacement at commit time.
64+ ExpectedCreatedAt *int64
61 }65 }
62 66
63 // Preview is a bounded view of the selected pane's visible frame.67 // Preview is a bounded view of the selected pane's visible frame.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close