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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 67 | 67 | sessions map[domain.SessionID]*session | |
| 68 | 68 | stopped map[string]stoppedSession | |
| 69 | 69 | 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 | |
| 71 | 75 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 72 | 76 | // atomically with the event that makes shutdown inevitable (the registry | |
| 73 | 77 | // 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 | 316 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | |
| 297 | 317 | // render debounce, and log receives diagnostics (defaults to slog.Default). | |
| 298 | 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 | 377 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 358 | 378 | } else { | |
| 359 | 379 | var maxSeq uint64 | |
| 380 | + | var maxCreatedAt int64 | |
| 381 | + | hasCreatedAt := false | |
| 360 | 382 | for _, r := range records { | |
| 361 | 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 | 388 | if r.LastUsedSeq > maxSeq { | |
| 363 | 389 | maxSeq = r.LastUsedSeq | |
| 364 | 390 | } | |
| 365 | 391 | } | |
| 392 | + | if hasCreatedAt { | |
| 393 | + | d.lastAllocatedCreatedAt = maxCreatedAt | |
| 394 | + | } | |
| 366 | 395 | d.mruSeq.Store(maxSeq) | |
| 367 | 396 | } | |
| 368 | 397 | return d | |
| @@ -67,7 +67,11 @@ type Daemon struct { | |||||
| 67 | sessions map[domain.SessionID]*session | 67 | sessions map[domain.SessionID]*session | ||
| 68 | stopped map[string]stoppedSession | 68 | stopped map[string]stoppedSession | ||
| 69 | nextID uint64 | 69 | 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 | |||
| 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 registry | 76 | // atomically with the event that makes shutdown inevitable (the registry | ||
| 73 | // emptying in killSession, or shutdownAll starting), and checked by route | 77 | // 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 the | 316 | // 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 uint64 | 379 | 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.LastUsedSeq | 389 | 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 d | 397 | return d | ||
M internal/usecase/daemon/picker.go +71 -3
| @@ -385,19 +385,59 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 385 | 385 | ||
| 386 | 386 | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { | |
| 387 | 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 | 408 | return d.resumeStoppedAndSwitch(sess, ac, target) | |
| 389 | 409 | } | |
| 410 | + | ||
| 390 | 411 | targetSess := d.sessionByID(target.Session) | |
| 391 | 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 | 419 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 393 | 420 | 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 | 434 | if targetSess == sess { | |
| 396 | - | if sess.switchTab(target.TabIndex) { | |
| 435 | + | switched := d.switchCurrentTarget(sess, target) | |
| 436 | + | if switched { | |
| 397 | 437 | d.activateTab(sess, sess.activeTab()) | |
| 398 | 438 | } | |
| 399 | 439 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 400 | - | return true | |
| 440 | + | return switched | |
| 401 | 441 | } | |
| 402 | 442 | old := d.stealClientForTarget(sess, ac, targetSess, target) | |
| 403 | 443 | if ac.currentSession() != targetSess { | |
| @@ -414,12 +454,40 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||
| 414 | 454 | 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 | 478 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 418 | 479 | d.mu.Lock() | |
| 419 | 480 | if d.sessions[target.Session] != targetSess { | |
| 420 | 481 | d.mu.Unlock() | |
| 421 | 482 | 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 | 491 | from.mu.Lock() | |
| 424 | 492 | if from.client != ac { | |
| 425 | 493 | from.mu.Unlock() | |
| @@ -458,7 +526,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 458 | 526 | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 459 | 527 | d.mu.Lock() | |
| 460 | 528 | stopped, ok := d.stopped[target.Name] | |
| 461 | - | if !ok || stopped.purging { | |
| 529 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 462 | 530 | d.mu.Unlock() | |
| 463 | 531 | d.invalidateRender(from, ac, true, "picker.go") | |
| 464 | 532 | 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 false | 420 | 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 true | 440 | + | 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 true | 454 | 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 nil | 482 | 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 false | 532 | 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 | |||
| 159 | 159 | ||
| 160 | 160 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 161 | 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 | 175 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 165 | 176 | for _, tb := range tabs { | |
| 166 | 177 | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 167 | 178 | } | |
| 168 | 179 | 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 | |
| 173 | 182 | } | |
| 174 | 183 | sess := &session{ | |
| 175 | 184 | id: id, | |
| @@ -495,10 +504,6 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 495 | 504 | oldName := sess.name | |
| 496 | 505 | wasEphemeral := sess.ephemeral | |
| 497 | 506 | createdAt := sess.createdAt | |
| 498 | - | if createdAt == 0 { | |
| 499 | - | createdAt = time.Now().UnixNano() | |
| 500 | - | sess.createdAt = createdAt | |
| 501 | - | } | |
| 502 | 507 | lastUsedSeq := sess.mruAt.Load() | |
| 503 | 508 | if wasEphemeral || oldName != name { | |
| 504 | 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 | 578 | func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |
| 574 | 579 | createdAt := s.createdAt | |
| 575 | - | if createdAt == 0 { | |
| 576 | - | createdAt = updatedAt | |
| 577 | - | s.createdAt = createdAt | |
| 578 | - | } | |
| 579 | 580 | tabNames := make([]string, len(s.tabs)) | |
| 580 | 581 | lastCustom := -1 | |
| 581 | 582 | 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.name | 504 | oldName := sess.name | ||
| 496 | wasEphemeral := sess.ephemeral | 505 | wasEphemeral := sess.ephemeral | ||
| 497 | createdAt := sess.createdAt | 506 | 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.createdAt | 579 | 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 := -1 | 581 | 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) { | |||
| 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 | + | } | |
| @@ -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 { | |||
| 58 | 58 | Name string | |
| 59 | 59 | TabIndex int | |
| 60 | 60 | 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 | 67 | // Preview is a bounded view of the selected pane's visible frame. | |
| @@ -58,6 +58,10 @@ type Target struct { | |||||
| 58 | Name string | 58 | Name string | ||
| 59 | TabIndex int | 59 | TabIndex int | ||
| 60 | Stopped bool | 60 | 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. | ||