detached f571e9b

Merge pull request #210 from bnema/fix/hybrid-remote-stopped-resurrection

fix(remote): resurrect inactive sessions across hosts
commit
f571e9bb840fca6d635487d9d2aaca1afd75636c
author
bnema · · 2026-08-24 17:22 UTC
committer
GitHub · · 2026-08-24 17:22 UTC
merge of
6305842 + 5664af3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
45 files · +1530 -906
M .gitignore +1 -0
@@ -2,6 +2,7 @@
22 *.test
33 .superpowers/
44 docs/superpowers/
5+.agent-reviews/
56 .worktrees/
67 scripts/demo/out/
78 /.worktrees/
M docs/durable-session-recovery.md +1 -1
@@ -4,7 +4,7 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle
44
55 ## Session states
66
7-`stopped` is safe fresh metadata, `restoring` is validation in progress, and `degraded` preserves uncertain state without starting a replacement shell. A degraded session remains visible in `vev ls`, but attach is refused until an explicit recovery action succeeds.
7+`stopped` is safe fresh metadata, `restoring` is validation in progress, and `degraded` preserves uncertain state without starting a replacement shell. A degraded session remains visible in `vev ls`, but attach is refused until an explicit recovery action succeeds. A session reserved for durable purge is hidden from listings and pickers until deletion either completes or leaves a retained broken record.
88
99 ## Recovery commands
1010
M docs/remote-resilience.md +4 -4
@@ -61,10 +61,10 @@ version 5 records restore normally. Older binaries reject version 5 records, so
6161 a downgrade also resets sessions when the older binary can read the catalogue,
6262 or fails closed when it cannot. Back up the vev state before a rollback.
6363
64-Remote catalogues are bounded and versioned. Complete catalogues contain ordered
65-tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped
66-tab metadata is retained across daemon restart; legacy count-only catalogues are
67-read-only compatibility rows and cannot provide exact tab identity.
64+Remote catalogues are bounded and versioned. The current schema is mandatory
65+and contains lifecycle IDs, ordered typed tab records, state, active tab,
66+attachment state, and MRU sequence. Peers without the exact schema are rejected
67+as version-incompatible rather than exposed as partial picker rows.
6868
6969 Remote picker previews are bounded styled-cell snapshots fetched without
7070 attachment or PTY mutation. They are held in a short-lived in-memory cache only;
M internal/adapters/remote/catalog.go +82 -39
@@ -30,7 +30,6 @@ var (
3030 errCatalogDecode = errors.New("remote catalog: invalid JSON")
3131 errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
3232 errCatalogRequired = errors.New("remote catalog: missing required fields")
33- errCatalogSession = errors.New("remote catalog: invalid session")
3433 errCatalogSSH = errors.New("remote catalog: ssh command failed")
3534 errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
3635 )
@@ -112,65 +111,118 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa
112111 return catalog, nil
113112 }
114113
114+type catalogVersionEnvelope struct {
115+ ProtocolVersion *uint16 `json:"protocol_version"`
116+ SchemaVersion *uint16 `json:"schema_version"`
117+ Sessions json.RawMessage `json:"sessions"`
118+}
119+
115120 type catalogEnvelope struct {
116- ProtocolVersion *uint16 `json:"protocol_version"`
117- SchemaVersion *uint16 `json:"schema_version"`
118- Sessions *[]ports.RemoteCatalogSession `json:"sessions"`
121+ ProtocolVersion *uint16 `json:"protocol_version"`
122+ SchemaVersion *uint16 `json:"schema_version"`
123+ Sessions *[]catalogSessionEnvelope `json:"sessions"`
124+}
125+
126+type catalogSessionEnvelope struct {
127+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
128+ Name *string `json:"name"`
129+ State *ports.RemoteCatalogSessionState `json:"state"`
130+ Ephemeral *bool `json:"ephemeral"`
131+ Tabs *[]catalogTabEnvelope `json:"tabs"`
132+ Attached *bool `json:"attached"`
133+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
134+ ActiveTabID string `json:"active_tab_id,omitempty"`
135+ Reason string `json:"reason,omitempty"`
136+}
137+
138+type catalogTabEnvelope struct {
139+ ID *string `json:"id"`
140+ Index *uint16 `json:"index"`
141+ Name *string `json:"name"`
142+ Detail string `json:"detail,omitempty"`
143+ Attention bool `json:"attention,omitempty"`
119144 }
120145
121146 func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
122- dec := json.NewDecoder(bytes.NewReader(raw))
123- var envelope catalogEnvelope
124- if err := dec.Decode(&envelope); err != nil {
125- return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogDecode, err)
147+ var version catalogVersionEnvelope
148+ if err := decodeCatalogEnvelope(raw, false, &version); err != nil {
149+ return ports.RemoteCatalog{}, err
126150 }
127- if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
151+ if version.ProtocolVersion == nil {
128152 return ports.RemoteCatalog{}, errCatalogRequired
129153 }
130- if err := rejectTrailingJSON(dec); err != nil {
131- return ports.RemoteCatalog{}, err
132- }
133-
134- schemaVersion := uint16(0)
135- if envelope.SchemaVersion != nil {
136- schemaVersion = *envelope.SchemaVersion
154+ if version.SchemaVersion == nil {
155+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
156+ Got: 0,
157+ Want: ports.RemoteCatalogSchemaVersion,
158+ Kind: "catalog",
159+ }
137160 }
138- if *envelope.ProtocolVersion != ports.ProtocolVersion {
161+ if *version.ProtocolVersion != ports.ProtocolVersion {
139162 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
140- Got: *envelope.ProtocolVersion,
163+ Got: *version.ProtocolVersion,
141164 Want: ports.ProtocolVersion,
142165 Kind: "protocol",
143166 }
144167 }
145- if schemaVersion != 0 && schemaVersion != ports.RemoteCatalogSchemaVersion {
168+ if *version.SchemaVersion != ports.RemoteCatalogSchemaVersion {
146169 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
147- Got: schemaVersion,
170+ Got: *version.SchemaVersion,
148171 Want: ports.RemoteCatalogSchemaVersion,
149172 Kind: "catalog",
150173 }
151174 }
152175
153- sessions := *envelope.Sessions
154- if sessions == nil {
155- sessions = []ports.RemoteCatalogSession{}
176+ var envelope catalogEnvelope
177+ if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil {
178+ return ports.RemoteCatalog{}, err
179+ }
180+ if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil {
181+ return ports.RemoteCatalog{}, errCatalogRequired
182+ }
183+ sessions := make([]ports.RemoteCatalogSession, 0, len(*envelope.Sessions))
184+ for _, session := range *envelope.Sessions {
185+ if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
186+ return ports.RemoteCatalog{}, errCatalogRequired
187+ }
188+ tabs := make([]ports.RemoteCatalogTab, 0, len(*session.Tabs))
189+ for _, tab := range *session.Tabs {
190+ if tab.ID == nil || tab.Index == nil || tab.Name == nil {
191+ return ports.RemoteCatalog{}, errCatalogRequired
192+ }
193+ tabs = append(tabs, ports.RemoteCatalogTab{
194+ ID: *tab.ID, Index: *tab.Index, Name: *tab.Name,
195+ Detail: tab.Detail, Attention: tab.Attention,
196+ })
197+ }
198+ sessions = append(sessions, ports.RemoteCatalogSession{
199+ LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State,
200+ Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached,
201+ LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason,
202+ })
156203 }
157204 catalog := ports.RemoteCatalog{
158205 ProtocolVersion: *envelope.ProtocolVersion,
159- SchemaVersion: schemaVersion,
206+ SchemaVersion: *envelope.SchemaVersion,
160207 Sessions: sessions,
161208 }
162209 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
163- if schemaVersion == 0 {
164- // Keep the old adapter error taxonomy for pre-parity peers and
165- // direct CLI compatibility. Current-schema payloads retain the
166- // typed validation error for callers that need to classify it.
167- return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err)
168- }
169210 return ports.RemoteCatalog{}, err
170211 }
171212 return catalog, nil
172213 }
173214
215+func decodeCatalogEnvelope(raw []byte, strict bool, dst any) error {
216+ dec := json.NewDecoder(bytes.NewReader(raw))
217+ if strict {
218+ dec.DisallowUnknownFields()
219+ }
220+ if err := dec.Decode(dst); err != nil {
221+ return fmt.Errorf("%w: %w", errCatalogDecode, err)
222+ }
223+ return rejectTrailingJSON(dec)
224+}
225+
174226 func rejectTrailingJSON(dec *json.Decoder) error {
175227 var extra json.RawMessage
176228 err := dec.Decode(&extra)
@@ -183,15 +235,6 @@ func rejectTrailingJSON(dec *json.Decoder) error {
183235 return fmt.Errorf("%w: %w", errCatalogTrailing, err)
184236 }
185237
186-func validCatalogState(state string) bool {
187- switch state {
188- case "up", "down", "broken":
189- return true
190- default:
191- return false
192- }
193-}
194-
195238 // sanitizeCatalogDiagnostic strips terminal escapes and control bytes while
196239 // preserving useful printable diagnostic text for errors and logs.
197240 func sanitizeCatalogDiagnostic(raw string) string {
M internal/adapters/remote/catalog_cache.go +28 -44
@@ -17,7 +17,7 @@ import (
1717
1818 const (
1919 catalogCacheFileName = "remote-catalog-cache.json"
20- catalogCacheFileVersion = 2
20+ catalogCacheFileVersion = 3
2121 )
2222
2323 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
@@ -35,15 +35,14 @@ type catalogCacheHost struct {
3535 }
3636
3737 type catalogCacheSession struct {
38- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
39- Name *string `json:"name"`
40- State *string `json:"state"`
41- Ephemeral *bool `json:"ephemeral"`
42- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43- Tabs *uint16 `json:"tabs,omitempty"`
44- TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`
45- ActiveTabID *string `json:"active_tab_id,omitempty"`
46- Attached *bool `json:"attached"`
38+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
39+ Name *string `json:"name"`
40+ State *ports.RemoteCatalogSessionState `json:"state"`
41+ Ephemeral *bool `json:"ephemeral"`
42+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43+ Tabs *[]ports.RemoteCatalogTab `json:"tabs"`
44+ ActiveTabID *string `json:"active_tab_id,omitempty"`
45+ Attached *bool `json:"attached"`
4746 }
4847
4948 type fileCatalogCache struct {
@@ -107,32 +106,28 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
107106 Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
108107 }
109108 for _, session := range *host.Sessions {
110- if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil && session.TabList == nil {
109+ if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {
111110 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
112111 }
113- if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {
112+ if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
114113 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
115114 }
115+ tabs := make([]ports.RemoteCatalogTab, len(*session.Tabs))
116+ copy(tabs, *session.Tabs)
116117 decoded := ports.RemoteCatalogSession{
117- Name: *session.Name,
118- State: *session.State,
119- Ephemeral: *session.Ephemeral,
120- Attached: *session.Attached,
118+ LifecycleID: *session.LifecycleID,
119+ Name: *session.Name,
120+ State: *session.State,
121+ Ephemeral: *session.Ephemeral,
122+ Tabs: tabs,
123+ Attached: *session.Attached,
121124 }
122125 if session.LastUsedSeq != nil {
123126 decoded.LastUsedSeq = *session.LastUsedSeq
124127 }
125- if session.LifecycleID != nil {
126- decoded.LifecycleID = *session.LifecycleID
127- }
128128 if session.ActiveTabID != nil {
129129 decoded.ActiveTabID = *session.ActiveTabID
130130 }
131- if session.TabList != nil {
132- decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...)
133- } else {
134- decoded.Tabs = int(*session.Tabs)
135- }
136131 entry.Sessions = append(entry.Sessions, decoded)
137132 }
138133 entries = append(entries, entry)
@@ -160,26 +155,18 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
160155 state := session.State
161156 ephemeral := session.Ephemeral
162157 attached := session.Attached
163- row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached}
158+ id := session.LifecycleID
159+ tabs := make([]ports.RemoteCatalogTab, len(session.Tabs))
160+ copy(tabs, session.Tabs)
161+ row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached}
164162 if session.LastUsedSeq != 0 {
165163 lastUsed := session.LastUsedSeq
166164 row.LastUsedSeq = &lastUsed
167165 }
168- if session.LifecycleID != (domain.SessionLifecycleID{}) {
169- id := session.LifecycleID
170- row.LifecycleID = &id
171- }
172166 if session.ActiveTabID != "" {
173167 active := session.ActiveTabID
174168 row.ActiveTabID = &active
175169 }
176- if tabs := ports.CatalogTabs(session); tabs != nil {
177- copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...)
178- row.TabList = &copyTabs
179- } else {
180- count := ports.SaturateUint16(ports.CatalogTabCount(session))
181- row.Tabs = &count
182- }
183170 sessions = append(sessions, row)
184171 }
185172 hosts = append(hosts, catalogCacheHost{
@@ -221,13 +208,13 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
221208 }
222209 sessions := make(map[string]struct{}, len(entry.Sessions))
223210 for _, session := range entry.Sessions {
224- if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) {
211+ if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) {
225212 return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
226213 }
227214 if err := domain.ValidateSessionName(session.Name); err != nil {
228215 return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
229216 }
230- if !validCatalogState(session.State) {
217+ if !session.State.Valid() {
231218 return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
232219 }
233220 if _, duplicate := sessions[session.Name]; duplicate {
@@ -235,12 +222,9 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
235222 }
236223 sessions[session.Name] = struct{}{}
237224 copySession := session
238- if tabs := ports.CatalogTabs(session); tabs != nil {
239- copyTabs := make([]ports.RemoteCatalogTab, len(tabs))
240- for i, tab := range tabs {
241- copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
242- }
243- copySession.Tabs = copyTabs
225+ copySession.Tabs = make([]ports.RemoteCatalogTab, len(session.Tabs))
226+ for i, tab := range session.Tabs {
227+ copySession.Tabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
244228 }
245229 copyEntry.Sessions = append(copyEntry.Sessions, copySession)
246230 }
M internal/adapters/remote/catalog_cache_test.go +29 -16
@@ -3,6 +3,7 @@ package remote
33 import (
44 "os"
55 "path/filepath"
6+ "reflect"
67 "slices"
78 "strings"
89 "testing"
@@ -30,8 +31,11 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
3031 Host: "zebra",
3132 FetchedAt: fetchedAt,
3233 Sessions: []ports.RemoteCatalogSession{
33- {Name: "work", State: "up", Tabs: 2},
34- {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
34+ {
35+ LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2",
36+ Tabs: []ports.RemoteCatalogTab{{ID: "work-1", Detail: "dynamic", Attention: true}, {ID: "work-2", Index: 1}},
37+ },
38+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
3539 },
3640 },
3741 {
@@ -58,6 +62,12 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
5862 if alpha < 0 || work < 0 || alpha >= work {
5963 t.Fatalf("stored sessions are not ordered: %s", raw)
6064 }
65+ if !strings.Contains(string(raw), `"version":3`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) {
66+ t.Fatalf("stored cache does not pin the v3 typed-tab contract: %s", raw)
67+ }
68+ if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) {
69+ t.Fatalf("stored cache contains dynamic tab fields: %s", raw)
70+ }
6171 got, err := cache.Load()
6272 if err != nil {
6373 t.Fatalf("Load() error = %v", err)
@@ -72,8 +82,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
7282 Host: "zebra",
7383 FetchedAt: fetchedAt,
7484 Sessions: []ports.RemoteCatalogSession{
75- {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
76- {Name: "work", State: "up", Tabs: 2},
85+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
86+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
7787 },
7888 },
7989 }
@@ -113,17 +123,20 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
113123 name string
114124 raw []byte
115125 }{
116- {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)},
117- {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)},
118- {name: "unknown version", raw: []byte(`{"version":3,"hosts":[]}`)},
119- {name: "missing hosts", raw: []byte(`{"version":1}`)},
120- {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)},
121- {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
122- {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
123- {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
124- {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
125- {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
126- {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
126+ {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
127+ {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
128+ {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)},
129+ {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},
130+ {name: "missing hosts", raw: []byte(`{"version":3}`)},
131+ {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},
132+ {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
133+ {name: "zero fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
134+ {name: "negative fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
135+ {name: "duplicate hosts", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
136+ {name: "missing exact session fields", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":[],"attached":false}]}]}`)},
137+ {name: "count-only tabs", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
138+ {name: "unknown field", raw: []byte(`{"version":3,"hosts":[],"future":true}`)},
139+ {name: "invalid utf8", raw: []byte("{\"version\":3,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127140 }
128141 for _, tc := range cases {
129142 t.Run(tc.name, func(t *testing.T) {
@@ -268,5 +281,5 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
268281 }
269282
270283 func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
271- return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions)
284+ return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)
272285 }
M internal/adapters/remote/catalog_test.go +74 -11
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "os/exec"
9+ "reflect"
910 "strings"
1011 "testing"
1112 "time"
@@ -37,6 +38,7 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
3738 gotArgs = append([]string(nil), args...)
3839 return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
3940 ProtocolVersion: ports.ProtocolVersion,
41+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
4042 Sessions: []ports.RemoteCatalogSession{},
4143 }))
4244 },
@@ -82,8 +84,9 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
8284
8385 valid := ports.RemoteCatalog{
8486 ProtocolVersion: ports.ProtocolVersion,
87+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
8588 Sessions: []ports.RemoteCatalogSession{
86- {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
89+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true},
8790 },
8891 }
8992 validJSON := mustCatalogJSON(t, valid)
@@ -109,18 +112,41 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
109112 },
110113 {
111114 name: "mismatching protocol version",
112- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}),
115+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{}}),
113116 check: func(t *testing.T, err error) {
114117 t.Helper()
115118 var mismatch *ports.RemoteCatalogVersionMismatchError
116119 if !errors.As(err, &mismatch) {
117120 t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
118121 }
119- if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
122+ if mismatch.Kind != "protocol" || mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
120123 t.Fatalf("mismatch = %#v", mismatch)
121124 }
122125 },
123126 },
127+ {
128+ name: "obsolete count-only schema is classified before session decoding",
129+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":2,"sessions":[{"tabs":1}]}`,
130+ ports.ProtocolVersion),
131+ check: func(t *testing.T, err error) {
132+ t.Helper()
133+ var mismatch *ports.RemoteCatalogVersionMismatchError
134+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
135+ t.Fatalf("error = %v, want obsolete catalogue version mismatch", err)
136+ }
137+ },
138+ },
139+ {
140+ name: "unknown catalogue schema",
141+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, ports.ProtocolVersion),
142+ check: func(t *testing.T, err error) {
143+ t.Helper()
144+ var mismatch *ports.RemoteCatalogVersionMismatchError
145+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
146+ t.Fatalf("error = %v, want unknown catalogue version mismatch", err)
147+ }
148+ },
149+ },
124150 {
125151 name: "malformed json",
126152 stdout: `{"protocol_version":`,
@@ -131,6 +157,24 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
131157 stdout: `{"protocol_version":19,"sessions":[`,
132158 wantErr: errCatalogDecode,
133159 },
160+ {
161+ name: "count-only tabs rejected",
162+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`,
163+ ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
164+ wantErr: errCatalogDecode,
165+ },
166+ {
167+ name: "unknown fields rejected",
168+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`,
169+ ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
170+ wantErr: errCatalogDecode,
171+ },
172+ {
173+ name: "missing required session field",
174+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","tabs":[],"attached":false}]}`,
175+ ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
176+ wantErr: errCatalogRequired,
177+ },
134178 {
135179 name: "trailing non-whitespace",
136180 stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
@@ -141,20 +185,35 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
141185 stdout: `{"sessions":[]}` + "\n",
142186 wantErr: errCatalogRequired,
143187 },
188+ {
189+ name: "missing catalogue schema",
190+ stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", ports.ProtocolVersion),
191+ check: func(t *testing.T, err error) {
192+ t.Helper()
193+ var mismatch *ports.RemoteCatalogVersionMismatchError
194+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
195+ t.Fatalf("error = %v, want missing catalogue version mismatch", err)
196+ }
197+ },
198+ },
144199 {
145200 name: "missing sessions",
146- stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
201+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
147202 wantErr: errCatalogRequired,
148203 },
149204 {
150- name: "invalid session name",
151- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "up"}}}),
152- wantErr: errCatalogSession,
205+ name: "invalid session name",
206+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
207+ LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []ports.RemoteCatalogTab{},
208+ }}}),
209+ wantErr: ports.ErrInvalidRemoteCatalog,
153210 },
154211 {
155- name: "invalid session state",
156- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
157- wantErr: errCatalogSession,
212+ name: "invalid session state",
213+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
214+ LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []ports.RemoteCatalogTab{},
215+ }}}),
216+ wantErr: ports.ErrRemoteCatalogUnknownState,
158217 },
159218 {
160219 name: "remote exit with stderr",
@@ -198,11 +257,14 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
198257 if got.ProtocolVersion != tt.want.ProtocolVersion {
199258 t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
200259 }
260+ if got.SchemaVersion != tt.want.SchemaVersion {
261+ t.Fatalf("SchemaVersion = %d, want %d", got.SchemaVersion, tt.want.SchemaVersion)
262+ }
201263 if len(got.Sessions) != len(tt.want.Sessions) {
202264 t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
203265 }
204266 for i := range tt.want.Sessions {
205- if got.Sessions[i] != tt.want.Sessions[i] {
267+ if !reflect.DeepEqual(got.Sessions[i], tt.want.Sessions[i]) {
206268 t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
207269 }
208270 }
@@ -231,6 +293,7 @@ func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
231293 commandCtx = ctx
232294 cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233295 ProtocolVersion: ports.ProtocolVersion,
296+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
234297 Sessions: []ports.RemoteCatalogSession{},
235298 }))
236299 return cmd
M internal/app/remote_hosts.go +3 -3
@@ -205,11 +205,11 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
205205 Attached: session.Attached,
206206 }
207207 switch session.State {
208- case "up":
208+ case ports.RemoteCatalogSessionUp:
209209 info.State = ports.SessionUp
210- case "down":
210+ case ports.RemoteCatalogSessionDown:
211211 info.State = ports.SessionDown
212- case "broken":
212+ case ports.RemoteCatalogSessionBroken:
213213 info.State = ports.SessionBroken
214214 default:
215215 slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
M internal/app/remote_test.go +4 -4
@@ -265,10 +265,10 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
265265 wantState ports.SessionState
266266 wantTabs uint16
267267 }{
268- {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, wantTabs: 2},
269- {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: 2}, wantState: ports.SessionDown, wantTabs: 2},
270- {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271- {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
268+ {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionUp, wantTabs: 2},
269+ {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionDown, wantTabs: 2},
270+ {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionBroken, wantTabs: 2},
271+ {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionBroken, wantTabs: 2},
272272 }
273273 for _, tt := range tests {
274274 t.Run(tt.name, func(t *testing.T) {
M internal/domain/lifecycle.go +25 -3
@@ -110,7 +110,9 @@ func (s TabSelector) Resolve(tabs []TabSelectorTab) (int, bool) {
110110
111111 // RemoteSessionTarget is the structured identity used by picker-based remote
112112 // access. Endpoint and DisplayOrigin are intentionally separate values: the
113-// latter is presentation-only and is never parsed back into a route.
113+// latter is presentation-only and is never parsed back into a route. Stopped
114+// records the selected down-row resume intent; it remains set while that exact
115+// lifecycle is created and attached as a live runtime.
114116 type RemoteSessionTarget struct {
115117 Endpoint string
116118 DisplayOrigin string
@@ -137,8 +139,11 @@ func (t RemoteSessionTarget) Validate() error {
137139 return err
138140 }
139141 if t.Stopped {
140- if t.LiveTabID != "" || t.StoppedTab.Validate() != nil {
141- return errors.New("down remote target requires an ordinal or stable tab selector")
142+ if t.LiveTabID != "" {
143+ return errors.New("down remote target cannot carry a live tab selector")
144+ }
145+ if t.StoppedTab != (TabSelector{}) && t.StoppedTab.Validate() != nil {
146+ return errors.New("down remote target has an invalid tab selector")
142147 }
143148 return nil
144149 }
@@ -151,6 +156,23 @@ func (t RemoteSessionTarget) Validate() error {
151156 return nil
152157 }
153158
159+// ResolveTab resolves the selected tab against authoritative metadata. A down
160+// record without retained tab metadata carries no selector and may choose only
161+// an unambiguous default tab: the empty durable record before creation or its
162+// sole fresh tab immediately afterward. It never guesses among multiple tabs.
163+func (t RemoteSessionTarget) ResolveTab(tabs []TabSelectorTab) (int, bool) {
164+ if t.Validate() != nil {
165+ return 0, false
166+ }
167+ if t.Stopped {
168+ if t.StoppedTab == (TabSelector{}) {
169+ return 0, len(tabs) <= 1
170+ }
171+ return t.StoppedTab.Resolve(tabs)
172+ }
173+ return NewStableTabSelector(t.LiveTabID).Resolve(tabs)
174+}
175+
154176 // ValidateRemoteDisplayOrigin validates presentation data without imposing
155177 // SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque.
156178 func ValidateRemoteDisplayOrigin(origin string) error {
M internal/domain/lifecycle_test.go +28 -0
@@ -21,6 +21,34 @@ func TestOrdinalTabSelectorPreservesUnnamedTabs(t *testing.T) {
2121 }
2222 }
2323
24+func TestRemoteSessionTargetResolveTab(t *testing.T) {
25+ base := RemoteSessionTarget{Endpoint: "host", DisplayOrigin: "host", LifecycleID: SessionLifecycleID{1}, SessionName: "work"}
26+ tests := []struct {
27+ name string
28+ target RemoteSessionTarget
29+ tabs []TabSelectorTab
30+ want int
31+ ok bool
32+ }{
33+ {name: "down without metadata", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, ok: true},
34+ {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true},
35+ {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}},
36+ {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},
37+ {name: "down invalid selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: TabSelector{Kind: TabSelectorByStableID, Ordinal: 1}}, tabs: []TabSelectorTab{{ID: "one"}}},
38+ {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},
39+ {name: "live missing tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "missing"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: -1},
40+ {name: "live duplicate tab rejected", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "two"}, {ID: "two"}}},
41+ }
42+ for _, test := range tests {
43+ t.Run(test.name, func(t *testing.T) {
44+ got, ok := test.target.ResolveTab(test.tabs)
45+ if got != test.want || ok != test.ok {
46+ t.Fatalf("ResolveTab() = %d, %t, want %d, %t", got, ok, test.want, test.ok)
47+ }
48+ })
49+ }
50+}
51+
2452 func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) {
2553 tests := []struct {
2654 name string
M internal/ports/remote.go +66 -149
@@ -2,7 +2,6 @@ package ports
22
33 import (
44 "context"
5- "encoding/json"
65 "errors"
76 "fmt"
87 "log/slog"
@@ -64,7 +63,7 @@ type RemotePreviewClient interface {
6463 const (
6564 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
6665 // catalogue can be rejected without changing the attachment wire layout.
67- RemoteCatalogSchemaVersion uint16 = 2
66+ RemoteCatalogSchemaVersion uint16 = 3
6867
6968 RemoteCatalogMaxHosts = 64
7069 RemoteCatalogMaxSessions = 256
@@ -75,10 +74,9 @@ const (
7574 RemoteCatalogMaxCatalogBytes = 1 << 20
7675 )
7776
78-// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for
79-// immediate remote discovery at daemon startup. The legacy-shaped Sessions
80-// field is retained as a source-compatible container, but cache writers strip
81-// dynamic Detail and Attention fields before persistence.
77+// RemoteCatalogCacheEntry is one immutable exact-schema host snapshot persisted
78+// for immediate remote discovery at daemon startup. Cache writers strip dynamic
79+// Detail and Attention fields before persistence.
8280 type RemoteCatalogCacheEntry struct {
8381 Host string
8482 FetchedAt time.Time
@@ -102,112 +100,53 @@ type RemoteCatalogTab struct {
102100 Attention bool `json:"attention,omitempty"`
103101 }
104102
105-// RemoteCatalogSession is one session in the remote discovery catalog. State
106-// is an explicit string contract (up|down|broken), not SessionState.
107-//
108-// Tabs is interface-typed only to keep old v0.x callers that supplied a count
109-// source-compatible while the versioned schema carries []RemoteCatalogTab.
110-// New code must use CatalogTabs and never infer routing identity from a count.
103+// RemoteCatalogSessionState is the JSON catalogue lifecycle contract. It is
104+// deliberately independent from SessionState, whose numeric values are part of
105+// the binary client/daemon protocol.
106+type RemoteCatalogSessionState string
107+
108+const (
109+ RemoteCatalogSessionUp RemoteCatalogSessionState = "up"
110+ RemoteCatalogSessionDown RemoteCatalogSessionState = "down"
111+ RemoteCatalogSessionBroken RemoteCatalogSessionState = "broken"
112+)
113+
114+// Valid reports whether the state is one of the bounded catalogue values.
115+func (s RemoteCatalogSessionState) Valid() bool {
116+ switch s {
117+ case RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken:
118+ return true
119+ default:
120+ return false
121+ }
122+}
123+
124+// RemoteCatalogSession is one exact-identity session in the remote discovery
125+// catalogue. Tabs is always an ordered typed snapshot; older count-only peers
126+// are rejected at the schema seam.
111127 type RemoteCatalogSession struct {
112- LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
128+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
113129 Name string `json:"name"`
114- State string `json:"state"`
130+ State RemoteCatalogSessionState `json:"state"`
115131 Ephemeral bool `json:"ephemeral"`
116- Tabs any `json:"tabs"`
132+ Tabs []RemoteCatalogTab `json:"tabs"`
117133 Attached bool `json:"attached"`
118134 LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
119135 ActiveTabID string `json:"active_tab_id,omitempty"`
120136 Reason string `json:"reason,omitempty"`
121137 }
122138
123-// MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues.
124-// Encoding/json does not apply omitempty to a zero array that implements
125-// encoding.TextMarshaler, so the pointer is made explicit here.
126-func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) {
127- type envelope struct {
128- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
129- Name string `json:"name"`
130- State string `json:"state"`
131- Ephemeral bool `json:"ephemeral"`
132- Tabs any `json:"tabs"`
133- Attached bool `json:"attached"`
134- LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
135- ActiveTabID string `json:"active_tab_id,omitempty"`
136- Reason string `json:"reason,omitempty"`
137- }
138- var lifecycle *domain.SessionLifecycleID
139- if s.LifecycleID != (domain.SessionLifecycleID{}) {
140- id := s.LifecycleID
141- lifecycle = &id
142- }
143- return json.Marshal(envelope{LifecycleID: lifecycle, Name: s.Name, State: s.State, Ephemeral: s.Ephemeral, Tabs: s.Tabs, Attached: s.Attached, LastUsedSeq: s.LastUsedSeq, ActiveTabID: s.ActiveTabID, Reason: s.Reason})
144-}
145-
146-// UnmarshalJSON preserves the legacy numeric tab-count representation while
147-// decoding the current array representation into concrete tab values.
148-func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error {
149- type envelope struct {
150- LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
151- Name string `json:"name"`
152- State string `json:"state"`
153- Ephemeral bool `json:"ephemeral"`
154- Tabs json.RawMessage `json:"tabs"`
155- Attached bool `json:"attached"`
156- LastUsedSeq uint64 `json:"last_used_seq"`
157- ActiveTabID string `json:"active_tab_id"`
158- Reason string `json:"reason"`
159- }
160- var raw envelope
161- if err := json.Unmarshal(data, &raw); err != nil {
162- return err
163- }
164- *s = RemoteCatalogSession{LifecycleID: raw.LifecycleID, Name: raw.Name, State: raw.State, Ephemeral: raw.Ephemeral, Attached: raw.Attached, LastUsedSeq: raw.LastUsedSeq, ActiveTabID: raw.ActiveTabID, Reason: raw.Reason}
165- if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" {
166- s.Tabs = 0
167- return nil
168- }
169- var tabs []RemoteCatalogTab
170- if err := json.Unmarshal(raw.Tabs, &tabs); err == nil {
171- s.Tabs = tabs
172- return nil
173- }
174- var count uint16
175- if err := json.Unmarshal(raw.Tabs, &count); err != nil {
176- return err
177- }
178- s.Tabs = int(count)
179- return nil
180-}
181-
182-// CatalogTabs returns a copied typed tab list. Legacy count-only entries have
183-// no stable tab identity and therefore return nil.
139+// CatalogTabs returns an independent copy of the ordered tab snapshot.
184140 func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
185- tabs, ok := session.Tabs.([]RemoteCatalogTab)
186- if !ok {
187- return nil
188- }
189- return slices.Clone(tabs)
141+ return slices.Clone(session.Tabs)
190142 }
191143
192-// CatalogTabCount returns the count represented by either schema form.
144+// CatalogTabCount returns the typed tab count.
193145 func CatalogTabCount(session RemoteCatalogSession) int {
194- switch tabs := session.Tabs.(type) {
195- case []RemoteCatalogTab:
196- return len(tabs)
197- case uint16:
198- return int(tabs)
199- case uint32:
200- return int(tabs)
201- case int:
202- return tabs
203- case float64:
204- return int(tabs)
205- default:
206- return 0
207- }
146+ return len(session.Tabs)
208147 }
209148
210-// SaturateUint16 clamps n to the uint16 range for legacy command output only.
149+// SaturateUint16 clamps a catalogue tab count to the list protocol range.
211150 func SaturateUint16(n int) uint16 {
212151 if n <= 0 {
213152 return 0
@@ -219,11 +158,11 @@ func SaturateUint16(n int) uint16 {
219158 }
220159
221160 // RemoteCatalog is the independently versioned JSON envelope returned by
222-// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers
223-// must also provide SchemaVersion.
161+// remote-catalog --json. Both protocol and catalogue schema versions are
162+// mandatory and must match exactly.
224163 type RemoteCatalog struct {
225164 ProtocolVersion uint16 `json:"protocol_version"`
226- SchemaVersion uint16 `json:"schema_version,omitempty"`
165+ SchemaVersion uint16 `json:"schema_version"`
227166 Sessions []RemoteCatalogSession `json:"sessions"`
228167 }
229168
@@ -250,23 +189,23 @@ var (
250189 ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
251190 )
252191
253-// ValidateRemoteCatalog applies all limits before a catalogue is cached or
254-// rendered. It is deliberately strict for the current schema; zero schema is
255-// the read-only compatibility form used by pre-parity v0.x peers.
192+// ValidateRemoteCatalog applies the exact current schema and all bounds before
193+// a catalogue is cached or rendered.
256194 func ValidateRemoteCatalog(c RemoteCatalog) error {
257195 if c.ProtocolVersion != ProtocolVersion {
258196 return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}
259197 }
260- if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion {
198+ if c.SchemaVersion != RemoteCatalogSchemaVersion {
261199 return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
262200 }
263201 if len(c.Sessions) > RemoteCatalogMaxSessions {
264202 return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
265203 }
266- seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
204+ seenLifecycles := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
205+ seenNames := make(map[string]struct{}, len(c.Sessions))
267206 bytes := 0
268207 for _, session := range c.Sessions {
269- if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {
208+ if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(string(session.State)) || !validRemoteCatalogText(session.Reason) {
270209 return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
271210 }
272211 if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
@@ -275,7 +214,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
275214 if err := domain.ValidateSessionName(session.Name); err != nil {
276215 return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
277216 }
278- if !validRemoteCatalogState(session.State) {
217+ if !session.State.Valid() {
279218 return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
280219 }
281220 if session.ActiveTabID != "" {
@@ -286,24 +225,24 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
286225 if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
287226 return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
288227 }
289- if c.SchemaVersion != 0 {
290- if session.LifecycleID == (domain.SessionLifecycleID{}) {
291- return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
292- }
293- if prior, exists := seen[session.LifecycleID]; exists {
294- return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
295- }
296- seen[session.LifecycleID] = session.Name
228+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
229+ return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
230+ }
231+ if _, exists := seenNames[session.Name]; exists {
232+ return fmt.Errorf("%w: duplicate session name %q", ErrInvalidRemoteCatalog, session.Name)
233+ }
234+ seenNames[session.Name] = struct{}{}
235+ if prior, exists := seenLifecycles[session.LifecycleID]; exists {
236+ return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
237+ }
238+ seenLifecycles[session.LifecycleID] = session.Name
239+ if session.Tabs == nil {
240+ return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
297241 }
298242 tabs := CatalogTabs(session)
299243 if len(tabs) == 0 {
300- if c.SchemaVersion != 0 {
301- if session.ActiveTabID != "" {
302- return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
303- }
304- if session.State == "up" && session.Tabs == nil {
305- return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
306- }
244+ if session.ActiveTabID != "" {
245+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
307246 }
308247 } else {
309248 if len(tabs) > RemoteCatalogMaxTabsPerSess {
@@ -320,7 +259,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
320259 if tab.Index != uint16(i) {
321260 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322261 }
323- if session.State == "up" && tab.ID == "" {
262+ if session.State == RemoteCatalogSessionUp && tab.ID == "" {
324263 return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325264 }
326265 if tab.ID != "" {
@@ -335,7 +274,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
335274 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336275 }
337276 if session.ActiveTabID != "" {
338- if session.State != "up" {
277+ if session.State != RemoteCatalogSessionUp {
339278 return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340279 }
341280 if _, ok := tabIDs[session.ActiveTabID]; !ok {
@@ -351,10 +290,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
351290 return nil
352291 }
353292
354-// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it
355-// is loaded into picker state or written to disk. A cache may contain legacy
356-// count-only sessions, but any complete typed snapshot must carry lifecycle
357-// identity and the same tab invariants as a live catalog.
293+// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache
294+// DTO before it is loaded into picker state or written to disk.
358295 func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
359296 if len(entries) > RemoteCatalogMaxHosts {
360297 return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
@@ -371,18 +308,7 @@ func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error
371308 return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
372309 }
373310 seenHosts[entry.Host] = struct{}{}
374- complete := false
375- for _, session := range entry.Sessions {
376- if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil {
377- complete = true
378- break
379- }
380- }
381- schema := uint16(0)
382- if complete {
383- schema = RemoteCatalogSchemaVersion
384- }
385- catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions}
311+ catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions}
386312 if err := ValidateRemoteCatalog(catalog); err != nil {
387313 return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
388314 }
@@ -402,15 +328,6 @@ func validRemoteCatalogText(value string) bool {
402328 return true
403329 }
404330
405-func validRemoteCatalogState(state string) bool {
406- switch state {
407- case "up", "down", "broken":
408- return true
409- default:
410- return false
411- }
412-}
413-
414331 func validRemoteCatalogReason(reason string) bool {
415332 switch reason {
416333 case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
M internal/ports/remote_state_test.go +32 -5
@@ -7,13 +7,17 @@ import (
77 )
88
99 func TestRemoteCatalogStateContract(t *testing.T) {
10- require.Equal(t, uint16(2), RemoteCatalogSchemaVersion)
10+ require.Equal(t, uint16(3), RemoteCatalogSchemaVersion)
1111
12- for _, state := range []string{"up", "down", "broken"} {
13- t.Run(state, func(t *testing.T) {
12+ for _, state := range []RemoteCatalogSessionState{RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken} {
13+ t.Run(string(state), func(t *testing.T) {
1414 err := ValidateRemoteCatalog(RemoteCatalog{
1515 ProtocolVersion: ProtocolVersion,
16- Sessions: []RemoteCatalogSession{{Name: "work", State: state, Tabs: 1}},
16+ SchemaVersion: RemoteCatalogSchemaVersion,
17+ Sessions: []RemoteCatalogSession{{
18+ LifecycleID: [16]byte{1}, Name: "work", State: state,
19+ Tabs: []RemoteCatalogTab{{ID: "tab-1"}},
20+ }},
1721 })
1822 require.NoError(t, err)
1923 })
@@ -21,7 +25,30 @@ func TestRemoteCatalogStateContract(t *testing.T) {
2125
2226 err := ValidateRemoteCatalog(RemoteCatalog{
2327 ProtocolVersion: ProtocolVersion,
24- Sessions: []RemoteCatalogSession{{Name: "work", State: "running", Tabs: 1}},
28+ SchemaVersion: RemoteCatalogSchemaVersion,
29+ Sessions: []RemoteCatalogSession{{
30+ LifecycleID: [16]byte{1}, Name: "work", State: RemoteCatalogSessionState("running"),
31+ Tabs: []RemoteCatalogTab{{ID: "tab-1"}},
32+ }},
2533 })
2634 require.ErrorIs(t, err, ErrRemoteCatalogUnknownState)
35+
36+ err = ValidateRemoteCatalog(RemoteCatalog{ProtocolVersion: ProtocolVersion, Sessions: []RemoteCatalogSession{}})
37+ var mismatch *RemoteCatalogVersionMismatchError
38+ require.ErrorAs(t, err, &mismatch)
39+ require.Equal(t, "catalog", mismatch.Kind)
40+ require.Equal(t, RemoteCatalogSchemaVersion, mismatch.Want)
41+}
42+
43+func TestValidateRemoteCatalogRejectsDuplicateSessionNames(t *testing.T) {
44+ err := ValidateRemoteCatalog(RemoteCatalog{
45+ ProtocolVersion: ProtocolVersion,
46+ SchemaVersion: RemoteCatalogSchemaVersion,
47+ Sessions: []RemoteCatalogSession{
48+ {LifecycleID: [16]byte{1}, Name: "work", State: RemoteCatalogSessionUp, Tabs: []RemoteCatalogTab{}},
49+ {LifecycleID: [16]byte{2}, Name: "work", State: RemoteCatalogSessionDown, Tabs: []RemoteCatalogTab{}},
50+ },
51+ })
52+ require.ErrorIs(t, err, ErrInvalidRemoteCatalog)
53+ require.ErrorContains(t, err, "duplicate session name")
2754 }
M internal/usecase/daemon/coderabbit_regression_test.go +156 -8
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 "github.com/bnema/vev/internal/usecase/picker"
1718 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1819 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -37,7 +38,7 @@ func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T)
3738 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
3839 require.Empty(t, repository.deleted)
3940 d.mu.Lock()
40- entry := d.stopped[record.Name]
41+ entry := d.inactive[record.Name]
4142 d.mu.Unlock()
4243 require.Equal(t, ports.SessionBroken, entry.state)
4344 require.Equal(t, persisted, entry.record)
@@ -59,7 +60,7 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) {
5960 require.True(t, ok)
6061 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
6162 d.mu.Lock()
62- entry := d.stopped[record.Name]
63+ entry := d.inactive[record.Name]
6364 d.mu.Unlock()
6465 require.Equal(t, ports.SessionBroken, entry.state)
6566 require.Equal(t, persisted, entry.record)
@@ -118,7 +119,7 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) {
118119 require.Nil(t, persisted.Committed)
119120 require.Empty(t, persisted.DegradedReason)
120121 d.mu.Lock()
121- entry := d.stopped[record.Name]
122+ entry := d.inactive[record.Name]
122123 d.mu.Unlock()
123124 require.Equal(t, persisted, entry.record)
124125 require.Equal(t, ports.SessionDown, entry.state)
@@ -182,7 +183,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
182183 require.True(t, ok)
183184 require.Equal(t, replacement, persisted)
184185 d.mu.Lock()
185- entry := d.stopped[record.Name]
186+ entry := d.inactive[record.Name]
186187 d.mu.Unlock()
187188 require.Equal(t, tt.state, entry.state)
188189 require.Equal(t, replacement, entry.record)
@@ -224,7 +225,7 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
224225 d := newTestDaemon(t, nil, stubClock{})
225226 d.catalogue = catalogue
226227 d.persistEnabled = true
227- d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, make(chan struct{}))
228+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, make(chan struct{}))
228229
229230 result := make(chan error, 1)
230231 go func() {
@@ -275,7 +276,7 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T)
275276 d.catalogue = catalogue
276277 d.persistEnabled = true
277278 d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
278- d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, nil)
279+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
279280
280281 target := picker.Target{
281282 Name: record.Name,
@@ -293,7 +294,7 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T)
293294 replacement.UpdatedAt = replacement.CreatedAt
294295 require.NoError(t, catalogue.Replace(replacement.Name, replacement))
295296 d.mu.Lock()
296- d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil)
297+ d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil)
297298 d.mu.Unlock()
298299 close(catalogue.release)
299300
@@ -303,7 +304,154 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T)
303304 d.mu.Lock()
304305 defer d.mu.Unlock()
305306 require.Nil(t, d.findByNameLocked(record.Name))
306- require.Equal(t, replacement.IncarnationID, d.stopped[record.Name].incarnation)
307+ require.Equal(t, replacement.IncarnationID, d.inactive[record.Name].incarnation)
308+}
309+
310+func TestRemoteInactiveRouteRejectsReplacementDuringCatalogueRead(t *testing.T) {
311+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
312+ record := durableRecoveryRecord(1)
313+ record.Name = "stopped"
314+ record.Committed = nil
315+ record.TabRecords = nil
316+ record.TabNames = nil
317+ catalogue := &blockingResumeCatalogue{
318+ durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}),
319+ entered: make(chan struct{}),
320+ release: make(chan struct{}),
321+ }
322+ d.catalogue = catalogue
323+ d.persistEnabled = true
324+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
325+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
326+
327+ target := domain.RemoteSessionTarget{
328+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: record.IncarnationID,
329+ SessionName: record.Name, Stopped: true,
330+ }
331+ transport, _ := newCapturingTransport(t)
332+ result := make(chan error, 1)
333+ go func() {
334+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
335+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name,
336+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
337+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
338+ }, transport)
339+ result <- err
340+ }()
341+
342+ <-catalogue.entered
343+ replacement := record
344+ replacement.IncarnationID = domain.IncarnationID{9}
345+ replacement.CreatedAt++
346+ replacement.UpdatedAt = replacement.CreatedAt
347+ require.NoError(t, catalogue.Replace(replacement.Name, replacement))
348+ d.mu.Lock()
349+ d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil)
350+ d.mu.Unlock()
351+ close(catalogue.release)
352+
353+ var protocol *protoErr
354+ require.ErrorAs(t, <-result, &protocol)
355+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
356+ require.Empty(t, catalogue.MetadataUpdates())
357+ d.mu.Lock()
358+ defer d.mu.Unlock()
359+ require.Nil(t, d.findByNameLocked(record.Name))
360+ require.Equal(t, replacement.IncarnationID, d.inactive[record.Name].incarnation)
361+}
362+
363+func TestRemoteInactiveRouteRejectsTabAuthorityChangeDuringCatalogueRead(t *testing.T) {
364+ for _, test := range []struct {
365+ name string
366+ record domain.CatalogueRecord
367+ target domain.RemoteSessionTarget
368+ mutate func(*domain.CatalogueRecord)
369+ updateMap bool
370+ }{
371+ {
372+ name: "stable selector replacement",
373+ record: domain.CatalogueRecord{TabNames: []string{"main"}, TabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1", Name: "main"}}},
374+ target: domain.RemoteSessionTarget{Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1")},
375+ mutate: func(record *domain.CatalogueRecord) {
376+ record.TabRecords[0].StableID = "tab-2"
377+ },
378+ updateMap: true,
379+ },
380+ {
381+ name: "ordinal selector replacement",
382+ record: domain.CatalogueRecord{TabNames: []string{"main"}, TabRecords: []domain.CatalogueTabRecord{{Name: "main"}}},
383+ target: domain.RemoteSessionTarget{Stopped: true, StoppedTab: domain.NewOrdinalTabSelector(0, "main", 1)},
384+ mutate: func(record *domain.CatalogueRecord) {
385+ record.TabNames[0] = "other"
386+ record.TabRecords[0].Name = "other"
387+ },
388+ updateMap: true,
389+ },
390+ {
391+ name: "catalogue-only metadata change",
392+ record: domain.CatalogueRecord{TabNames: []string{"main"}, TabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1", Name: "main"}}},
393+ target: domain.RemoteSessionTarget{Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1")},
394+ mutate: func(record *domain.CatalogueRecord) {
395+ record.TabRecords[0].StableID = "tab-2"
396+ },
397+ },
398+ } {
399+ t.Run(test.name, func(t *testing.T) {
400+ record := durableRecoveryRecord(1)
401+ record.Name = "stopped"
402+ record.Committed = nil
403+ record.TabNames = append([]string(nil), test.record.TabNames...)
404+ record.TabRecords = append([]domain.CatalogueTabRecord(nil), test.record.TabRecords...)
405+ catalogue := &blockingResumeCatalogue{
406+ durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}),
407+ entered: make(chan struct{}),
408+ release: make(chan struct{}),
409+ }
410+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
411+ d.catalogue = catalogue
412+ d.persistEnabled = true
413+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
414+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
415+
416+ target := test.target
417+ target.Endpoint = "remote"
418+ target.DisplayOrigin = "remote"
419+ target.LifecycleID = record.IncarnationID
420+ target.SessionName = record.Name
421+ transport, _ := newCapturingTransport(t)
422+ result := make(chan error, 1)
423+ go func() {
424+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
425+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name,
426+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
427+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
428+ }, transport)
429+ result <- err
430+ }()
431+
432+ <-catalogue.entered
433+ replacement := record
434+ replacement.TabNames = append([]string(nil), record.TabNames...)
435+ replacement.TabRecords = append([]domain.CatalogueTabRecord(nil), record.TabRecords...)
436+ test.mutate(&replacement)
437+ replacement.UpdatedAt++
438+ require.NoError(t, catalogue.Replace(replacement.Name, replacement))
439+ if test.updateMap {
440+ d.mu.Lock()
441+ d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil)
442+ d.mu.Unlock()
443+ }
444+ close(catalogue.release)
445+
446+ var protocol *protoErr
447+ require.ErrorAs(t, <-result, &protocol)
448+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
449+ require.Empty(t, catalogue.MetadataUpdates())
450+ d.mu.Lock()
451+ defer d.mu.Unlock()
452+ require.Nil(t, d.findByNameLocked(record.Name))
453+ })
454+ }
307455 }
308456
309457 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
M internal/usecase/daemon/control.go +13 -51
@@ -706,38 +706,16 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
706706 }
707707 e.d.mu.Lock()
708708 sessions := sessionsSnapshot(e.d.sessions)
709- stopped := make([]stoppedSession, 0, len(e.d.stopped))
710- for _, entry := range e.d.stopped {
711- if !entry.purging {
709+ stopped := make([]inactiveSession, 0, len(e.d.inactive))
710+ for _, entry := range e.d.inactive {
711+ if entry.visible() {
712712 stopped = append(stopped, entry)
713713 }
714714 }
715715 e.d.mu.Unlock()
716716
717- // Test doubles and pre-parity daemons may not have durable lifecycle
718- // identities. Keep their count-only catalogue form readable so direct CLI
719- // discovery remains compatible; every production session has a non-zero
720- // incarnation and therefore takes the strict complete-schema path below.
721- legacy := false
722- for _, sess := range sessions {
723- if sess.incarnation == (domain.IncarnationID{}) {
724- legacy = true
725- break
726- }
727- }
728- if !legacy {
729- for _, entry := range stopped {
730- if entry.incarnation == (domain.IncarnationID{}) {
731- legacy = true
732- break
733- }
734- }
735- }
736- if legacy {
737- return e.remoteCatalogLegacy(sessions)
738- }
739-
740717 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
718+ liveNames := make(map[string]struct{}, len(sessions))
741719 for _, sess := range sessions {
742720 e.d.refreshSessionFocusedTitles(sess)
743721 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
@@ -748,7 +726,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
748726 row := ports.RemoteCatalogSession{
749727 LifecycleID: snap.incarnation,
750728 Name: snap.name,
751- State: "up",
729+ State: ports.RemoteCatalogSessionUp,
752730 Ephemeral: snap.ephemeral,
753731 Tabs: tabs,
754732 Attached: snap.attached,
@@ -758,16 +736,20 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
758736 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
759737 }
760738 rows = append(rows, row)
739+ liveNames[row.Name] = struct{}{}
761740 }
762741 for _, entry := range stopped {
742+ if _, live := liveNames[entry.name]; live {
743+ continue
744+ }
763745 tabs, err := remoteCatalogStoppedTabs(entry)
764746 if err != nil {
765747 return "", err
766748 }
767- state := "down"
749+ state := ports.RemoteCatalogSessionDown
768750 reason := ""
769- if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {
770- state = "broken"
751+ if entry.broken() {
752+ state = ports.RemoteCatalogSessionBroken
771753 reason = "session_broken"
772754 }
773755 rows = append(rows, ports.RemoteCatalogSession{
@@ -797,26 +779,6 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
797779 return marshalListing(catalog)
798780 }
799781
800-func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) {
801- rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
802- for _, sess := range sessions {
803- snap := sess.snapshotView(viewOptions{})
804- rows = append(rows, ports.RemoteCatalogSession{
805- Name: snap.name,
806- State: "up",
807- Ephemeral: snap.ephemeral,
808- Tabs: ports.SaturateUint16(snap.tabCount),
809- Attached: snap.attached,
810- })
811- }
812- sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
813- catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: rows}
814- if err := ports.ValidateRemoteCatalog(catalog); err != nil {
815- return "", err
816- }
817- return marshalListing(catalog)
818-}
819-
820782 func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
821783 if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {
822784 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
@@ -834,7 +796,7 @@ func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
834796 return tabs, nil
835797 }
836798
837-func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) {
799+func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab, error) {
838800 records := entry.tabRecords
839801 if len(records) == 0 && len(entry.tabNames) != 0 {
840802 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
M internal/usecase/daemon/control_test.go +20 -19
@@ -5,6 +5,7 @@ import (
55 "context"
66 "encoding/json"
77 "errors"
8+ "fmt"
89 "io"
910 "log/slog"
1011 "strings"
@@ -719,6 +720,7 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
719720 func TestRemoteCatalogJSONOutput(t *testing.T) {
720721 d := newTestDaemon(t, nil, stubClock{})
721722 work := addControlSession(d, "work", "t_work", "p_work")
723+ work.incarnation[0] = 1
722724 work.ephemeral = false
723725 work.registerAttachment(&attachedClient{})
724726 work.mruAt.Store(2)
@@ -729,11 +731,14 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
729731 work.mu.Unlock()
730732
731733 build := addControlSession(d, "build", "t_build", "p_build")
734+ build.incarnation[0] = 2
732735 build.ephemeral = true
733736 build.mruAt.Store(1)
734737
735738 d.mu.Lock()
736- d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown}
739+ d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
740+ // Live runtime authority wins if both registries contain the same name.
741+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: ports.SessionDown}
737742 d.mu.Unlock()
738743
739744 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
@@ -757,20 +762,19 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
757762
758763 raw := map[string]json.RawMessage{}
759764 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
760- require.Len(t, raw, 2)
765+ require.Len(t, raw, 3)
761766 _, hasProtocol := raw["protocol_version"]
767+ _, hasSchema := raw["schema_version"]
762768 _, hasSessions := raw["sessions"]
763- require.True(t, hasProtocol && hasSessions)
769+ require.True(t, hasProtocol && hasSchema && hasSessions)
764770
765771 require.Equal(t, []ports.RemoteCatalogSession{
766- {Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false},
767- {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
772+ {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
773+ {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []ports.RemoteCatalogTab{}},
774+ {LifecycleID: work.incarnation, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "1", Detail: " (sh)"}, {ID: "t_work_2", Index: 1, Name: "2", Detail: " (sh)"}}, Attached: true, ActiveTabID: "t_work", LastUsedSeq: 2},
768775 }, catalog.Sessions)
769776
770- for _, session := range catalog.Sessions {
771- require.Equal(t, "up", session.State)
772- require.NotEqual(t, "old", session.Name)
773- }
777+ require.Equal(t, ports.RemoteCatalogSessionDown, catalog.Sessions[1].State)
774778
775779 listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
776780 require.True(t, listAfter.OK, listAfter.Text)
@@ -814,24 +818,21 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
814818 }}, catalog.Sessions)
815819 }
816820
817-func TestRemoteCatalogTabCountSaturates(t *testing.T) {
821+func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
818822 d := newTestDaemon(t, nil, stubClock{})
819823 sess := addControlSession(d, "work", "t_work", "p_work")
820824 sess.mu.Lock()
821- base := sess.tabs[0]
822- sess.tabs = make([]*tab, int(^uint16(0))+1)
825+ sess.tabs = make([]*tab, ports.RemoteCatalogMaxTabsPerSess+1)
823826 for i := range sess.tabs {
824- sess.tabs[i] = base
827+ sess.tabs[i] = newTabWithStableID(
828+ fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
829+ )
825830 }
826831 sess.mu.Unlock()
827832
828833 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
829- require.True(t, result.OK, result.Text)
830- var catalog ports.RemoteCatalog
831- require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
832- require.Equal(t, []ports.RemoteCatalogSession{{
833- Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
834- }}, catalog.Sessions)
834+ require.False(t, result.OK)
835+ require.Contains(t, result.Text, "too many tabs")
835836 }
836837
837838 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
M internal/usecase/daemon/daemon.go +56 -26
@@ -71,7 +71,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
7171 type Daemon struct {
7272 mu sync.Mutex
7373 sessions map[domain.SessionID]*session
74- stopped map[string]stoppedSession
74+ inactive map[string]inactiveSession
7575 // creating reserves names while durable creation I/O runs without mu.
7676 creating map[string]struct{}
7777 nextID uint64
@@ -348,9 +348,11 @@ func (p *parkingAttachment) closeDone() {
348348 p.doneOnce.Do(func() { close(p.done) })
349349 }
350350
351-// session is a single multiplexed session. It owns one or more full-screen
352-
353-type stoppedSession struct {
351+// inactiveSession is the durable authority for a named session without a live
352+// runtime. It remains present while healthy-down, restoring, broken, degraded,
353+// or purge-fenced; callers must use its predicates instead of inferring those
354+// states from map membership.
355+type inactiveSession struct {
354356 name string
355357 cwd string
356358 createdAt int64
@@ -364,6 +366,34 @@ type stoppedSession struct {
364366 restoreDone chan struct{}
365367 }
366368
369+func (s inactiveSession) restorePending() bool {
370+ if s.restoreDone == nil {
371+ return false
372+ }
373+ select {
374+ case <-s.restoreDone:
375+ return false
376+ default:
377+ return true
378+ }
379+}
380+
381+func (s inactiveSession) broken() bool {
382+ return s.state == ports.SessionBroken || s.record.DegradedReason != ""
383+}
384+
385+func (s inactiveSession) visible() bool {
386+ return !s.purging
387+}
388+
389+func (s inactiveSession) canResume() bool {
390+ return s.visible() && s.state == ports.SessionDown && !s.broken()
391+}
392+
393+func (s inactiveSession) sameLifecycle(other inactiveSession) bool {
394+ return s.name == other.name && s.createdAt == other.createdAt && s.incarnation == other.incarnation
395+}
396+
367397 type Option func(*Daemon)
368398
369399 // WithRuntimeObserver accepts only a composition-root serialized observer.
@@ -591,7 +621,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
591621 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
592622 d := &Daemon{
593623 sessions: make(map[domain.SessionID]*session),
594- stopped: make(map[string]stoppedSession),
624+ inactive: make(map[string]inactiveSession),
595625 creating: make(map[string]struct{}),
596626 parked: make(map[uint64]*parkedAttachment),
597627 parking: make(map[uint64]*parkingAttachment),
@@ -652,9 +682,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
652682 for _, r := range records {
653683 if d.catalogueRecordsProvided {
654684 state, done := initialSessionState(r)
655- d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)
685+ d.inactive[r.Name] = inactiveSessionFromRecord(r, state, done)
656686 } else {
657- d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionDown, nil)
687+ d.inactive[r.Name] = inactiveSessionFromRecord(r, ports.SessionDown, nil)
658688 }
659689 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
660690 maxCreatedAt = r.CreatedAt
@@ -1009,7 +1039,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
10091039 defer func() { _ = tr.Close() }()
10101040
10111041 d.mu.Lock()
1012- infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
1042+ infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.inactive))
10131043 liveNames := make(map[string]struct{}, len(d.sessions))
10141044 for _, s := range d.sessions {
10151045 if s == nil {
@@ -1028,14 +1058,12 @@ func (d *Daemon) handleList(tr ports.Transport) {
10281058 s.mu.Unlock()
10291059 infos = append(infos, info)
10301060 }
1031- for name, stopped := range d.stopped {
1032- if _, live := liveNames[name]; live {
1061+ for name, inactive := range d.inactive {
1062+ if _, live := liveNames[name]; live || !inactive.visible() {
10331063 continue
10341064 }
10351065 state := ports.SessionDown
1036- if stopped.purging || stopped.state == ports.SessionBroken {
1037- // Purge is the dominant externally visible state: restoration must
1038- // never make a deletion-reserved record appear attachable.
1066+ if inactive.broken() {
10391067 state = ports.SessionBroken
10401068 }
10411069 infos = append(infos, ports.SessionInfo{Name: name, State: state})
@@ -1065,7 +1093,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
10651093 d.mu.Lock()
10661094 target := d.findByNameLocked(k.Name)
10671095 if target == nil {
1068- if _, ok := d.stopped[k.Name]; ok {
1096+ if _, ok := d.inactive[k.Name]; ok {
10691097 d.mu.Unlock()
10701098 // Stopped sessions use the same catalogue-first, incarnation-second
10711099 // deletion order as live and offline purges.
@@ -1330,7 +1358,7 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
13301358 d.mu.Lock()
13311359 var (
13321360 done chan struct{}
1333- stopped stoppedSession
1361+ stopped inactiveSession
13341362 ok bool
13351363 )
13361364 if sess := d.findByNameLocked(name); sess != nil {
@@ -1338,12 +1366,14 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
13381366 done = sess.restoreDone
13391367 sess.mu.Unlock()
13401368 } else {
1341- stopped, ok = d.stopped[name]
1342- if !ok || stopped.purging {
1369+ stopped, ok = d.inactive[name]
1370+ if !ok || !stopped.visible() {
13431371 d.mu.Unlock()
13441372 return nil
13451373 }
1346- done = stopped.restoreDone
1374+ if stopped.restorePending() {
1375+ done = stopped.restoreDone
1376+ }
13471377 }
13481378 d.mu.Unlock()
13491379
@@ -1360,11 +1390,11 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
13601390 if d.findByNameLocked(name) != nil {
13611391 return nil
13621392 }
1363- stopped, ok = d.stopped[name]
1393+ stopped, ok = d.inactive[name]
13641394 if !ok || stopped.record.Name == "" {
13651395 return nil
13661396 }
1367- if stopped.state == ports.SessionBroken {
1397+ if stopped.broken() {
13681398 return &protoErr{ports.ErrInternal, "session durable state is broken: " + name}
13691399 }
13701400 if stopped.record.Committed == nil {
@@ -1383,8 +1413,8 @@ func (d *Daemon) validateExactSessionTargetLocked(target ports.ExactSessionTarge
13831413 }
13841414 return nil
13851415 }
1386- stopped, ok := d.stopped[target.SessionName]
1387- if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
1416+ stopped, ok := d.inactive[target.SessionName]
1417+ if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID {
13881418 return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
13891419 }
13901420 return nil
@@ -1537,10 +1567,10 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15371567 sess := d.findByNameLocked(h.Name)
15381568 created := false
15391569 if sess == nil {
1540- stopped, ok := d.stopped[h.Name]
1541- if !ok || stopped.purging {
1570+ stopped, ok := d.inactive[h.Name]
1571+ if !ok || !stopped.canResume() {
15421572 d.mu.Unlock()
1543- return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
1573+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such resumable session: " + h.Name}
15441574 }
15451575 cwd := d.dirOrHome(stopped.cwd)
15461576 env := h.Env
@@ -1548,7 +1578,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15481578 env = copyEnvironment(d.baseEnv)
15491579 }
15501580 var err error
1551- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, env, stopped.tabNames)
1581+ sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, sz, env, stopped, stopped.tabNames)
15521582 if err != nil {
15531583 d.mu.Unlock()
15541584 return nil, nil, err
M internal/usecase/daemon/durable_recovery_test.go +33 -33
@@ -301,8 +301,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
301301 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
302302
303303 d.mu.Lock()
304- healthyEntry := d.stopped[healthy.Name]
305- brokenEntry := d.stopped[broken.Name]
304+ healthyEntry := d.inactive[healthy.Name]
305+ brokenEntry := d.inactive[broken.Name]
306306 d.mu.Unlock()
307307 require.Equal(t, ports.SessionDown, healthyEntry.state)
308308 require.Equal(t, ports.SessionBroken, brokenEntry.state)
@@ -344,14 +344,14 @@ func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) {
344344 require.Empty(t, fresh.DegradedReason)
345345 require.Empty(t, fresh.TabNames)
346346 require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted)
347- require.Equal(t, ports.SessionDown, d.stopped[incompatible.Name].state)
348- require.Equal(t, fresh, d.stopped[incompatible.Name].record)
347+ require.Equal(t, ports.SessionDown, d.inactive[incompatible.Name].state)
348+ require.Equal(t, fresh, d.inactive[incompatible.Name].record)
349349
350350 restoredSibling, ok, err := catalogue.Record(healthy.Name)
351351 require.NoError(t, err)
352352 require.True(t, ok)
353353 require.Equal(t, healthy, restoredSibling)
354- require.Equal(t, ports.SessionDown, d.stopped[healthy.Name].state)
354+ require.Equal(t, ports.SessionDown, d.inactive[healthy.Name].state)
355355 require.Equal(t, 1, repository.loads[healthy.Name])
356356 }
357357
@@ -366,7 +366,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
366366 }
367367 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
368368 d.mu.Lock()
369- restoreDone := d.stopped[record.Name].restoreDone
369+ restoreDone := d.inactive[record.Name].restoreDone
370370 d.mu.Unlock()
371371
372372 restoreErr := d.restoreRecord(context.Background(), record)
@@ -379,7 +379,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
379379 require.Nil(t, fresh.Committed)
380380 require.Empty(t, fresh.DegradedReason)
381381 d.mu.Lock()
382- entry := d.stopped[record.Name]
382+ entry := d.inactive[record.Name]
383383 d.mu.Unlock()
384384 require.Equal(t, ports.SessionDown, entry.state)
385385 require.Equal(t, fresh, entry.record)
@@ -392,7 +392,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
392392
393393 d.finishRecordRestore(record, restoreErr, restoreDone)
394394 d.mu.Lock()
395- entry = d.stopped[record.Name]
395+ entry = d.inactive[record.Name]
396396 d.mu.Unlock()
397397 require.Equal(t, ports.SessionDown, entry.state)
398398 require.Equal(t, fresh, entry.record)
@@ -452,7 +452,7 @@ func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) {
452452 require.Equal(t, record.IncarnationID, persisted.IncarnationID)
453453 require.Equal(t, record.Committed, persisted.Committed)
454454 require.Equal(t, "checkpoint validation failed", persisted.DegradedReason)
455- require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
455+ require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state)
456456 require.Empty(t, repository.deleted)
457457 })
458458 }
@@ -470,9 +470,9 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
470470
471471 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
472472
473- require.Len(t, d.stopped, count)
473+ require.Len(t, d.inactive, count)
474474 for _, record := range records {
475- require.Equal(t, ports.SessionDown, d.stopped[record.Name].state)
475+ require.Equal(t, ports.SessionDown, d.inactive[record.Name].state)
476476 require.Equal(t, 1, repository.loads[record.Name])
477477 }
478478 }
@@ -486,7 +486,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
486486 // discovery API and therefore performs exactly one direct catalogue lookup.
487487 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
488488
489- require.Equal(t, ports.SessionDown, d.stopped[record.Name].state)
489+ require.Equal(t, ports.SessionDown, d.inactive[record.Name].state)
490490 require.Equal(t, 1, repository.loads[record.Name])
491491 }
492492
@@ -499,7 +499,7 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
499499
500500 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
501501
502- require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
502+ require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state)
503503 got, _, _ := catalogue.Record(record.Name)
504504 require.Equal(t, "checkpoint validation failed", got.DegradedReason)
505505 }
@@ -648,13 +648,13 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T)
648648 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
649649 tt.setup(d, catalogue, record)
650650 d.mu.Lock()
651- before := d.stopped[record.Name]
651+ before := d.inactive[record.Name]
652652 d.mu.Unlock()
653653
654654 d.finishRecordRestore(record, errors.New("invalid checkpoint"), before.restoreDone)
655655
656656 d.mu.Lock()
657- after := d.stopped[record.Name]
657+ after := d.inactive[record.Name]
658658 d.mu.Unlock()
659659 require.Equal(t, before.record, after.record)
660660 require.Equal(t, before.state, after.state)
@@ -666,7 +666,7 @@ func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEnt
666666 record := durableRecoveryRecord(0)
667667 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
668668 d.mu.Lock()
669- before := d.stopped[record.Name]
669+ before := d.inactive[record.Name]
670670 collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
671671 d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
672672 d.mu.Unlock()
@@ -678,7 +678,7 @@ func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEnt
678678 require.ErrorContains(t, err, "registry rejected")
679679 require.False(t, registered)
680680 d.mu.Lock()
681- after := d.stopped[record.Name]
681+ after := d.inactive[record.Name]
682682 d.mu.Unlock()
683683 require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier")
684684 require.Equal(t, before.restoreDone, after.restoreDone)
@@ -705,14 +705,14 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T
705705
706706 d.mu.Lock()
707707 for _, record := range tc.records {
708- d.stopped[record.Name] = stoppedSession{
708+ d.inactive[record.Name] = inactiveSession{
709709 name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,
710710 incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,
711711 tabNames: append([]string(nil), record.TabNames...), record: record,
712712 state: ports.SessionDown, restoreDone: make(chan struct{}),
713713 }
714714 }
715- beforeStopped, hadStopped := d.stopped[tc.sessName]
715+ beforeStopped, hadStopped := d.inactive[tc.sessName]
716716 collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
717717 d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
718718 d.mu.Unlock()
@@ -723,7 +723,7 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T
723723 require.ErrorContains(t, err, "registry rejected")
724724 require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation")
725725 d.mu.Lock()
726- afterStopped, stillStopped := d.stopped[tc.sessName]
726+ afterStopped, stillStopped := d.inactive[tc.sessName]
727727 d.mu.Unlock()
728728 require.Equal(t, hadStopped, stillStopped)
729729 if hadStopped {
@@ -769,8 +769,8 @@ func TestSessionRecoveryCommand(t *testing.T) {
769769
770770 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
771771 require.NoError(t, err)
772- require.Equal(t, ports.SessionDown, d.stopped[degraded.Name].state)
773- require.Equal(t, fresh, d.stopped[degraded.Name].record)
772+ require.Equal(t, ports.SessionDown, d.inactive[degraded.Name].state)
773+ require.Equal(t, fresh, d.inactive[degraded.Name].record)
774774 }
775775
776776 func TestListShowsDegraded(t *testing.T) {
@@ -789,18 +789,18 @@ func TestListShowsDegraded(t *testing.T) {
789789 }, listed.Sessions)
790790 }
791791
792-func TestListPurgingDominatesRestoringState(t *testing.T) {
792+func TestListHidesPurgingInactiveSession(t *testing.T) {
793793 record := durableRecoveryRecord(0)
794794 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
795795 d.mu.Lock()
796- entry := d.stopped[record.Name]
796+ entry := d.inactive[record.Name]
797797 entry.purging = true
798798 entry.state = ports.SessionDown
799- d.stopped[record.Name] = entry
799+ d.inactive[record.Name] = entry
800800 d.mu.Unlock()
801801
802802 listed := listSessions(t, d)
803- require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions)
803+ require.Empty(t, listed.Sessions)
804804 }
805805
806806 func TestAttachWaitsForRestore(t *testing.T) {
@@ -825,7 +825,7 @@ func TestAttachWaitsForRestore(t *testing.T) {
825825
826826 d.setStoppedRecovery(record, ports.SessionBroken)
827827 d.mu.Lock()
828- closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
828+ closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone)
829829 d.mu.Unlock()
830830 var protocolError *protoErr
831831 require.ErrorAs(t, <-result, &protocolError)
@@ -837,7 +837,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) {
837837 record := durableRecoveryRecord(0)
838838 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
839839 d.mu.Lock()
840- done := d.stopped[record.Name].restoreDone
840+ done := d.inactive[record.Name].restoreDone
841841 d.mu.Unlock()
842842 sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}}
843843
@@ -894,7 +894,7 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
894894 require.ErrorAs(t, <-attached, &protocolError)
895895 require.Equal(t, ports.ErrInternal, protocolError.code)
896896 <-restored
897- entry := d.stopped[record.Name]
897+ entry := d.inactive[record.Name]
898898 require.Equal(t, ports.SessionBroken, entry.state)
899899 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
900900 select {
@@ -910,7 +910,7 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
910910 record := durableRecoveryRecord(0)
911911 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
912912 d.mu.Lock()
913- closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
913+ closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone)
914914 d.mu.Unlock()
915915
916916 result := make(chan error, 1)
@@ -943,7 +943,7 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
943943 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
944944 d.setStoppedRecovery(record, tt.state)
945945 d.mu.Lock()
946- closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
946+ closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone)
947947 d.mu.Unlock()
948948
949949 var protocolError *protoErr
@@ -975,7 +975,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
975975 d.closeRestoreDone()
976976
977977 d.mu.Lock()
978- entry := d.stopped[record.Name]
978+ entry := d.inactive[record.Name]
979979 d.mu.Unlock()
980980 require.Equal(t, ports.SessionBroken, entry.state)
981981 select {
@@ -993,6 +993,6 @@ func TestCatalogueRestoreDegradedVisible(t *testing.T) {
993993
994994 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
995995
996- require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
996+ require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state)
997997 require.Zero(t, repository.loads[record.Name])
998998 }
M internal/usecase/daemon/handshake.go +1 -8
@@ -153,13 +153,6 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
153153 _ = tr.Close()
154154 }
155155 if ac.routeCreatedSession {
156- sess.mu.Lock()
157- empty := len(sess.attachments) == 0
158- sess.mu.Unlock()
159- if empty {
160- // killSession revalidates the attachment set under its publication
161- // locks before removing the session.
162- _ = d.killSession(sess, ports.ReasonSessionKilled, ac.routeSessionPurge)
163- }
156+ _ = d.killSessionIfEmpty(sess, ports.ReasonSessionKilled, ac.routeSessionPurge)
164157 }
165158 }
M internal/usecase/daemon/handshake_test.go +3 -3
@@ -240,7 +240,7 @@ func TestHandshakeTimeoutCancelsRouteRestoreWait(t *testing.T) {
240240 d := newTestDaemon(t, nil, clock)
241241 restoreDone := make(chan struct{})
242242 d.mu.Lock()
243- d.stopped["restoring"] = stoppedSession{name: "restoring", restoreDone: restoreDone}
243+ d.inactive["restoring"] = inactiveSession{name: "restoring", restoreDone: restoreDone}
244244 d.mu.Unlock()
245245
246246 tr := newHandshakeBlockingTransport(false)
@@ -266,7 +266,7 @@ func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) {
266266 defer release()
267267 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
268268 d.mu.Lock()
269- d.stopped["restored"] = stoppedSession{name: "restored", cwd: "/tmp"}
269+ d.inactive["restored"] = inactiveSession{name: "restored", cwd: "/tmp", state: ports.SessionDown}
270270 d.mu.Unlock()
271271
272272 clock := &signalClock{timers: make(chan *signalTimer, 4)}
@@ -286,7 +286,7 @@ func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) {
286286 requireClosedHandshakeTransport(t, tr)
287287 d.mu.Lock()
288288 require.Empty(t, d.sessions)
289- _, retained := d.stopped["restored"]
289+ _, retained := d.inactive["restored"]
290290 d.mu.Unlock()
291291 require.True(t, retained, "failed attach must retain the stopped session authority")
292292 }
A internal/usecase/daemon/inactive_session_test.go +63 -0
@@ -0,0 +1,63 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestInactiveSessionPredicates(t *testing.T) {
13+ pending := make(chan struct{})
14+ settled := make(chan struct{})
15+ close(settled)
16+
17+ tests := []struct {
18+ name string
19+ entry inactiveSession
20+ visible bool
21+ broken bool
22+ canResume bool
23+ restorePending bool
24+ }{
25+ {name: "healthy down", entry: inactiveSession{state: ports.SessionDown}, visible: true, canResume: true},
26+ {name: "restore pending remains resumable after wait", entry: inactiveSession{state: ports.SessionDown, restoreDone: pending}, visible: true, canResume: true, restorePending: true},
27+ {name: "settled restore", entry: inactiveSession{state: ports.SessionDown, restoreDone: settled}, visible: true, canResume: true},
28+ {name: "broken", entry: inactiveSession{state: ports.SessionBroken}, visible: true, broken: true},
29+ {name: "degraded", entry: inactiveSession{state: ports.SessionDown, record: domain.CatalogueRecord{DegradedReason: "checkpoint unavailable"}}, visible: true, broken: true},
30+ {name: "purging", entry: inactiveSession{state: ports.SessionDown, purging: true}},
31+ }
32+
33+ for _, test := range tests {
34+ t.Run(test.name, func(t *testing.T) {
35+ require.Equal(t, test.visible, test.entry.visible())
36+ require.Equal(t, test.broken, test.entry.broken())
37+ require.Equal(t, test.canResume, test.entry.canResume())
38+ require.Equal(t, test.restorePending, test.entry.restorePending())
39+ })
40+ }
41+}
42+
43+func TestInactiveSessionLifecycleMatch(t *testing.T) {
44+ entry := inactiveSession{name: "work", createdAt: 7, incarnation: domain.IncarnationID{1}}
45+ for _, test := range []struct {
46+ name string
47+ mutate func(*inactiveSession)
48+ want bool
49+ }{
50+ {name: "same lifecycle", want: true},
51+ {name: "different name", mutate: func(candidate *inactiveSession) { candidate.name = "other" }},
52+ {name: "different creation time", mutate: func(candidate *inactiveSession) { candidate.createdAt++ }},
53+ {name: "different incarnation", mutate: func(candidate *inactiveSession) { candidate.incarnation[0]++ }},
54+ } {
55+ t.Run(test.name, func(t *testing.T) {
56+ candidate := entry
57+ if test.mutate != nil {
58+ test.mutate(&candidate)
59+ }
60+ require.Equal(t, test.want, entry.sameLifecycle(candidate))
61+ })
62+ }
63+}
M internal/usecase/daemon/navigation_attachment_handoff_test.go +4 -4
@@ -51,14 +51,14 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
5151 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
5252 d.mu.Lock()
5353 delete(d.sessions, target.id)
54- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}
54+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}
5555 d.mu.Unlock()
5656 expectedCreatedAt := int64(9)
5757 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
5858 },
5959 check: func(t *testing.T, d *Daemon, _, _ *session) {
6060 d.mu.Lock()
61- _, stopped := d.stopped["stopped"]
61+ _, stopped := d.inactive["stopped"]
6262 resumed := d.findByNameLocked("stopped")
6363 d.mu.Unlock()
6464 require.True(t, stopped)
@@ -149,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
149149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
150150 d, source, old, _ := newManualSessionWithPTYs(t, nil)
151151 d.mu.Lock()
152- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
152+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
153153 d.mu.Unlock()
154154
155155 rc := d.attachCoordinator(source, nil, old, true)
@@ -189,7 +189,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
189189 require.NoError(t, <-done)
190190
191191 d.mu.Lock()
192- _, stillStopped := d.stopped["stopped"]
192+ _, stillStopped := d.inactive["stopped"]
193193 resumed := d.findByNameLocked("stopped")
194194 d.mu.Unlock()
195195 require.True(t, stillStopped)
M internal/usecase/daemon/navigation_overflow_test.go +2 -2
@@ -119,7 +119,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
119119 d.sessions[echo.id] = echo
120120 d.sessions[alpha.id] = alpha
121121 d.sessions[charlie.id] = charlie
122- d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"}
122+ d.inactive["bravo"] = inactiveSession{name: "bravo", cwd: "/tmp/bravo"}
123123 d.mu.Unlock()
124124 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
125125 handler := daemonKeyHandler{d: d, ac: ac}
@@ -150,7 +150,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
150150 target.tabs[1].mu.Unlock()
151151 }
152152 d.mu.Lock()
153- _, stopped := d.stopped["bravo"]
153+ _, stopped := d.inactive["bravo"]
154154 var bravoLive bool
155155 for _, candidate := range d.sessions {
156156 if candidate == nil {
M internal/usecase/daemon/palette.go +5 -4
@@ -60,9 +60,11 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
6060 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result {
6161 d.mu.Lock()
6262 sessions := d.sessionsSnapshotLocked()
63- stopped := make([]stoppedSession, 0, len(d.stopped))
64- for _, entry := range d.stopped {
65- if !entry.purging {
63+ stopped := make([]inactiveSession, 0, len(d.inactive))
64+ localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(d.inactive))
65+ for _, entry := range d.inactive {
66+ localLifecycles[entry.incarnation] = struct{}{}
67+ if entry.canResume() {
6668 stopped = append(stopped, entry)
6769 }
6870 }
@@ -73,7 +75,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
7375 results = append(results, palette.NewCommandResult(cmd))
7476 }
7577 active := make([]palette.Result, 0, len(sessions))
76- localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped))
7778 for _, candidate := range sessions {
7879 snap := candidate.snapshotView(viewOptions{})
7980 if snap.name == "" || snap.ephemeral {
M internal/usecase/daemon/palette_test.go +13 -6
@@ -228,8 +228,15 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
228228 d, current, ac, _ := newManualSessionWithPTYs(t, p)
229229 d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
230230 d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}}
231- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}
232- d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}
231+ d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 12, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
232+ d.inactive["purging"] = inactiveSession{name: "purging", createdAt: 13, incarnation: domain.IncarnationID{4}, state: ports.SessionDown, purging: true}
233+ d.inactive["broken"] = inactiveSession{name: "broken", createdAt: 14, incarnation: domain.IncarnationID{5}, state: ports.SessionBroken}
234+ d.inactive["degraded"] = inactiveSession{name: "degraded", createdAt: 15, incarnation: domain.IncarnationID{6}, state: ports.SessionDown, record: domain.CatalogueRecord{DegradedReason: "checkpoint unavailable"}}
235+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Entries: []ports.RecentRouteEntry{
236+ testRouteEntry(1, 1, "purging", 4, ports.RouteKindLocal),
237+ testRouteEntry(2, 1, "broken", 5, ports.RouteKindLocal),
238+ testRouteEntry(3, 1, "degraded", 6, ports.RouteKindLocal),
239+ }})
233240
234241 d.enterPalette(current, ac)
235242 ac.overlays.paletteMu.Lock()
@@ -314,8 +321,8 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
314321 p, release := newBlockingPTY(t)
315322 defer release()
316323 d, current, ac, sends := newManualSessionWithPTYs(t, p)
317- stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}}
318- d.stopped[stopped.name] = stopped
324+ stopped := inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}, state: ports.SessionDown}
325+ d.inactive[stopped.name] = stopped
319326 ptys := portsmocks.NewMockPTYFactory(t)
320327 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
321328 d.ptys = ptys
@@ -332,7 +339,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
332339 ac.overlays.paletteMu.Lock()
333340 require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
334341 ac.overlays.paletteMu.Unlock()
335- require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata")
342+ require.Equal(t, stopped, d.inactive[stopped.name], "failed resume must retain stopped lifecycle metadata")
336343 awaitFrame(t, sends, ports.MsgOutput)
337344 }
338345
@@ -343,7 +350,7 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
343350 defer release2()
344351 d, current, ac, sends := newManualSessionWithPTYs(t, p1)
345352 d.ptys = newFactory(t, p2)
346- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}
353+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, state: ports.SessionDown}
347354
348355 d.handleInput(current, ac, []byte("\x1b "))
349356 awaitFrame(t, sends, ports.MsgOutput)
M internal/usecase/daemon/picker.go +43 -94
@@ -100,9 +100,9 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
100100 }
101101 sessions = append(sessions, entry)
102102 }
103- stopped := make([]stoppedSession, 0, len(d.stopped))
104- for _, s := range d.stopped {
105- if !s.purging {
103+ stopped := make([]inactiveSession, 0, len(d.inactive))
104+ for _, s := range d.inactive {
105+ if s.visible() {
106106 stopped = append(stopped, s)
107107 }
108108 }
@@ -179,15 +179,8 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
179179 for _, item := range live {
180180 views = append(views, item.view.pickerView())
181181 }
182- seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
183182 for _, host := range catalog {
184183 for _, session := range host.entry.Sessions {
185- if session.LifecycleID != (domain.SessionLifecycleID{}) {
186- if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate {
187- continue
188- }
189- seenRemoteLifecycles[session.LifecycleID] = struct{}{}
190- }
191184 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
192185 if key.Validate() != nil {
193186 continue
@@ -886,15 +879,8 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
886879 if token.sess == nil || token.ac == nil || token.effect == nil {
887880 return nil
888881 }
889- if target.RemoteTarget != nil {
890- var key domain.RemoteSessionKey
891- if target.RemoteKey != nil {
892- key = *target.RemoteKey
893- }
894- return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
895- }
896- if target.RemoteKey != nil {
897- return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
882+ if target.RemoteTarget != nil || target.RemoteKey != nil {
883+ return d.sendRemoteAttachTargetForAttachment(token, target, guard, action)
898884 }
899885 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
900886 }
@@ -910,7 +896,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
910896 var exactTarget *ports.ExactSessionTarget
911897 var matches bool
912898 if target.Name != "" {
913- var stopped stoppedSession
899+ var stopped inactiveSession
914900 var stoppedTarget bool
915901 targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
916902 if stoppedTarget && matches {
@@ -971,36 +957,27 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
971957 // endpoint to the thin client. The daemon owns no remote session shadow: after
972958 // the target is sent, the local attachment is detached and the client opens a
973959 // fresh connection to the owning daemon.
974-func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
960+func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, _ string) error {
975961 failUnavailable := func() error {
976962 if token.attachmentCurrent() {
977963 d.notifyRemotePickerUnavailable(token.sess, target)
978964 }
979965 return errAttachmentTransition
980966 }
981- var handoff ports.AttachTarget
982- if target.RemoteTarget != nil {
983- remoteTarget := *target.RemoteTarget
984- if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
985- return failUnavailable()
986- }
987- if target.RemoteKey != nil {
988- if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
989- return failUnavailable()
990- }
991- }
992- handoff = ports.AttachTarget{
993- Endpoint: remoteTarget.Endpoint,
994- Session: remoteTarget.SessionName,
995- Intent: ports.IntentAttach,
996- RemoteTarget: &remoteTarget,
997- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
998- }
999- } else {
1000- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
1001- return failUnavailable()
1002- }
1003- handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
967+ if target.RemoteTarget == nil || target.RemoteKey == nil {
968+ return failUnavailable()
969+ }
970+ remoteTarget := *target.RemoteTarget
971+ key := *target.RemoteKey
972+ if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remotePickerTargetReadyTarget(remoteTarget) {
973+ return failUnavailable()
974+ }
975+ handoff := ports.AttachTarget{
976+ Endpoint: remoteTarget.Endpoint,
977+ Session: remoteTarget.SessionName,
978+ Intent: ports.IntentAttach,
979+ RemoteTarget: &remoteTarget,
980+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1004981 }
1005982 payload := ports.MarshalAttachTarget(handoff)
1006983 if payload == nil {
@@ -1028,24 +1005,6 @@ func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCache
10281005 return entry, true
10291006 }
10301007
1031-func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
1032- if d == nil {
1033- return false
1034- }
1035- d.remoteCatalog.mu.Lock()
1036- defer d.remoteCatalog.mu.Unlock()
1037- entry, ok := d.remoteCatalogEntryLocked(key.Host)
1038- if !ok {
1039- return false
1040- }
1041- for _, session := range entry.Sessions {
1042- if session.Name == key.Name {
1043- return session.State == "up"
1044- }
1045- }
1046- return false
1047-}
1048-
10491008 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
10501009 if d == nil || target.Validate() != nil {
10511010 return false
@@ -1060,7 +1019,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
10601019 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
10611020 continue
10621021 }
1063- if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
1022+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == ports.RemoteCatalogSessionBroken {
10641023 return false
10651024 }
10661025 tabs := ports.CatalogTabs(session)
@@ -1068,21 +1027,8 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
10681027 for _, tab := range tabs {
10691028 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
10701029 }
1071- if target.Stopped {
1072- _, ok := target.StoppedTab.Resolve(metadata)
1073- return ok
1074- }
1075- found := false
1076- for _, tab := range tabs {
1077- if tab.ID != string(target.LiveTabID) {
1078- continue
1079- }
1080- if found {
1081- return false
1082- }
1083- found = true
1084- }
1085- return found
1030+ _, ok := target.ResolveTab(metadata)
1031+ return ok
10861032 }
10871033 return false
10881034 }
@@ -1175,19 +1121,19 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
11751121 // resolveNamedLifecycleTargetLocked chooses exactly one current representation
11761122 // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
11771123 // identity here closes the lookup-to-handoff window for palette targets.
1178-func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
1124+func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, inactiveSession, bool, bool) {
11791125 if target.Name == "" {
1180- return nil, stoppedSession{}, false, false
1126+ return nil, inactiveSession{}, false, false
11811127 }
11821128 if active := d.findByNameLocked(target.Name); active != nil {
11831129 active.mu.Lock()
11841130 matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
11851131 active.mu.Unlock()
1186- return active, stoppedSession{}, false, matches
1132+ return active, inactiveSession{}, false, matches
11871133 }
1188- stopped, ok := d.stopped[target.Name]
1189- if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1190- return nil, stoppedSession{}, false, false
1134+ stopped, ok := d.inactive[target.Name]
1135+ if !ok || !stopped.canResume() || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1136+ return nil, inactiveSession{}, false, false
11911137 }
11921138 return nil, stopped, true, true
11931139 }
@@ -1282,10 +1228,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12821228 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
12831229 // the handoff while d.mu is held. Creation failure leaves the source client
12841230 // and stopped record untouched.
1285-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1286- if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
1231+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1232+ if stopped.broken() {
12871233 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
12881234 }
1235+ if !stopped.canResume() {
1236+ return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1237+ }
12891238
12901239 if sourceToken != nil {
12911240 var targetSess *session
@@ -1295,14 +1244,14 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12951244 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
12961245 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
12971246 createTargetLocked: func() (*session, error) {
1298- current, ok := d.stopped[target.Name]
1299- if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1247+ current, ok := d.inactive[target.Name]
1248+ if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
13001249 return nil, errAttachmentTransition
13011250 }
13021251 from.mu.Lock()
13031252 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
13041253 from.mu.Unlock()
1305- created, createErr := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
1254+ created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
13061255 targetSess = created
13071256 return created, createErr
13081257 },
@@ -1328,12 +1277,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
13281277 }
13291278 env := copyEnvironment(from.env)
13301279 from.mu.Unlock()
1331- current, ok := d.stopped[target.Name]
1332- if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1280+ current, ok := d.inactive[target.Name]
1281+ if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
13331282 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
13341283 }
13351284 cwd := d.dirOrHome(current.cwd)
1336- targetSess, err := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
1285+ targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
13371286 if err != nil {
13381287 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
13391288 return nil, attachmentTransitionResult{}, false, err
@@ -1376,12 +1325,12 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
13761325 // its stopped target and commits creation under one d.mu critical section.
13771326 func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
13781327 d.mu.Lock()
1379- stopped, ok := d.stopped[target.Name]
1328+ stopped, ok := d.inactive[target.Name]
13801329 var (
13811330 transition attachmentTransitionResult
13821331 switched bool
13831332 )
1384- if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1333+ if ok && stopped.canResume() && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
13851334 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
13861335 }
13871336 d.mu.Unlock()
M internal/usecase/daemon/picker_test.go +16 -16
@@ -136,8 +136,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
136136 d.sessions[old.id] = old
137137 d.sessions[eph.id] = eph
138138 d.sessions[recent.id] = recent
139- d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
140- d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
139+ d.inactive["halted-old"] = inactiveSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
140+ d.inactive["halted-new"] = inactiveSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
141141
142142 views, _ := d.pickerViews(recent, nil)
143143
@@ -177,7 +177,7 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
177177 d.sessions[eph.id] = eph
178178 d.sessions[named.id] = named
179179 d.sessions[named2.id] = named2
180- d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
180+ d.inactive["halted"] = inactiveSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
181181
182182 views, _ := d.pickerViews(named, nil)
183183
@@ -217,7 +217,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
217217 eph.mruAt.Store(5)
218218 d.mu.Lock()
219219 d.sessions[eph.id] = eph
220- d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
220+ d.inactive["halted"] = inactiveSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
221221 d.mu.Unlock()
222222
223223 d.enterPicker(sess, ac)
@@ -288,7 +288,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
288288 defer cancel()
289289 active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
290290 d.sessions[active.id] = active
291- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
291+ d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 24}
292292
293293 views, _ := d.pickerViews(active, nil)
294294
@@ -310,7 +310,7 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
310310 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}
311311 d.sessions[source.id] = source
312312 d.sessions[destination.id] = destination
313- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
313+ d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
314314 sourceLocator := moveSourceLocator{
315315 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
316316 TabID: "source-tab",
@@ -664,7 +664,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
664664 record := durableRecoveryRecord(0)
665665 record.Name = "restoring"
666666 state, done := initialSessionState(record)
667- d.stopped[record.Name] = stoppedSession{
667+ d.inactive[record.Name] = inactiveSession{
668668 name: record.Name,
669669 createdAt: record.CreatedAt,
670670 incarnation: record.IncarnationID,
@@ -692,7 +692,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
692692 defer cancel()
693693 target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
694694 d.mu.Lock()
695- delete(d.stopped, record.Name)
695+ delete(d.inactive, record.Name)
696696 d.sessions[target.id] = target
697697 closeRuntimeRestoreDoneLocked(done)
698698 d.mu.Unlock()
@@ -731,7 +731,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
731731 <-restored
732732 require.Same(t, from, ac.currentSession())
733733 d.mu.Lock()
734- entry := d.stopped[record.Name]
734+ entry := d.inactive[record.Name]
735735 d.mu.Unlock()
736736 require.Equal(t, ports.SessionBroken, entry.state)
737737 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
@@ -765,7 +765,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
765765 }
766766 catalogue := persist.New(store)
767767 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
768- d.stopped[record.Name] = stoppedSession{
768+ d.inactive[record.Name] = inactiveSession{
769769 name: record.Name,
770770 createdAt: record.CreatedAt,
771771 incarnation: record.IncarnationID,
@@ -791,7 +791,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
791791 storeState.mu.Unlock()
792792 require.Zero(t, sets, "unsafe target must not mutate the catalogue")
793793 d.mu.Lock()
794- entry := d.stopped[record.Name]
794+ entry := d.inactive[record.Name]
795795 d.mu.Unlock()
796796 require.Equal(t, record, entry.record)
797797 require.Equal(t, tt.runtimeState, entry.state)
@@ -808,7 +808,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
808808 defer release3()
809809 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
810810 d.ptys = newFactorySeq(t, p2, p3)
811- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown}
811+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown}
812812
813813 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
814814 awaitFrame(t, sends, ports.MsgOutput)
@@ -1406,7 +1406,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
14061406 return got != normalSize && got.Valid()
14071407 })).Return(floating, nil).Once()
14081408 d := newTestDaemon(t, f, stubClock{})
1409- d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
1409+ d.inactive["old"] = inactiveSession{name: "old", cwd: t.TempDir(), createdAt: 1, state: ports.SessionDown}
14101410 tr := portsmocks.NewMockTransport(t)
14111411 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
14121412 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1448,7 +1448,7 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
14481448 p, release := newBlockingPTY(t)
14491449 defer release()
14501450 d, from, ac, sends := newManualSessionWithPTYs(t, p)
1451- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1451+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, state: ports.SessionDown}
14521452 cause := errors.New("open failed")
14531453 ptys := portsmocks.NewMockPTYFactory(t)
14541454 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
@@ -1659,7 +1659,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
16591659 state.mu.Lock()
16601660 state.deleteErr = func(string) error { return cause }
16611661 state.mu.Unlock()
1662- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record}
1662+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record}
16631663
16641664 d.enterPicker(from, ac)
16651665 awaitFrame(t, sends, ports.MsgOutput)
@@ -1677,7 +1677,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
16771677 require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
16781678
16791679 d.mu.Lock()
1680- stopped, retained := d.stopped["stopped"]
1680+ stopped, retained := d.inactive["stopped"]
16811681 d.mu.Unlock()
16821682 require.True(t, retained, "failed deletion must retain the reserved name")
16831683 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
M internal/usecase/daemon/recovery_observability.go +5 -5
@@ -26,10 +26,10 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) {
2626 records, err = d.catalogue.Records()
2727 if err != nil {
2828 d.log.Error("daemon_startup_catalogue_read_failed", "err", err)
29- records = d.stoppedRecords()
29+ records = d.inactiveRecords()
3030 }
3131 } else {
32- records = d.stoppedRecords()
32+ records = d.inactiveRecords()
3333 }
3434 healthy, fresh, broken := 0, 0, 0
3535 for _, record := range records {
@@ -50,11 +50,11 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) {
5050 )
5151 }
5252
53-func (d *Daemon) stoppedRecords() []domain.CatalogueRecord {
53+func (d *Daemon) inactiveRecords() []domain.CatalogueRecord {
5454 d.mu.Lock()
5555 defer d.mu.Unlock()
56- records := make([]domain.CatalogueRecord, 0, len(d.stopped))
57- for _, entry := range d.stopped {
56+ records := make([]domain.CatalogueRecord, 0, len(d.inactive))
57+ for _, entry := range d.inactive {
5858 records = append(records, entry.record)
5959 }
6060 return records
M internal/usecase/daemon/recovery_observability_test.go +1 -1
@@ -57,7 +57,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
5757 d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))
5858 d.persistEnabled = true
5959 d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")}
60- d.stopped[record.Name] = stoppedSession{record: record}
60+ d.inactive[record.Name] = inactiveSession{record: record}
6161
6262 d.logStartupRecoveryCounts(0)
6363
M internal/usecase/daemon/remote_attach.go +34 -28
@@ -27,6 +27,10 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
2727 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires attach or resume"}
2828 }
2929 if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
30+ var protocol *protoErr
31+ if errors.As(err, &protocol) && protocol.code == ports.ErrInternal {
32+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is unavailable"}
33+ }
3034 return nil, nil, remoteTargetError(err)
3135 }
3236
@@ -52,16 +56,20 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
5256 return live, ac, err
5357 }
5458
55- stopped, ok := d.stopped[target.SessionName]
56- if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
59+ if !target.Stopped {
60+ d.mu.Unlock()
61+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "live remote target has no active runtime"}
62+ }
63+ inactive, ok := d.inactive[target.SessionName]
64+ if !ok || inactive.incarnation != target.LifecycleID {
5765 d.mu.Unlock()
5866 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
5967 }
60- if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" {
68+ if !inactive.canResume() {
6169 d.mu.Unlock()
62- return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"}
70+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is unavailable"}
6371 }
64- if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok {
72+ if _, ok := remoteTargetTabIndexInactive(inactive, target); !ok {
6573 d.mu.Unlock()
6674 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}
6775 }
@@ -70,16 +78,30 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
7078 // client's Env/Cwd fields remain present for wire compatibility but are not
7179 // trusted for this branch.
7280 env := copyEnvironment(d.baseEnv)
73- cwd := d.dirOrHome(stopped.cwd)
74- sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, env, stopped.tabNames)
81+ cwd := d.dirOrHome(inactive.cwd)
82+ sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Size, env, inactive)
7583 if err != nil {
7684 d.mu.Unlock()
85+ if errors.Is(err, errAttachmentTransition) || errors.Is(err, errSessionNameInUse) {
86+ return nil, nil, remoteTargetError(err)
87+ }
7788 return nil, nil, err
7889 }
7990 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
8091 return sess, ac, err
8192 }
8293
94+func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, size domain.Size, env []string, expected inactiveSession) (*session, error) {
95+ validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
96+ if !target.Stopped || d.persistEnabled && !authoritativeExists {
97+ return false
98+ }
99+ _, ok := target.ResolveTab(stoppedTabMetadata(current))
100+ return ok
101+ }
102+ return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, size, env, &expected, validate, expected.tabNames)
103+}
104+
83105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84106 payload := ports.MarshalNavigationAction(action)
85107 if payload == nil {
@@ -116,7 +138,7 @@ func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnect
116138 func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
117139 ac, err := d.finishAttach(sess, tr, sz, h)
118140 if err != nil && routeCreated {
119- if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
141+ if cleanupErr := d.killSessionIfEmpty(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
120142 err = errors.Join(err, cleanupErr)
121143 }
122144 return nil, err
@@ -156,7 +178,7 @@ func remoteTargetError(err error) error {
156178 return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}
157179 }
158180
159-func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
181+func stoppedTabMetadata(stopped inactiveSession) []domain.TabSelectorTab {
160182 records := stopped.tabRecords
161183 if len(records) == 0 {
162184 records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
@@ -171,8 +193,8 @@ func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
171193 return metadata
172194 }
173195
174-func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) {
175- return target.StoppedTab.Resolve(stoppedTabMetadata(stopped))
196+func remoteTargetTabIndexInactive(inactive inactiveSession, target domain.RemoteSessionTarget) (int, bool) {
197+ return target.ResolveTab(stoppedTabMetadata(inactive))
176198 }
177199
178200 // remoteTargetTabIndexLocked resolves the selector against the live session's
@@ -194,21 +216,5 @@ func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget
194216 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
195217 tab.mu.Unlock()
196218 }
197- if target.Stopped {
198- return target.StoppedTab.Resolve(metadata)
199- }
200- if target.LiveTabID == "" {
201- return 0, false
202- }
203- found := -1
204- for i, tab := range metadata {
205- if tab.ID != target.LiveTabID {
206- continue
207- }
208- if found >= 0 {
209- return 0, false
210- }
211- found = i
212- }
213- return found, found >= 0
219+ return target.ResolveTab(metadata)
214220 }
M internal/usecase/daemon/remote_attach_test.go +52 -2
@@ -58,7 +58,7 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
5858 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
5959 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
6060 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
61- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
61+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
6262 frame := receiveRemotePicker(t, sends, "rich attach target")
6363 got, err := ports.UnmarshalAttachTarget(frame.Payload)
6464 require.NoError(t, err)
@@ -67,6 +67,56 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
6767 require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
6868 }
6969
70+func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
71+ for _, test := range []struct {
72+ name string
73+ mutate func(*picker.Target)
74+ }{
75+ {name: "missing key", mutate: func(target *picker.Target) { target.RemoteKey = nil }},
76+ {name: "endpoint mismatch", mutate: func(target *picker.Target) {
77+ key := *target.RemoteKey
78+ key.Host = "other"
79+ target.RemoteKey = &key
80+ target.Session = key.ID()
81+ }},
82+ } {
83+ t.Run(test.name, func(t *testing.T) {
84+ d := newRemotePickerDaemon(nil)
85+ lifecycle := remoteLifecycleForTest()
86+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
87+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
88+ LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
89+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
90+ }},
91+ }})
92+ d.remoteCatalog.mu.Lock()
93+ d.remoteCatalog.status["arch"] = remoteHostFresh
94+ d.remoteCatalog.mu.Unlock()
95+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
96+ token := sess.attachmentToken(ac, ac.transport())
97+ effect, admitted := ac.beginAttachmentEffect(token)
98+ require.True(t, admitted)
99+ token.effect = effect
100+ defer effect.End()
101+
102+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
103+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
104+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
105+ test.mutate(&target)
106+
107+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
108+ for {
109+ select {
110+ case frame := <-sends:
111+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
112+ default:
113+ return
114+ }
115+ }
116+ })
117+ }
118+}
119+
70120 func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
71121 d := newRemotePickerDaemon(nil)
72122 lifecycle := remoteLifecycleForTest()
@@ -89,7 +139,7 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
89139 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
90140 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
91141 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
92- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
142+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
93143 for {
94144 select {
95145 case frame := <-sends:
M internal/usecase/daemon/remote_picker.go +15 -21
@@ -124,8 +124,8 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
124124 }
125125 }
126126
127-func remoteSessionStateStopped(state string) bool {
128- return state == "down" || state == "stopped"
127+func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool {
128+ return state == ports.RemoteCatalogSessionDown
129129 }
130130
131131 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
@@ -133,7 +133,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
133133 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
134134 availability := remotePickerAvailability(status)
135135 stopped := remoteSessionStateStopped(session.State)
136- broken := session.State == "broken"
136+ broken := session.State == ports.RemoteCatalogSessionBroken
137137 tabs := ports.CatalogTabs(session)
138138 viewTabs := make([]picker.TabEntry, 0, len(tabs))
139139 active := 0
@@ -153,7 +153,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
153153 Attention: tab.Attention,
154154 })
155155 }
156- legacy := session.LifecycleID == (domain.SessionLifecycleID{})
157156 target := domain.RemoteSessionTarget{
158157 Endpoint: key.Host,
159158 DisplayOrigin: key.DisplayOrigin,
@@ -178,23 +177,22 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178177 // malformed or a broken session cannot be activated. Cursor navigation
179178 // must remain possible, while sendRemoteAttachTargetForAttachment will
180179 // fail closed on the invalid target.
181- var remoteTarget *domain.RemoteSessionTarget
182- if !legacy {
183- remoteTarget = &target
180+ remoteTarget := &target
181+ if stopped && len(viewTabs) == 0 {
182+ // A healthy durable record may intentionally have no retained tabs,
183+ // for example after a protocol reset. Mirror local stopped rows with
184+ // one default picker target; the owning daemon creates that sole tab.
185+ viewTabs = append(viewTabs, picker.TabEntry{})
184186 }
185187 reason := remoteReasonForStatus(status)
186- targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
188+ targetValid := remoteTarget.Validate() == nil
187189 activation := picker.RemoteUnavailable
188190 if broken {
189191 reason = "session_broken"
190- } else if !legacy && !targetValid {
192+ } else if !targetValid {
191193 reason = "identity_changed"
192194 } else if status == remoteHostFresh {
193- if legacy {
194- if !stopped {
195- activation = picker.RemoteAttach
196- }
197- } else if stopped {
195+ if stopped {
198196 activation = picker.RemoteRestart
199197 } else {
200198 activation = picker.RemoteAttach
@@ -206,18 +204,16 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
206204 switch {
207205 case broken:
208206 detail = "session broken"
209- case !legacy && !targetValid:
207+ case !targetValid:
210208 detail = "identity changed"
211- case legacy && stopped:
212- detail = "stopped — exact identity required"
213209 case activation == picker.RemoteRestart:
214210 detail = "stopped — Enter to restart"
215- case session.State == "up":
211+ case session.State == ports.RemoteCatalogSessionUp:
216212 detail = "up"
217213 case stopped:
218214 detail = "stopped"
219215 default:
220- detail = session.State
216+ detail = string(session.State)
221217 }
222218 }
223219 return picker.SessionView{
@@ -233,7 +229,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
233229 Tabs: viewTabs,
234230 Active: active,
235231 Stopped: stopped,
236- ConnectOnly: legacy,
237232 CannotAcceptMoves: true,
238233 }
239234 }
@@ -266,7 +261,6 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
266261 RemoteAvailability: remotePickerAvailability(status),
267262 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
268263 RemoteActivation: picker.RemoteUnavailable,
269- ConnectOnly: true,
270264 CannotAcceptMoves: true,
271265 }
272266 }
M internal/usecase/daemon/remote_picker_test.go +129 -68
@@ -59,30 +59,19 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
5959 }
6060
6161 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
62- for _, state := range []string{"down", "stopped"} {
63- t.Run(state, func(t *testing.T) {
64- lifecycle := remoteLifecycleForTest()
65- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
66- session := ports.RemoteCatalogSession{
67- LifecycleID: lifecycle, Name: "work", State: state,
68- Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
69- }
70- view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
71- require.True(t, view.Stopped)
72- require.NotNil(t, view.RemoteTarget)
73- require.True(t, view.RemoteTarget.Stopped)
74- require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target")
75- require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)
76-
77- legacy := session
78- legacy.LifecycleID = domain.SessionLifecycleID{}
79- legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
80- require.True(t, legacyView.Stopped)
81- require.True(t, legacyView.ConnectOnly)
82- require.Equal(t, picker.RemoteUnavailable, legacyView.RemoteActivation, "legacy stopped rows cannot be selected without lifecycle identity")
83- require.Equal(t, "stopped — exact identity required", legacyView.RemoteDetail)
84- })
62+ lifecycle := remoteLifecycleForTest()
63+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
64+ session := ports.RemoteCatalogSession{
65+ LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionDown,
66+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
8567 }
68+ view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
69+ require.True(t, view.Stopped)
70+ require.NotNil(t, view.RemoteTarget)
71+ require.True(t, view.RemoteTarget.Stopped)
72+ require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target")
73+ require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)
74+
8675 }
8776
8877 func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
@@ -106,7 +95,7 @@ func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
10695 }
10796 }
10897
109-func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) {
98+func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) {
11099 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
111100 d := newRemotePickerDaemon(store)
112101 lifecycle := remoteLifecycleForTest()
@@ -130,8 +119,9 @@ func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testi
130119 targets = append(targets, view.RemoteTarget)
131120 }
132121 }
133- require.Len(t, targets, 1)
134- require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint")
122+ require.Len(t, targets, 2)
123+ require.Equal(t, "remote", targets[0].Endpoint)
124+ require.Equal(t, "vev@remote", targets[1].Endpoint)
135125 }
136126
137127 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
@@ -465,6 +455,14 @@ func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog,
465455 }
466456 }
467457
458+func remoteCatalogForTest(sessions ...ports.RemoteCatalogSession) ports.RemoteCatalog {
459+ return ports.RemoteCatalog{
460+ ProtocolVersion: ports.ProtocolVersion,
461+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
462+ Sessions: append([]ports.RemoteCatalogSession{}, sessions...),
463+ }
464+}
465+
468466 func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
469467 t.Helper()
470468 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
@@ -523,7 +521,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
523521 }
524522 for range 2 {
525523 request := receiveRemotePicker(t, catalog.requests, "catalog request")
526- request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
524+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
527525 }
528526 for range 2 {
529527 receiveRemotePicker(t, cache.stores, "remote cache store")
@@ -553,7 +551,7 @@ func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
553551
554552 d.startRemotePickerRefresh(instance)
555553 request := receiveRemotePicker(t, catalog.requests, "catalog request")
556- request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}
554+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
557555 receiveRemotePicker(t, cache.stores, "remote cache store")
558556 requireNoRemoteLockViolations(t, catalog, cache)
559557 }
@@ -596,7 +594,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
596594 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
597595 d.remoteCatalog.mu.Unlock()
598596
599- require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))
597+ require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil))
600598 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
601599 require.Equal(t, []ports.RemoteCatalogCacheEntry{
602600 {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
@@ -619,9 +617,15 @@ func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
619617 d.remoteCatalog.refresh = 9
620618 d.remoteCatalog.mu.Unlock()
621619
622- require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "up"}}}, nil))
620+ require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{
621+ LifecycleID: remoteLifecycleForTest(), Name: "late", State: ports.RemoteCatalogSessionUp,
622+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
623+ }), nil))
623624 hosts.set()
624- require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "up"}}}, nil))
625+ require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{
626+ LifecycleID: remoteLifecycleForTest(), Name: "removed", State: ports.RemoteCatalogSessionUp,
627+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
628+ }), nil))
625629 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
626630 requireNoRemoteLockViolations(t, nil, cache)
627631 d.remoteCatalog.mu.Lock()
@@ -669,9 +673,14 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
669673 }
670674
671675 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
676+ lifecycle := remoteLifecycleForTest()
677+ remoteSession := ports.RemoteCatalogSession{
678+ LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
679+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
680+ }
672681 d := newRemotePickerDaemon(nil)
673682 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
674- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
683+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},
675684 }})
676685 d.remoteCatalog.mu.Lock()
677686 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -681,15 +690,16 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
681690 effect, admitted := ac.beginAttachmentEffect(token)
682691 require.True(t, admitted)
683692 token.effect = effect
684- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
685- target := picker.Target{Session: key.ID(), RemoteKey: &key}
686- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
693+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
694+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
695+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
696+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
687697
688698 frame := receiveRemotePicker(t, sends, "attach target")
689699 require.Equal(t, ports.MsgAttachTarget, frame.Type)
690700 got, err := ports.UnmarshalAttachTarget(frame.Payload)
691701 require.NoError(t, err)
692- require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got)
702+ require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got)
693703 require.Nil(t, ac.currentAttachmentSession())
694704 d.mu.Lock()
695705 require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
@@ -736,7 +746,7 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
736746 effect, admitted := localAttachment.beginAttachmentEffect(token)
737747 require.True(t, admitted)
738748 token.effect = effect
739- require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
749+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
740750
741751 frame := receiveRemotePicker(t, sends, "stopped remote target")
742752 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
@@ -749,8 +759,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
749759
750760 remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
751761 remote.mu.Lock()
752- remote.stopped["work"] = stoppedSession{
753- name: "work", cwd: "/remote/work", incarnation: lifecycle,
762+ remote.inactive["work"] = inactiveSession{
763+ name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
754764 tabNames: []string{"alpha", "beta"},
755765 tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}},
756766 }
@@ -767,6 +777,71 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
767777 require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID)
768778 }
769779
780+func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing.T) {
781+ lifecycle := remoteLifecycleForTest()
782+ remoteSession := ports.RemoteCatalogSession{
783+ LifecycleID: lifecycle,
784+ Name: "work",
785+ State: "down",
786+ Tabs: []ports.RemoteCatalogTab{},
787+ }
788+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
789+ remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
790+ require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation)
791+
792+ model := picker.New([]picker.SessionView{
793+ {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}},
794+ remoteView,
795+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
796+ var selected picker.Target
797+ for range 3 {
798+ model.Down()
799+ candidate, ok := model.Selected()
800+ if ok && candidate.RemoteTarget != nil {
801+ selected = candidate
802+ break
803+ }
804+ }
805+ require.NotNil(t, selected.RemoteTarget)
806+ require.True(t, selected.RemoteTarget.Stopped)
807+ require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)
808+
809+ local := newRemotePickerDaemon(nil)
810+ local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
811+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},
812+ }})
813+ local.remoteCatalog.mu.Lock()
814+ local.remoteCatalog.status["arch"] = remoteHostFresh
815+ local.remoteCatalog.mu.Unlock()
816+ localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
817+ token := localSession.attachmentToken(localAttachment, localAttachment.transport())
818+ effect, admitted := localAttachment.beginAttachmentEffect(token)
819+ require.True(t, admitted)
820+ token.effect = effect
821+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
822+
823+ frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")
824+ handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
825+ require.NoError(t, err)
826+ require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget)
827+
828+ remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
829+ remote.mu.Lock()
830+ remote.inactive["work"] = inactiveSession{name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown}
831+ remote.mu.Unlock()
832+ transport, _ := newCapturingTransport(t)
833+ restored, attachment, err := remote.routeWithContext(context.Background(), ports.Hello{
834+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: handoff.Session,
835+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget,
836+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
837+ }, transport)
838+ require.NoError(t, err)
839+ t.Cleanup(func() { remote.clientGone(restored, attachment, transport, false) })
840+ require.Equal(t, lifecycle, restored.incarnation)
841+ require.Len(t, restored.tabs, 1)
842+ require.Equal(t, restored.tabs[0].stableID, string(attachment.viewSnapshot().tabID))
843+}
844+
770845 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
771846 tests := []struct {
772847 name string
@@ -795,9 +870,14 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
795870 }
796871
797872 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
873+ lifecycle := remoteLifecycleForTest()
874+ remoteSession := ports.RemoteCatalogSession{
875+ LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp,
876+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}},
877+ }
798878 d := newRemotePickerDaemon(nil)
799879 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
800- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
880+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession},
801881 }})
802882 d.remoteCatalog.mu.Lock()
803883 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -818,9 +898,10 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
818898 require.True(t, admitted)
819899 token.effect = effect
820900 defer effect.End()
821- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
822- target := picker.Target{Session: key.ID(), RemoteKey: &key}
823- err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
901+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
902+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
903+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
904+ err := d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select")
824905 var userErr *domain.UserError
825906 require.ErrorAs(t, err, &userErr)
826907 require.Equal(t, "couldn't attach to remote session", userErr.Msg)
@@ -877,7 +958,11 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
877958
878959 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
879960 ProtocolVersion: ports.ProtocolVersion,
880- Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up", Tabs: 2}},
961+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
962+ Sessions: []ports.RemoteCatalogSession{{
963+ LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up",
964+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}},
965+ }},
881966 }, nil))
882967 receiveRemotePicker(t, cache.stores, "remote cache store")
883968 requireNoRemoteLockViolations(t, catalog, cache)
@@ -1186,27 +1271,3 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
11861271 require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
11871272 ac.overlays.pickerMu.Unlock()
11881273 }
1189-
1190-func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
1191- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1192- for _, tt := range []struct {
1193- status remoteHostStatus
1194- activation picker.RemoteActivation
1195- }{
1196- {status: remoteHostCached, activation: picker.RemoteUnavailable},
1197- {status: remoteHostRefreshing, activation: picker.RemoteUnavailable},
1198- {status: remoteHostStale, activation: picker.RemoteUnavailable},
1199- {status: remoteHostMalformed, activation: picker.RemoteUnavailable},
1200- {status: remoteHostFresh, activation: picker.RemoteAttach},
1201- {status: remoteHostUnreachable, activation: picker.RemoteUnavailable},
1202- } {
1203- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0))
1204- require.Equal(t, tt.activation, view.RemoteActivation, "status %d activation", tt.status)
1205- _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1206- require.Equal(t, tt.activation == picker.RemoteAttach, selectable, "status %d selection", tt.status)
1207- }
1208- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})
1209- require.Equal(t, picker.RemoteUnavailable, mismatch.RemoteActivation)
1210- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1211- require.False(t, selectable)
1212-}
M internal/usecase/daemon/remote_route_test.go +75 -0
@@ -50,6 +50,81 @@ func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) {
5050 require.False(t, retained, "failed route attach must remove its newly created session")
5151 }
5252
53+func TestFinishRouteAttachPreservesConcurrentAttachment(t *testing.T) {
54+ pty, release := newBlockingPTY(t)
55+ defer release()
56+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
57+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", defaultSize, terminalEnv{}, d.baseEnv)
58+ require.NoError(t, err)
59+ winner, winnerTransport := attachWhenRouteCleanupSnapshots(t, d, sess)
60+
61+ missing := domain.RemoteSessionTarget{LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: "missing-tab"}
62+ d.mu.Lock()
63+ _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, ports.Hello{
64+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
65+ RemoteTarget: &missing, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
66+ }, true, true)
67+
68+ var protocol *protoErr
69+ require.ErrorAs(t, err, &protocol)
70+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
71+ require.Same(t, sess, winner().currentAttachmentSession())
72+ d.mu.Lock()
73+ require.Same(t, sess, d.sessions[sess.id])
74+ d.mu.Unlock()
75+ d.clientGone(sess, winner(), winnerTransport, false)
76+}
77+
78+func TestFailedHandshakeCleanupPreservesConcurrentAttachment(t *testing.T) {
79+ pty, release := newBlockingPTY(t)
80+ defer release()
81+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
82+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", defaultSize, terminalEnv{}, d.baseEnv)
83+ require.NoError(t, err)
84+ initialTransport, _ := newCapturingTransport(t)
85+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: domain.TabStableID(sess.tabs[0].stableID)}
86+ _, initial, err := d.routeWithContext(context.Background(), ports.Hello{
87+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
88+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
89+ }, initialTransport)
90+ require.NoError(t, err)
91+ initial.routeCreatedSession = true
92+ initial.routeSessionPurge = true
93+ winner, winnerTransport := attachWhenRouteCleanupSnapshots(t, d, sess)
94+
95+ d.failHandshakeAttachment(sess, initial, initialTransport, false)
96+
97+ require.Same(t, sess, winner().currentAttachmentSession())
98+ d.mu.Lock()
99+ require.Same(t, sess, d.sessions[sess.id])
100+ d.mu.Unlock()
101+ d.clientGone(sess, winner(), winnerTransport, false)
102+}
103+
104+func attachWhenRouteCleanupSnapshots(t *testing.T, d *Daemon, sess *session) (func() *attachedClient, ports.Transport) {
105+ t.Helper()
106+ winnerTransport, _ := newCapturingTransport(t)
107+ var winner *attachedClient
108+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
109+ d.afterAttachmentEffectParticipantsSnapshotted = nil
110+ target := domain.RemoteSessionTarget{
111+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
112+ SessionName: sess.name, LiveTabID: domain.TabStableID(sess.tabs[0].stableID),
113+ }
114+ var err error
115+ _, winner, err = d.routeWithContext(context.Background(), ports.Hello{
116+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, Size: defaultSize,
117+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
118+ }, winnerTransport)
119+ require.NoError(t, err)
120+ }
121+ return func() *attachedClient {
122+ t.Helper()
123+ require.NotNil(t, winner)
124+ return winner
125+ }, winnerTransport
126+}
127+
53128 func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {
54129 d := newTestDaemon(t, nil, stubClock{})
55130 sess := addControlSession(d, "work", "tab-new", "pane-new")
M internal/usecase/daemon/remote_stopped_route_test.go +190 -4
@@ -2,12 +2,16 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67
8+ "github.com/stretchr/testify/mock"
79 "github.com/stretchr/testify/require"
810
911 "github.com/bnema/vev/internal/domain"
1012 "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1115 )
1216
1317 func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) {
@@ -37,11 +41,11 @@ func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) {
3741 }
3842 }
3943
40-func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
44+func TestDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
4145 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
4246 d.baseEnv = []string{"DAEMON=owned"}
4347 d.mu.Lock()
44- d.stopped["work"] = stoppedSession{name: "work", cwd: "/remote/work", tabNames: []string{"main"}, state: ports.SessionDown}
48+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/remote/work", tabNames: []string{"main"}, state: ports.SessionDown}
4549 d.mu.Unlock()
4650
4751 hello := ports.Hello{
@@ -58,12 +62,194 @@ func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
5862 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
5963 }
6064
65+func TestRouteRemoteTargetRejectsLiveIntentForInactiveSession(t *testing.T) {
66+ d := newTestDaemon(t, nil, stubClock{})
67+ lifecycle := remoteLifecycleForTest()
68+ d.mu.Lock()
69+ d.inactive["work"] = inactiveSession{
70+ name: "work", incarnation: lifecycle, state: ports.SessionDown,
71+ tabNames: []string{"main"},
72+ tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1", Name: "main"}},
73+ }
74+ d.mu.Unlock()
75+
76+ target := domain.RemoteSessionTarget{
77+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
78+ SessionName: "work", LiveTabID: "tab-1",
79+ }
80+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
81+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
82+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
83+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
84+ }, &closeTrackingTransport{})
85+
86+ var protocol *protoErr
87+ require.ErrorAs(t, err, &protocol)
88+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
89+ d.mu.Lock()
90+ defer d.mu.Unlock()
91+ require.Empty(t, d.sessions)
92+ require.Contains(t, d.inactive, "work")
93+}
94+
95+func TestRouteRemoteTargetMapsUnavailableInactiveStatesToNoSuchTarget(t *testing.T) {
96+ lifecycle := remoteLifecycleForTest()
97+ for _, test := range []struct {
98+ name string
99+ entry inactiveSession
100+ }{
101+ {name: "broken", entry: inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionBroken}},
102+ {name: "degraded", entry: inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionDown, record: domain.CatalogueRecord{Name: "work", DegradedReason: "checkpoint unavailable"}}},
103+ {name: "purging", entry: inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionDown, purging: true}},
104+ } {
105+ t.Run(test.name, func(t *testing.T) {
106+ d := newTestDaemon(t, nil, stubClock{})
107+ d.inactive["work"] = test.entry
108+ target := domain.RemoteSessionTarget{
109+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
110+ SessionName: "work", Stopped: true,
111+ }
112+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
113+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
114+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
115+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
116+ }, &closeTrackingTransport{})
117+
118+ var protocol *protoErr
119+ require.ErrorAs(t, err, &protocol)
120+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
121+ })
122+ }
123+}
124+
125+func TestRouteRemoteTargetMapsConcurrentInactiveResumeToNoSuchTarget(t *testing.T) {
126+ d := newTestDaemon(t, nil, stubClock{})
127+ lifecycle := remoteLifecycleForTest()
128+ d.inactive["work"] = inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionDown}
129+ d.creating["work"] = struct{}{}
130+ target := domain.RemoteSessionTarget{
131+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
132+ SessionName: "work", Stopped: true,
133+ }
134+
135+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
136+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
137+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
138+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
139+ }, &closeTrackingTransport{})
140+
141+ var protocol *protoErr
142+ require.ErrorAs(t, err, &protocol)
143+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
144+}
145+
146+func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) {
147+ newTarget := func(record domain.CatalogueRecord) domain.RemoteSessionTarget {
148+ return domain.RemoteSessionTarget{
149+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID,
150+ SessionName: record.Name, Stopped: true,
151+ }
152+ }
153+ route := func(d *Daemon, target domain.RemoteSessionTarget) error {
154+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
155+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: target.SessionName,
156+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
157+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
158+ }, &closeTrackingTransport{})
159+ return err
160+ }
161+
162+ t.Run("PTY startup", func(t *testing.T) {
163+ cause := errors.New("PTY unavailable")
164+ factory := portsmocks.NewMockPTYFactory(t)
165+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
166+ d := newTestDaemon(t, factory, stubClock{})
167+ record := durableRecoveryRecord(1)
168+ record.Committed = nil
169+ record.TabNames = nil
170+ record.TabRecords = nil
171+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
172+
173+ err := route(d, newTarget(record))
174+ require.ErrorIs(t, err, cause)
175+ var protocol *protoErr
176+ require.False(t, errors.As(err, &protocol))
177+ })
178+
179+ t.Run("catalogue read", func(t *testing.T) {
180+ cause := errors.New("catalogue unavailable")
181+ record := durableRecoveryRecord(1)
182+ record.Committed = nil
183+ record.TabNames = nil
184+ record.TabRecords = nil
185+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
186+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
187+ d.catalogue = recordErrorCatalogue{durableRecoveryCatalogue: catalogue, err: cause}
188+ d.persistEnabled = true
189+ d.recovery = recoveryusecase.NewCoordinator(d.catalogue, noOpSnapshotRepository{}, nil)
190+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
191+
192+ err := route(d, newTarget(record))
193+ require.ErrorIs(t, err, cause)
194+ var protocol *protoErr
195+ require.False(t, errors.As(err, &protocol))
196+ })
197+}
198+
199+func TestRouteRemoteTargetResumesCanonicalPersistedTabMetadata(t *testing.T) {
200+ for _, test := range []struct {
201+ name string
202+ tabNames []string
203+ tabRecords []domain.CatalogueTabRecord
204+ selector domain.TabSelector
205+ }{
206+ {
207+ name: "tab records without compatibility names",
208+ tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1"}},
209+ selector: domain.NewStableTabSelector("tab-1"),
210+ },
211+ {
212+ name: "tab names without stable records",
213+ tabNames: []string{"main"},
214+ selector: domain.NewOrdinalTabSelector(0, "main", 1),
215+ },
216+ } {
217+ t.Run(test.name, func(t *testing.T) {
218+ record := durableRecoveryRecord(1)
219+ record.Committed = nil
220+ record.TabNames = append([]string(nil), test.tabNames...)
221+ record.TabRecords = append([]domain.CatalogueTabRecord(nil), test.tabRecords...)
222+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
223+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
224+ d.catalogue = catalogue
225+ d.persistEnabled = true
226+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
227+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
228+
229+ target := domain.RemoteSessionTarget{
230+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID,
231+ SessionName: record.Name, Stopped: true, StoppedTab: test.selector,
232+ }
233+ transport, _ := newCapturingTransport(t)
234+ sess, attachment, err := d.routeWithContext(context.Background(), ports.Hello{
235+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name,
236+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
237+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
238+ }, transport)
239+ require.NoError(t, err)
240+ t.Cleanup(func() { d.clientGone(sess, attachment, transport, false) })
241+ require.Len(t, sess.tabs, 1)
242+ require.Equal(t, sess.tabs[0].stableID, string(attachment.viewSnapshot().tabID))
243+ })
244+ }
245+}
246+
61247 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
62248 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
63249 lifecycle := remoteLifecycleForTest()
64250 d.mu.Lock()
65- d.stopped["work"] = stoppedSession{
66- name: "work", cwd: "/remote/work", incarnation: lifecycle,
251+ d.inactive["work"] = inactiveSession{
252+ name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
67253 tabNames: []string{"alpha", "beta"},
68254 tabRecords: []domain.CatalogueTabRecord{
69255 {StableID: "tab-a", Name: "alpha"},
M internal/usecase/daemon/remote_stopped_selector_test.go +5 -5
@@ -10,7 +10,7 @@ import (
1010
1111 func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) {
1212 lifecycle := remoteLifecycleForTest()
13- stopped := stoppedSession{
13+ stopped := inactiveSession{
1414 name: "work", incarnation: lifecycle,
1515 tabNames: []string{"alpha", "beta"},
1616 tabRecords: []domain.CatalogueTabRecord{
@@ -24,13 +24,13 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T)
2424 SessionName: "work", Stopped: true,
2525 StoppedTab: domain.NewStableTabSelector("tab-b"),
2626 }
27- index, ok := remoteTargetTabIndexStopped(stopped, stable)
27+ index, ok := remoteTargetTabIndexInactive(stopped, stable)
2828 require.True(t, ok)
2929 require.Equal(t, 1, index)
3030
3131 ordinal := stable
3232 ordinal.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
33- index, ok = remoteTargetTabIndexStopped(stopped, ordinal)
33+ index, ok = remoteTargetTabIndexInactive(stopped, ordinal)
3434 require.True(t, ok)
3535 require.Equal(t, 1, index)
3636
@@ -51,7 +51,7 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T)
5151 return candidate
5252 }(),
5353 } {
54- _, ok := remoteTargetTabIndexStopped(stopped, invalid)
54+ _, ok := remoteTargetTabIndexInactive(stopped, invalid)
5555 require.False(t, ok)
5656 }
5757
@@ -60,7 +60,7 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T)
6060 {StableID: "tab-b", Name: "beta"},
6161 {StableID: "tab-a", Name: "alpha"},
6262 }
63- index, ok = remoteTargetTabIndexStopped(reordered, stable)
63+ index, ok = remoteTargetTabIndexInactive(reordered, stable)
6464 require.True(t, ok)
6565 require.Equal(t, 0, index, "stable selectors follow identity, not the old ordinal")
6666 }
M internal/usecase/daemon/session.go +69 -34
@@ -186,29 +186,48 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
186186 return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
187187 }
188188
189-// createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the
190-// stopped lifecycle after catalogue I/O, which intentionally runs without d.mu.
191-func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) {
192- return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...)
189+// resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks
190+// lifecycle and resumability after catalogue I/O, which intentionally runs
191+// without d.mu.
192+func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) {
193+ return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, sz, env, &expectedInactive, nil, restoredTabNames...)
193194 }
194195
195-func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool {
196- return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation
196+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
197+ return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, sz, env, nil, nil, restoredTabNames...)
197198 }
198199
199-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
200- return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...)
200+type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool
201+
202+func (s inactiveSession) matchesRecord(record domain.CatalogueRecord) bool {
203+ canonical := inactiveSessionFromRecord(record, s.state, s.restoreDone)
204+ return s.sameLifecycle(canonical) && s.cwd == canonical.cwd &&
205+ slices.Equal(s.tabNames, canonical.tabNames) && slices.Equal(s.tabRecords, canonical.tabRecords) &&
206+ s.record.DegradedReason == canonical.record.DegradedReason
207+}
208+
209+func inactiveResumeFenceAllows(current inactiveSession, exists bool, expected *inactiveSession, authoritative domain.CatalogueRecord, authoritativeExists bool, validate inactiveResumeValidator) bool {
210+ if expected == nil {
211+ return true
212+ }
213+ if !exists || !current.sameLifecycle(*expected) || !current.canResume() {
214+ return false
215+ }
216+ if authoritativeExists && !current.matchesRecord(authoritative) {
217+ return false
218+ }
219+ return validate == nil || validate(current, authoritative, authoritativeExists)
201220 }
202221
203-func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) {
222+func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {
204223 env = copyEnvironment(env)
205224 if _, reserved := d.creating[name]; reserved {
206225 return nil, errSessionNameInUse
207226 }
208227 d.creating[name] = struct{}{}
209228 defer delete(d.creating, name)
210- stopped, resuming := d.stopped[name]
211- if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {
229+ stopped, resuming := d.inactive[name]
230+ if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, domain.CatalogueRecord{}, false, nil) {
212231 return nil, errAttachmentTransition
213232 }
214233 var authoritative domain.CatalogueRecord
@@ -224,10 +243,12 @@ func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemer
224243 if d.closing {
225244 return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
226245 }
227- stopped, resuming = d.stopped[name]
228- if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {
246+ stopped, resuming = d.inactive[name]
247+ if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, authoritative, authoritativeExists, validateInactive) {
229248 return nil, errAttachmentTransition
230249 }
250+ } else if validateInactive != nil && !validateInactive(stopped, domain.CatalogueRecord{}, false) {
251+ return nil, errAttachmentTransition
231252 }
232253 var createdAt int64
233254 var incarnation domain.IncarnationID
@@ -409,7 +430,7 @@ func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemer
409430 if !ephemeral {
410431 // Keep stopped lifecycle authority intact until runtime publication and
411432 // its durable side effects have both succeeded.
412- delete(d.stopped, name)
433+ delete(d.inactive, name)
413434 }
414435 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
415436 for i, tb := range tabs {
@@ -934,7 +955,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
934955 return errSessionNameInUse
935956 }
936957 sess.mu.Lock()
937- if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
958+ if stopped, ok := d.inactive[name]; ok && stopped.name != sess.name {
938959 sess.mu.Unlock()
939960 d.mu.Unlock()
940961 return errSessionNameInUse
@@ -992,8 +1013,8 @@ func (d *Daemon) renameSession(sess *session, name string) error {
9921013 }
9931014 }
9941015
995- delete(d.stopped, oldName)
996- delete(d.stopped, name)
1016+ delete(d.inactive, oldName)
1017+ delete(d.inactive, name)
9971018 sess.name = name
9981019 sess.createdAt = createdAt
9991020 sess.incarnation = record.IncarnationID
@@ -1238,13 +1259,13 @@ func (d *Daemon) retryStoppedPurge(name string) error {
12381259
12391260 func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
12401261 d.mu.Lock()
1241- stopped, ok := d.stopped[name]
1262+ stopped, ok := d.inactive[name]
12421263 if !ok {
12431264 d.mu.Unlock()
12441265 return nil
12451266 }
12461267 stopped.purging = true
1247- d.stopped[name] = stopped
1268+ d.inactive[name] = stopped
12481269
12491270 if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
12501271 d.mu.Unlock()
@@ -1254,8 +1275,8 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro
12541275 d.mu.Unlock()
12551276 return err
12561277 }
1257- if current, ok := d.stopped[name]; ok && current.purging {
1258- delete(d.stopped, name)
1278+ if current, ok := d.inactive[name]; ok && current.purging {
1279+ delete(d.inactive, name)
12591280 }
12601281 d.mu.Unlock()
12611282 return nil
@@ -1288,7 +1309,7 @@ func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge boo
12881309 // architecture locks, but releases every lock before any attachment gate is frozen or
12891310 // drained. The initiator is part of the same deduplicated global gate set even
12901311 // when it belongs to a different source session.
1291-func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
1312+func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, requireEmpty bool) (sessionKillParticipants, bool) {
12921313 snapshot := sessionKillParticipants{target: target}
12931314 if admission != nil {
12941315 token := admission.token
@@ -1313,6 +1334,12 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
13131334 source = target
13141335 }
13151336 unlockSessions := lockAttachmentSessions(source, target)
1337+ if requireEmpty && len(target.attachments) != 0 {
1338+ unlockSessions()
1339+ d.notices.routingMu.Unlock()
1340+ d.mu.Unlock()
1341+ return sessionKillParticipants{}, false
1342+ }
13161343 snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
13171344 snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
13181345 for ac := range target.attachments {
@@ -1503,13 +1530,24 @@ func (s *session) finishTeardown() {
15031530 // only immutable capture state; it never observes closed worker channels or
15041531 // session-owned cache that teardown has released.
15051532 func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error {
1533+ return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, deadline, admission, false)
1534+}
1535+
1536+// killSessionIfEmpty tears down sess only while its exact registry entry has no
1537+// attachments. Membership is captured under the normal architecture locks and
1538+// fenced again at publication, so an attachment winning either side survives.
1539+func (d *Daemon) killSessionIfEmpty(sess *session, reason uint8, purge bool) error {
1540+ return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, nil, nil, true)
1541+}
1542+
1543+func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission, requireEmpty bool) error {
15061544 if sess == nil {
15071545 return nil
15081546 }
15091547 // Discover every attachment whose membership can be invalidated before freezing
15101548 // any one of them. The common gate helper deduplicates and freezes the whole
15111549 // immutable identity set in the same global order used by transitions.
1512- participants, ok := d.snapshotSessionKillParticipants(sess, admission)
1550+ participants, ok := d.snapshotSessionKillParticipants(sess, admission, requireEmpty)
15131551 if !ok {
15141552 return nil
15151553 }
@@ -1693,18 +1731,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16931731 }
16941732 clear(sess.attachments)
16951733 stoppedName := sess.name
1696- stoppedCwd := sess.cwd
1697- createdAt := sess.createdAt
16981734 stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1699- tabNames := stoppedRecord.TabNames
1700- tabRecords := stoppedRecord.TabRecords
17011735 ephemeral := sess.ephemeral
17021736 unlockCoordinators()
17031737 unlockSessions()
17041738 d.notices.routingMu.Unlock()
17051739 if !ephemeral {
1706- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, tabRecords: tabRecords, record: stoppedRecord, state: ports.SessionDown, purging: purge}
1707- d.stopped[stoppedName] = stopped
1740+ stopped := inactiveSessionFromRecord(stoppedRecord, ports.SessionDown, nil)
1741+ stopped.purging = purge
1742+ d.inactive[stoppedName] = stopped
17081743 }
17091744 empty := len(d.sessions) == 0
17101745 if empty {
@@ -1763,8 +1798,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
17631798 if err != nil {
17641799 purgeErr = errors.Join(purgeErr, err)
17651800 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
1766- } else if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
1767- delete(d.stopped, stoppedName)
1801+ } else if stopped, ok := d.inactive[stoppedName]; ok && stopped.purging {
1802+ delete(d.inactive, stoppedName)
17681803 }
17691804 d.mu.Unlock()
17701805 }
@@ -1783,13 +1818,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
17831818 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
17841819 // d.mu.
17851820 func (d *Daemon) allocEphemeralNameLocked() string {
1786- used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1821+ used := make(map[string]struct{}, len(d.sessions)+len(d.inactive))
17871822 for _, s := range d.sessions {
17881823 if s != nil {
17891824 used[s.name] = struct{}{}
17901825 }
17911826 }
1792- for name := range d.stopped {
1827+ for name := range d.inactive {
17931828 used[name] = struct{}{}
17941829 }
17951830 for i := 0; ; i++ {
@@ -1813,7 +1848,7 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
18131848 if d.findByNameLocked(name) != nil {
18141849 return true
18151850 }
1816- if _, ok := d.stopped[name]; ok {
1851+ if _, ok := d.inactive[name]; ok {
18171852 return true
18181853 }
18191854 _, reserved := d.creating[name]
M internal/usecase/daemon/session_purge_control_test.go +1 -1
@@ -23,7 +23,7 @@ func TestStoppedPurgeMetadataFailureFencesCatalogue(t *testing.T) {
2323 state.mu.Lock()
2424 state.deleteErr = func(string) error { return metadataErr }
2525 state.mu.Unlock()
26- d.stopped["work"] = stoppedSession{name: "work", incarnation: record.IncarnationID, record: record}
26+ d.inactive["work"] = inactiveSession{name: "work", incarnation: record.IncarnationID, record: record}
2727
2828 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
2929 require.Empty(t, repository.calls, "snapshot deletion must wait for catalogue removal")
M internal/usecase/daemon/session_test.go +23 -20
@@ -772,7 +772,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
772772
773773 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
774774 d.mu.Lock()
775- stopped := d.stopped["work"]
775+ stopped := d.inactive["work"]
776776 d.mu.Unlock()
777777 require.Equal(t, "work", stopped.name)
778778 require.Equal(t, "/tmp/work", stopped.cwd)
@@ -795,7 +795,7 @@ func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
795795 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))
796796 require.NotNil(t, d)
797797 require.False(t, d.persistEnabled)
798- require.Empty(t, d.stopped)
798+ require.Empty(t, d.inactive)
799799 require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
800800 }
801801
@@ -868,7 +868,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
868868 store, state := newMockStore(t)
869869 d := newTestDaemon(t, newFactory(t, p), stubClock{})
870870 WithStore(t, store)(d)
871- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
871+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
872872 d.mruSeq.Store(42)
873873
874874 sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
@@ -1015,7 +1015,10 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
10151015 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
10161016 WithStore(t, store)(d)
10171017 require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
1018- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
1018+ record, ok, err := d.catalogueRecord("work")
1019+ require.NoError(t, err)
1020+ require.True(t, ok)
1021+ d.inactive["work"] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
10191022 tr := portsmocks.NewMockTransport(t)
10201023 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
10211024 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1035,7 +1038,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
10351038 store, state := newMockStore(t)
10361039 d := newTestDaemon(t, newFactory(t, p), stubClock{})
10371040 WithStore(t, store)(d)
1038- d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
1041+ d.inactive["taken"] = inactiveSession{name: "taken", cwd: "/tmp", createdAt: 1}
10391042
10401043 sess, err := createSessionForTest(d, "0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
10411044 require.NoError(t, err)
@@ -1189,7 +1192,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
11891192 d := newTestDaemon(t, newFactory(t, p), stubClock{})
11901193 WithStore(t, store)(d)
11911194 cwd := t.TempDir()
1192- d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1}
1195+ d.inactive["work"] = inactiveSession{name: "work", cwd: cwd, createdAt: 1, state: ports.SessionDown}
11931196 tr := portsmocks.NewMockTransport(t)
11941197 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
11951198 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1200,7 +1203,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
12001203 require.Equal(t, "work", sess.name)
12011204 require.Equal(t, cwd, sess.cwd)
12021205 d.mu.Lock()
1203- _, stillStopped := d.stopped["work"]
1206+ _, stillStopped := d.inactive["work"]
12041207 d.mu.Unlock()
12051208 require.False(t, stillStopped)
12061209 }
@@ -1212,7 +1215,7 @@ func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
12121215 store, _ := newMockStore(t)
12131216 d := newTestDaemon(t, newFactory(t, p), stubClock{})
12141217 WithStore(t, store)(d)
1215- d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1}
1218+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1, state: ports.SessionDown}
12161219 tr := portsmocks.NewMockTransport(t)
12171220 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
12181221 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1227,11 +1230,11 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
12271230 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
12281231 WithStore(t, store)(d)
12291232 require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
1230- d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
1233+ d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp", createdAt: 1}
12311234 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
12321235 require.False(t, state.has("old"))
12331236 d.mu.Lock()
1234- _, ok := d.stopped["old"]
1237+ _, ok := d.inactive["old"]
12351238 d.mu.Unlock()
12361239 require.False(t, ok)
12371240 }
@@ -1293,7 +1296,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
12931296
12941297 func TestIntentNewStoppedNameRejected(t *testing.T) {
12951298 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1296- d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
1299+ d.inactive["taken"] = inactiveSession{name: "taken", cwd: "/tmp", createdAt: 1}
12971300 tr := portsmocks.NewMockTransport(t)
12981301 _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
12991302 require.ErrorContains(t, err, "name already in use")
@@ -1363,7 +1366,7 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
13631366 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
13641367 require.True(t, state.has("natural"))
13651368 d.mu.Lock()
1366- stopped := d.stopped["natural"]
1369+ stopped := d.inactive["natural"]
13671370 d.mu.Unlock()
13681371 require.Equal(t, "/tmp/latest", stopped.cwd)
13691372
@@ -1688,7 +1691,7 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
16881691 d := newTestDaemon(t, nil, stubClock{})
16891692 d.lastAllocatedCreatedAt = math.MaxInt64
16901693 d.nextID = 17
1691- d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}
1694+ d.inactive["retained"] = inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}
16921695
16931696 sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
16941697
@@ -1697,13 +1700,13 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
16971700 require.Empty(t, d.sessions)
16981701 require.Equal(t, uint64(17), d.nextID)
16991702 require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1700- require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
1703+ require.Equal(t, inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.inactive["retained"])
17011704
17021705 _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil)
17031706 require.ErrorContains(t, err, "lifecycle identities exhausted")
17041707 require.Empty(t, d.sessions)
17051708 require.Equal(t, uint64(17), d.nextID)
1706- require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
1709+ require.Equal(t, inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.inactive["retained"])
17071710 }
17081711
17091712 func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
@@ -1716,7 +1719,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
17161719
17171720 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))
17181721
1719- alpha := d.stopped["alpha"]
1722+ alpha := d.inactive["alpha"]
17201723 require.Equal(t, records[0], alpha.record)
17211724 require.Equal(t, ports.SessionDown, alpha.state)
17221725 require.NotNil(t, alpha.restoreDone)
@@ -1725,7 +1728,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
17251728 t.Fatal("healthy catalogue record must begin in restoring state")
17261729 default:
17271730 }
1728- work := d.stopped["work"]
1731+ work := d.inactive["work"]
17291732 require.Equal(t, records[1], work.record)
17301733 require.Equal(t, ports.SessionBroken, work.state)
17311734 require.NotNil(t, work.restoreDone)
@@ -1765,7 +1768,7 @@ func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testin
17651768 store, state := newMockStore(t)
17661769 WithStore(t, store)(d)
17671770 d.ptys = newFactory(t, targetPTY)
1768- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77}
1771+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 77, state: ports.SessionDown}
17691772
17701773 require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
17711774 resumed := ac.currentSession()
@@ -1793,7 +1796,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
17931796 d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
17941797 d.ptys = newFactory(t, targetPTY)
17951798 expected := int64(31)
1796- d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
1799+ d.inactive["target"] = inactiveSession{name: "target", cwd: "/tmp", createdAt: expected, state: ports.SessionDown}
17971800
17981801 require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
17991802 require.Equal(t, "target", ac.currentSession().name)
@@ -1815,7 +1818,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
18151818 fromPTY, releaseFrom := newBlockingPTY(t)
18161819 defer releaseFrom()
18171820 d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1818- d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
1821+ d.inactive["target"] = inactiveSession{name: "target", cwd: "/tmp", createdAt: 52, state: ports.SessionDown}
18191822 expected := int64(51)
18201823
18211824 require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
M internal/usecase/daemon/snapshot_restore.go +7 -7
@@ -16,7 +16,7 @@ import (
1616
1717 func (d *Daemon) closeRestoreDone() {
1818 d.mu.Lock()
19- for name, entry := range d.stopped {
19+ for name, entry := range d.inactive {
2020 if entry.restoreDone == nil {
2121 continue
2222 }
@@ -29,7 +29,7 @@ func (d *Daemon) closeRestoreDone() {
2929 if entry.record.DegradedReason == "" {
3030 entry.record.DegradedReason = "restore unavailable"
3131 }
32- d.stopped[name] = entry
32+ d.inactive[name] = entry
3333 closeRuntimeRestoreDoneLocked(entry.restoreDone)
3434 }
3535 d.mu.Unlock()
@@ -201,7 +201,7 @@ func closeRestoredTabs(tabs []*tab) {
201201 func (d *Daemon) restoredSessionTabNames(name string) []string {
202202 d.mu.Lock()
203203 defer d.mu.Unlock()
204- return append([]string(nil), d.stopped[name].tabNames...)
204+ return append([]string(nil), d.inactive[name].tabNames...)
205205 }
206206
207207 func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, allowlist map[string]struct{}) (*tab, error) {
@@ -303,7 +303,7 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context
303303 sess.cwd = snap.Tabs[0].Panes[0].Cwd
304304 }
305305 d.mu.Lock()
306- if stopped, ok := d.stopped[snap.Name]; ok {
306+ if stopped, ok := d.inactive[snap.Name]; ok {
307307 sess.mruAt.Store(stopped.lastUsedSeq)
308308 }
309309 d.mu.Unlock()
@@ -315,7 +315,7 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context
315315 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
316316 if sess.incarnation == (domain.IncarnationID{}) {
317317 d.mu.Lock()
318- stopped := d.stopped[sess.name]
318+ stopped := d.inactive[sess.name]
319319 d.mu.Unlock()
320320 sess.incarnation = stopped.incarnation
321321 if sess.incarnation == (domain.IncarnationID{}) {
@@ -347,7 +347,7 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
347347 }
348348 sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
349349 d.nextID++
350- stopped := d.stopped[sess.name]
350+ stopped := d.inactive[sess.name]
351351 sess.mu.Lock()
352352 sess.restoreDone = stopped.restoreDone
353353 sess.mu.Unlock()
@@ -357,7 +357,7 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
357357 // The stopped entry is the durable authority and restore barrier until the
358358 // runtime identity has actually been published. A registry rejection must
359359 // leave that exact entry available for attach/recovery retries.
360- delete(d.stopped, sess.name)
360+ delete(d.inactive, sess.name)
361361 sess.snapDirty.Store(false)
362362 return true, nil
363363 }
M internal/usecase/daemon/snapshot_restore_incremental.go +10 -10
@@ -68,7 +68,7 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu
6868 wg.Wait()
6969 }
7070
71-func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) stoppedSession {
71+func inactiveSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) inactiveSession {
7272 tabRecords := append([]domain.CatalogueTabRecord(nil), record.TabRecords...)
7373 tabNames := append([]string(nil), record.TabNames...)
7474 if len(tabRecords) != 0 {
@@ -85,7 +85,7 @@ func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.Session
8585 tabRecords[i] = domain.CatalogueTabRecord{Name: name}
8686 }
8787 }
88- return stoppedSession{
88+ return inactiveSession{
8989 name: record.Name,
9090 cwd: record.Cwd,
9191 createdAt: record.CreatedAt,
@@ -102,11 +102,11 @@ func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.Session
102102 func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) chan struct{} {
103103 d.mu.Lock()
104104 defer d.mu.Unlock()
105- if entry, ok := d.stopped[record.Name]; ok {
105+ if entry, ok := d.inactive[record.Name]; ok {
106106 return entry.restoreDone
107107 }
108108 state, done := initialSessionState(record)
109- d.stopped[record.Name] = stoppedSessionFromRecord(record, state, done)
109+ d.inactive[record.Name] = inactiveSessionFromRecord(record, state, done)
110110 return done
111111 }
112112
@@ -126,11 +126,11 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) {
126126 func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) {
127127 d.mu.Lock()
128128 defer d.mu.Unlock()
129- entry, ok := d.stopped[record.Name]
129+ entry, ok := d.inactive[record.Name]
130130 if !ok {
131131 return
132132 }
133- d.stopped[record.Name] = stoppedSessionFromRecord(record, state, entry.restoreDone)
133+ d.inactive[record.Name] = inactiveSessionFromRecord(record, state, entry.restoreDone)
134134 }
135135
136136 func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {
@@ -155,8 +155,8 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
155155 }
156156
157157 d.mu.Lock()
158- if entry, ok := d.stopped[record.Name]; ok && degraded {
159- d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionBroken, entry.restoreDone)
158+ if entry, ok := d.inactive[record.Name]; ok && degraded {
159+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionBroken, entry.restoreDone)
160160 }
161161 closeRuntimeRestoreDoneLocked(done)
162162 d.mu.Unlock()
@@ -251,8 +251,8 @@ func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.
251251 }
252252
253253 d.mu.Lock()
254- if entry, ok := d.stopped[record.Name]; ok {
255- d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone)
254+ if entry, ok := d.inactive[record.Name]; ok {
255+ d.inactive[record.Name] = inactiveSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone)
256256 }
257257 d.mu.Unlock()
258258
M internal/usecase/daemon/snapshot_resume_publication_test.go +1 -1
@@ -174,7 +174,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
174174
175175 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
176176 d.mu.Lock()
177- stopped, retained := d.stopped[sess.name]
177+ stopped, retained := d.inactive[sess.name]
178178 d.mu.Unlock()
179179 require.True(t, retained, "non-purged named session must remain stopped")
180180 require.False(t, stopped.purging)
M internal/usecase/picker/model.go +54 -50
@@ -57,7 +57,6 @@ type SessionView struct {
5757 RemoteDetail string
5858 RemoteReason string
5959 RemoteActivation RemoteActivation
60- ConnectOnly bool
6160 // CannotAcceptMoves reports whether this session cannot receive a moved tab
6261 // or pane. False for ordinary local (and stopped) sessions; true for
6362 // restricted remote rows.
@@ -248,7 +247,11 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
248247 // rowsForSession is the sole owner of mode-specific destination eligibility.
249248 // The daemon supplies canonical lifecycle/tab snapshots without prefiltering.
250249 func rowsForSession(session SessionView, config SelectionConfig) []row {
251- if config.Mode != SelectNavigationTab && session.Stopped {
250+ stopped := session.Stopped
251+ if session.RemoteTarget != nil {
252+ stopped = session.RemoteTarget.Stopped
253+ }
254+ if config.Mode != SelectNavigationTab && stopped {
252255 return nil
253256 }
254257 if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
@@ -259,7 +262,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
259262 }
260263
261264 targetName := session.TargetName
262- if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil {
265+ if config.Mode == SelectNavigationTab && !stopped && session.ExpectedCreatedAt == nil {
263266 targetName = ""
264267 } else if targetName == "" {
265268 targetName = session.Name
@@ -267,7 +270,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
267270 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
268271 common := row{
269272 session: session.ID, incarnation: session.Incarnation, targetName: targetName,
270- stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
273+ stopped: stopped, expectedCreatedAt: expectedCreatedAt,
271274 hasExpectedCreatedAt: hasExpectedCreatedAt,
272275 }
273276 if session.RemoteKey != nil {
@@ -299,11 +302,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
299302 header.selectable = false
300303 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
301304 } else {
302- header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session)
303- header.focusable = header.selectable
304- if !common.hasRemoteKey && !common.hasRemoteTarget {
305- header.focusable = config.Mode == SelectNavigationTab
306- }
305+ header.selectable = false
306+ header.focusable = config.Mode == SelectNavigationTab && session.RemoteHost != ""
307307 }
308308 header.unavailableReason = session.RemoteReason
309309 header.dim = session.RemoteActivation == RemoteUnavailable
@@ -312,53 +312,50 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
312312 header.selectable, header.focusable, header.dim = false, false, true
313313 }
314314 rows := []row{header}
315- if !session.ConnectOnly || common.hasRemoteTarget {
316- for i, tab := range session.Tabs {
317- if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
318- continue
319- }
320- tabRow := common
321- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
322- tabRow.tabID, tabRow.tabIndex = tab.TabID, i
323- tabRow.selectable = tabRow.kind.selectable(config.Mode)
324- tabRow.focusable = tabRow.selectable
325- if common.remote {
326- tabRow.unavailableReason = session.RemoteReason
327- if common.hasRemoteTarget {
328- remoteTarget := common.remoteTarget
329- if session.Stopped {
330- remoteTarget.LiveTabID = ""
331- if tab.TabID != "" {
332- remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
333- } else {
334- tabCount := min(len(session.Tabs), math.MaxUint16)
335- remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
336- }
337- } else {
338- remoteTarget.StoppedTab = domain.TabSelector{}
339- remoteTarget.LiveTabID = tab.TabID
315+ for i, tab := range session.Tabs {
316+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
317+ continue
318+ }
319+ tabRow := common
320+ remoteTargetResolvable := true
321+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
322+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
323+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
324+ tabRow.focusable = tabRow.selectable
325+ if common.remote {
326+ tabRow.unavailableReason = session.RemoteReason
327+ if common.hasRemoteTarget {
328+ remoteTarget := common.remoteTarget
329+ if remoteTarget.Stopped {
330+ remoteTarget.LiveTabID = ""
331+ if tab.TabID != "" {
332+ remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
333+ } else if remoteTarget.StoppedTab != (domain.TabSelector{}) {
334+ remoteTarget.StoppedTab, remoteTargetResolvable = remoteStoppedOrdinalSelector(i, tab.RawName, len(session.Tabs))
340335 }
341- // Keep the selected tab's structured identity even when it is
342- // invalid. Focus remains possible for diagnostics, while both
343- // the readiness gate and daemon wire validation reject it.
344- tabRow.remoteTarget = remoteTarget
345- tabRow.hasRemoteTarget = true
346- }
347- if !tabRow.hasRemoteTarget {
348- legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session)
349- tabRow.focusable = legacyRemote
350- tabRow.selectable = legacyRemote
351336 } else {
352- tabRow.focusable = true
353- tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
337+ remoteTarget.StoppedTab = domain.TabSelector{}
338+ remoteTarget.LiveTabID = tab.TabID
354339 }
355- tabRow.dim = session.RemoteActivation == RemoteUnavailable
340+ // Keep the selected tab's structured identity even when it is
341+ // invalid. Focus remains possible for diagnostics, while both
342+ // the readiness gate and daemon wire validation reject it.
343+ tabRow.remoteTarget = remoteTarget
344+ tabRow.hasRemoteTarget = true
356345 }
357- if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
358- tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
346+ if !tabRow.hasRemoteTarget {
347+ tabRow.focusable = false
348+ tabRow.selectable = false
349+ } else {
350+ tabRow.focusable = true
351+ tabRow.selectable = remoteTargetResolvable && tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
359352 }
360- rows = append(rows, tabRow)
353+ tabRow.dim = session.RemoteActivation == RemoteUnavailable
354+ }
355+ if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
356+ tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
361357 }
358+ rows = append(rows, tabRow)
362359 }
363360 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
364361 return nil
@@ -366,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
366363 return rows
367364 }
368365
366+func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
367+ if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {
368+ return domain.TabSelector{}, false
369+ }
370+ return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
371+}
372+
369373 func remoteActivatable(session SessionView) bool {
370374 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
371375 }
M internal/usecase/picker/model_test.go +52 -94
@@ -1,6 +1,7 @@
11 package picker
22
33 import (
4+ "math"
45 "reflect"
56 "strings"
67 "testing"
@@ -740,107 +741,64 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
740741 }
741742 }
742743
743-func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
744- key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}
745- availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}
746- sessions := make([]SessionView, 0, len(availability))
747- for _, state := range availability {
748- sessions = append(sessions, SessionView{
749- ID: key.ID(),
750- Name: "presentation text is not routing data",
751- Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}},
752- RemoteKey: &key,
753- RemoteAvailability: state,
754- RemoteDetail: "remote state detail",
755- RemoteActivation: RemoteUnavailable,
756- ConnectOnly: true,
757- })
758- }
744+func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) {
745+ for _, test := range []struct {
746+ name string
747+ viewStopped bool
748+ targetStopped bool
749+ }{
750+ {name: "down target overrides live presentation flag", targetStopped: true},
751+ {name: "live target overrides stopped presentation flag", viewStopped: true},
752+ } {
753+ t.Run(test.name, func(t *testing.T) {
754+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: domain.SessionLifecycleID{1}, DisplayOrigin: "arch"}
755+ remoteTarget := domain.RemoteSessionTarget{
756+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: key.LifecycleID,
757+ SessionName: "work", LiveTabID: "tab-1", Stopped: test.targetStopped,
758+ }
759+ activation := RemoteAttach
760+ if test.targetStopped {
761+ remoteTarget.LiveTabID = ""
762+ remoteTarget.StoppedTab = domain.NewStableTabSelector("tab-1")
763+ activation = RemoteRestart
764+ }
765+ model := New([]SessionView{{
766+ ID: key.ID(), Name: key.Display(), Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}},
767+ RemoteKey: &key, RemoteTarget: &remoteTarget, RemoteActivation: activation,
768+ Stopped: test.viewStopped,
769+ }}, SelectionConfig{Mode: SelectNavigationTab})
759770
760- model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
761- require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")
762- for i, pickerRow := range model.rows {
763- require.Equal(t, rowSession, pickerRow.kind)
764- require.Equal(t, key, pickerRow.remoteKey)
765- require.True(t, pickerRow.hasRemoteKey)
766- require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
767- require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
768- require.Equal(t, "@mule (remote state detail)", pickerRow.detail)
769- }
770- _, ok := model.Selected()
771- require.False(t, ok)
772- frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{})
773- for y := range availability {
774- require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim)
771+ selected, ok := model.Selected()
772+ require.True(t, ok)
773+ require.Equal(t, test.targetStopped, selected.Stopped)
774+ require.NotNil(t, selected.RemoteTarget)
775+ require.Equal(t, test.targetStopped, selected.RemoteTarget.Stopped)
776+ })
775777 }
776778 }
777779
778-func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) {
779- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
780- cached := New([]SessionView{{
781- ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
782- RemoteActivation: RemoteUnavailable, ConnectOnly: true,
783- }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
784-
785- cursor, ok := cached.Cursor()
786- require.True(t, ok)
787- require.Equal(t, key.ID(), cursor.Session)
788- require.Equal(t, &key, cursor.RemoteKey)
789- _, selectable := cached.Selected()
790- require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate")
791-
792- live := New([]SessionView{{
793- ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
794- RemoteActivation: RemoteUnavailable, ConnectOnly: true,
795- }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
796- Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
797- }})
798-
799- upgraded, ok := live.Cursor()
800- require.True(t, ok)
801- require.Equal(t, cursor, upgraded)
802- _, selectable = live.Selected()
803- require.False(t, selectable, "cache-to-live cursor preservation must not enable attach")
804-}
805-
806-func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
807- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808- model := New([]SessionView{{
809- ID: key.ID(),
810- Name: key.Display(),
811- Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
812- RemoteKey: &key,
813- RemoteAvailability: RemoteFresh,
814- RemoteActivation: RemoteUnavailable,
815- }}, SelectionConfig{Mode: SelectNavigationTab})
816-
817- require.Len(t, model.rows, 2)
818- for _, pickerRow := range model.rows {
819- require.False(t, pickerRow.selectable)
820- require.True(t, pickerRow.dim)
780+func TestRemoteStoppedOrdinalSelectorBounds(t *testing.T) {
781+ for _, test := range []struct {
782+ name string
783+ index int
784+ tabCount int
785+ want domain.TabSelector
786+ ok bool
787+ }{
788+ {name: "largest representable", index: math.MaxUint16 - 1, tabCount: math.MaxUint16, want: domain.NewOrdinalTabSelector(math.MaxUint16-1, "tab", math.MaxUint16), ok: true},
789+ {name: "unrepresentable count", index: math.MaxUint16, tabCount: math.MaxUint16 + 1},
790+ {name: "negative index", index: -1, tabCount: 1},
791+ {name: "index equals count", index: 1, tabCount: 1},
792+ {name: "empty count", index: 0, tabCount: 0},
793+ } {
794+ t.Run(test.name, func(t *testing.T) {
795+ selector, ok := remoteStoppedOrdinalSelector(test.index, "tab", test.tabCount)
796+ require.Equal(t, test.ok, ok)
797+ require.Equal(t, test.want, selector)
798+ })
821799 }
822800 }
823801
824-func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
825- key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
826- model := New([]SessionView{{
827- ID: key.ID(),
828- Name: "not a parseable remote display label",
829- RemoteKey: &key,
830- RemoteAvailability: RemoteFresh,
831- RemoteActivation: RemoteAttach,
832- ConnectOnly: true,
833- }}, SelectionConfig{Mode: SelectNavigationTab})
834- wantKey := key
835- key.Host = "mutated-after-model-construction"
836-
837- got, ok := model.Selected()
838- require.True(t, ok)
839- require.Equal(t, wantKey.ID(), got.Session)
840- require.Equal(t, &wantKey, got.RemoteKey)
841- require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text")
842-}
843-
844802 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
845803 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
846804 local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
M scripts/remote-picker-harness/main.go +2 -2
@@ -930,11 +930,11 @@ func targetEndpoint(endpoint string) string {
930930 return endpoint
931931 }
932932
933-func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string) (ports.RemoteCatalogSession, error) {
933+func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state ports.RemoteCatalogSessionState) (ports.RemoteCatalogSession, error) {
934934 return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
935935 }
936936
937-func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string, tabs int) (ports.RemoteCatalogSession, error) {
937+func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state ports.RemoteCatalogSessionState, tabs int) (ports.RemoteCatalogSession, error) {
938938 deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
939939 defer cancel()
940940 for {

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