detached 64bb8b2

fix(recovery): preserve promotion lifecycle

commit
64bb8b298f15c6acd37efea9df8024c995b8dacc
author
brice · · 2026-08-11 09:01 UTC
parent
08343ae
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +25 -4
M internal/usecase/daemon/session_rename_purge_test.go +19 -0
@@ -65,6 +65,25 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
6565 require.False(t, identity.Ephemeral)
6666 }
6767
68+func TestEphemeralPromotionPreservesIncarnation(t *testing.T) {
69+ p, release := newBlockingPTY(t)
70+ defer release()
71+ store, state := newMockStore(t)
72+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
73+ WithStore(t, store)(d)
74+
75+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
76+ require.NoError(t, err)
77+ sess.mu.Lock()
78+ original := sess.incarnation
79+ sess.mu.Unlock()
80+ require.NotZero(t, original)
81+
82+ require.NoError(t, d.renameSession(sess, "local"))
83+ require.Equal(t, original, sess.incarnation)
84+ require.Equal(t, original, state.record(t, "local").IncarnationID)
85+}
86+
6887 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
6988 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
7089 repository := &retryablePurgeRepository{}
@@ -65,6 +65,25 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
65 require.False(t, identity.Ephemeral)65 require.False(t, identity.Ephemeral)
66 }66 }
67 67
68+func TestEphemeralPromotionPreservesIncarnation(t *testing.T) {
69+ p, release := newBlockingPTY(t)
70+ defer release()
71+ store, state := newMockStore(t)
72+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
73+ WithStore(t, store)(d)
74+
75+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
76+ require.NoError(t, err)
77+ sess.mu.Lock()
78+ original := sess.incarnation
79+ sess.mu.Unlock()
80+ require.NotZero(t, original)
81+
82+ require.NoError(t, d.renameSession(sess, "local"))
83+ require.Equal(t, original, sess.incarnation)
84+ require.Equal(t, original, state.record(t, "local").IncarnationID)
85+}
86+
68 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {87 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
69 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})88 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
70 repository := &retryablePurgeRepository{}89 repository := &retryablePurgeRepository{}
M internal/usecase/recovery/coordinator.go +6 -4
@@ -115,11 +115,13 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
115115 } else if exists {
116116 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)
117117 }
118- id, err := domain.NewIncarnationID(c.random)
119- if err != nil {
120- return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
118+ if record.IncarnationID == (domain.IncarnationID{}) {
119+ id, err := domain.NewIncarnationID(c.random)
120+ if err != nil {
121+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
122+ }
123+ record.IncarnationID = id
121124 }
122- record.IncarnationID = id
123125 record.Committed = nil
124126 record.DegradedReason = ""
125127 if err := record.Validate(); err != nil {
@@ -115,11 +115,13 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
115 } else if exists {115 } else if exists {
116 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)116 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)
117 }117 }
118- id, err := domain.NewIncarnationID(c.random)118+ if record.IncarnationID == (domain.IncarnationID{}) {
119- if err != nil {119+ id, err := domain.NewIncarnationID(c.random)
120- return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)120+ if err != nil {
121+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
122+ }
123+ record.IncarnationID = id
121 }124 }
122- record.IncarnationID = id
123 record.Committed = nil125 record.Committed = nil
124 record.DegradedReason = ""126 record.DegradedReason = ""
125 if err := record.Validate(); err != nil {127 if err := record.Validate(); err != nil {

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