detached 2be2cd7

fix(remote): resurrect inactive sessions across hosts

commit
2be2cd78724b9de0a0065c014e89fa5c03c31464
author
brice · · 2026-08-24 11:29 UTC
parent
6305842
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
43 files · +1215 -875
A .agent-reviews/redteam.md +32 -0
@@ -0,0 +1,32 @@
1+# Remote down-session lifecycle refactor
2+
3+## Decision
4+
5+- Keep remote catalogue state as a dedicated string-backed JSON contract with `up`, `down`, and `broken` constants. Do not couple it to the binary `SessionState` enum. Require exact catalogue schema version 3 and cache version 3.
6+- Keep `RemoteSessionTarget.Stopped` on the wire. It describes the selected down-row resume intent across the transition to a live runtime; it is not a claim that the destination remains stopped.
7+- For structured remote picker rows, derive picker presentation state from `RemoteSessionTarget`; local rows retain their presentation flag. Remove pre-parity count-only catalogue rows and `ConnectOnly`.
8+- Rename the daemon's non-live durable registry representation to `inactiveSession`/`inactive`, then add narrow predicates for visibility, resumability, broken/degraded state, purge fencing, and restore waiting rather than one broad state check.
9+- Route remote down targets through exact inactive-record fencing. Recheck lifecycle and resumability after catalogue I/O before publishing a runtime.
10+- Support a healthy down record with no retained tabs as an exact default-tab resume target. A missing stopped selector is valid only when the authoritative inactive metadata has at most one default tab; it must not ambiguously select among multiple tabs.
11+
12+## Resolved objections
13+
14+- **Lifecycle TOCTOU:** use the expected inactive entry with the fenced creation helper and map stale-fence failures to `ErrNoSuchTarget`.
15+- **Target state transition:** document and test `down catalogue row -> exact same-lifecycle resume -> live runtime`; preserve the stopped intent through initial attach.
16+- **Typed-state compatibility:** retain JSON state tokens, bump the changed catalogue and cache contracts to version 3, and preserve ingress validation/error taxonomy.
17+- **Broken and obsolete rows:** map only exact-schema `down` entries to resumable targets; broken remains unavailable and older peers fail with a catalogue version mismatch.
18+- **Cross-host identity:** validate endpoint, name, lifecycle, and picker row identity together; never authorize a route from a display label.
19+- **Protocol layout:** retain the existing target boolean and wire layout, so no protocol bump is required.
20+
21+## Accepted risks
22+
23+- Renaming the inactive registry is mechanically broad inside the daemon package. It will be isolated from behavior changes where practical and verified with focused recovery tests plus the race suite.
24+- Older count-only catalogue peers and cache files are rejected rather than exposed as partial routes. Remote discovery starts empty when an obsolete local cache is encountered.
25+
26+## Evidence
27+
28+- `internal/usecase/daemon/remote_attach.go`: exact remote route and initial attach transition.
29+- `internal/usecase/daemon/session.go`: expected inactive lifecycle fence around catalogue I/O.
30+- `internal/usecase/daemon/coderabbit_regression_test.go`: existing same-name replacement race shape.
31+- `internal/usecase/daemon/remote_picker_test.go`: remote catalogue-to-picker-to-route seam.
32+- Critic and Go review passes: subagent `77a5f587-1e4f-40cb-b90b-44dde1598fbc`.
@@ -0,0 +1,32 @@
1+# Remote down-session lifecycle refactor
2+
3+## Decision
4+
5+- Keep remote catalogue state as a dedicated string-backed JSON contract with `up`, `down`, and `broken` constants. Do not couple it to the binary `SessionState` enum. Require exact catalogue schema version 3 and cache version 3.
6+- Keep `RemoteSessionTarget.Stopped` on the wire. It describes the selected down-row resume intent across the transition to a live runtime; it is not a claim that the destination remains stopped.
7+- For structured remote picker rows, derive picker presentation state from `RemoteSessionTarget`; local rows retain their presentation flag. Remove pre-parity count-only catalogue rows and `ConnectOnly`.
8+- Rename the daemon's non-live durable registry representation to `inactiveSession`/`inactive`, then add narrow predicates for visibility, resumability, broken/degraded state, purge fencing, and restore waiting rather than one broad state check.
9+- Route remote down targets through exact inactive-record fencing. Recheck lifecycle and resumability after catalogue I/O before publishing a runtime.
10+- Support a healthy down record with no retained tabs as an exact default-tab resume target. A missing stopped selector is valid only when the authoritative inactive metadata has at most one default tab; it must not ambiguously select among multiple tabs.
11+
12+## Resolved objections
13+
14+- **Lifecycle TOCTOU:** use the expected inactive entry with the fenced creation helper and map stale-fence failures to `ErrNoSuchTarget`.
15+- **Target state transition:** document and test `down catalogue row -> exact same-lifecycle resume -> live runtime`; preserve the stopped intent through initial attach.
16+- **Typed-state compatibility:** retain JSON state tokens, bump the changed catalogue and cache contracts to version 3, and preserve ingress validation/error taxonomy.
17+- **Broken and obsolete rows:** map only exact-schema `down` entries to resumable targets; broken remains unavailable and older peers fail with a catalogue version mismatch.
18+- **Cross-host identity:** validate endpoint, name, lifecycle, and picker row identity together; never authorize a route from a display label.
19+- **Protocol layout:** retain the existing target boolean and wire layout, so no protocol bump is required.
20+
21+## Accepted risks
22+
23+- Renaming the inactive registry is mechanically broad inside the daemon package. It will be isolated from behavior changes where practical and verified with focused recovery tests plus the race suite.
24+- Older count-only catalogue peers and cache files are rejected rather than exposed as partial routes. Remote discovery starts empty when an obsolete local cache is encountered.
25+
26+## Evidence
27+
28+- `internal/usecase/daemon/remote_attach.go`: exact remote route and initial attach transition.
29+- `internal/usecase/daemon/session.go`: expected inactive lifecycle fence around catalogue I/O.
30+- `internal/usecase/daemon/coderabbit_regression_test.go`: existing same-name replacement race shape.
31+- `internal/usecase/daemon/remote_picker_test.go`: remote catalogue-to-picker-to-route seam.
32+- Critic and Go review passes: subagent `77a5f587-1e4f-40cb-b90b-44dde1598fbc`.
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
@@ -4,7 +4,7 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle
4 4
5 ## Session states5 ## Session states
6 6
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.
8 8
9 ## Recovery commands9 ## Recovery commands
10 10
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;
@@ -61,10 +61,10 @@ version 5 records restore normally. Older binaries reject version 5 records, so
61 a downgrade also resets sessions when the older binary can read the catalogue,61 a downgrade also resets sessions when the older binary can read the catalogue,
62 or fails closed when it cannot. Back up the vev state before a rollback.62 or fails closed when it cannot. Back up the vev state before a rollback.
63 63
64-Remote catalogues are bounded and versioned. Complete catalogues contain ordered64+Remote catalogues are bounded and versioned. The current schema is mandatory
65-tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped65+and contains lifecycle IDs, ordered typed tab records, state, active tab,
66-tab metadata is retained across daemon restart; legacy count-only catalogues are66+attachment state, and MRU sequence. Peers without the exact schema are rejected
67-read-only compatibility rows and cannot provide exact tab identity.67+as version-incompatible rather than exposed as partial picker rows.
68 68
69 Remote picker previews are bounded styled-cell snapshots fetched without69 Remote picker previews are bounded styled-cell snapshots fetched without
70 attachment or PTY mutation. They are held in a short-lived in-memory cache only;70 attachment or PTY mutation. They are held in a short-lived in-memory cache only;
M internal/adapters/remote/catalog.go +9 -21
@@ -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 )
@@ -127,14 +126,18 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
127126 if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
128127 return ports.RemoteCatalog{}, errCatalogRequired
129128 }
129+ if envelope.SchemaVersion == nil {
130+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
131+ Got: 0,
132+ Want: ports.RemoteCatalogSchemaVersion,
133+ Kind: "catalog",
134+ }
135+ }
130136 if err := rejectTrailingJSON(dec); err != nil {
131137 return ports.RemoteCatalog{}, err
132138 }
133139
134- schemaVersion := uint16(0)
135- if envelope.SchemaVersion != nil {
136- schemaVersion = *envelope.SchemaVersion
137- }
140+ schemaVersion := *envelope.SchemaVersion
138141 if *envelope.ProtocolVersion != ports.ProtocolVersion {
139142 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
140143 Got: *envelope.ProtocolVersion,
@@ -142,7 +145,7 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
142145 Kind: "protocol",
143146 }
144147 }
145- if schemaVersion != 0 && schemaVersion != ports.RemoteCatalogSchemaVersion {
148+ if schemaVersion != ports.RemoteCatalogSchemaVersion {
146149 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
147150 Got: schemaVersion,
148151 Want: ports.RemoteCatalogSchemaVersion,
@@ -160,12 +163,6 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
160163 Sessions: sessions,
161164 }
162165 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- }
169166 return ports.RemoteCatalog{}, err
170167 }
171168 return catalog, nil
@@ -183,15 +180,6 @@ func rejectTrailingJSON(dec *json.Decoder) error {
183180 return fmt.Errorf("%w: %w", errCatalogTrailing, err)
184181 }
185182
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-
195183 // sanitizeCatalogDiagnostic strips terminal escapes and control bytes while
196184 // preserving useful printable diagnostic text for errors and logs.
197185 func sanitizeCatalogDiagnostic(raw string) string {
@@ -30,7 +30,6 @@ var (
30 errCatalogDecode = errors.New("remote catalog: invalid JSON")30 errCatalogDecode = errors.New("remote catalog: invalid JSON")
31 errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")31 errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
32 errCatalogRequired = errors.New("remote catalog: missing required fields")32 errCatalogRequired = errors.New("remote catalog: missing required fields")
33- errCatalogSession = errors.New("remote catalog: invalid session")
34 errCatalogSSH = errors.New("remote catalog: ssh command failed")33 errCatalogSSH = errors.New("remote catalog: ssh command failed")
35 errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")34 errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
36 )35 )
@@ -127,14 +126,18 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
127 if envelope.ProtocolVersion == nil || envelope.Sessions == nil {126 if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
128 return ports.RemoteCatalog{}, errCatalogRequired127 return ports.RemoteCatalog{}, errCatalogRequired
129 }128 }
129+ if envelope.SchemaVersion == nil {
130+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
131+ Got: 0,
132+ Want: ports.RemoteCatalogSchemaVersion,
133+ Kind: "catalog",
134+ }
135+ }
130 if err := rejectTrailingJSON(dec); err != nil {136 if err := rejectTrailingJSON(dec); err != nil {
131 return ports.RemoteCatalog{}, err137 return ports.RemoteCatalog{}, err
132 }138 }
133 139
134- schemaVersion := uint16(0)140+ schemaVersion := *envelope.SchemaVersion
135- if envelope.SchemaVersion != nil {
136- schemaVersion = *envelope.SchemaVersion
137- }
138 if *envelope.ProtocolVersion != ports.ProtocolVersion {141 if *envelope.ProtocolVersion != ports.ProtocolVersion {
139 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{142 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
140 Got: *envelope.ProtocolVersion,143 Got: *envelope.ProtocolVersion,
@@ -142,7 +145,7 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
142 Kind: "protocol",145 Kind: "protocol",
143 }146 }
144 }147 }
145- if schemaVersion != 0 && schemaVersion != ports.RemoteCatalogSchemaVersion {148+ if schemaVersion != ports.RemoteCatalogSchemaVersion {
146 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{149 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
147 Got: schemaVersion,150 Got: schemaVersion,
148 Want: ports.RemoteCatalogSchemaVersion,151 Want: ports.RemoteCatalogSchemaVersion,
@@ -160,12 +163,6 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
160 Sessions: sessions,163 Sessions: sessions,
161 }164 }
162 if err := ports.ValidateRemoteCatalog(catalog); err != nil {165 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- }
169 return ports.RemoteCatalog{}, err166 return ports.RemoteCatalog{}, err
170 }167 }
171 return catalog, nil168 return catalog, nil
@@ -183,15 +180,6 @@ func rejectTrailingJSON(dec *json.Decoder) error {
183 return fmt.Errorf("%w: %w", errCatalogTrailing, err)180 return fmt.Errorf("%w: %w", errCatalogTrailing, err)
184 }181 }
185 182
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-
195 // sanitizeCatalogDiagnostic strips terminal escapes and control bytes while183 // sanitizeCatalogDiagnostic strips terminal escapes and control bytes while
196 // preserving useful printable diagnostic text for errors and logs.184 // preserving useful printable diagnostic text for errors and logs.
197 func sanitizeCatalogDiagnostic(raw string) string {185 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 }
@@ -17,7 +17,7 @@ import (
17 17
18 const (18 const (
19 catalogCacheFileName = "remote-catalog-cache.json"19 catalogCacheFileName = "remote-catalog-cache.json"
20- catalogCacheFileVersion = 220+ catalogCacheFileVersion = 3
21 )21 )
22 22
23 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.23 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
@@ -35,15 +35,14 @@ type catalogCacheHost struct {
35 }35 }
36 36
37 type catalogCacheSession struct {37 type catalogCacheSession struct {
38- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`38+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
39- Name *string `json:"name"`39+ Name *string `json:"name"`
40- State *string `json:"state"`40+ State *ports.RemoteCatalogSessionState `json:"state"`
41- Ephemeral *bool `json:"ephemeral"`41+ Ephemeral *bool `json:"ephemeral"`
42- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`42+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43- Tabs *uint16 `json:"tabs,omitempty"`43+ Tabs *[]ports.RemoteCatalogTab `json:"tabs"`
44- TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`44+ ActiveTabID *string `json:"active_tab_id,omitempty"`
45- ActiveTabID *string `json:"active_tab_id,omitempty"`45+ Attached *bool `json:"attached"`
46- Attached *bool `json:"attached"`
47 }46 }
48 47
49 type fileCatalogCache struct {48 type fileCatalogCache struct {
@@ -107,32 +106,28 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
107 Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),106 Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
108 }107 }
109 for _, session := range *host.Sessions {108 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 {
111 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")110 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
112 }111 }
113- if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {112+ if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
114 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")113 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
115 }114 }
115+ tabs := make([]ports.RemoteCatalogTab, len(*session.Tabs))
116+ copy(tabs, *session.Tabs)
116 decoded := ports.RemoteCatalogSession{117 decoded := ports.RemoteCatalogSession{
117- Name: *session.Name,118+ LifecycleID: *session.LifecycleID,
118- State: *session.State,119+ Name: *session.Name,
119- Ephemeral: *session.Ephemeral,120+ State: *session.State,
120- Attached: *session.Attached,121+ Ephemeral: *session.Ephemeral,
122+ Tabs: tabs,
123+ Attached: *session.Attached,
121 }124 }
122 if session.LastUsedSeq != nil {125 if session.LastUsedSeq != nil {
123 decoded.LastUsedSeq = *session.LastUsedSeq126 decoded.LastUsedSeq = *session.LastUsedSeq
124 }127 }
125- if session.LifecycleID != nil {
126- decoded.LifecycleID = *session.LifecycleID
127- }
128 if session.ActiveTabID != nil {128 if session.ActiveTabID != nil {
129 decoded.ActiveTabID = *session.ActiveTabID129 decoded.ActiveTabID = *session.ActiveTabID
130 }130 }
131- if session.TabList != nil {
132- decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...)
133- } else {
134- decoded.Tabs = int(*session.Tabs)
135- }
136 entry.Sessions = append(entry.Sessions, decoded)131 entry.Sessions = append(entry.Sessions, decoded)
137 }132 }
138 entries = append(entries, entry)133 entries = append(entries, entry)
@@ -160,26 +155,18 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
160 state := session.State155 state := session.State
161 ephemeral := session.Ephemeral156 ephemeral := session.Ephemeral
162 attached := session.Attached157 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}
164 if session.LastUsedSeq != 0 {162 if session.LastUsedSeq != 0 {
165 lastUsed := session.LastUsedSeq163 lastUsed := session.LastUsedSeq
166 row.LastUsedSeq = &lastUsed164 row.LastUsedSeq = &lastUsed
167 }165 }
168- if session.LifecycleID != (domain.SessionLifecycleID{}) {
169- id := session.LifecycleID
170- row.LifecycleID = &id
171- }
172 if session.ActiveTabID != "" {166 if session.ActiveTabID != "" {
173 active := session.ActiveTabID167 active := session.ActiveTabID
174 row.ActiveTabID = &active168 row.ActiveTabID = &active
175 }169 }
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- }
183 sessions = append(sessions, row)170 sessions = append(sessions, row)
184 }171 }
185 hosts = append(hosts, catalogCacheHost{172 hosts = append(hosts, catalogCacheHost{
@@ -221,13 +208,13 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
221 }208 }
222 sessions := make(map[string]struct{}, len(entry.Sessions))209 sessions := make(map[string]struct{}, len(entry.Sessions))
223 for _, session := range entry.Sessions {210 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)) {
225 return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")212 return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
226 }213 }
227 if err := domain.ValidateSessionName(session.Name); err != nil {214 if err := domain.ValidateSessionName(session.Name); err != nil {
228 return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)215 return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
229 }216 }
230- if !validCatalogState(session.State) {217+ if !session.State.Valid() {
231 return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)218 return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
232 }219 }
233 if _, duplicate := sessions[session.Name]; duplicate {220 if _, duplicate := sessions[session.Name]; duplicate {
@@ -235,12 +222,9 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
235 }222 }
236 sessions[session.Name] = struct{}{}223 sessions[session.Name] = struct{}{}
237 copySession := session224 copySession := session
238- if tabs := ports.CatalogTabs(session); tabs != nil {225+ copySession.Tabs = make([]ports.RemoteCatalogTab, len(session.Tabs))
239- copyTabs := make([]ports.RemoteCatalogTab, len(tabs))226+ for i, tab := range session.Tabs {
240- for i, tab := range tabs {227+ copySession.Tabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
241- copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
242- }
243- copySession.Tabs = copyTabs
244 }228 }
245 copyEntry.Sessions = append(copyEntry.Sessions, copySession)229 copyEntry.Sessions = append(copyEntry.Sessions, copySession)
246 }230 }
M internal/adapters/remote/catalog_cache_test.go +18 -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,8 @@ 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+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
35+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
3536 },
3637 },
3738 {
@@ -72,8 +73,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
7273 Host: "zebra",
7374 FetchedAt: fetchedAt,
7475 Sessions: []ports.RemoteCatalogSession{
75- {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
76- {Name: "work", State: "up", Tabs: 2},
76+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
77+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
7778 },
7879 },
7980 }
@@ -113,17 +114,18 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
113114 name string
114115 raw []byte
115116 }{
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\":[]}]}")},
117+ {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
118+ {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
119+ {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)},
120+ {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},
121+ {name: "missing hosts", raw: []byte(`{"version":3}`)},
122+ {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},
123+ {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
124+ {name: "zero fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
125+ {name: "negative fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
126+ {name: "duplicate hosts", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
127+ {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}]}]}`)},
128+ {name: "invalid utf8", raw: []byte("{\"version\":3,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127129 }
128130 for _, tc := range cases {
129131 t.Run(tc.name, func(t *testing.T) {
@@ -268,5 +270,5 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
268270 }
269271
270272 func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
271- return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions)
273+ return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)
272274 }
@@ -3,6 +3,7 @@ package remote
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "reflect"
6 "slices"7 "slices"
7 "strings"8 "strings"
8 "testing"9 "testing"
@@ -30,8 +31,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
30 Host: "zebra",31 Host: "zebra",
31 FetchedAt: fetchedAt,32 FetchedAt: fetchedAt,
32 Sessions: []ports.RemoteCatalogSession{33 Sessions: []ports.RemoteCatalogSession{
33- {Name: "work", State: "up", Tabs: 2},34+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
34- {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},35+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
35 },36 },
36 },37 },
37 {38 {
@@ -72,8 +73,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
72 Host: "zebra",73 Host: "zebra",
73 FetchedAt: fetchedAt,74 FetchedAt: fetchedAt,
74 Sessions: []ports.RemoteCatalogSession{75 Sessions: []ports.RemoteCatalogSession{
75- {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},76+ {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
76- {Name: "work", State: "up", Tabs: 2},77+ {LifecycleID: [16]byte{1}, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
77 },78 },
78 },79 },
79 }80 }
@@ -113,17 +114,18 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
113 name string114 name string
114 raw []byte115 raw []byte
115 }{116 }{
116- {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)},117+ {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
117- {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)},118+ {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
118- {name: "unknown version", raw: []byte(`{"version":3,"hosts":[]}`)},119+ {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)},
119- {name: "missing hosts", raw: []byte(`{"version":1}`)},120+ {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},
120- {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)},121+ {name: "missing hosts", raw: []byte(`{"version":3}`)},
121- {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},122+ {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},
122- {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},123+ {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
123- {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},124+ {name: "zero fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"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: "negative fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"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: "duplicate hosts", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
126- {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},127+ {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}]}]}`)},
128+ {name: "invalid utf8", raw: []byte("{\"version\":3,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127 }129 }
128 for _, tc := range cases {130 for _, tc := range cases {
129 t.Run(tc.name, func(t *testing.T) {131 t.Run(tc.name, func(t *testing.T) {
@@ -268,5 +270,5 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
268 }270 }
269 271
270 func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {272 func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
271- return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions)273+ return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)
272 }274 }
M internal/adapters/remote/catalog_test.go +35 -10
@@ -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,7 +112,7 @@ 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
@@ -131,6 +134,12 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
131134 stdout: `{"protocol_version":19,"sessions":[`,
132135 wantErr: errCatalogDecode,
133136 },
137+ {
138+ name: "count-only tabs rejected",
139+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`,
140+ ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
141+ wantErr: errCatalogDecode,
142+ },
134143 {
135144 name: "trailing non-whitespace",
136145 stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
@@ -141,20 +150,35 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
141150 stdout: `{"sessions":[]}` + "\n",
142151 wantErr: errCatalogRequired,
143152 },
153+ {
154+ name: "missing catalogue schema",
155+ stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", ports.ProtocolVersion),
156+ check: func(t *testing.T, err error) {
157+ t.Helper()
158+ var mismatch *ports.RemoteCatalogVersionMismatchError
159+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
160+ t.Fatalf("error = %v, want missing catalogue version mismatch", err)
161+ }
162+ },
163+ },
144164 {
145165 name: "missing sessions",
146- stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
166+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
147167 wantErr: errCatalogRequired,
148168 },
149169 {
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,
170+ name: "invalid session name",
171+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
172+ LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []ports.RemoteCatalogTab{},
173+ }}}),
174+ wantErr: ports.ErrInvalidRemoteCatalog,
153175 },
154176 {
155- name: "invalid session state",
156- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
157- wantErr: errCatalogSession,
177+ name: "invalid session state",
178+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
179+ LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []ports.RemoteCatalogTab{},
180+ }}}),
181+ wantErr: ports.ErrRemoteCatalogUnknownState,
158182 },
159183 {
160184 name: "remote exit with stderr",
@@ -202,7 +226,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
202226 t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
203227 }
204228 for i := range tt.want.Sessions {
205- if got.Sessions[i] != tt.want.Sessions[i] {
229+ if !reflect.DeepEqual(got.Sessions[i], tt.want.Sessions[i]) {
206230 t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
207231 }
208232 }
@@ -231,6 +255,7 @@ func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
231255 commandCtx = ctx
232256 cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233257 ProtocolVersion: ports.ProtocolVersion,
258+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
234259 Sessions: []ports.RemoteCatalogSession{},
235260 }))
236261 return cmd
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "os/exec"8 "os/exec"
9+ "reflect"
9 "strings"10 "strings"
10 "testing"11 "testing"
11 "time"12 "time"
@@ -37,6 +38,7 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
37 gotArgs = append([]string(nil), args...)38 gotArgs = append([]string(nil), args...)
38 return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{39 return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
39 ProtocolVersion: ports.ProtocolVersion,40 ProtocolVersion: ports.ProtocolVersion,
41+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
40 Sessions: []ports.RemoteCatalogSession{},42 Sessions: []ports.RemoteCatalogSession{},
41 }))43 }))
42 },44 },
@@ -82,8 +84,9 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
82 84
83 valid := ports.RemoteCatalog{85 valid := ports.RemoteCatalog{
84 ProtocolVersion: ports.ProtocolVersion,86 ProtocolVersion: ports.ProtocolVersion,
87+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
85 Sessions: []ports.RemoteCatalogSession{88 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},
87 },90 },
88 }91 }
89 validJSON := mustCatalogJSON(t, valid)92 validJSON := mustCatalogJSON(t, valid)
@@ -109,7 +112,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
109 },112 },
110 {113 {
111 name: "mismatching protocol version",114 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{}}),
113 check: func(t *testing.T, err error) {116 check: func(t *testing.T, err error) {
114 t.Helper()117 t.Helper()
115 var mismatch *ports.RemoteCatalogVersionMismatchError118 var mismatch *ports.RemoteCatalogVersionMismatchError
@@ -131,6 +134,12 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
131 stdout: `{"protocol_version":19,"sessions":[`,134 stdout: `{"protocol_version":19,"sessions":[`,
132 wantErr: errCatalogDecode,135 wantErr: errCatalogDecode,
133 },136 },
137+ {
138+ name: "count-only tabs rejected",
139+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`,
140+ ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
141+ wantErr: errCatalogDecode,
142+ },
134 {143 {
135 name: "trailing non-whitespace",144 name: "trailing non-whitespace",
136 stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,145 stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
@@ -141,20 +150,35 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
141 stdout: `{"sessions":[]}` + "\n",150 stdout: `{"sessions":[]}` + "\n",
142 wantErr: errCatalogRequired,151 wantErr: errCatalogRequired,
143 },152 },
153+ {
154+ name: "missing catalogue schema",
155+ stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", ports.ProtocolVersion),
156+ check: func(t *testing.T, err error) {
157+ t.Helper()
158+ var mismatch *ports.RemoteCatalogVersionMismatchError
159+ if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != ports.RemoteCatalogSchemaVersion {
160+ t.Fatalf("error = %v, want missing catalogue version mismatch", err)
161+ }
162+ },
163+ },
144 {164 {
145 name: "missing sessions",165 name: "missing sessions",
146- stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),166+ stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion),
147 wantErr: errCatalogRequired,167 wantErr: errCatalogRequired,
148 },168 },
149 {169 {
150- name: "invalid session name",170+ name: "invalid session name",
151- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "up"}}}),171+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
152- wantErr: errCatalogSession,172+ LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []ports.RemoteCatalogTab{},
173+ }}}),
174+ wantErr: ports.ErrInvalidRemoteCatalog,
153 },175 },
154 {176 {
155- name: "invalid session state",177+ name: "invalid session state",
156- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),178+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
157- wantErr: errCatalogSession,179+ LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []ports.RemoteCatalogTab{},
180+ }}}),
181+ wantErr: ports.ErrRemoteCatalogUnknownState,
158 },182 },
159 {183 {
160 name: "remote exit with stderr",184 name: "remote exit with stderr",
@@ -202,7 +226,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
202 t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))226 t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
203 }227 }
204 for i := range tt.want.Sessions {228 for i := range tt.want.Sessions {
205- if got.Sessions[i] != tt.want.Sessions[i] {229+ if !reflect.DeepEqual(got.Sessions[i], tt.want.Sessions[i]) {
206 t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])230 t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
207 }231 }
208 }232 }
@@ -231,6 +255,7 @@ func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
231 commandCtx = ctx255 commandCtx = ctx
232 cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{256 cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233 ProtocolVersion: ports.ProtocolVersion,257 ProtocolVersion: ports.ProtocolVersion,
258+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
234 Sessions: []ports.RemoteCatalogSession{},259 Sessions: []ports.RemoteCatalogSession{},
235 }))260 }))
236 return cmd261 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)
@@ -205,11 +205,11 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
205 Attached: session.Attached,205 Attached: session.Attached,
206 }206 }
207 switch session.State {207 switch session.State {
208- case "up":208+ case ports.RemoteCatalogSessionUp:
209 info.State = ports.SessionUp209 info.State = ports.SessionUp
210- case "down":210+ case ports.RemoteCatalogSessionDown:
211 info.State = ports.SessionDown211 info.State = ports.SessionDown
212- case "broken":212+ case ports.RemoteCatalogSessionBroken:
213 info.State = ports.SessionBroken213 info.State = ports.SessionBroken
214 default:214 default:
215 slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)215 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) {
@@ -265,10 +265,10 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
265 wantState ports.SessionState265 wantState ports.SessionState
266 wantTabs uint16266 wantTabs uint16
267 }{267 }{
268- {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, 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: 2}, wantState: ports.SessionDown, 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: 2}, wantState: ports.SessionBroken, 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: 2}, 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},
272 }272 }
273 for _, tt := range tests {273 for _, tt := range tests {
274 t.Run(tt.name, func(t *testing.T) {274 t.Run(tt.name, func(t *testing.T) {
M internal/domain/lifecycle.go +35 -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,33 @@ 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+ found := -1
174+ for i, tab := range tabs {
175+ if tab.ID != t.LiveTabID {
176+ continue
177+ }
178+ if found >= 0 {
179+ return 0, false
180+ }
181+ found = i
182+ }
183+ return found, found >= 0
184+}
185+
154186 // ValidateRemoteDisplayOrigin validates presentation data without imposing
155187 // SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque.
156188 func ValidateRemoteDisplayOrigin(origin string) error {
@@ -110,7 +110,9 @@ func (s TabSelector) Resolve(tabs []TabSelectorTab) (int, bool) {
110 110
111 // RemoteSessionTarget is the structured identity used by picker-based remote111 // RemoteSessionTarget is the structured identity used by picker-based remote
112 // access. Endpoint and DisplayOrigin are intentionally separate values: the112 // 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.
114 type RemoteSessionTarget struct {116 type RemoteSessionTarget struct {
115 Endpoint string117 Endpoint string
116 DisplayOrigin string118 DisplayOrigin string
@@ -137,8 +139,11 @@ func (t RemoteSessionTarget) Validate() error {
137 return err139 return err
138 }140 }
139 if t.Stopped {141 if t.Stopped {
140- if t.LiveTabID != "" || t.StoppedTab.Validate() != nil {142+ if t.LiveTabID != "" {
141- return errors.New("down remote target requires an ordinal or stable tab selector")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")
142 }147 }
143 return nil148 return nil
144 }149 }
@@ -151,6 +156,33 @@ func (t RemoteSessionTarget) Validate() error {
151 return nil156 return nil
152 }157 }
153 158
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+ found := -1
174+ for i, tab := range tabs {
175+ if tab.ID != t.LiveTabID {
176+ continue
177+ }
178+ if found >= 0 {
179+ return 0, false
180+ }
181+ found = i
182+ }
183+ return found, found >= 0
184+}
185+
154 // ValidateRemoteDisplayOrigin validates presentation data without imposing186 // ValidateRemoteDisplayOrigin validates presentation data without imposing
155 // SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque.187 // SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque.
156 func ValidateRemoteDisplayOrigin(origin string) error {188 func ValidateRemoteDisplayOrigin(origin string) error {
M internal/domain/lifecycle_test.go +25 -0
@@ -21,6 +21,31 @@ 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: "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},
38+ }
39+ for _, test := range tests {
40+ t.Run(test.name, func(t *testing.T) {
41+ got, ok := test.target.ResolveTab(test.tabs)
42+ if got != test.want || ok != test.ok {
43+ t.Fatalf("ResolveTab() = %d, %t, want %d, %t", got, ok, test.want, test.ok)
44+ }
45+ })
46+ }
47+}
48+
2449 func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) {
2550 tests := []struct {
2651 name string
@@ -21,6 +21,31 @@ func TestOrdinalTabSelectorPreservesUnnamedTabs(t *testing.T) {
21 }21 }
22 }22 }
23 23
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: "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},
38+ }
39+ for _, test := range tests {
40+ t.Run(test.name, func(t *testing.T) {
41+ got, ok := test.target.ResolveTab(test.tabs)
42+ if got != test.want || ok != test.ok {
43+ t.Fatalf("ResolveTab() = %d, %t, want %d, %t", got, ok, test.want, test.ok)
44+ }
45+ })
46+ }
47+}
48+
24 func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) {49 func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) {
25 tests := []struct {50 tests := []struct {
26 name string51 name string
M internal/ports/remote.go +60 -148
@@ -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,14 +189,13 @@ 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 {
@@ -266,7 +204,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
266204 seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
267205 bytes := 0
268206 for _, session := range c.Sessions {
269- if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {
207+ if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(string(session.State)) || !validRemoteCatalogText(session.Reason) {
270208 return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
271209 }
272210 if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
@@ -275,7 +213,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
275213 if err := domain.ValidateSessionName(session.Name); err != nil {
276214 return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
277215 }
278- if !validRemoteCatalogState(session.State) {
216+ if !session.State.Valid() {
279217 return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
280218 }
281219 if session.ActiveTabID != "" {
@@ -286,24 +224,20 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
286224 if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
287225 return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
288226 }
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
227+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
228+ return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
229+ }
230+ if prior, exists := seen[session.LifecycleID]; exists {
231+ return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
232+ }
233+ seen[session.LifecycleID] = session.Name
234+ if session.Tabs == nil {
235+ return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
297236 }
298237 tabs := CatalogTabs(session)
299238 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- }
239+ if session.ActiveTabID != "" {
240+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
307241 }
308242 } else {
309243 if len(tabs) > RemoteCatalogMaxTabsPerSess {
@@ -320,7 +254,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
320254 if tab.Index != uint16(i) {
321255 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322256 }
323- if session.State == "up" && tab.ID == "" {
257+ if session.State == RemoteCatalogSessionUp && tab.ID == "" {
324258 return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325259 }
326260 if tab.ID != "" {
@@ -335,7 +269,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
335269 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336270 }
337271 if session.ActiveTabID != "" {
338- if session.State != "up" {
272+ if session.State != RemoteCatalogSessionUp {
339273 return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340274 }
341275 if _, ok := tabIDs[session.ActiveTabID]; !ok {
@@ -351,10 +285,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
351285 return nil
352286 }
353287
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.
288+// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache
289+// DTO before it is loaded into picker state or written to disk.
358290 func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
359291 if len(entries) > RemoteCatalogMaxHosts {
360292 return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
@@ -371,18 +303,7 @@ func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error
371303 return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
372304 }
373305 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}
306+ catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions}
386307 if err := ValidateRemoteCatalog(catalog); err != nil {
387308 return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
388309 }
@@ -402,15 +323,6 @@ func validRemoteCatalogText(value string) bool {
402323 return true
403324 }
404325
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-
414326 func validRemoteCatalogReason(reason string) bool {
415327 switch reason {
416328 case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
@@ -2,7 +2,6 @@ package ports
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/json"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "log/slog"7 "log/slog"
@@ -64,7 +63,7 @@ type RemotePreviewClient interface {
64 const (63 const (
65 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A64 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
66 // catalogue can be rejected without changing the attachment wire layout.65 // catalogue can be rejected without changing the attachment wire layout.
67- RemoteCatalogSchemaVersion uint16 = 266+ RemoteCatalogSchemaVersion uint16 = 3
68 67
69 RemoteCatalogMaxHosts = 6468 RemoteCatalogMaxHosts = 64
70 RemoteCatalogMaxSessions = 25669 RemoteCatalogMaxSessions = 256
@@ -75,10 +74,9 @@ const (
75 RemoteCatalogMaxCatalogBytes = 1 << 2074 RemoteCatalogMaxCatalogBytes = 1 << 20
76 )75 )
77 76
78-// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for77+// RemoteCatalogCacheEntry is one immutable exact-schema host snapshot persisted
79-// immediate remote discovery at daemon startup. The legacy-shaped Sessions78+// for immediate remote discovery at daemon startup. Cache writers strip dynamic
80-// field is retained as a source-compatible container, but cache writers strip79+// Detail and Attention fields before persistence.
81-// dynamic Detail and Attention fields before persistence.
82 type RemoteCatalogCacheEntry struct {80 type RemoteCatalogCacheEntry struct {
83 Host string81 Host string
84 FetchedAt time.Time82 FetchedAt time.Time
@@ -102,112 +100,53 @@ type RemoteCatalogTab struct {
102 Attention bool `json:"attention,omitempty"`100 Attention bool `json:"attention,omitempty"`
103 }101 }
104 102
105-// RemoteCatalogSession is one session in the remote discovery catalog. State103+// RemoteCatalogSessionState is the JSON catalogue lifecycle contract. It is
106-// is an explicit string contract (up|down|broken), not SessionState.104+// deliberately independent from SessionState, whose numeric values are part of
107-//105+// the binary client/daemon protocol.
108-// Tabs is interface-typed only to keep old v0.x callers that supplied a count106+type RemoteCatalogSessionState string
109-// source-compatible while the versioned schema carries []RemoteCatalogTab.107+
110-// New code must use CatalogTabs and never infer routing identity from a count.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.
111 type RemoteCatalogSession struct {127 type RemoteCatalogSession struct {
112- LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`128+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
113 Name string `json:"name"`129 Name string `json:"name"`
114- State string `json:"state"`130+ State RemoteCatalogSessionState `json:"state"`
115 Ephemeral bool `json:"ephemeral"`131 Ephemeral bool `json:"ephemeral"`
116- Tabs any `json:"tabs"`132+ Tabs []RemoteCatalogTab `json:"tabs"`
117 Attached bool `json:"attached"`133 Attached bool `json:"attached"`
118 LastUsedSeq uint64 `json:"last_used_seq,omitempty"`134 LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
119 ActiveTabID string `json:"active_tab_id,omitempty"`135 ActiveTabID string `json:"active_tab_id,omitempty"`
120 Reason string `json:"reason,omitempty"`136 Reason string `json:"reason,omitempty"`
121 }137 }
122 138
123-// MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues.139+// CatalogTabs returns an independent copy of the ordered tab snapshot.
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.
184 func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {140 func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
185- tabs, ok := session.Tabs.([]RemoteCatalogTab)141+ return slices.Clone(session.Tabs)
186- if !ok {
187- return nil
188- }
189- return slices.Clone(tabs)
190 }142 }
191 143
192-// CatalogTabCount returns the count represented by either schema form.144+// CatalogTabCount returns the typed tab count.
193 func CatalogTabCount(session RemoteCatalogSession) int {145 func CatalogTabCount(session RemoteCatalogSession) int {
194- switch tabs := session.Tabs.(type) {146+ return len(session.Tabs)
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- }
208 }147 }
209 148
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.
211 func SaturateUint16(n int) uint16 {150 func SaturateUint16(n int) uint16 {
212 if n <= 0 {151 if n <= 0 {
213 return 0152 return 0
@@ -219,11 +158,11 @@ func SaturateUint16(n int) uint16 {
219 }158 }
220 159
221 // RemoteCatalog is the independently versioned JSON envelope returned by160 // RemoteCatalog is the independently versioned JSON envelope returned by
222-// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers161+// remote-catalog --json. Both protocol and catalogue schema versions are
223-// must also provide SchemaVersion.162+// mandatory and must match exactly.
224 type RemoteCatalog struct {163 type RemoteCatalog struct {
225 ProtocolVersion uint16 `json:"protocol_version"`164 ProtocolVersion uint16 `json:"protocol_version"`
226- SchemaVersion uint16 `json:"schema_version,omitempty"`165+ SchemaVersion uint16 `json:"schema_version"`
227 Sessions []RemoteCatalogSession `json:"sessions"`166 Sessions []RemoteCatalogSession `json:"sessions"`
228 }167 }
229 168
@@ -250,14 +189,13 @@ var (
250 ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")189 ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
251 )190 )
252 191
253-// ValidateRemoteCatalog applies all limits before a catalogue is cached or192+// ValidateRemoteCatalog applies the exact current schema and all bounds before
254-// rendered. It is deliberately strict for the current schema; zero schema is193+// a catalogue is cached or rendered.
255-// the read-only compatibility form used by pre-parity v0.x peers.
256 func ValidateRemoteCatalog(c RemoteCatalog) error {194 func ValidateRemoteCatalog(c RemoteCatalog) error {
257 if c.ProtocolVersion != ProtocolVersion {195 if c.ProtocolVersion != ProtocolVersion {
258 return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}196 return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}
259 }197 }
260- if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion {198+ if c.SchemaVersion != RemoteCatalogSchemaVersion {
261 return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}199 return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
262 }200 }
263 if len(c.Sessions) > RemoteCatalogMaxSessions {201 if len(c.Sessions) > RemoteCatalogMaxSessions {
@@ -266,7 +204,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
266 seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))204 seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
267 bytes := 0205 bytes := 0
268 for _, session := range c.Sessions {206 for _, session := range c.Sessions {
269- if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {207+ if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(string(session.State)) || !validRemoteCatalogText(session.Reason) {
270 return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)208 return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
271 }209 }
272 if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {210 if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
@@ -275,7 +213,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
275 if err := domain.ValidateSessionName(session.Name); err != nil {213 if err := domain.ValidateSessionName(session.Name); err != nil {
276 return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)214 return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
277 }215 }
278- if !validRemoteCatalogState(session.State) {216+ if !session.State.Valid() {
279 return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)217 return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
280 }218 }
281 if session.ActiveTabID != "" {219 if session.ActiveTabID != "" {
@@ -286,24 +224,20 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
286 if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {224 if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
287 return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)225 return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
288 }226 }
289- if c.SchemaVersion != 0 {227+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
290- if session.LifecycleID == (domain.SessionLifecycleID{}) {228+ return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
291- return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)229+ }
292- }230+ if prior, exists := seen[session.LifecycleID]; exists {
293- if prior, exists := seen[session.LifecycleID]; exists {231+ return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
294- return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)232+ }
295- }233+ seen[session.LifecycleID] = session.Name
296- seen[session.LifecycleID] = session.Name234+ if session.Tabs == nil {
235+ return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
297 }236 }
298 tabs := CatalogTabs(session)237 tabs := CatalogTabs(session)
299 if len(tabs) == 0 {238 if len(tabs) == 0 {
300- if c.SchemaVersion != 0 {239+ if session.ActiveTabID != "" {
301- if session.ActiveTabID != "" {240+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
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- }
307 }241 }
308 } else {242 } else {
309 if len(tabs) > RemoteCatalogMaxTabsPerSess {243 if len(tabs) > RemoteCatalogMaxTabsPerSess {
@@ -320,7 +254,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
320 if tab.Index != uint16(i) {254 if tab.Index != uint16(i) {
321 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)255 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322 }256 }
323- if session.State == "up" && tab.ID == "" {257+ if session.State == RemoteCatalogSessionUp && tab.ID == "" {
324 return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)258 return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325 }259 }
326 if tab.ID != "" {260 if tab.ID != "" {
@@ -335,7 +269,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
335 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)269 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336 }270 }
337 if session.ActiveTabID != "" {271 if session.ActiveTabID != "" {
338- if session.State != "up" {272+ if session.State != RemoteCatalogSessionUp {
339 return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)273 return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340 }274 }
341 if _, ok := tabIDs[session.ActiveTabID]; !ok {275 if _, ok := tabIDs[session.ActiveTabID]; !ok {
@@ -351,10 +285,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
351 return nil285 return nil
352 }286 }
353 287
354-// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it288+// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache
355-// is loaded into picker state or written to disk. A cache may contain legacy289+// DTO before it is loaded into picker state or written to disk.
356-// count-only sessions, but any complete typed snapshot must carry lifecycle
357-// identity and the same tab invariants as a live catalog.
358 func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {290 func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
359 if len(entries) > RemoteCatalogMaxHosts {291 if len(entries) > RemoteCatalogMaxHosts {
360 return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)292 return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
@@ -371,18 +303,7 @@ func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error
371 return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)303 return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
372 }304 }
373 seenHosts[entry.Host] = struct{}{}305 seenHosts[entry.Host] = struct{}{}
374- complete := false306+ catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions}
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}
386 if err := ValidateRemoteCatalog(catalog); err != nil {307 if err := ValidateRemoteCatalog(catalog); err != nil {
387 return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)308 return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
388 }309 }
@@ -402,15 +323,6 @@ func validRemoteCatalogText(value string) bool {
402 return true323 return true
403 }324 }
404 325
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-
414 func validRemoteCatalogReason(reason string) bool {326 func validRemoteCatalogReason(reason string) bool {
415 switch reason {327 switch reason {
416 case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":328 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 +19 -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,17 @@ 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)
2741 }
@@ -7,13 +7,17 @@ import (
7 )7 )
8 8
9 func TestRemoteCatalogStateContract(t *testing.T) {9 func TestRemoteCatalogStateContract(t *testing.T) {
10- require.Equal(t, uint16(2), RemoteCatalogSchemaVersion)10+ require.Equal(t, uint16(3), RemoteCatalogSchemaVersion)
11 11
12- for _, state := range []string{"up", "down", "broken"} {12+ for _, state := range []RemoteCatalogSessionState{RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken} {
13- t.Run(state, func(t *testing.T) {13+ t.Run(string(state), func(t *testing.T) {
14 err := ValidateRemoteCatalog(RemoteCatalog{14 err := ValidateRemoteCatalog(RemoteCatalog{
15 ProtocolVersion: ProtocolVersion,15 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+ }},
17 })21 })
18 require.NoError(t, err)22 require.NoError(t, err)
19 })23 })
@@ -21,7 +25,17 @@ func TestRemoteCatalogStateContract(t *testing.T) {
21 25
22 err := ValidateRemoteCatalog(RemoteCatalog{26 err := ValidateRemoteCatalog(RemoteCatalog{
23 ProtocolVersion: ProtocolVersion,27 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+ }},
25 })33 })
26 require.ErrorIs(t, err, ErrRemoteCatalogUnknownState)34 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)
27 }41 }
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, uint16(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, uint16(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) {
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
17 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"18 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
18 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -37,7 +38,7 @@ func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T)
37 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)38 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
38 require.Empty(t, repository.deleted)39 require.Empty(t, repository.deleted)
39 d.mu.Lock()40 d.mu.Lock()
40- entry := d.stopped[record.Name]41+ entry := d.inactive[record.Name]
41 d.mu.Unlock()42 d.mu.Unlock()
42 require.Equal(t, ports.SessionBroken, entry.state)43 require.Equal(t, ports.SessionBroken, entry.state)
43 require.Equal(t, persisted, entry.record)44 require.Equal(t, persisted, entry.record)
@@ -59,7 +60,7 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) {
59 require.True(t, ok)60 require.True(t, ok)
60 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)61 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
61 d.mu.Lock()62 d.mu.Lock()
62- entry := d.stopped[record.Name]63+ entry := d.inactive[record.Name]
63 d.mu.Unlock()64 d.mu.Unlock()
64 require.Equal(t, ports.SessionBroken, entry.state)65 require.Equal(t, ports.SessionBroken, entry.state)
65 require.Equal(t, persisted, entry.record)66 require.Equal(t, persisted, entry.record)
@@ -118,7 +119,7 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) {
118 require.Nil(t, persisted.Committed)119 require.Nil(t, persisted.Committed)
119 require.Empty(t, persisted.DegradedReason)120 require.Empty(t, persisted.DegradedReason)
120 d.mu.Lock()121 d.mu.Lock()
121- entry := d.stopped[record.Name]122+ entry := d.inactive[record.Name]
122 d.mu.Unlock()123 d.mu.Unlock()
123 require.Equal(t, persisted, entry.record)124 require.Equal(t, persisted, entry.record)
124 require.Equal(t, ports.SessionDown, entry.state)125 require.Equal(t, ports.SessionDown, entry.state)
@@ -182,7 +183,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
182 require.True(t, ok)183 require.True(t, ok)
183 require.Equal(t, replacement, persisted)184 require.Equal(t, replacement, persisted)
184 d.mu.Lock()185 d.mu.Lock()
185- entry := d.stopped[record.Name]186+ entry := d.inactive[record.Name]
186 d.mu.Unlock()187 d.mu.Unlock()
187 require.Equal(t, tt.state, entry.state)188 require.Equal(t, tt.state, entry.state)
188 require.Equal(t, replacement, entry.record)189 require.Equal(t, replacement, entry.record)
@@ -224,7 +225,7 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
224 d := newTestDaemon(t, nil, stubClock{})225 d := newTestDaemon(t, nil, stubClock{})
225 d.catalogue = catalogue226 d.catalogue = catalogue
226 d.persistEnabled = true227 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{}))
228 229
229 result := make(chan error, 1)230 result := make(chan error, 1)
230 go func() {231 go func() {
@@ -275,7 +276,7 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T)
275 d.catalogue = catalogue276 d.catalogue = catalogue
276 d.persistEnabled = true277 d.persistEnabled = true
277 d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)278 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)
279 280
280 target := picker.Target{281 target := picker.Target{
281 Name: record.Name,282 Name: record.Name,
@@ -293,7 +294,7 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T)
293 replacement.UpdatedAt = replacement.CreatedAt294 replacement.UpdatedAt = replacement.CreatedAt
294 require.NoError(t, catalogue.Replace(replacement.Name, replacement))295 require.NoError(t, catalogue.Replace(replacement.Name, replacement))
295 d.mu.Lock()296 d.mu.Lock()
296- d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil)297+ d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil)
297 d.mu.Unlock()298 d.mu.Unlock()
298 close(catalogue.release)299 close(catalogue.release)
299 300
@@ -303,7 +304,154 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T)
303 d.mu.Lock()304 d.mu.Lock()
304 defer d.mu.Unlock()305 defer d.mu.Unlock()
305 require.Nil(t, d.findByNameLocked(record.Name))306 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, uint16(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, uint16(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+ }
307 }455 }
308 456
309 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {457 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
M internal/usecase/daemon/control.go +8 -51
@@ -706,37 +706,14 @@ 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))
741718 for _, sess := range sessions {
742719 e.d.refreshSessionFocusedTitles(sess)
@@ -748,7 +725,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
748725 row := ports.RemoteCatalogSession{
749726 LifecycleID: snap.incarnation,
750727 Name: snap.name,
751- State: "up",
728+ State: ports.RemoteCatalogSessionUp,
752729 Ephemeral: snap.ephemeral,
753730 Tabs: tabs,
754731 Attached: snap.attached,
@@ -764,10 +741,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
764741 if err != nil {
765742 return "", err
766743 }
767- state := "down"
744+ state := ports.RemoteCatalogSessionDown
768745 reason := ""
769- if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {
770- state = "broken"
746+ if entry.broken() {
747+ state = ports.RemoteCatalogSessionBroken
771748 reason = "session_broken"
772749 }
773750 rows = append(rows, ports.RemoteCatalogSession{
@@ -797,26 +774,6 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
797774 return marshalListing(catalog)
798775 }
799776
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-
820777 func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
821778 if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {
822779 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
@@ -834,7 +791,7 @@ func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
834791 return tabs, nil
835792 }
836793
837-func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) {
794+func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab, error) {
838795 records := entry.tabRecords
839796 if len(records) == 0 && len(entry.tabNames) != 0 {
840797 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
@@ -706,37 +706,14 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
706 }706 }
707 e.d.mu.Lock()707 e.d.mu.Lock()
708 sessions := sessionsSnapshot(e.d.sessions)708 sessions := sessionsSnapshot(e.d.sessions)
709- stopped := make([]stoppedSession, 0, len(e.d.stopped))709+ stopped := make([]inactiveSession, 0, len(e.d.inactive))
710- for _, entry := range e.d.stopped {710+ for _, entry := range e.d.inactive {
711- if !entry.purging {711+ if entry.visible() {
712 stopped = append(stopped, entry)712 stopped = append(stopped, entry)
713 }713 }
714 }714 }
715 e.d.mu.Unlock()715 e.d.mu.Unlock()
716 716
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-
740 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))717 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
741 for _, sess := range sessions {718 for _, sess := range sessions {
742 e.d.refreshSessionFocusedTitles(sess)719 e.d.refreshSessionFocusedTitles(sess)
@@ -748,7 +725,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
748 row := ports.RemoteCatalogSession{725 row := ports.RemoteCatalogSession{
749 LifecycleID: snap.incarnation,726 LifecycleID: snap.incarnation,
750 Name: snap.name,727 Name: snap.name,
751- State: "up",728+ State: ports.RemoteCatalogSessionUp,
752 Ephemeral: snap.ephemeral,729 Ephemeral: snap.ephemeral,
753 Tabs: tabs,730 Tabs: tabs,
754 Attached: snap.attached,731 Attached: snap.attached,
@@ -764,10 +741,10 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
764 if err != nil {741 if err != nil {
765 return "", err742 return "", err
766 }743 }
767- state := "down"744+ state := ports.RemoteCatalogSessionDown
768 reason := ""745 reason := ""
769- if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {746+ if entry.broken() {
770- state = "broken"747+ state = ports.RemoteCatalogSessionBroken
771 reason = "session_broken"748 reason = "session_broken"
772 }749 }
773 rows = append(rows, ports.RemoteCatalogSession{750 rows = append(rows, ports.RemoteCatalogSession{
@@ -797,26 +774,6 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
797 return marshalListing(catalog)774 return marshalListing(catalog)
798 }775 }
799 776
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-
820 func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {777 func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
821 if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {778 if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {
822 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)779 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
@@ -834,7 +791,7 @@ func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
834 return tabs, nil791 return tabs, nil
835 }792 }
836 793
837-func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) {794+func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab, error) {
838 records := entry.tabRecords795 records := entry.tabRecords
839 if len(records) == 0 && len(entry.tabNames) != 0 {796 if len(records) == 0 && len(entry.tabNames) != 0 {
840 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))797 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
M internal/usecase/daemon/control_test.go +14 -17
@@ -719,6 +719,7 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
719719 func TestRemoteCatalogJSONOutput(t *testing.T) {
720720 d := newTestDaemon(t, nil, stubClock{})
721721 work := addControlSession(d, "work", "t_work", "p_work")
722+ work.incarnation[0] = 1
722723 work.ephemeral = false
723724 work.registerAttachment(&attachedClient{})
724725 work.mruAt.Store(2)
@@ -729,11 +730,12 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
729730 work.mu.Unlock()
730731
731732 build := addControlSession(d, "build", "t_build", "p_build")
733+ build.incarnation[0] = 2
732734 build.ephemeral = true
733735 build.mruAt.Store(1)
734736
735737 d.mu.Lock()
736- d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown}
738+ d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
737739 d.mu.Unlock()
738740
739741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
@@ -757,20 +759,19 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
757759
758760 raw := map[string]json.RawMessage{}
759761 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
760- require.Len(t, raw, 2)
762+ require.Len(t, raw, 3)
761763 _, hasProtocol := raw["protocol_version"]
764+ _, hasSchema := raw["schema_version"]
762765 _, hasSessions := raw["sessions"]
763- require.True(t, hasProtocol && hasSessions)
766+ require.True(t, hasProtocol && hasSchema && hasSessions)
764767
765768 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},
769+ {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
770+ {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []ports.RemoteCatalogTab{}},
771+ {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},
768772 }, catalog.Sessions)
769773
770- for _, session := range catalog.Sessions {
771- require.Equal(t, "up", session.State)
772- require.NotEqual(t, "old", session.Name)
773- }
774+ require.Equal(t, ports.RemoteCatalogSessionDown, catalog.Sessions[1].State)
774775
775776 listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
776777 require.True(t, listAfter.OK, listAfter.Text)
@@ -814,24 +815,20 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
814815 }}, catalog.Sessions)
815816 }
816817
817-func TestRemoteCatalogTabCountSaturates(t *testing.T) {
818+func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
818819 d := newTestDaemon(t, nil, stubClock{})
819820 sess := addControlSession(d, "work", "t_work", "p_work")
820821 sess.mu.Lock()
821822 base := sess.tabs[0]
822- sess.tabs = make([]*tab, int(^uint16(0))+1)
823+ sess.tabs = make([]*tab, ports.RemoteCatalogMaxTabsPerSess+1)
823824 for i := range sess.tabs {
824825 sess.tabs[i] = base
825826 }
826827 sess.mu.Unlock()
827828
828829 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)
830+ require.False(t, result.OK)
831+ require.Contains(t, result.Text, "too many tabs")
835832 }
836833
837834 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
@@ -719,6 +719,7 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
719 func TestRemoteCatalogJSONOutput(t *testing.T) {719 func TestRemoteCatalogJSONOutput(t *testing.T) {
720 d := newTestDaemon(t, nil, stubClock{})720 d := newTestDaemon(t, nil, stubClock{})
721 work := addControlSession(d, "work", "t_work", "p_work")721 work := addControlSession(d, "work", "t_work", "p_work")
722+ work.incarnation[0] = 1
722 work.ephemeral = false723 work.ephemeral = false
723 work.registerAttachment(&attachedClient{})724 work.registerAttachment(&attachedClient{})
724 work.mruAt.Store(2)725 work.mruAt.Store(2)
@@ -729,11 +730,12 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
729 work.mu.Unlock()730 work.mu.Unlock()
730 731
731 build := addControlSession(d, "build", "t_build", "p_build")732 build := addControlSession(d, "build", "t_build", "p_build")
733+ build.incarnation[0] = 2
732 build.ephemeral = true734 build.ephemeral = true
733 build.mruAt.Store(1)735 build.mruAt.Store(1)
734 736
735 d.mu.Lock()737 d.mu.Lock()
736- d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown}738+ d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
737 d.mu.Unlock()739 d.mu.Unlock()
738 740
739 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
@@ -757,20 +759,19 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
757 759
758 raw := map[string]json.RawMessage{}760 raw := map[string]json.RawMessage{}
759 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))761 require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
760- require.Len(t, raw, 2)762+ require.Len(t, raw, 3)
761 _, hasProtocol := raw["protocol_version"]763 _, hasProtocol := raw["protocol_version"]
764+ _, hasSchema := raw["schema_version"]
762 _, hasSessions := raw["sessions"]765 _, hasSessions := raw["sessions"]
763- require.True(t, hasProtocol && hasSessions)766+ require.True(t, hasProtocol && hasSchema && hasSessions)
764 767
765 require.Equal(t, []ports.RemoteCatalogSession{768 require.Equal(t, []ports.RemoteCatalogSession{
766- {Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false},769+ {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
767- {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},770+ {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []ports.RemoteCatalogTab{}},
771+ {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},
768 }, catalog.Sessions)772 }, catalog.Sessions)
769 773
770- for _, session := range catalog.Sessions {774+ require.Equal(t, ports.RemoteCatalogSessionDown, catalog.Sessions[1].State)
771- require.Equal(t, "up", session.State)
772- require.NotEqual(t, "old", session.Name)
773- }
774 775
775 listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})776 listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
776 require.True(t, listAfter.OK, listAfter.Text)777 require.True(t, listAfter.OK, listAfter.Text)
@@ -814,24 +815,20 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
814 }}, catalog.Sessions)815 }}, catalog.Sessions)
815 }816 }
816 817
817-func TestRemoteCatalogTabCountSaturates(t *testing.T) {818+func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
818 d := newTestDaemon(t, nil, stubClock{})819 d := newTestDaemon(t, nil, stubClock{})
819 sess := addControlSession(d, "work", "t_work", "p_work")820 sess := addControlSession(d, "work", "t_work", "p_work")
820 sess.mu.Lock()821 sess.mu.Lock()
821 base := sess.tabs[0]822 base := sess.tabs[0]
822- sess.tabs = make([]*tab, int(^uint16(0))+1)823+ sess.tabs = make([]*tab, ports.RemoteCatalogMaxTabsPerSess+1)
823 for i := range sess.tabs {824 for i := range sess.tabs {
824 sess.tabs[i] = base825 sess.tabs[i] = base
825 }826 }
826 sess.mu.Unlock()827 sess.mu.Unlock()
827 828
828 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})829 result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
829- require.True(t, result.OK, result.Text)830+ require.False(t, result.OK)
830- var catalog ports.RemoteCatalog831+ require.Contains(t, result.Text, "too many tabs")
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)
835 }832 }
836 833
837 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {834 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
@@ -71,7 +71,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
71 type Daemon struct {71 type Daemon struct {
72 mu sync.Mutex72 mu sync.Mutex
73 sessions map[domain.SessionID]*session73 sessions map[domain.SessionID]*session
74- stopped map[string]stoppedSession74+ inactive map[string]inactiveSession
75 // creating reserves names while durable creation I/O runs without mu.75 // creating reserves names while durable creation I/O runs without mu.
76 creating map[string]struct{}76 creating map[string]struct{}
77 nextID uint6477 nextID uint64
@@ -348,9 +348,11 @@ func (p *parkingAttachment) closeDone() {
348 p.doneOnce.Do(func() { close(p.done) })348 p.doneOnce.Do(func() { close(p.done) })
349 }349 }
350 350
351-// session is a single multiplexed session. It owns one or more full-screen351+// inactiveSession is the durable authority for a named session without a live
352-352+// runtime. It remains present while healthy-down, restoring, broken, degraded,
353-type stoppedSession struct {353+// or purge-fenced; callers must use its predicates instead of inferring those
354+// states from map membership.
355+type inactiveSession struct {
354 name string356 name string
355 cwd string357 cwd string
356 createdAt int64358 createdAt int64
@@ -364,6 +366,34 @@ type stoppedSession struct {
364 restoreDone chan struct{}366 restoreDone chan struct{}
365 }367 }
366 368
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+
367 type Option func(*Daemon)397 type Option func(*Daemon)
368 398
369 // WithRuntimeObserver accepts only a composition-root serialized observer.399 // 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
591 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())621 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
592 d := &Daemon{622 d := &Daemon{
593 sessions: make(map[domain.SessionID]*session),623 sessions: make(map[domain.SessionID]*session),
594- stopped: make(map[string]stoppedSession),624+ inactive: make(map[string]inactiveSession),
595 creating: make(map[string]struct{}),625 creating: make(map[string]struct{}),
596 parked: make(map[uint64]*parkedAttachment),626 parked: make(map[uint64]*parkedAttachment),
597 parking: make(map[uint64]*parkingAttachment),627 parking: make(map[uint64]*parkingAttachment),
@@ -652,9 +682,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
652 for _, r := range records {682 for _, r := range records {
653 if d.catalogueRecordsProvided {683 if d.catalogueRecordsProvided {
654 state, done := initialSessionState(r)684 state, done := initialSessionState(r)
655- d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)685+ d.inactive[r.Name] = inactiveSessionFromRecord(r, state, done)
656 } else {686 } else {
657- d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionDown, nil)687+ d.inactive[r.Name] = inactiveSessionFromRecord(r, ports.SessionDown, nil)
658 }688 }
659 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {689 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
660 maxCreatedAt = r.CreatedAt690 maxCreatedAt = r.CreatedAt
@@ -1009,7 +1039,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
1009 defer func() { _ = tr.Close() }()1039 defer func() { _ = tr.Close() }()
1010 1040
1011 d.mu.Lock()1041 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))
1013 liveNames := make(map[string]struct{}, len(d.sessions))1043 liveNames := make(map[string]struct{}, len(d.sessions))
1014 for _, s := range d.sessions {1044 for _, s := range d.sessions {
1015 if s == nil {1045 if s == nil {
@@ -1028,14 +1058,12 @@ func (d *Daemon) handleList(tr ports.Transport) {
1028 s.mu.Unlock()1058 s.mu.Unlock()
1029 infos = append(infos, info)1059 infos = append(infos, info)
1030 }1060 }
1031- for name, stopped := range d.stopped {1061+ for name, inactive := range d.inactive {
1032- if _, live := liveNames[name]; live {1062+ if _, live := liveNames[name]; live || !inactive.visible() {
1033 continue1063 continue
1034 }1064 }
1035 state := ports.SessionDown1065 state := ports.SessionDown
1036- if stopped.purging || stopped.state == ports.SessionBroken {1066+ if inactive.broken() {
1037- // Purge is the dominant externally visible state: restoration must
1038- // never make a deletion-reserved record appear attachable.
1039 state = ports.SessionBroken1067 state = ports.SessionBroken
1040 }1068 }
1041 infos = append(infos, ports.SessionInfo{Name: name, State: state})1069 infos = append(infos, ports.SessionInfo{Name: name, State: state})
@@ -1065,7 +1093,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
1065 d.mu.Lock()1093 d.mu.Lock()
1066 target := d.findByNameLocked(k.Name)1094 target := d.findByNameLocked(k.Name)
1067 if target == nil {1095 if target == nil {
1068- if _, ok := d.stopped[k.Name]; ok {1096+ if _, ok := d.inactive[k.Name]; ok {
1069 d.mu.Unlock()1097 d.mu.Unlock()
1070 // Stopped sessions use the same catalogue-first, incarnation-second1098 // Stopped sessions use the same catalogue-first, incarnation-second
1071 // deletion order as live and offline purges.1099 // deletion order as live and offline purges.
@@ -1330,7 +1358,7 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1330 d.mu.Lock()1358 d.mu.Lock()
1331 var (1359 var (
1332 done chan struct{}1360 done chan struct{}
1333- stopped stoppedSession1361+ stopped inactiveSession
1334 ok bool1362 ok bool
1335 )1363 )
1336 if sess := d.findByNameLocked(name); sess != nil {1364 if sess := d.findByNameLocked(name); sess != nil {
@@ -1338,12 +1366,14 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1338 done = sess.restoreDone1366 done = sess.restoreDone
1339 sess.mu.Unlock()1367 sess.mu.Unlock()
1340 } else {1368 } else {
1341- stopped, ok = d.stopped[name]1369+ stopped, ok = d.inactive[name]
1342- if !ok || stopped.purging {1370+ if !ok || !stopped.visible() {
1343 d.mu.Unlock()1371 d.mu.Unlock()
1344 return nil1372 return nil
1345 }1373 }
1346- done = stopped.restoreDone1374+ if stopped.restorePending() {
1375+ done = stopped.restoreDone
1376+ }
1347 }1377 }
1348 d.mu.Unlock()1378 d.mu.Unlock()
1349 1379
@@ -1360,11 +1390,11 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1360 if d.findByNameLocked(name) != nil {1390 if d.findByNameLocked(name) != nil {
1361 return nil1391 return nil
1362 }1392 }
1363- stopped, ok = d.stopped[name]1393+ stopped, ok = d.inactive[name]
1364 if !ok || stopped.record.Name == "" {1394 if !ok || stopped.record.Name == "" {
1365 return nil1395 return nil
1366 }1396 }
1367- if stopped.state == ports.SessionBroken {1397+ if stopped.broken() {
1368 return &protoErr{ports.ErrInternal, "session durable state is broken: " + name}1398 return &protoErr{ports.ErrInternal, "session durable state is broken: " + name}
1369 }1399 }
1370 if stopped.record.Committed == nil {1400 if stopped.record.Committed == nil {
@@ -1383,8 +1413,8 @@ func (d *Daemon) validateExactSessionTargetLocked(target ports.ExactSessionTarge
1383 }1413 }
1384 return nil1414 return nil
1385 }1415 }
1386- stopped, ok := d.stopped[target.SessionName]1416+ stopped, ok := d.inactive[target.SessionName]
1387- if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {1417+ if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID {
1388 return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}1418 return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1389 }1419 }
1390 return nil1420 return nil
@@ -1537,10 +1567,10 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1537 sess := d.findByNameLocked(h.Name)1567 sess := d.findByNameLocked(h.Name)
1538 created := false1568 created := false
1539 if sess == nil {1569 if sess == nil {
1540- stopped, ok := d.stopped[h.Name]1570+ stopped, ok := d.inactive[h.Name]
1541- if !ok || stopped.purging {1571+ if !ok || !stopped.canResume() {
1542 d.mu.Unlock()1572 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}
1544 }1574 }
1545 cwd := d.dirOrHome(stopped.cwd)1575 cwd := d.dirOrHome(stopped.cwd)
1546 env := h.Env1576 env := h.Env
@@ -1548,7 +1578,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1548 env = copyEnvironment(d.baseEnv)1578 env = copyEnvironment(d.baseEnv)
1549 }1579 }
1550 var err error1580 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)
1552 if err != nil {1582 if err != nil {
1553 d.mu.Unlock()1583 d.mu.Unlock()
1554 return nil, nil, err1584 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 }
@@ -301,8 +301,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
301 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))301 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
302 302
303 d.mu.Lock()303 d.mu.Lock()
304- healthyEntry := d.stopped[healthy.Name]304+ healthyEntry := d.inactive[healthy.Name]
305- brokenEntry := d.stopped[broken.Name]305+ brokenEntry := d.inactive[broken.Name]
306 d.mu.Unlock()306 d.mu.Unlock()
307 require.Equal(t, ports.SessionDown, healthyEntry.state)307 require.Equal(t, ports.SessionDown, healthyEntry.state)
308 require.Equal(t, ports.SessionBroken, brokenEntry.state)308 require.Equal(t, ports.SessionBroken, brokenEntry.state)
@@ -344,14 +344,14 @@ func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) {
344 require.Empty(t, fresh.DegradedReason)344 require.Empty(t, fresh.DegradedReason)
345 require.Empty(t, fresh.TabNames)345 require.Empty(t, fresh.TabNames)
346 require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted)346 require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted)
347- require.Equal(t, ports.SessionDown, d.stopped[incompatible.Name].state)347+ require.Equal(t, ports.SessionDown, d.inactive[incompatible.Name].state)
348- require.Equal(t, fresh, d.stopped[incompatible.Name].record)348+ require.Equal(t, fresh, d.inactive[incompatible.Name].record)
349 349
350 restoredSibling, ok, err := catalogue.Record(healthy.Name)350 restoredSibling, ok, err := catalogue.Record(healthy.Name)
351 require.NoError(t, err)351 require.NoError(t, err)
352 require.True(t, ok)352 require.True(t, ok)
353 require.Equal(t, healthy, restoredSibling)353 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)
355 require.Equal(t, 1, repository.loads[healthy.Name])355 require.Equal(t, 1, repository.loads[healthy.Name])
356 }356 }
357 357
@@ -366,7 +366,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
366 }366 }
367 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)367 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
368 d.mu.Lock()368 d.mu.Lock()
369- restoreDone := d.stopped[record.Name].restoreDone369+ restoreDone := d.inactive[record.Name].restoreDone
370 d.mu.Unlock()370 d.mu.Unlock()
371 371
372 restoreErr := d.restoreRecord(context.Background(), record)372 restoreErr := d.restoreRecord(context.Background(), record)
@@ -379,7 +379,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
379 require.Nil(t, fresh.Committed)379 require.Nil(t, fresh.Committed)
380 require.Empty(t, fresh.DegradedReason)380 require.Empty(t, fresh.DegradedReason)
381 d.mu.Lock()381 d.mu.Lock()
382- entry := d.stopped[record.Name]382+ entry := d.inactive[record.Name]
383 d.mu.Unlock()383 d.mu.Unlock()
384 require.Equal(t, ports.SessionDown, entry.state)384 require.Equal(t, ports.SessionDown, entry.state)
385 require.Equal(t, fresh, entry.record)385 require.Equal(t, fresh, entry.record)
@@ -392,7 +392,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
392 392
393 d.finishRecordRestore(record, restoreErr, restoreDone)393 d.finishRecordRestore(record, restoreErr, restoreDone)
394 d.mu.Lock()394 d.mu.Lock()
395- entry = d.stopped[record.Name]395+ entry = d.inactive[record.Name]
396 d.mu.Unlock()396 d.mu.Unlock()
397 require.Equal(t, ports.SessionDown, entry.state)397 require.Equal(t, ports.SessionDown, entry.state)
398 require.Equal(t, fresh, entry.record)398 require.Equal(t, fresh, entry.record)
@@ -452,7 +452,7 @@ func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) {
452 require.Equal(t, record.IncarnationID, persisted.IncarnationID)452 require.Equal(t, record.IncarnationID, persisted.IncarnationID)
453 require.Equal(t, record.Committed, persisted.Committed)453 require.Equal(t, record.Committed, persisted.Committed)
454 require.Equal(t, "checkpoint validation failed", persisted.DegradedReason)454 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)
456 require.Empty(t, repository.deleted)456 require.Empty(t, repository.deleted)
457 })457 })
458 }458 }
@@ -470,9 +470,9 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
470 470
471 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))471 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
472 472
473- require.Len(t, d.stopped, count)473+ require.Len(t, d.inactive, count)
474 for _, record := range records {474 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)
476 require.Equal(t, 1, repository.loads[record.Name])476 require.Equal(t, 1, repository.loads[record.Name])
477 }477 }
478 }478 }
@@ -486,7 +486,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
486 // discovery API and therefore performs exactly one direct catalogue lookup.486 // discovery API and therefore performs exactly one direct catalogue lookup.
487 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))487 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
488 488
489- require.Equal(t, ports.SessionDown, d.stopped[record.Name].state)489+ require.Equal(t, ports.SessionDown, d.inactive[record.Name].state)
490 require.Equal(t, 1, repository.loads[record.Name])490 require.Equal(t, 1, repository.loads[record.Name])
491 }491 }
492 492
@@ -499,7 +499,7 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
499 499
500 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))500 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
501 501
502- require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)502+ require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state)
503 got, _, _ := catalogue.Record(record.Name)503 got, _, _ := catalogue.Record(record.Name)
504 require.Equal(t, "checkpoint validation failed", got.DegradedReason)504 require.Equal(t, "checkpoint validation failed", got.DegradedReason)
505 }505 }
@@ -648,13 +648,13 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T)
648 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})648 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
649 tt.setup(d, catalogue, record)649 tt.setup(d, catalogue, record)
650 d.mu.Lock()650 d.mu.Lock()
651- before := d.stopped[record.Name]651+ before := d.inactive[record.Name]
652 d.mu.Unlock()652 d.mu.Unlock()
653 653
654 d.finishRecordRestore(record, errors.New("invalid checkpoint"), before.restoreDone)654 d.finishRecordRestore(record, errors.New("invalid checkpoint"), before.restoreDone)
655 655
656 d.mu.Lock()656 d.mu.Lock()
657- after := d.stopped[record.Name]657+ after := d.inactive[record.Name]
658 d.mu.Unlock()658 d.mu.Unlock()
659 require.Equal(t, before.record, after.record)659 require.Equal(t, before.record, after.record)
660 require.Equal(t, before.state, after.state)660 require.Equal(t, before.state, after.state)
@@ -666,7 +666,7 @@ func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEnt
666 record := durableRecoveryRecord(0)666 record := durableRecoveryRecord(0)
667 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})667 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
668 d.mu.Lock()668 d.mu.Lock()
669- before := d.stopped[record.Name]669+ before := d.inactive[record.Name]
670 collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))670 collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
671 d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}671 d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
672 d.mu.Unlock()672 d.mu.Unlock()
@@ -678,7 +678,7 @@ func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEnt
678 require.ErrorContains(t, err, "registry rejected")678 require.ErrorContains(t, err, "registry rejected")
679 require.False(t, registered)679 require.False(t, registered)
680 d.mu.Lock()680 d.mu.Lock()
681- after := d.stopped[record.Name]681+ after := d.inactive[record.Name]
682 d.mu.Unlock()682 d.mu.Unlock()
683 require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier")683 require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier")
684 require.Equal(t, before.restoreDone, after.restoreDone)684 require.Equal(t, before.restoreDone, after.restoreDone)
@@ -705,14 +705,14 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T
705 705
706 d.mu.Lock()706 d.mu.Lock()
707 for _, record := range tc.records {707 for _, record := range tc.records {
708- d.stopped[record.Name] = stoppedSession{708+ d.inactive[record.Name] = inactiveSession{
709 name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,709 name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,
710 incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,710 incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,
711 tabNames: append([]string(nil), record.TabNames...), record: record,711 tabNames: append([]string(nil), record.TabNames...), record: record,
712 state: ports.SessionDown, restoreDone: make(chan struct{}),712 state: ports.SessionDown, restoreDone: make(chan struct{}),
713 }713 }
714 }714 }
715- beforeStopped, hadStopped := d.stopped[tc.sessName]715+ beforeStopped, hadStopped := d.inactive[tc.sessName]
716 collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))716 collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
717 d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}717 d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
718 d.mu.Unlock()718 d.mu.Unlock()
@@ -723,7 +723,7 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T
723 require.ErrorContains(t, err, "registry rejected")723 require.ErrorContains(t, err, "registry rejected")
724 require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation")724 require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation")
725 d.mu.Lock()725 d.mu.Lock()
726- afterStopped, stillStopped := d.stopped[tc.sessName]726+ afterStopped, stillStopped := d.inactive[tc.sessName]
727 d.mu.Unlock()727 d.mu.Unlock()
728 require.Equal(t, hadStopped, stillStopped)728 require.Equal(t, hadStopped, stillStopped)
729 if hadStopped {729 if hadStopped {
@@ -769,8 +769,8 @@ func TestSessionRecoveryCommand(t *testing.T) {
769 769
770 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")770 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
771 require.NoError(t, err)771 require.NoError(t, err)
772- require.Equal(t, ports.SessionDown, d.stopped[degraded.Name].state)772+ require.Equal(t, ports.SessionDown, d.inactive[degraded.Name].state)
773- require.Equal(t, fresh, d.stopped[degraded.Name].record)773+ require.Equal(t, fresh, d.inactive[degraded.Name].record)
774 }774 }
775 775
776 func TestListShowsDegraded(t *testing.T) {776 func TestListShowsDegraded(t *testing.T) {
@@ -789,18 +789,18 @@ func TestListShowsDegraded(t *testing.T) {
789 }, listed.Sessions)789 }, listed.Sessions)
790 }790 }
791 791
792-func TestListPurgingDominatesRestoringState(t *testing.T) {792+func TestListHidesPurgingInactiveSession(t *testing.T) {
793 record := durableRecoveryRecord(0)793 record := durableRecoveryRecord(0)
794 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})794 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
795 d.mu.Lock()795 d.mu.Lock()
796- entry := d.stopped[record.Name]796+ entry := d.inactive[record.Name]
797 entry.purging = true797 entry.purging = true
798 entry.state = ports.SessionDown798 entry.state = ports.SessionDown
799- d.stopped[record.Name] = entry799+ d.inactive[record.Name] = entry
800 d.mu.Unlock()800 d.mu.Unlock()
801 801
802 listed := listSessions(t, d)802 listed := listSessions(t, d)
803- require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions)803+ require.Empty(t, listed.Sessions)
804 }804 }
805 805
806 func TestAttachWaitsForRestore(t *testing.T) {806 func TestAttachWaitsForRestore(t *testing.T) {
@@ -825,7 +825,7 @@ func TestAttachWaitsForRestore(t *testing.T) {
825 825
826 d.setStoppedRecovery(record, ports.SessionBroken)826 d.setStoppedRecovery(record, ports.SessionBroken)
827 d.mu.Lock()827 d.mu.Lock()
828- closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)828+ closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone)
829 d.mu.Unlock()829 d.mu.Unlock()
830 var protocolError *protoErr830 var protocolError *protoErr
831 require.ErrorAs(t, <-result, &protocolError)831 require.ErrorAs(t, <-result, &protocolError)
@@ -837,7 +837,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) {
837 record := durableRecoveryRecord(0)837 record := durableRecoveryRecord(0)
838 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})838 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
839 d.mu.Lock()839 d.mu.Lock()
840- done := d.stopped[record.Name].restoreDone840+ done := d.inactive[record.Name].restoreDone
841 d.mu.Unlock()841 d.mu.Unlock()
842 sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}}842 sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}}
843 843
@@ -894,7 +894,7 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
894 require.ErrorAs(t, <-attached, &protocolError)894 require.ErrorAs(t, <-attached, &protocolError)
895 require.Equal(t, ports.ErrInternal, protocolError.code)895 require.Equal(t, ports.ErrInternal, protocolError.code)
896 <-restored896 <-restored
897- entry := d.stopped[record.Name]897+ entry := d.inactive[record.Name]
898 require.Equal(t, ports.SessionBroken, entry.state)898 require.Equal(t, ports.SessionBroken, entry.state)
899 require.Equal(t, "restore interrupted", entry.record.DegradedReason)899 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
900 select {900 select {
@@ -910,7 +910,7 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
910 record := durableRecoveryRecord(0)910 record := durableRecoveryRecord(0)
911 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})911 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
912 d.mu.Lock()912 d.mu.Lock()
913- closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)913+ closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone)
914 d.mu.Unlock()914 d.mu.Unlock()
915 915
916 result := make(chan error, 1)916 result := make(chan error, 1)
@@ -943,7 +943,7 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
943 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})943 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
944 d.setStoppedRecovery(record, tt.state)944 d.setStoppedRecovery(record, tt.state)
945 d.mu.Lock()945 d.mu.Lock()
946- closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)946+ closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone)
947 d.mu.Unlock()947 d.mu.Unlock()
948 948
949 var protocolError *protoErr949 var protocolError *protoErr
@@ -975,7 +975,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
975 d.closeRestoreDone()975 d.closeRestoreDone()
976 976
977 d.mu.Lock()977 d.mu.Lock()
978- entry := d.stopped[record.Name]978+ entry := d.inactive[record.Name]
979 d.mu.Unlock()979 d.mu.Unlock()
980 require.Equal(t, ports.SessionBroken, entry.state)980 require.Equal(t, ports.SessionBroken, entry.state)
981 select {981 select {
@@ -993,6 +993,6 @@ func TestCatalogueRestoreDegradedVisible(t *testing.T) {
993 993
994 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))994 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
995 995
996- require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)996+ require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state)
997 require.Zero(t, repository.loads[record.Name])997 require.Zero(t, repository.loads[record.Name])
998 }998 }
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 }
@@ -240,7 +240,7 @@ func TestHandshakeTimeoutCancelsRouteRestoreWait(t *testing.T) {
240 d := newTestDaemon(t, nil, clock)240 d := newTestDaemon(t, nil, clock)
241 restoreDone := make(chan struct{})241 restoreDone := make(chan struct{})
242 d.mu.Lock()242 d.mu.Lock()
243- d.stopped["restoring"] = stoppedSession{name: "restoring", restoreDone: restoreDone}243+ d.inactive["restoring"] = inactiveSession{name: "restoring", restoreDone: restoreDone}
244 d.mu.Unlock()244 d.mu.Unlock()
245 245
246 tr := newHandshakeBlockingTransport(false)246 tr := newHandshakeBlockingTransport(false)
@@ -266,7 +266,7 @@ func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) {
266 defer release()266 defer release()
267 d := newTestDaemon(t, newFactory(t, pty), stubClock{})267 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
268 d.mu.Lock()268 d.mu.Lock()
269- d.stopped["restored"] = stoppedSession{name: "restored", cwd: "/tmp"}269+ d.inactive["restored"] = inactiveSession{name: "restored", cwd: "/tmp", state: ports.SessionDown}
270 d.mu.Unlock()270 d.mu.Unlock()
271 271
272 clock := &signalClock{timers: make(chan *signalTimer, 4)}272 clock := &signalClock{timers: make(chan *signalTimer, 4)}
@@ -286,7 +286,7 @@ func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) {
286 requireClosedHandshakeTransport(t, tr)286 requireClosedHandshakeTransport(t, tr)
287 d.mu.Lock()287 d.mu.Lock()
288 require.Empty(t, d.sessions)288 require.Empty(t, d.sessions)
289- _, retained := d.stopped["restored"]289+ _, retained := d.inactive["restored"]
290 d.mu.Unlock()290 d.mu.Unlock()
291 require.True(t, retained, "failed attach must retain the stopped session authority")291 require.True(t, retained, "failed attach must retain the stopped session authority")
292 }292 }
A internal/usecase/daemon/inactive_session_test.go +50 -0
@@ -0,0 +1,50 @@
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+ require.True(t, entry.sameLifecycle(entry))
46+
47+ replacement := entry
48+ replacement.incarnation[0]++
49+ require.False(t, entry.sameLifecycle(replacement))
50+}
@@ -0,0 +1,50 @@
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+ require.True(t, entry.sameLifecycle(entry))
46+
47+ replacement := entry
48+ replacement.incarnation[0]++
49+ require.False(t, entry.sameLifecycle(replacement))
50+}
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)
@@ -51,14 +51,14 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
51 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {51 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
52 d.mu.Lock()52 d.mu.Lock()
53 delete(d.sessions, target.id)53 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}
55 d.mu.Unlock()55 d.mu.Unlock()
56 expectedCreatedAt := int64(9)56 expectedCreatedAt := int64(9)
57 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")57 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
58 },58 },
59 check: func(t *testing.T, d *Daemon, _, _ *session) {59 check: func(t *testing.T, d *Daemon, _, _ *session) {
60 d.mu.Lock()60 d.mu.Lock()
61- _, stopped := d.stopped["stopped"]61+ _, stopped := d.inactive["stopped"]
62 resumed := d.findByNameLocked("stopped")62 resumed := d.findByNameLocked("stopped")
63 d.mu.Unlock()63 d.mu.Unlock()
64 require.True(t, stopped)64 require.True(t, stopped)
@@ -149,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
150 d, source, old, _ := newManualSessionWithPTYs(t, nil)150 d, source, old, _ := newManualSessionWithPTYs(t, nil)
151 d.mu.Lock()151 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}
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155 rc := d.attachCoordinator(source, nil, old, true)155 rc := d.attachCoordinator(source, nil, old, true)
@@ -189,7 +189,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
189 require.NoError(t, <-done)189 require.NoError(t, <-done)
190 190
191 d.mu.Lock()191 d.mu.Lock()
192- _, stillStopped := d.stopped["stopped"]192+ _, stillStopped := d.inactive["stopped"]
193 resumed := d.findByNameLocked("stopped")193 resumed := d.findByNameLocked("stopped")
194 d.mu.Unlock()194 d.mu.Unlock()
195 require.True(t, stillStopped)195 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 {
@@ -119,7 +119,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
119 d.sessions[echo.id] = echo119 d.sessions[echo.id] = echo
120 d.sessions[alpha.id] = alpha120 d.sessions[alpha.id] = alpha
121 d.sessions[charlie.id] = charlie121 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"}
123 d.mu.Unlock()123 d.mu.Unlock()
124 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})124 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
125 handler := daemonKeyHandler{d: d, ac: ac}125 handler := daemonKeyHandler{d: d, ac: ac}
@@ -150,7 +150,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
150 target.tabs[1].mu.Unlock()150 target.tabs[1].mu.Unlock()
151 }151 }
152 d.mu.Lock()152 d.mu.Lock()
153- _, stopped := d.stopped["bravo"]153+ _, stopped := d.inactive["bravo"]
154 var bravoLive bool154 var bravoLive bool
155 for _, candidate := range d.sessions {155 for _, candidate := range d.sessions {
156 if candidate == nil {156 if candidate == nil {
M internal/usecase/daemon/palette.go +3 -3
@@ -60,9 +60,9 @@ 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+ for _, entry := range d.inactive {
65+ if entry.visible() {
6666 stopped = append(stopped, entry)
6767 }
6868 }
@@ -60,9 +60,9 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
60 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result {60 func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result {
61 d.mu.Lock()61 d.mu.Lock()
62 sessions := d.sessionsSnapshotLocked()62 sessions := d.sessionsSnapshotLocked()
63- stopped := make([]stoppedSession, 0, len(d.stopped))63+ stopped := make([]inactiveSession, 0, len(d.inactive))
64- for _, entry := range d.stopped {64+ for _, entry := range d.inactive {
65- if !entry.purging {65+ if entry.visible() {
66 stopped = append(stopped, entry)66 stopped = append(stopped, entry)
67 }67 }
68 }68 }
M internal/usecase/daemon/palette_test.go +6 -6
@@ -228,8 +228,8 @@ 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}
232+ d.inactive["purging"] = inactiveSession{name: "purging", createdAt: 13, purging: true}
233233
234234 d.enterPalette(current, ac)
235235 ac.overlays.paletteMu.Lock()
@@ -314,8 +314,8 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
314314 p, release := newBlockingPTY(t)
315315 defer release()
316316 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
317+ stopped := inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}, state: ports.SessionDown}
318+ d.inactive[stopped.name] = stopped
319319 ptys := portsmocks.NewMockPTYFactory(t)
320320 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
321321 d.ptys = ptys
@@ -332,7 +332,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
332332 ac.overlays.paletteMu.Lock()
333333 require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
334334 ac.overlays.paletteMu.Unlock()
335- require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata")
335+ require.Equal(t, stopped, d.inactive[stopped.name], "failed resume must retain stopped lifecycle metadata")
336336 awaitFrame(t, sends, ports.MsgOutput)
337337 }
338338
@@ -343,7 +343,7 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
343343 defer release2()
344344 d, current, ac, sends := newManualSessionWithPTYs(t, p1)
345345 d.ptys = newFactory(t, p2)
346- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}
346+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, state: ports.SessionDown}
347347
348348 d.handleInput(current, ac, []byte("\x1b "))
349349 awaitFrame(t, sends, ports.MsgOutput)
@@ -228,8 +228,8 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
228 d, current, ac, _ := newManualSessionWithPTYs(t, p)228 d, current, ac, _ := newManualSessionWithPTYs(t, p)
229 d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}229 d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
230 d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}}230 d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}}
231- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}231+ d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 12}
232- d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}232+ d.inactive["purging"] = inactiveSession{name: "purging", createdAt: 13, purging: true}
233 233
234 d.enterPalette(current, ac)234 d.enterPalette(current, ac)
235 ac.overlays.paletteMu.Lock()235 ac.overlays.paletteMu.Lock()
@@ -314,8 +314,8 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
314 p, release := newBlockingPTY(t)314 p, release := newBlockingPTY(t)
315 defer release()315 defer release()
316 d, current, ac, sends := newManualSessionWithPTYs(t, p)316 d, current, ac, sends := newManualSessionWithPTYs(t, p)
317- stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}}317+ stopped := inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}, state: ports.SessionDown}
318- d.stopped[stopped.name] = stopped318+ d.inactive[stopped.name] = stopped
319 ptys := portsmocks.NewMockPTYFactory(t)319 ptys := portsmocks.NewMockPTYFactory(t)
320 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()320 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
321 d.ptys = ptys321 d.ptys = ptys
@@ -332,7 +332,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te
332 ac.overlays.paletteMu.Lock()332 ac.overlays.paletteMu.Lock()
333 require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)333 require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
334 ac.overlays.paletteMu.Unlock()334 ac.overlays.paletteMu.Unlock()
335- require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata")335+ require.Equal(t, stopped, d.inactive[stopped.name], "failed resume must retain stopped lifecycle metadata")
336 awaitFrame(t, sends, ports.MsgOutput)336 awaitFrame(t, sends, ports.MsgOutput)
337 }337 }
338 338
@@ -343,7 +343,7 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
343 defer release2()343 defer release2()
344 d, current, ac, sends := newManualSessionWithPTYs(t, p1)344 d, current, ac, sends := newManualSessionWithPTYs(t, p1)
345 d.ptys = newFactory(t, p2)345 d.ptys = newFactory(t, p2)
346- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}346+ d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, state: ports.SessionDown}
347 347
348 d.handleInput(current, ac, []byte("\x1b "))348 d.handleInput(current, ac, []byte("\x1b "))
349 awaitFrame(t, sends, ports.MsgOutput)349 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()
@@ -100,9 +100,9 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
100 }100 }
101 sessions = append(sessions, entry)101 sessions = append(sessions, entry)
102 }102 }
103- stopped := make([]stoppedSession, 0, len(d.stopped))103+ stopped := make([]inactiveSession, 0, len(d.inactive))
104- for _, s := range d.stopped {104+ for _, s := range d.inactive {
105- if !s.purging {105+ if s.visible() {
106 stopped = append(stopped, s)106 stopped = append(stopped, s)
107 }107 }
108 }108 }
@@ -179,15 +179,8 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
179 for _, item := range live {179 for _, item := range live {
180 views = append(views, item.view.pickerView())180 views = append(views, item.view.pickerView())
181 }181 }
182- seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
183 for _, host := range catalog {182 for _, host := range catalog {
184 for _, session := range host.entry.Sessions {183 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- }
191 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}184 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
192 if key.Validate() != nil {185 if key.Validate() != nil {
193 continue186 continue
@@ -886,15 +879,8 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
886 if token.sess == nil || token.ac == nil || token.effect == nil {879 if token.sess == nil || token.ac == nil || token.effect == nil {
887 return nil880 return nil
888 }881 }
889- if target.RemoteTarget != nil {882+ if target.RemoteTarget != nil || target.RemoteKey != nil {
890- var key domain.RemoteSessionKey883+ return d.sendRemoteAttachTargetForAttachment(token, target, guard, action)
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)
898 }884 }
899 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)885 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
900 }886 }
@@ -910,7 +896,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
910 var exactTarget *ports.ExactSessionTarget896 var exactTarget *ports.ExactSessionTarget
911 var matches bool897 var matches bool
912 if target.Name != "" {898 if target.Name != "" {
913- var stopped stoppedSession899+ var stopped inactiveSession
914 var stoppedTarget bool900 var stoppedTarget bool
915 targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)901 targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
916 if stoppedTarget && matches {902 if stoppedTarget && matches {
@@ -971,36 +957,27 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
971 // endpoint to the thin client. The daemon owns no remote session shadow: after957 // endpoint to the thin client. The daemon owns no remote session shadow: after
972 // the target is sent, the local attachment is detached and the client opens a958 // the target is sent, the local attachment is detached and the client opens a
973 // fresh connection to the owning daemon.959 // 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 {
975 failUnavailable := func() error {961 failUnavailable := func() error {
976 if token.attachmentCurrent() {962 if token.attachmentCurrent() {
977 d.notifyRemotePickerUnavailable(token.sess, target)963 d.notifyRemotePickerUnavailable(token.sess, target)
978 }964 }
979 return errAttachmentTransition965 return errAttachmentTransition
980 }966 }
981- var handoff ports.AttachTarget967+ if target.RemoteTarget == nil || target.RemoteKey == nil {
982- if target.RemoteTarget != nil {968+ return failUnavailable()
983- remoteTarget := *target.RemoteTarget969+ }
984- if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {970+ remoteTarget := *target.RemoteTarget
985- return failUnavailable()971+ key := *target.RemoteKey
986- }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) {
987- if target.RemoteKey != nil {973+ return failUnavailable()
988- if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {974+ }
989- return failUnavailable()975+ handoff := ports.AttachTarget{
990- }976+ Endpoint: remoteTarget.Endpoint,
991- }977+ Session: remoteTarget.SessionName,
992- handoff = ports.AttachTarget{978+ Intent: ports.IntentAttach,
993- Endpoint: remoteTarget.Endpoint,979+ RemoteTarget: &remoteTarget,
994- Session: remoteTarget.SessionName,980+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
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}
1004 }981 }
1005 payload := ports.MarshalAttachTarget(handoff)982 payload := ports.MarshalAttachTarget(handoff)
1006 if payload == nil {983 if payload == nil {
@@ -1028,24 +1005,6 @@ func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCache
1028 return entry, true1005 return entry, true
1029 }1006 }
1030 1007
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-
1049 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {1008 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
1050 if d == nil || target.Validate() != nil {1009 if d == nil || target.Validate() != nil {
1051 return false1010 return false
@@ -1060,7 +1019,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
1060 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {1019 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
1061 continue1020 continue
1062 }1021 }
1063- if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {1022+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == ports.RemoteCatalogSessionBroken {
1064 return false1023 return false
1065 }1024 }
1066 tabs := ports.CatalogTabs(session)1025 tabs := ports.CatalogTabs(session)
@@ -1068,21 +1027,8 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
1068 for _, tab := range tabs {1027 for _, tab := range tabs {
1069 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})1028 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1070 }1029 }
1071- if target.Stopped {1030+ _, ok := target.ResolveTab(metadata)
1072- _, ok := target.StoppedTab.Resolve(metadata)1031+ return ok
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
1086 }1032 }
1087 return false1033 return false
1088 }1034 }
@@ -1175,19 +1121,19 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1175 // resolveNamedLifecycleTargetLocked chooses exactly one current representation1121 // resolveNamedLifecycleTargetLocked chooses exactly one current representation
1176 // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle1122 // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
1177 // identity here closes the lookup-to-handoff window for palette targets.1123 // 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) {
1179 if target.Name == "" {1125 if target.Name == "" {
1180- return nil, stoppedSession{}, false, false1126+ return nil, inactiveSession{}, false, false
1181 }1127 }
1182 if active := d.findByNameLocked(target.Name); active != nil {1128 if active := d.findByNameLocked(target.Name); active != nil {
1183 active.mu.Lock()1129 active.mu.Lock()
1184 matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)1130 matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
1185 active.mu.Unlock()1131 active.mu.Unlock()
1186- return active, stoppedSession{}, false, matches1132+ return active, inactiveSession{}, false, matches
1187 }1133 }
1188- stopped, ok := d.stopped[target.Name]1134+ stopped, ok := d.inactive[target.Name]
1189- if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {1135+ if !ok || !stopped.canResume() || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
1190- return nil, stoppedSession{}, false, false1136+ return nil, inactiveSession{}, false, false
1191 }1137 }
1192 return nil, stopped, true, true1138 return nil, stopped, true, true
1193 }1139 }
@@ -1282,10 +1228,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1282 // resumeStoppedAndSwitchLocked creates the stopped representation and commits1228 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
1283 // the handoff while d.mu is held. Creation failure leaves the source client1229 // the handoff while d.mu is held. Creation failure leaves the source client
1284 // and stopped record untouched.1230 // 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) {1231+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1286- if stopped.record.Name != "" && stopped.state == ports.SessionBroken {1232+ if stopped.broken() {
1287 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}1233 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1288 }1234 }
1235+ if !stopped.canResume() {
1236+ return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1237+ }
1289 1238
1290 if sourceToken != nil {1239 if sourceToken != nil {
1291 var targetSess *session1240 var targetSess *session
@@ -1295,14 +1244,14 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1295 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,1244 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1296 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,1245 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1297 createTargetLocked: func() (*session, error) {1246 createTargetLocked: func() (*session, error) {
1298- current, ok := d.stopped[target.Name]1247+ current, ok := d.inactive[target.Name]
1299- if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {1248+ if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1300 return nil, errAttachmentTransition1249 return nil, errAttachmentTransition
1301 }1250 }
1302 from.mu.Lock()1251 from.mu.Lock()
1303 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)1252 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
1304 from.mu.Unlock()1253 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)
1306 targetSess = created1255 targetSess = created
1307 return created, createErr1256 return created, createErr
1308 },1257 },
@@ -1328,12 +1277,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1328 }1277 }
1329 env := copyEnvironment(from.env)1278 env := copyEnvironment(from.env)
1330 from.mu.Unlock()1279 from.mu.Unlock()
1331- current, ok := d.stopped[target.Name]1280+ current, ok := d.inactive[target.Name]
1332- if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {1281+ if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
1333 return nil, attachmentTransitionResult{}, false, errAttachmentTransition1282 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1334 }1283 }
1335 cwd := d.dirOrHome(current.cwd)1284 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)
1337 if err != nil {1286 if err != nil {
1338 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)1287 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1339 return nil, attachmentTransitionResult{}, false, err1288 return nil, attachmentTransitionResult{}, false, err
@@ -1376,12 +1325,12 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
1376 // its stopped target and commits creation under one d.mu critical section.1325 // its stopped target and commits creation under one d.mu critical section.
1377 func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {1326 func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
1378 d.mu.Lock()1327 d.mu.Lock()
1379- stopped, ok := d.stopped[target.Name]1328+ stopped, ok := d.inactive[target.Name]
1380 var (1329 var (
1381 transition attachmentTransitionResult1330 transition attachmentTransitionResult
1382 switched bool1331 switched bool
1383 )1332 )
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) {
1385 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")1334 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1386 }1335 }
1387 d.mu.Unlock()1336 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")
@@ -136,8 +136,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
136 d.sessions[old.id] = old136 d.sessions[old.id] = old
137 d.sessions[eph.id] = eph137 d.sessions[eph.id] = eph
138 d.sessions[recent.id] = recent138 d.sessions[recent.id] = recent
139- d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}139+ d.inactive["halted-old"] = inactiveSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
140- d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}140+ d.inactive["halted-new"] = inactiveSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
141 141
142 views, _ := d.pickerViews(recent, nil)142 views, _ := d.pickerViews(recent, nil)
143 143
@@ -177,7 +177,7 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
177 d.sessions[eph.id] = eph177 d.sessions[eph.id] = eph
178 d.sessions[named.id] = named178 d.sessions[named.id] = named
179 d.sessions[named2.id] = named2179 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}
181 181
182 views, _ := d.pickerViews(named, nil)182 views, _ := d.pickerViews(named, nil)
183 183
@@ -217,7 +217,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
217 eph.mruAt.Store(5)217 eph.mruAt.Store(5)
218 d.mu.Lock()218 d.mu.Lock()
219 d.sessions[eph.id] = eph219 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}
221 d.mu.Unlock()221 d.mu.Unlock()
222 222
223 d.enterPicker(sess, ac)223 d.enterPicker(sess, ac)
@@ -288,7 +288,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
288 defer cancel()288 defer cancel()
289 active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}289 active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
290 d.sessions[active.id] = active290 d.sessions[active.id] = active
291- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}291+ d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 24}
292 292
293 views, _ := d.pickerViews(active, nil)293 views, _ := d.pickerViews(active, nil)
294 294
@@ -310,7 +310,7 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
310 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}310 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}
311 d.sessions[source.id] = source311 d.sessions[source.id] = source
312 d.sessions[destination.id] = destination312 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}}
314 sourceLocator := moveSourceLocator{314 sourceLocator := moveSourceLocator{
315 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},315 Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
316 TabID: "source-tab",316 TabID: "source-tab",
@@ -664,7 +664,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
664 record := durableRecoveryRecord(0)664 record := durableRecoveryRecord(0)
665 record.Name = "restoring"665 record.Name = "restoring"
666 state, done := initialSessionState(record)666 state, done := initialSessionState(record)
667- d.stopped[record.Name] = stoppedSession{667+ d.inactive[record.Name] = inactiveSession{
668 name: record.Name,668 name: record.Name,
669 createdAt: record.CreatedAt,669 createdAt: record.CreatedAt,
670 incarnation: record.IncarnationID,670 incarnation: record.IncarnationID,
@@ -692,7 +692,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
692 defer cancel()692 defer cancel()
693 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})}}693 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})}}
694 d.mu.Lock()694 d.mu.Lock()
695- delete(d.stopped, record.Name)695+ delete(d.inactive, record.Name)
696 d.sessions[target.id] = target696 d.sessions[target.id] = target
697 closeRuntimeRestoreDoneLocked(done)697 closeRuntimeRestoreDoneLocked(done)
698 d.mu.Unlock()698 d.mu.Unlock()
@@ -731,7 +731,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
731 <-restored731 <-restored
732 require.Same(t, from, ac.currentSession())732 require.Same(t, from, ac.currentSession())
733 d.mu.Lock()733 d.mu.Lock()
734- entry := d.stopped[record.Name]734+ entry := d.inactive[record.Name]
735 d.mu.Unlock()735 d.mu.Unlock()
736 require.Equal(t, ports.SessionBroken, entry.state)736 require.Equal(t, ports.SessionBroken, entry.state)
737 require.Equal(t, "restore interrupted", entry.record.DegradedReason)737 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
@@ -765,7 +765,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
765 }765 }
766 catalogue := persist.New(store)766 catalogue := persist.New(store)
767 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)767 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
768- d.stopped[record.Name] = stoppedSession{768+ d.inactive[record.Name] = inactiveSession{
769 name: record.Name,769 name: record.Name,
770 createdAt: record.CreatedAt,770 createdAt: record.CreatedAt,
771 incarnation: record.IncarnationID,771 incarnation: record.IncarnationID,
@@ -791,7 +791,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
791 storeState.mu.Unlock()791 storeState.mu.Unlock()
792 require.Zero(t, sets, "unsafe target must not mutate the catalogue")792 require.Zero(t, sets, "unsafe target must not mutate the catalogue")
793 d.mu.Lock()793 d.mu.Lock()
794- entry := d.stopped[record.Name]794+ entry := d.inactive[record.Name]
795 d.mu.Unlock()795 d.mu.Unlock()
796 require.Equal(t, record, entry.record)796 require.Equal(t, record, entry.record)
797 require.Equal(t, tt.runtimeState, entry.state)797 require.Equal(t, tt.runtimeState, entry.state)
@@ -808,7 +808,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
808 defer release3()808 defer release3()
809 d, from, ac, sends := newManualSessionWithPTYs(t, p1)809 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
810 d.ptys = newFactorySeq(t, p2, p3)810 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}
812 812
813 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})813 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
814 awaitFrame(t, sends, ports.MsgOutput)814 awaitFrame(t, sends, ports.MsgOutput)
@@ -1406,7 +1406,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1406 return got != normalSize && got.Valid()1406 return got != normalSize && got.Valid()
1407 })).Return(floating, nil).Once()1407 })).Return(floating, nil).Once()
1408 d := newTestDaemon(t, f, stubClock{})1408 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}
1410 tr := portsmocks.NewMockTransport(t)1410 tr := portsmocks.NewMockTransport(t)
1411 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1411 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1412 tr.EXPECT().Close().Return(nil).Maybe()1412 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1448,7 +1448,7 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
1448 p, release := newBlockingPTY(t)1448 p, release := newBlockingPTY(t)
1449 defer release()1449 defer release()
1450 d, from, ac, sends := newManualSessionWithPTYs(t, p)1450 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}
1452 cause := errors.New("open failed")1452 cause := errors.New("open failed")
1453 ptys := portsmocks.NewMockPTYFactory(t)1453 ptys := portsmocks.NewMockPTYFactory(t)
1454 ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()1454 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
1659 state.mu.Lock()1659 state.mu.Lock()
1660 state.deleteErr = func(string) error { return cause }1660 state.deleteErr = func(string) error { return cause }
1661 state.mu.Unlock()1661 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}
1663 1663
1664 d.enterPicker(from, ac)1664 d.enterPicker(from, ac)
1665 awaitFrame(t, sends, ports.MsgOutput)1665 awaitFrame(t, sends, ports.MsgOutput)
@@ -1677,7 +1677,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1677 require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)1677 require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
1678 1678
1679 d.mu.Lock()1679 d.mu.Lock()
1680- stopped, retained := d.stopped["stopped"]1680+ stopped, retained := d.inactive["stopped"]
1681 d.mu.Unlock()1681 d.mu.Unlock()
1682 require.True(t, retained, "failed deletion must retain the reserved name")1682 require.True(t, retained, "failed deletion must retain the reserved name")
1683 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")1683 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
@@ -26,10 +26,10 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) {
26 records, err = d.catalogue.Records()26 records, err = d.catalogue.Records()
27 if err != nil {27 if err != nil {
28 d.log.Error("daemon_startup_catalogue_read_failed", "err", err)28 d.log.Error("daemon_startup_catalogue_read_failed", "err", err)
29- records = d.stoppedRecords()29+ records = d.inactiveRecords()
30 }30 }
31 } else {31 } else {
32- records = d.stoppedRecords()32+ records = d.inactiveRecords()
33 }33 }
34 healthy, fresh, broken := 0, 0, 034 healthy, fresh, broken := 0, 0, 0
35 for _, record := range records {35 for _, record := range records {
@@ -50,11 +50,11 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) {
50 )50 )
51 }51 }
52 52
53-func (d *Daemon) stoppedRecords() []domain.CatalogueRecord {53+func (d *Daemon) inactiveRecords() []domain.CatalogueRecord {
54 d.mu.Lock()54 d.mu.Lock()
55 defer d.mu.Unlock()55 defer d.mu.Unlock()
56- records := make([]domain.CatalogueRecord, 0, len(d.stopped))56+ records := make([]domain.CatalogueRecord, 0, len(d.inactive))
57- for _, entry := range d.stopped {57+ for _, entry := range d.inactive {
58 records = append(records, entry.record)58 records = append(records, entry.record)
59 }59 }
60 return records60 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
@@ -57,7 +57,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
57 d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))57 d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))
58 d.persistEnabled = true58 d.persistEnabled = true
59 d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")}59 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}
61 61
62 d.logStartupRecoveryCounts(0)62 d.logStartupRecoveryCounts(0)
63 63
M internal/usecase/daemon/remote_attach.go +29 -27
@@ -52,16 +52,20 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
5252 return live, ac, err
5353 }
5454
55- stopped, ok := d.stopped[target.SessionName]
56- if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
55+ if !target.Stopped {
56+ d.mu.Unlock()
57+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "live remote target has no active runtime"}
58+ }
59+ inactive, ok := d.inactive[target.SessionName]
60+ if !ok || inactive.incarnation != target.LifecycleID {
5761 d.mu.Unlock()
5862 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
5963 }
60- if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" {
64+ if !inactive.canResume() {
6165 d.mu.Unlock()
62- return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"}
66+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is unavailable"}
6367 }
64- if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok {
68+ if _, ok := remoteTargetTabIndexInactive(inactive, target); !ok {
6569 d.mu.Unlock()
6670 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}
6771 }
@@ -70,16 +74,30 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
7074 // client's Env/Cwd fields remain present for wire compatibility but are not
7175 // trusted for this branch.
7276 env := copyEnvironment(d.baseEnv)
73- cwd := d.dirOrHome(stopped.cwd)
74- sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, env, stopped.tabNames)
77+ cwd := d.dirOrHome(inactive.cwd)
78+ sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Size, env, inactive)
7579 if err != nil {
7680 d.mu.Unlock()
81+ if errors.Is(err, errAttachmentTransition) {
82+ return nil, nil, remoteTargetError(err)
83+ }
7784 return nil, nil, err
7885 }
7986 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
8087 return sess, ac, err
8188 }
8289
90+func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, size domain.Size, env []string, expected inactiveSession) (*session, error) {
91+ validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
92+ if !target.Stopped || d.persistEnabled && !authoritativeExists {
93+ return false
94+ }
95+ _, ok := target.ResolveTab(stoppedTabMetadata(current))
96+ return ok
97+ }
98+ return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, size, env, &expected, validate, expected.tabNames)
99+}
100+
83101 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84102 payload := ports.MarshalNavigationAction(action)
85103 if payload == nil {
@@ -156,7 +174,7 @@ func remoteTargetError(err error) error {
156174 return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}
157175 }
158176
159-func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
177+func stoppedTabMetadata(stopped inactiveSession) []domain.TabSelectorTab {
160178 records := stopped.tabRecords
161179 if len(records) == 0 {
162180 records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
@@ -171,8 +189,8 @@ func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
171189 return metadata
172190 }
173191
174-func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) {
175- return target.StoppedTab.Resolve(stoppedTabMetadata(stopped))
192+func remoteTargetTabIndexInactive(inactive inactiveSession, target domain.RemoteSessionTarget) (int, bool) {
193+ return target.ResolveTab(stoppedTabMetadata(inactive))
176194 }
177195
178196 // remoteTargetTabIndexLocked resolves the selector against the live session's
@@ -194,21 +212,5 @@ func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget
194212 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
195213 tab.mu.Unlock()
196214 }
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
215+ return target.ResolveTab(metadata)
214216 }
@@ -52,16 +52,20 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
52 return live, ac, err52 return live, ac, err
53 }53 }
54 54
55- stopped, ok := d.stopped[target.SessionName]55+ if !target.Stopped {
56- if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {56+ d.mu.Unlock()
57+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "live remote target has no active runtime"}
58+ }
59+ inactive, ok := d.inactive[target.SessionName]
60+ if !ok || inactive.incarnation != target.LifecycleID {
57 d.mu.Unlock()61 d.mu.Unlock()
58 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}62 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
59 }63 }
60- if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" {64+ if !inactive.canResume() {
61 d.mu.Unlock()65 d.mu.Unlock()
62- return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"}66+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is unavailable"}
63 }67 }
64- if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok {68+ if _, ok := remoteTargetTabIndexInactive(inactive, target); !ok {
65 d.mu.Unlock()69 d.mu.Unlock()
66 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}70 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}
67 }71 }
@@ -70,16 +74,30 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
70 // client's Env/Cwd fields remain present for wire compatibility but are not74 // client's Env/Cwd fields remain present for wire compatibility but are not
71 // trusted for this branch.75 // trusted for this branch.
72 env := copyEnvironment(d.baseEnv)76 env := copyEnvironment(d.baseEnv)
73- cwd := d.dirOrHome(stopped.cwd)77+ cwd := d.dirOrHome(inactive.cwd)
74- sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, env, stopped.tabNames)78+ sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Size, env, inactive)
75 if err != nil {79 if err != nil {
76 d.mu.Unlock()80 d.mu.Unlock()
81+ if errors.Is(err, errAttachmentTransition) {
82+ return nil, nil, remoteTargetError(err)
83+ }
77 return nil, nil, err84 return nil, nil, err
78 }85 }
79 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)86 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
80 return sess, ac, err87 return sess, ac, err
81 }88 }
82 89
90+func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, size domain.Size, env []string, expected inactiveSession) (*session, error) {
91+ validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
92+ if !target.Stopped || d.persistEnabled && !authoritativeExists {
93+ return false
94+ }
95+ _, ok := target.ResolveTab(stoppedTabMetadata(current))
96+ return ok
97+ }
98+ return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, size, env, &expected, validate, expected.tabNames)
99+}
100+
83 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {101 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84 payload := ports.MarshalNavigationAction(action)102 payload := ports.MarshalNavigationAction(action)
85 if payload == nil {103 if payload == nil {
@@ -156,7 +174,7 @@ func remoteTargetError(err error) error {
156 return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}174 return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}
157 }175 }
158 176
159-func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {177+func stoppedTabMetadata(stopped inactiveSession) []domain.TabSelectorTab {
160 records := stopped.tabRecords178 records := stopped.tabRecords
161 if len(records) == 0 {179 if len(records) == 0 {
162 records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))180 records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
@@ -171,8 +189,8 @@ func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
171 return metadata189 return metadata
172 }190 }
173 191
174-func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) {192+func remoteTargetTabIndexInactive(inactive inactiveSession, target domain.RemoteSessionTarget) (int, bool) {
175- return target.StoppedTab.Resolve(stoppedTabMetadata(stopped))193+ return target.ResolveTab(stoppedTabMetadata(inactive))
176 }194 }
177 195
178 // remoteTargetTabIndexLocked resolves the selector against the live session's196 // remoteTargetTabIndexLocked resolves the selector against the live session's
@@ -194,21 +212,5 @@ func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget
194 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})212 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
195 tab.mu.Unlock()213 tab.mu.Unlock()
196 }214 }
197- if target.Stopped {215+ return target.ResolveTab(metadata)
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
214 }216 }
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:
@@ -58,7 +58,7 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
58 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}58 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
59 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}59 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
60 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}60 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"))
62 frame := receiveRemotePicker(t, sends, "rich attach target")62 frame := receiveRemotePicker(t, sends, "rich attach target")
63 got, err := ports.UnmarshalAttachTarget(frame.Payload)63 got, err := ports.UnmarshalAttachTarget(frame.Payload)
64 require.NoError(t, err)64 require.NoError(t, err)
@@ -67,6 +67,56 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
67 require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)67 require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
68 }68 }
69 69
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+
70 func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {120 func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
71 d := newRemotePickerDaemon(nil)121 d := newRemotePickerDaemon(nil)
72 lifecycle := remoteLifecycleForTest()122 lifecycle := remoteLifecycleForTest()
@@ -89,7 +139,7 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
89 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}139 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
90 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}140 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
91 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}141 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)
93 for {143 for {
94 select {144 select {
95 case frame := <-sends:145 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 }
@@ -124,8 +124,8 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
124 }124 }
125 }125 }
126 126
127-func remoteSessionStateStopped(state string) bool {127+func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool {
128- return state == "down" || state == "stopped"128+ return state == ports.RemoteCatalogSessionDown
129 }129 }
130 130
131 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {131 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
133 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)133 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
134 availability := remotePickerAvailability(status)134 availability := remotePickerAvailability(status)
135 stopped := remoteSessionStateStopped(session.State)135 stopped := remoteSessionStateStopped(session.State)
136- broken := session.State == "broken"136+ broken := session.State == ports.RemoteCatalogSessionBroken
137 tabs := ports.CatalogTabs(session)137 tabs := ports.CatalogTabs(session)
138 viewTabs := make([]picker.TabEntry, 0, len(tabs))138 viewTabs := make([]picker.TabEntry, 0, len(tabs))
139 active := 0139 active := 0
@@ -153,7 +153,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
153 Attention: tab.Attention,153 Attention: tab.Attention,
154 })154 })
155 }155 }
156- legacy := session.LifecycleID == (domain.SessionLifecycleID{})
157 target := domain.RemoteSessionTarget{156 target := domain.RemoteSessionTarget{
158 Endpoint: key.Host,157 Endpoint: key.Host,
159 DisplayOrigin: key.DisplayOrigin,158 DisplayOrigin: key.DisplayOrigin,
@@ -178,23 +177,22 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178 // malformed or a broken session cannot be activated. Cursor navigation177 // malformed or a broken session cannot be activated. Cursor navigation
179 // must remain possible, while sendRemoteAttachTargetForAttachment will178 // must remain possible, while sendRemoteAttachTargetForAttachment will
180 // fail closed on the invalid target.179 // fail closed on the invalid target.
181- var remoteTarget *domain.RemoteSessionTarget180+ remoteTarget := &target
182- if !legacy {181+ if stopped && len(viewTabs) == 0 {
183- remoteTarget = &target182+ // 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{})
184 }186 }
185 reason := remoteReasonForStatus(status)187 reason := remoteReasonForStatus(status)
186- targetValid := remoteTarget != nil && remoteTarget.Validate() == nil188+ targetValid := remoteTarget.Validate() == nil
187 activation := picker.RemoteUnavailable189 activation := picker.RemoteUnavailable
188 if broken {190 if broken {
189 reason = "session_broken"191 reason = "session_broken"
190- } else if !legacy && !targetValid {192+ } else if !targetValid {
191 reason = "identity_changed"193 reason = "identity_changed"
192 } else if status == remoteHostFresh {194 } else if status == remoteHostFresh {
193- if legacy {195+ if stopped {
194- if !stopped {
195- activation = picker.RemoteAttach
196- }
197- } else if stopped {
198 activation = picker.RemoteRestart196 activation = picker.RemoteRestart
199 } else {197 } else {
200 activation = picker.RemoteAttach198 activation = picker.RemoteAttach
@@ -206,18 +204,16 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
206 switch {204 switch {
207 case broken:205 case broken:
208 detail = "session broken"206 detail = "session broken"
209- case !legacy && !targetValid:207+ case !targetValid:
210 detail = "identity changed"208 detail = "identity changed"
211- case legacy && stopped:
212- detail = "stopped — exact identity required"
213 case activation == picker.RemoteRestart:209 case activation == picker.RemoteRestart:
214 detail = "stopped — Enter to restart"210 detail = "stopped — Enter to restart"
215- case session.State == "up":211+ case session.State == ports.RemoteCatalogSessionUp:
216 detail = "up"212 detail = "up"
217 case stopped:213 case stopped:
218 detail = "stopped"214 detail = "stopped"
219 default:215 default:
220- detail = session.State216+ detail = string(session.State)
221 }217 }
222 }218 }
223 return picker.SessionView{219 return picker.SessionView{
@@ -233,7 +229,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
233 Tabs: viewTabs,229 Tabs: viewTabs,
234 Active: active,230 Active: active,
235 Stopped: stopped,231 Stopped: stopped,
236- ConnectOnly: legacy,
237 CannotAcceptMoves: true,232 CannotAcceptMoves: true,
238 }233 }
239 }234 }
@@ -266,7 +261,6 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
266 RemoteAvailability: remotePickerAvailability(status),261 RemoteAvailability: remotePickerAvailability(status),
267 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),262 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
268 RemoteActivation: picker.RemoteUnavailable,263 RemoteActivation: picker.RemoteUnavailable,
269- ConnectOnly: true,
270 CannotAcceptMoves: true,264 CannotAcceptMoves: true,
271 }265 }
272 }266 }
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-}
@@ -59,30 +59,19 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
59 }59 }
60 60
61 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {61 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
62- for _, state := range []string{"down", "stopped"} {62+ lifecycle := remoteLifecycleForTest()
63- t.Run(state, func(t *testing.T) {63+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
64- lifecycle := remoteLifecycleForTest()64+ session := ports.RemoteCatalogSession{
65- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}65+ LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionDown,
66- session := ports.RemoteCatalogSession{66+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
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- })
85 }67 }
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+
86 }75 }
87 76
88 func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {77 func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
@@ -106,7 +95,7 @@ func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
106 }95 }
107 }96 }
108 97
109-func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) {98+func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) {
110 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}99 store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
111 d := newRemotePickerDaemon(store)100 d := newRemotePickerDaemon(store)
112 lifecycle := remoteLifecycleForTest()101 lifecycle := remoteLifecycleForTest()
@@ -130,8 +119,9 @@ func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testi
130 targets = append(targets, view.RemoteTarget)119 targets = append(targets, view.RemoteTarget)
131 }120 }
132 }121 }
133- require.Len(t, targets, 1)122+ require.Len(t, targets, 2)
134- require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint")123+ require.Equal(t, "remote", targets[0].Endpoint)
124+ require.Equal(t, "vev@remote", targets[1].Endpoint)
135 }125 }
136 126
137 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {127 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
@@ -465,6 +455,14 @@ func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog,
465 }455 }
466 }456 }
467 457
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+
468 func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {466 func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
469 t.Helper()467 t.Helper()
470 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}468 catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
@@ -523,7 +521,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
523 }521 }
524 for range 2 {522 for range 2 {
525 request := receiveRemotePicker(t, catalog.requests, "catalog request")523 request := receiveRemotePicker(t, catalog.requests, "catalog request")
526- request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}524+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
527 }525 }
528 for range 2 {526 for range 2 {
529 receiveRemotePicker(t, cache.stores, "remote cache store")527 receiveRemotePicker(t, cache.stores, "remote cache store")
@@ -553,7 +551,7 @@ func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
553 551
554 d.startRemotePickerRefresh(instance)552 d.startRemotePickerRefresh(instance)
555 request := receiveRemotePicker(t, catalog.requests, "catalog request")553 request := receiveRemotePicker(t, catalog.requests, "catalog request")
556- request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}}554+ request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
557 receiveRemotePicker(t, cache.stores, "remote cache store")555 receiveRemotePicker(t, cache.stores, "remote cache store")
558 requireNoRemoteLockViolations(t, catalog, cache)556 requireNoRemoteLockViolations(t, catalog, cache)
559 }557 }
@@ -596,7 +594,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
596 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])594 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
597 d.remoteCatalog.mu.Unlock()595 d.remoteCatalog.mu.Unlock()
598 596
599- require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil))597+ require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil))
600 stored := receiveRemotePicker(t, cache.stores, "remote cache store")598 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
601 require.Equal(t, []ports.RemoteCatalogCacheEntry{599 require.Equal(t, []ports.RemoteCatalogCacheEntry{
602 {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},600 {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
@@ -619,9 +617,15 @@ func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
619 d.remoteCatalog.refresh = 9617 d.remoteCatalog.refresh = 9
620 d.remoteCatalog.mu.Unlock()618 d.remoteCatalog.mu.Unlock()
621 619
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))
623 hosts.set()624 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))
625 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))629 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
626 requireNoRemoteLockViolations(t, nil, cache)630 requireNoRemoteLockViolations(t, nil, cache)
627 d.remoteCatalog.mu.Lock()631 d.remoteCatalog.mu.Lock()
@@ -669,9 +673,14 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
669 }673 }
670 674
671 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {675 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+ }
672 d := newRemotePickerDaemon(nil)681 d := newRemotePickerDaemon(nil)
673 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{682 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},
675 }})684 }})
676 d.remoteCatalog.mu.Lock()685 d.remoteCatalog.mu.Lock()
677 d.remoteCatalog.status["arch"] = remoteHostFresh686 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -681,15 +690,16 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
681 effect, admitted := ac.beginAttachmentEffect(token)690 effect, admitted := ac.beginAttachmentEffect(token)
682 require.True(t, admitted)691 require.True(t, admitted)
683 token.effect = effect692 token.effect = effect
684- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}693+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
685- target := picker.Target{Session: key.ID(), RemoteKey: &key}694+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
686- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))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"))
687 697
688 frame := receiveRemotePicker(t, sends, "attach target")698 frame := receiveRemotePicker(t, sends, "attach target")
689 require.Equal(t, ports.MsgAttachTarget, frame.Type)699 require.Equal(t, ports.MsgAttachTarget, frame.Type)
690 got, err := ports.UnmarshalAttachTarget(frame.Payload)700 got, err := ports.UnmarshalAttachTarget(frame.Payload)
691 require.NoError(t, err)701 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)
693 require.Nil(t, ac.currentAttachmentSession())703 require.Nil(t, ac.currentAttachmentSession())
694 d.mu.Lock()704 d.mu.Lock()
695 require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")705 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) {
736 effect, admitted := localAttachment.beginAttachmentEffect(token)746 effect, admitted := localAttachment.beginAttachmentEffect(token)
737 require.True(t, admitted)747 require.True(t, admitted)
738 token.effect = effect748 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"))
740 750
741 frame := receiveRemotePicker(t, sends, "stopped remote target")751 frame := receiveRemotePicker(t, sends, "stopped remote target")
742 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)752 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
@@ -749,8 +759,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
749 759
750 remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})760 remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
751 remote.mu.Lock()761 remote.mu.Lock()
752- remote.stopped["work"] = stoppedSession{762+ remote.inactive["work"] = inactiveSession{
753- name: "work", cwd: "/remote/work", incarnation: lifecycle,763+ name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
754 tabNames: []string{"alpha", "beta"},764 tabNames: []string{"alpha", "beta"},
755 tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}},765 tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}},
756 }766 }
@@ -767,6 +777,71 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
767 require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID)777 require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID)
768 }778 }
769 779
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+
770 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {845 func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
771 tests := []struct {846 tests := []struct {
772 name string847 name string
@@ -795,9 +870,14 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
795 }870 }
796 871
797 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {872 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+ }
798 d := newRemotePickerDaemon(nil)878 d := newRemotePickerDaemon(nil)
799 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{879 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},
801 }})881 }})
802 d.remoteCatalog.mu.Lock()882 d.remoteCatalog.mu.Lock()
803 d.remoteCatalog.status["arch"] = remoteHostFresh883 d.remoteCatalog.status["arch"] = remoteHostFresh
@@ -818,9 +898,10 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
818 require.True(t, admitted)898 require.True(t, admitted)
819 token.effect = effect899 token.effect = effect
820 defer effect.End()900 defer effect.End()
821- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}901+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
822- target := picker.Target{Session: key.ID(), RemoteKey: &key}902+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
823- err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")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")
824 var userErr *domain.UserError905 var userErr *domain.UserError
825 require.ErrorAs(t, err, &userErr)906 require.ErrorAs(t, err, &userErr)
826 require.Equal(t, "couldn't attach to remote session", userErr.Msg)907 require.Equal(t, "couldn't attach to remote session", userErr.Msg)
@@ -877,7 +958,11 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
877 958
878 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{959 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
879 ProtocolVersion: ports.ProtocolVersion,960 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+ }},
881 }, nil))966 }, nil))
882 receiveRemotePicker(t, cache.stores, "remote cache store")967 receiveRemotePicker(t, cache.stores, "remote cache store")
883 requireNoRemoteLockViolations(t, catalog, cache)968 requireNoRemoteLockViolations(t, catalog, cache)
@@ -1186,27 +1271,3 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1186 require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")1271 require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
1187 ac.overlays.pickerMu.Unlock()1272 ac.overlays.pickerMu.Unlock()
1188 }1273 }
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_stopped_route_test.go +139 -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,143 @@ 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, uint16(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 TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) {
96+ newTarget := func(record domain.CatalogueRecord) domain.RemoteSessionTarget {
97+ return domain.RemoteSessionTarget{
98+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID,
99+ SessionName: record.Name, Stopped: true,
100+ }
101+ }
102+ route := func(d *Daemon, target domain.RemoteSessionTarget) error {
103+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
104+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: target.SessionName,
105+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
106+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
107+ }, &closeTrackingTransport{})
108+ return err
109+ }
110+
111+ t.Run("PTY startup", func(t *testing.T) {
112+ cause := errors.New("PTY unavailable")
113+ factory := portsmocks.NewMockPTYFactory(t)
114+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
115+ d := newTestDaemon(t, factory, stubClock{})
116+ record := durableRecoveryRecord(1)
117+ record.Committed = nil
118+ record.TabNames = nil
119+ record.TabRecords = nil
120+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
121+
122+ err := route(d, newTarget(record))
123+ require.ErrorIs(t, err, cause)
124+ var protocol *protoErr
125+ require.False(t, errors.As(err, &protocol))
126+ })
127+
128+ t.Run("catalogue read", func(t *testing.T) {
129+ cause := errors.New("catalogue unavailable")
130+ record := durableRecoveryRecord(1)
131+ record.Committed = nil
132+ record.TabNames = nil
133+ record.TabRecords = nil
134+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
135+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
136+ d.catalogue = recordErrorCatalogue{durableRecoveryCatalogue: catalogue, err: cause}
137+ d.persistEnabled = true
138+ d.recovery = recoveryusecase.NewCoordinator(d.catalogue, noOpSnapshotRepository{}, nil)
139+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
140+
141+ err := route(d, newTarget(record))
142+ require.ErrorIs(t, err, cause)
143+ var protocol *protoErr
144+ require.False(t, errors.As(err, &protocol))
145+ })
146+}
147+
148+func TestRouteRemoteTargetResumesCanonicalPersistedTabMetadata(t *testing.T) {
149+ for _, test := range []struct {
150+ name string
151+ tabNames []string
152+ tabRecords []domain.CatalogueTabRecord
153+ selector domain.TabSelector
154+ }{
155+ {
156+ name: "tab records without compatibility names",
157+ tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1"}},
158+ selector: domain.NewStableTabSelector("tab-1"),
159+ },
160+ {
161+ name: "tab names without stable records",
162+ tabNames: []string{"main"},
163+ selector: domain.NewOrdinalTabSelector(0, "main", 1),
164+ },
165+ } {
166+ t.Run(test.name, func(t *testing.T) {
167+ record := durableRecoveryRecord(1)
168+ record.Committed = nil
169+ record.TabNames = append([]string(nil), test.tabNames...)
170+ record.TabRecords = append([]domain.CatalogueTabRecord(nil), test.tabRecords...)
171+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
172+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
173+ d.catalogue = catalogue
174+ d.persistEnabled = true
175+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
176+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
177+
178+ target := domain.RemoteSessionTarget{
179+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID,
180+ SessionName: record.Name, Stopped: true, StoppedTab: test.selector,
181+ }
182+ transport, _ := newCapturingTransport(t)
183+ sess, attachment, err := d.routeWithContext(context.Background(), ports.Hello{
184+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name,
185+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
186+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
187+ }, transport)
188+ require.NoError(t, err)
189+ t.Cleanup(func() { d.clientGone(sess, attachment, transport, false) })
190+ require.Len(t, sess.tabs, 1)
191+ require.Equal(t, sess.tabs[0].stableID, string(attachment.viewSnapshot().tabID))
192+ })
193+ }
194+}
195+
61196 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
62197 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
63198 lifecycle := remoteLifecycleForTest()
64199 d.mu.Lock()
65- d.stopped["work"] = stoppedSession{
66- name: "work", cwd: "/remote/work", incarnation: lifecycle,
200+ d.inactive["work"] = inactiveSession{
201+ name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
67202 tabNames: []string{"alpha", "beta"},
68203 tabRecords: []domain.CatalogueTabRecord{
69204 {StableID: "tab-a", Name: "alpha"},
@@ -2,12 +2,16 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "testing"6 "testing"
6 7
8+ "github.com/stretchr/testify/mock"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 10
9 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
11 )15 )
12 16
13 func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) {17 func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) {
@@ -37,11 +41,11 @@ func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) {
37 }41 }
38 }42 }
39 43
40-func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {44+func TestDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
41 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})45 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
42 d.baseEnv = []string{"DAEMON=owned"}46 d.baseEnv = []string{"DAEMON=owned"}
43 d.mu.Lock()47 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}
45 d.mu.Unlock()49 d.mu.Unlock()
46 50
47 hello := ports.Hello{51 hello := ports.Hello{
@@ -58,12 +62,143 @@ func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
58 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))62 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
59 }63 }
60 64
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, uint16(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 TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) {
96+ newTarget := func(record domain.CatalogueRecord) domain.RemoteSessionTarget {
97+ return domain.RemoteSessionTarget{
98+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID,
99+ SessionName: record.Name, Stopped: true,
100+ }
101+ }
102+ route := func(d *Daemon, target domain.RemoteSessionTarget) error {
103+ _, _, err := d.routeWithContext(context.Background(), ports.Hello{
104+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: target.SessionName,
105+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
106+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
107+ }, &closeTrackingTransport{})
108+ return err
109+ }
110+
111+ t.Run("PTY startup", func(t *testing.T) {
112+ cause := errors.New("PTY unavailable")
113+ factory := portsmocks.NewMockPTYFactory(t)
114+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
115+ d := newTestDaemon(t, factory, stubClock{})
116+ record := durableRecoveryRecord(1)
117+ record.Committed = nil
118+ record.TabNames = nil
119+ record.TabRecords = nil
120+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
121+
122+ err := route(d, newTarget(record))
123+ require.ErrorIs(t, err, cause)
124+ var protocol *protoErr
125+ require.False(t, errors.As(err, &protocol))
126+ })
127+
128+ t.Run("catalogue read", func(t *testing.T) {
129+ cause := errors.New("catalogue unavailable")
130+ record := durableRecoveryRecord(1)
131+ record.Committed = nil
132+ record.TabNames = nil
133+ record.TabRecords = nil
134+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
135+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
136+ d.catalogue = recordErrorCatalogue{durableRecoveryCatalogue: catalogue, err: cause}
137+ d.persistEnabled = true
138+ d.recovery = recoveryusecase.NewCoordinator(d.catalogue, noOpSnapshotRepository{}, nil)
139+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
140+
141+ err := route(d, newTarget(record))
142+ require.ErrorIs(t, err, cause)
143+ var protocol *protoErr
144+ require.False(t, errors.As(err, &protocol))
145+ })
146+}
147+
148+func TestRouteRemoteTargetResumesCanonicalPersistedTabMetadata(t *testing.T) {
149+ for _, test := range []struct {
150+ name string
151+ tabNames []string
152+ tabRecords []domain.CatalogueTabRecord
153+ selector domain.TabSelector
154+ }{
155+ {
156+ name: "tab records without compatibility names",
157+ tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1"}},
158+ selector: domain.NewStableTabSelector("tab-1"),
159+ },
160+ {
161+ name: "tab names without stable records",
162+ tabNames: []string{"main"},
163+ selector: domain.NewOrdinalTabSelector(0, "main", 1),
164+ },
165+ } {
166+ t.Run(test.name, func(t *testing.T) {
167+ record := durableRecoveryRecord(1)
168+ record.Committed = nil
169+ record.TabNames = append([]string(nil), test.tabNames...)
170+ record.TabRecords = append([]domain.CatalogueTabRecord(nil), test.tabRecords...)
171+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
172+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
173+ d.catalogue = catalogue
174+ d.persistEnabled = true
175+ d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil)
176+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil)
177+
178+ target := domain.RemoteSessionTarget{
179+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID,
180+ SessionName: record.Name, Stopped: true, StoppedTab: test.selector,
181+ }
182+ transport, _ := newCapturingTransport(t)
183+ sess, attachment, err := d.routeWithContext(context.Background(), ports.Hello{
184+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name,
185+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
186+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
187+ }, transport)
188+ require.NoError(t, err)
189+ t.Cleanup(func() { d.clientGone(sess, attachment, transport, false) })
190+ require.Len(t, sess.tabs, 1)
191+ require.Equal(t, sess.tabs[0].stableID, string(attachment.viewSnapshot().tabID))
192+ })
193+ }
194+}
195+
61 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {196 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
62 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})197 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
63 lifecycle := remoteLifecycleForTest()198 lifecycle := remoteLifecycleForTest()
64 d.mu.Lock()199 d.mu.Lock()
65- d.stopped["work"] = stoppedSession{200+ d.inactive["work"] = inactiveSession{
66- name: "work", cwd: "/remote/work", incarnation: lifecycle,201+ name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown,
67 tabNames: []string{"alpha", "beta"},202 tabNames: []string{"alpha", "beta"},
68 tabRecords: []domain.CatalogueTabRecord{203 tabRecords: []domain.CatalogueTabRecord{
69 {StableID: "tab-a", Name: "alpha"},204 {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 }
@@ -10,7 +10,7 @@ import (
10 10
11 func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) {11 func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) {
12 lifecycle := remoteLifecycleForTest()12 lifecycle := remoteLifecycleForTest()
13- stopped := stoppedSession{13+ stopped := inactiveSession{
14 name: "work", incarnation: lifecycle,14 name: "work", incarnation: lifecycle,
15 tabNames: []string{"alpha", "beta"},15 tabNames: []string{"alpha", "beta"},
16 tabRecords: []domain.CatalogueTabRecord{16 tabRecords: []domain.CatalogueTabRecord{
@@ -24,13 +24,13 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T)
24 SessionName: "work", Stopped: true,24 SessionName: "work", Stopped: true,
25 StoppedTab: domain.NewStableTabSelector("tab-b"),25 StoppedTab: domain.NewStableTabSelector("tab-b"),
26 }26 }
27- index, ok := remoteTargetTabIndexStopped(stopped, stable)27+ index, ok := remoteTargetTabIndexInactive(stopped, stable)
28 require.True(t, ok)28 require.True(t, ok)
29 require.Equal(t, 1, index)29 require.Equal(t, 1, index)
30 30
31 ordinal := stable31 ordinal := stable
32 ordinal.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)32 ordinal.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
33- index, ok = remoteTargetTabIndexStopped(stopped, ordinal)33+ index, ok = remoteTargetTabIndexInactive(stopped, ordinal)
34 require.True(t, ok)34 require.True(t, ok)
35 require.Equal(t, 1, index)35 require.Equal(t, 1, index)
36 36
@@ -51,7 +51,7 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T)
51 return candidate51 return candidate
52 }(),52 }(),
53 } {53 } {
54- _, ok := remoteTargetTabIndexStopped(stopped, invalid)54+ _, ok := remoteTargetTabIndexInactive(stopped, invalid)
55 require.False(t, ok)55 require.False(t, ok)
56 }56 }
57 57
@@ -60,7 +60,7 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T)
60 {StableID: "tab-b", Name: "beta"},60 {StableID: "tab-b", Name: "beta"},
61 {StableID: "tab-a", Name: "alpha"},61 {StableID: "tab-a", Name: "alpha"},
62 }62 }
63- index, ok = remoteTargetTabIndexStopped(reordered, stable)63+ index, ok = remoteTargetTabIndexInactive(reordered, stable)
64 require.True(t, ok)64 require.True(t, ok)
65 require.Equal(t, 0, index, "stable selectors follow identity, not the old ordinal")65 require.Equal(t, 0, index, "stable selectors follow identity, not the old ordinal")
66 }66 }
M internal/usecase/daemon/session.go +50 -32
@@ -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
201207 }
202208
203-func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) {
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)
220+}
221+
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
@@ -1693,18 +1714,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16931714 }
16941715 clear(sess.attachments)
16951716 stoppedName := sess.name
1696- stoppedCwd := sess.cwd
1697- createdAt := sess.createdAt
16981717 stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1699- tabNames := stoppedRecord.TabNames
1700- tabRecords := stoppedRecord.TabRecords
17011718 ephemeral := sess.ephemeral
17021719 unlockCoordinators()
17031720 unlockSessions()
17041721 d.notices.routingMu.Unlock()
17051722 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
1723+ stopped := inactiveSessionFromRecord(stoppedRecord, ports.SessionDown, nil)
1724+ stopped.purging = purge
1725+ d.inactive[stoppedName] = stopped
17081726 }
17091727 empty := len(d.sessions) == 0
17101728 if empty {
@@ -1763,8 +1781,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
17631781 if err != nil {
17641782 purgeErr = errors.Join(purgeErr, err)
17651783 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)
1784+ } else if stopped, ok := d.inactive[stoppedName]; ok && stopped.purging {
1785+ delete(d.inactive, stoppedName)
17681786 }
17691787 d.mu.Unlock()
17701788 }
@@ -1783,13 +1801,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
17831801 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
17841802 // d.mu.
17851803 func (d *Daemon) allocEphemeralNameLocked() string {
1786- used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1804+ used := make(map[string]struct{}, len(d.sessions)+len(d.inactive))
17871805 for _, s := range d.sessions {
17881806 if s != nil {
17891807 used[s.name] = struct{}{}
17901808 }
17911809 }
1792- for name := range d.stopped {
1810+ for name := range d.inactive {
17931811 used[name] = struct{}{}
17941812 }
17951813 for i := 0; ; i++ {
@@ -1813,7 +1831,7 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
18131831 if d.findByNameLocked(name) != nil {
18141832 return true
18151833 }
1816- if _, ok := d.stopped[name]; ok {
1834+ if _, ok := d.inactive[name]; ok {
18171835 return true
18181836 }
18191837 _, reserved := d.creating[name]
@@ -186,29 +186,48 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
186 return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)186 return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
187 }187 }
188 188
189-// createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the189+// resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks
190-// stopped lifecycle after catalogue I/O, which intentionally runs without d.mu.190+// lifecycle and resumability after catalogue I/O, which intentionally runs
191-func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) {191+// without d.mu.
192- return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...)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...)
193 }194 }
194 195
195-func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool {196+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
196- return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation197+ return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, sz, env, nil, nil, restoredTabNames...)
197 }198 }
198 199
199-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {200+type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool
200- return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...)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
201 }207 }
202 208
203-func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) {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)
220+}
221+
222+func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {
204 env = copyEnvironment(env)223 env = copyEnvironment(env)
205 if _, reserved := d.creating[name]; reserved {224 if _, reserved := d.creating[name]; reserved {
206 return nil, errSessionNameInUse225 return nil, errSessionNameInUse
207 }226 }
208 d.creating[name] = struct{}{}227 d.creating[name] = struct{}{}
209 defer delete(d.creating, name)228 defer delete(d.creating, name)
210- stopped, resuming := d.stopped[name]229+ stopped, resuming := d.inactive[name]
211- if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {230+ if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, domain.CatalogueRecord{}, false, nil) {
212 return nil, errAttachmentTransition231 return nil, errAttachmentTransition
213 }232 }
214 var authoritative domain.CatalogueRecord233 var authoritative domain.CatalogueRecord
@@ -224,10 +243,12 @@ func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemer
224 if d.closing {243 if d.closing {
225 return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}244 return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
226 }245 }
227- stopped, resuming = d.stopped[name]246+ stopped, resuming = d.inactive[name]
228- if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) {247+ if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, authoritative, authoritativeExists, validateInactive) {
229 return nil, errAttachmentTransition248 return nil, errAttachmentTransition
230 }249 }
250+ } else if validateInactive != nil && !validateInactive(stopped, domain.CatalogueRecord{}, false) {
251+ return nil, errAttachmentTransition
231 }252 }
232 var createdAt int64253 var createdAt int64
233 var incarnation domain.IncarnationID254 var incarnation domain.IncarnationID
@@ -409,7 +430,7 @@ func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemer
409 if !ephemeral {430 if !ephemeral {
410 // Keep stopped lifecycle authority intact until runtime publication and431 // Keep stopped lifecycle authority intact until runtime publication and
411 // its durable side effects have both succeeded.432 // its durable side effects have both succeeded.
412- delete(d.stopped, name)433+ delete(d.inactive, name)
413 }434 }
414 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)435 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
415 for i, tb := range tabs {436 for i, tb := range tabs {
@@ -934,7 +955,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
934 return errSessionNameInUse955 return errSessionNameInUse
935 }956 }
936 sess.mu.Lock()957 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 {
938 sess.mu.Unlock()959 sess.mu.Unlock()
939 d.mu.Unlock()960 d.mu.Unlock()
940 return errSessionNameInUse961 return errSessionNameInUse
@@ -992,8 +1013,8 @@ func (d *Daemon) renameSession(sess *session, name string) error {
992 }1013 }
993 }1014 }
994 1015
995- delete(d.stopped, oldName)1016+ delete(d.inactive, oldName)
996- delete(d.stopped, name)1017+ delete(d.inactive, name)
997 sess.name = name1018 sess.name = name
998 sess.createdAt = createdAt1019 sess.createdAt = createdAt
999 sess.incarnation = record.IncarnationID1020 sess.incarnation = record.IncarnationID
@@ -1238,13 +1259,13 @@ func (d *Daemon) retryStoppedPurge(name string) error {
1238 1259
1239 func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {1260 func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
1240 d.mu.Lock()1261 d.mu.Lock()
1241- stopped, ok := d.stopped[name]1262+ stopped, ok := d.inactive[name]
1242 if !ok {1263 if !ok {
1243 d.mu.Unlock()1264 d.mu.Unlock()
1244 return nil1265 return nil
1245 }1266 }
1246 stopped.purging = true1267 stopped.purging = true
1247- d.stopped[name] = stopped1268+ d.inactive[name] = stopped
1248 1269
1249 if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {1270 if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
1250 d.mu.Unlock()1271 d.mu.Unlock()
@@ -1254,8 +1275,8 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro
1254 d.mu.Unlock()1275 d.mu.Unlock()
1255 return err1276 return err
1256 }1277 }
1257- if current, ok := d.stopped[name]; ok && current.purging {1278+ if current, ok := d.inactive[name]; ok && current.purging {
1258- delete(d.stopped, name)1279+ delete(d.inactive, name)
1259 }1280 }
1260 d.mu.Unlock()1281 d.mu.Unlock()
1261 return nil1282 return nil
@@ -1693,18 +1714,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1693 }1714 }
1694 clear(sess.attachments)1715 clear(sess.attachments)
1695 stoppedName := sess.name1716 stoppedName := sess.name
1696- stoppedCwd := sess.cwd
1697- createdAt := sess.createdAt
1698 stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))1717 stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1699- tabNames := stoppedRecord.TabNames
1700- tabRecords := stoppedRecord.TabRecords
1701 ephemeral := sess.ephemeral1718 ephemeral := sess.ephemeral
1702 unlockCoordinators()1719 unlockCoordinators()
1703 unlockSessions()1720 unlockSessions()
1704 d.notices.routingMu.Unlock()1721 d.notices.routingMu.Unlock()
1705 if !ephemeral {1722 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}1723+ stopped := inactiveSessionFromRecord(stoppedRecord, ports.SessionDown, nil)
1707- d.stopped[stoppedName] = stopped1724+ stopped.purging = purge
1725+ d.inactive[stoppedName] = stopped
1708 }1726 }
1709 empty := len(d.sessions) == 01727 empty := len(d.sessions) == 0
1710 if empty {1728 if empty {
@@ -1763,8 +1781,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1763 if err != nil {1781 if err != nil {
1764 purgeErr = errors.Join(purgeErr, err)1782 purgeErr = errors.Join(purgeErr, err)
1765 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)1783 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
1766- } else if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {1784+ } else if stopped, ok := d.inactive[stoppedName]; ok && stopped.purging {
1767- delete(d.stopped, stoppedName)1785+ delete(d.inactive, stoppedName)
1768 }1786 }
1769 d.mu.Unlock()1787 d.mu.Unlock()
1770 }1788 }
@@ -1783,13 +1801,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1783 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds1801 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
1784 // d.mu.1802 // d.mu.
1785 func (d *Daemon) allocEphemeralNameLocked() string {1803 func (d *Daemon) allocEphemeralNameLocked() string {
1786- used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))1804+ used := make(map[string]struct{}, len(d.sessions)+len(d.inactive))
1787 for _, s := range d.sessions {1805 for _, s := range d.sessions {
1788 if s != nil {1806 if s != nil {
1789 used[s.name] = struct{}{}1807 used[s.name] = struct{}{}
1790 }1808 }
1791 }1809 }
1792- for name := range d.stopped {1810+ for name := range d.inactive {
1793 used[name] = struct{}{}1811 used[name] = struct{}{}
1794 }1812 }
1795 for i := 0; ; i++ {1813 for i := 0; ; i++ {
@@ -1813,7 +1831,7 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
1813 if d.findByNameLocked(name) != nil {1831 if d.findByNameLocked(name) != nil {
1814 return true1832 return true
1815 }1833 }
1816- if _, ok := d.stopped[name]; ok {1834+ if _, ok := d.inactive[name]; ok {
1817 return true1835 return true
1818 }1836 }
1819 _, reserved := d.creating[name]1837 _, 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")
@@ -23,7 +23,7 @@ func TestStoppedPurgeMetadataFailureFencesCatalogue(t *testing.T) {
23 state.mu.Lock()23 state.mu.Lock()
24 state.deleteErr = func(string) error { return metadataErr }24 state.deleteErr = func(string) error { return metadataErr }
25 state.mu.Unlock()25 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}
27 27
28 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)28 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
29 require.Empty(t, repository.calls, "snapshot deletion must wait for catalogue removal")29 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}))
@@ -772,7 +772,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
772 772
773 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))773 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
774 d.mu.Lock()774 d.mu.Lock()
775- stopped := d.stopped["work"]775+ stopped := d.inactive["work"]
776 d.mu.Unlock()776 d.mu.Unlock()
777 require.Equal(t, "work", stopped.name)777 require.Equal(t, "work", stopped.name)
778 require.Equal(t, "/tmp/work", stopped.cwd)778 require.Equal(t, "/tmp/work", stopped.cwd)
@@ -795,7 +795,7 @@ func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
795 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))795 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))
796 require.NotNil(t, d)796 require.NotNil(t, d)
797 require.False(t, d.persistEnabled)797 require.False(t, d.persistEnabled)
798- require.Empty(t, d.stopped)798+ require.Empty(t, d.inactive)
799 require.NotContains(t, logBuf.String(), "loading persisted sessions failed")799 require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
800 }800 }
801 801
@@ -868,7 +868,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
868 store, state := newMockStore(t)868 store, state := newMockStore(t)
869 d := newTestDaemon(t, newFactory(t, p), stubClock{})869 d := newTestDaemon(t, newFactory(t, p), stubClock{})
870 WithStore(t, store)(d)870 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}
872 d.mruSeq.Store(42)872 d.mruSeq.Store(42)
873 873
874 sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)874 sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
@@ -1015,7 +1015,10 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
1015 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})1015 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1016 WithStore(t, store)(d)1016 WithStore(t, store)(d)
1017 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"}}))1017 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)
1019 tr := portsmocks.NewMockTransport(t)1022 tr := portsmocks.NewMockTransport(t)
1020 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1023 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1021 tr.EXPECT().Close().Return(nil).Maybe()1024 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1035,7 +1038,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
1035 store, state := newMockStore(t)1038 store, state := newMockStore(t)
1036 d := newTestDaemon(t, newFactory(t, p), stubClock{})1039 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1037 WithStore(t, store)(d)1040 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}
1039 1042
1040 sess, err := createSessionForTest(d, "0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)1043 sess, err := createSessionForTest(d, "0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
1041 require.NoError(t, err)1044 require.NoError(t, err)
@@ -1189,7 +1192,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
1189 d := newTestDaemon(t, newFactory(t, p), stubClock{})1192 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1190 WithStore(t, store)(d)1193 WithStore(t, store)(d)
1191 cwd := t.TempDir()1194 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}
1193 tr := portsmocks.NewMockTransport(t)1196 tr := portsmocks.NewMockTransport(t)
1194 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1197 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1195 tr.EXPECT().Close().Return(nil).Maybe()1198 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1200,7 +1203,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
1200 require.Equal(t, "work", sess.name)1203 require.Equal(t, "work", sess.name)
1201 require.Equal(t, cwd, sess.cwd)1204 require.Equal(t, cwd, sess.cwd)
1202 d.mu.Lock()1205 d.mu.Lock()
1203- _, stillStopped := d.stopped["work"]1206+ _, stillStopped := d.inactive["work"]
1204 d.mu.Unlock()1207 d.mu.Unlock()
1205 require.False(t, stillStopped)1208 require.False(t, stillStopped)
1206 }1209 }
@@ -1212,7 +1215,7 @@ func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
1212 store, _ := newMockStore(t)1215 store, _ := newMockStore(t)
1213 d := newTestDaemon(t, newFactory(t, p), stubClock{})1216 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1214 WithStore(t, store)(d)1217 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}
1216 tr := portsmocks.NewMockTransport(t)1219 tr := portsmocks.NewMockTransport(t)
1217 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1220 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1218 tr.EXPECT().Close().Return(nil).Maybe()1221 tr.EXPECT().Close().Return(nil).Maybe()
@@ -1227,11 +1230,11 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
1227 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})1230 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1228 WithStore(t, store)(d)1231 WithStore(t, store)(d)
1229 require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))1232 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}
1231 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))1234 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
1232 require.False(t, state.has("old"))1235 require.False(t, state.has("old"))
1233 d.mu.Lock()1236 d.mu.Lock()
1234- _, ok := d.stopped["old"]1237+ _, ok := d.inactive["old"]
1235 d.mu.Unlock()1238 d.mu.Unlock()
1236 require.False(t, ok)1239 require.False(t, ok)
1237 }1240 }
@@ -1293,7 +1296,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1293 1296
1294 func TestIntentNewStoppedNameRejected(t *testing.T) {1297 func TestIntentNewStoppedNameRejected(t *testing.T) {
1295 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})1298 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}
1297 tr := portsmocks.NewMockTransport(t)1300 tr := portsmocks.NewMockTransport(t)
1298 _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)1301 _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
1299 require.ErrorContains(t, err, "name already in use")1302 require.ErrorContains(t, err, "name already in use")
@@ -1363,7 +1366,7 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
1363 _ = d.killSession(natural, ports.ReasonSessionKilled, false)1366 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
1364 require.True(t, state.has("natural"))1367 require.True(t, state.has("natural"))
1365 d.mu.Lock()1368 d.mu.Lock()
1366- stopped := d.stopped["natural"]1369+ stopped := d.inactive["natural"]
1367 d.mu.Unlock()1370 d.mu.Unlock()
1368 require.Equal(t, "/tmp/latest", stopped.cwd)1371 require.Equal(t, "/tmp/latest", stopped.cwd)
1369 1372
@@ -1688,7 +1691,7 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
1688 d := newTestDaemon(t, nil, stubClock{})1691 d := newTestDaemon(t, nil, stubClock{})
1689 d.lastAllocatedCreatedAt = math.MaxInt641692 d.lastAllocatedCreatedAt = math.MaxInt64
1690 d.nextID = 171693 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}
1692 1695
1693 sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)1696 sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1694 1697
@@ -1697,13 +1700,13 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
1697 require.Empty(t, d.sessions)1700 require.Empty(t, d.sessions)
1698 require.Equal(t, uint64(17), d.nextID)1701 require.Equal(t, uint64(17), d.nextID)
1699 require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)1702 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"])
1701 1704
1702 _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil)1705 _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil)
1703 require.ErrorContains(t, err, "lifecycle identities exhausted")1706 require.ErrorContains(t, err, "lifecycle identities exhausted")
1704 require.Empty(t, d.sessions)1707 require.Empty(t, d.sessions)
1705 require.Equal(t, uint64(17), d.nextID)1708 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"])
1707 }1710 }
1708 1711
1709 func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {1712 func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
@@ -1716,7 +1719,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1716 1719
1717 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))1720 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))
1718 1721
1719- alpha := d.stopped["alpha"]1722+ alpha := d.inactive["alpha"]
1720 require.Equal(t, records[0], alpha.record)1723 require.Equal(t, records[0], alpha.record)
1721 require.Equal(t, ports.SessionDown, alpha.state)1724 require.Equal(t, ports.SessionDown, alpha.state)
1722 require.NotNil(t, alpha.restoreDone)1725 require.NotNil(t, alpha.restoreDone)
@@ -1725,7 +1728,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1725 t.Fatal("healthy catalogue record must begin in restoring state")1728 t.Fatal("healthy catalogue record must begin in restoring state")
1726 default:1729 default:
1727 }1730 }
1728- work := d.stopped["work"]1731+ work := d.inactive["work"]
1729 require.Equal(t, records[1], work.record)1732 require.Equal(t, records[1], work.record)
1730 require.Equal(t, ports.SessionBroken, work.state)1733 require.Equal(t, ports.SessionBroken, work.state)
1731 require.NotNil(t, work.restoreDone)1734 require.NotNil(t, work.restoreDone)
@@ -1765,7 +1768,7 @@ func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testin
1765 store, state := newMockStore(t)1768 store, state := newMockStore(t)
1766 WithStore(t, store)(d)1769 WithStore(t, store)(d)
1767 d.ptys = newFactory(t, targetPTY)1770 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}
1769 1772
1770 require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))1773 require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
1771 resumed := ac.currentSession()1774 resumed := ac.currentSession()
@@ -1793,7 +1796,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1793 d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)1796 d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1794 d.ptys = newFactory(t, targetPTY)1797 d.ptys = newFactory(t, targetPTY)
1795 expected := int64(31)1798 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}
1797 1800
1798 require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))1801 require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1799 require.Equal(t, "target", ac.currentSession().name)1802 require.Equal(t, "target", ac.currentSession().name)
@@ -1815,7 +1818,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1815 fromPTY, releaseFrom := newBlockingPTY(t)1818 fromPTY, releaseFrom := newBlockingPTY(t)
1816 defer releaseFrom()1819 defer releaseFrom()
1817 d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)1820 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}
1819 expected := int64(51)1822 expected := int64(51)
1820 1823
1821 require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))1824 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 }
@@ -16,7 +16,7 @@ import (
16 16
17 func (d *Daemon) closeRestoreDone() {17 func (d *Daemon) closeRestoreDone() {
18 d.mu.Lock()18 d.mu.Lock()
19- for name, entry := range d.stopped {19+ for name, entry := range d.inactive {
20 if entry.restoreDone == nil {20 if entry.restoreDone == nil {
21 continue21 continue
22 }22 }
@@ -29,7 +29,7 @@ func (d *Daemon) closeRestoreDone() {
29 if entry.record.DegradedReason == "" {29 if entry.record.DegradedReason == "" {
30 entry.record.DegradedReason = "restore unavailable"30 entry.record.DegradedReason = "restore unavailable"
31 }31 }
32- d.stopped[name] = entry32+ d.inactive[name] = entry
33 closeRuntimeRestoreDoneLocked(entry.restoreDone)33 closeRuntimeRestoreDoneLocked(entry.restoreDone)
34 }34 }
35 d.mu.Unlock()35 d.mu.Unlock()
@@ -201,7 +201,7 @@ func closeRestoredTabs(tabs []*tab) {
201 func (d *Daemon) restoredSessionTabNames(name string) []string {201 func (d *Daemon) restoredSessionTabNames(name string) []string {
202 d.mu.Lock()202 d.mu.Lock()
203 defer d.mu.Unlock()203 defer d.mu.Unlock()
204- return append([]string(nil), d.stopped[name].tabNames...)204+ return append([]string(nil), d.inactive[name].tabNames...)
205 }205 }
206 206
207 func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, allowlist map[string]struct{}) (*tab, error) {207 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
303 sess.cwd = snap.Tabs[0].Panes[0].Cwd303 sess.cwd = snap.Tabs[0].Panes[0].Cwd
304 }304 }
305 d.mu.Lock()305 d.mu.Lock()
306- if stopped, ok := d.stopped[snap.Name]; ok {306+ if stopped, ok := d.inactive[snap.Name]; ok {
307 sess.mruAt.Store(stopped.lastUsedSeq)307 sess.mruAt.Store(stopped.lastUsedSeq)
308 }308 }
309 d.mu.Unlock()309 d.mu.Unlock()
@@ -315,7 +315,7 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context
315 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {315 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
316 if sess.incarnation == (domain.IncarnationID{}) {316 if sess.incarnation == (domain.IncarnationID{}) {
317 d.mu.Lock()317 d.mu.Lock()
318- stopped := d.stopped[sess.name]318+ stopped := d.inactive[sess.name]
319 d.mu.Unlock()319 d.mu.Unlock()
320 sess.incarnation = stopped.incarnation320 sess.incarnation = stopped.incarnation
321 if sess.incarnation == (domain.IncarnationID{}) {321 if sess.incarnation == (domain.IncarnationID{}) {
@@ -347,7 +347,7 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
347 }347 }
348 sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))348 sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
349 d.nextID++349 d.nextID++
350- stopped := d.stopped[sess.name]350+ stopped := d.inactive[sess.name]
351 sess.mu.Lock()351 sess.mu.Lock()
352 sess.restoreDone = stopped.restoreDone352 sess.restoreDone = stopped.restoreDone
353 sess.mu.Unlock()353 sess.mu.Unlock()
@@ -357,7 +357,7 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
357 // The stopped entry is the durable authority and restore barrier until the357 // The stopped entry is the durable authority and restore barrier until the
358 // runtime identity has actually been published. A registry rejection must358 // runtime identity has actually been published. A registry rejection must
359 // leave that exact entry available for attach/recovery retries.359 // leave that exact entry available for attach/recovery retries.
360- delete(d.stopped, sess.name)360+ delete(d.inactive, sess.name)
361 sess.snapDirty.Store(false)361 sess.snapDirty.Store(false)
362 return true, nil362 return true, nil
363 }363 }
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
@@ -68,7 +68,7 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu
68 wg.Wait()68 wg.Wait()
69 }69 }
70 70
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 {
72 tabRecords := append([]domain.CatalogueTabRecord(nil), record.TabRecords...)72 tabRecords := append([]domain.CatalogueTabRecord(nil), record.TabRecords...)
73 tabNames := append([]string(nil), record.TabNames...)73 tabNames := append([]string(nil), record.TabNames...)
74 if len(tabRecords) != 0 {74 if len(tabRecords) != 0 {
@@ -85,7 +85,7 @@ func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.Session
85 tabRecords[i] = domain.CatalogueTabRecord{Name: name}85 tabRecords[i] = domain.CatalogueTabRecord{Name: name}
86 }86 }
87 }87 }
88- return stoppedSession{88+ return inactiveSession{
89 name: record.Name,89 name: record.Name,
90 cwd: record.Cwd,90 cwd: record.Cwd,
91 createdAt: record.CreatedAt,91 createdAt: record.CreatedAt,
@@ -102,11 +102,11 @@ func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.Session
102 func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) chan struct{} {102 func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) chan struct{} {
103 d.mu.Lock()103 d.mu.Lock()
104 defer d.mu.Unlock()104 defer d.mu.Unlock()
105- if entry, ok := d.stopped[record.Name]; ok {105+ if entry, ok := d.inactive[record.Name]; ok {
106 return entry.restoreDone106 return entry.restoreDone
107 }107 }
108 state, done := initialSessionState(record)108 state, done := initialSessionState(record)
109- d.stopped[record.Name] = stoppedSessionFromRecord(record, state, done)109+ d.inactive[record.Name] = inactiveSessionFromRecord(record, state, done)
110 return done110 return done
111 }111 }
112 112
@@ -126,11 +126,11 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) {
126 func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) {126 func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) {
127 d.mu.Lock()127 d.mu.Lock()
128 defer d.mu.Unlock()128 defer d.mu.Unlock()
129- entry, ok := d.stopped[record.Name]129+ entry, ok := d.inactive[record.Name]
130 if !ok {130 if !ok {
131 return131 return
132 }132 }
133- d.stopped[record.Name] = stoppedSessionFromRecord(record, state, entry.restoreDone)133+ d.inactive[record.Name] = inactiveSessionFromRecord(record, state, entry.restoreDone)
134 }134 }
135 135
136 func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {136 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
155 }155 }
156 156
157 d.mu.Lock()157 d.mu.Lock()
158- if entry, ok := d.stopped[record.Name]; ok && degraded {158+ if entry, ok := d.inactive[record.Name]; ok && degraded {
159- d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionBroken, entry.restoreDone)159+ d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionBroken, entry.restoreDone)
160 }160 }
161 closeRuntimeRestoreDoneLocked(done)161 closeRuntimeRestoreDoneLocked(done)
162 d.mu.Unlock()162 d.mu.Unlock()
@@ -251,8 +251,8 @@ func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.
251 }251 }
252 252
253 d.mu.Lock()253 d.mu.Lock()
254- if entry, ok := d.stopped[record.Name]; ok {254+ if entry, ok := d.inactive[record.Name]; ok {
255- d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone)255+ d.inactive[record.Name] = inactiveSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone)
256 }256 }
257 d.mu.Unlock()257 d.mu.Unlock()
258 258
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)
@@ -174,7 +174,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing
174 174
175 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))175 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
176 d.mu.Lock()176 d.mu.Lock()
177- stopped, retained := d.stopped[sess.name]177+ stopped, retained := d.inactive[sess.name]
178 d.mu.Unlock()178 d.mu.Unlock()
179 require.True(t, retained, "non-purged named session must remain stopped")179 require.True(t, retained, "non-purged named session must remain stopped")
180 require.False(t, stopped.purging)180 require.False(t, stopped.purging)
M internal/usecase/picker/model.go +47 -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+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
321+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
322+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
323+ tabRow.focusable = tabRow.selectable
324+ if common.remote {
325+ tabRow.unavailableReason = session.RemoteReason
326+ if common.hasRemoteTarget {
327+ remoteTarget := common.remoteTarget
328+ if remoteTarget.Stopped {
329+ remoteTarget.LiveTabID = ""
330+ if tab.TabID != "" {
331+ remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
332+ } else if remoteTarget.StoppedTab != (domain.TabSelector{}) {
333+ tabCount := min(len(session.Tabs), math.MaxUint16)
334+ remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
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 = 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
@@ -57,7 +57,6 @@ type SessionView struct {
57 RemoteDetail string57 RemoteDetail string
58 RemoteReason string58 RemoteReason string
59 RemoteActivation RemoteActivation59 RemoteActivation RemoteActivation
60- ConnectOnly bool
61 // CannotAcceptMoves reports whether this session cannot receive a moved tab60 // CannotAcceptMoves reports whether this session cannot receive a moved tab
62 // or pane. False for ordinary local (and stopped) sessions; true for61 // or pane. False for ordinary local (and stopped) sessions; true for
63 // restricted remote rows.62 // restricted remote rows.
@@ -248,7 +247,11 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
248 // rowsForSession is the sole owner of mode-specific destination eligibility.247 // rowsForSession is the sole owner of mode-specific destination eligibility.
249 // The daemon supplies canonical lifecycle/tab snapshots without prefiltering.248 // The daemon supplies canonical lifecycle/tab snapshots without prefiltering.
250 func rowsForSession(session SessionView, config SelectionConfig) []row {249 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 {
252 return nil255 return nil
253 }256 }
254 if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {257 if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
@@ -259,7 +262,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
259 }262 }
260 263
261 targetName := session.TargetName264 targetName := session.TargetName
262- if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil {265+ if config.Mode == SelectNavigationTab && !stopped && session.ExpectedCreatedAt == nil {
263 targetName = ""266 targetName = ""
264 } else if targetName == "" {267 } else if targetName == "" {
265 targetName = session.Name268 targetName = session.Name
@@ -267,7 +270,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
267 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)270 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
268 common := row{271 common := row{
269 session: session.ID, incarnation: session.Incarnation, targetName: targetName,272 session: session.ID, incarnation: session.Incarnation, targetName: targetName,
270- stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,273+ stopped: stopped, expectedCreatedAt: expectedCreatedAt,
271 hasExpectedCreatedAt: hasExpectedCreatedAt,274 hasExpectedCreatedAt: hasExpectedCreatedAt,
272 }275 }
273 if session.RemoteKey != nil {276 if session.RemoteKey != nil {
@@ -299,11 +302,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
299 header.selectable = false302 header.selectable = false
300 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0303 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
301 } else {304 } else {
302- header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session)305+ header.selectable = false
303- header.focusable = header.selectable306+ header.focusable = config.Mode == SelectNavigationTab && session.RemoteHost != ""
304- if !common.hasRemoteKey && !common.hasRemoteTarget {
305- header.focusable = config.Mode == SelectNavigationTab
306- }
307 }307 }
308 header.unavailableReason = session.RemoteReason308 header.unavailableReason = session.RemoteReason
309 header.dim = session.RemoteActivation == RemoteUnavailable309 header.dim = session.RemoteActivation == RemoteUnavailable
@@ -312,53 +312,50 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
312 header.selectable, header.focusable, header.dim = false, false, true312 header.selectable, header.focusable, header.dim = false, false, true
313 }313 }
314 rows := []row{header}314 rows := []row{header}
315- if !session.ConnectOnly || common.hasRemoteTarget {315+ for i, tab := range session.Tabs {
316- for i, tab := range session.Tabs {316+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
317- if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {317+ continue
318- continue318+ }
319- }319+ tabRow := common
320- tabRow := common320+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
321- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention321+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
322- tabRow.tabID, tabRow.tabIndex = tab.TabID, i322+ tabRow.selectable = tabRow.kind.selectable(config.Mode)
323- tabRow.selectable = tabRow.kind.selectable(config.Mode)323+ tabRow.focusable = tabRow.selectable
324- tabRow.focusable = tabRow.selectable324+ if common.remote {
325- if common.remote {325+ tabRow.unavailableReason = session.RemoteReason
326- tabRow.unavailableReason = session.RemoteReason326+ if common.hasRemoteTarget {
327- if common.hasRemoteTarget {327+ remoteTarget := common.remoteTarget
328- remoteTarget := common.remoteTarget328+ if remoteTarget.Stopped {
329- if session.Stopped {329+ remoteTarget.LiveTabID = ""
330- remoteTarget.LiveTabID = ""330+ if tab.TabID != "" {
331- if tab.TabID != "" {331+ remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
332- remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)332+ } else if remoteTarget.StoppedTab != (domain.TabSelector{}) {
333- } else {333+ tabCount := min(len(session.Tabs), math.MaxUint16)
334- tabCount := min(len(session.Tabs), math.MaxUint16)334+ remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
335- remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
336- }
337- } else {
338- remoteTarget.StoppedTab = domain.TabSelector{}
339- remoteTarget.LiveTabID = tab.TabID
340 }335 }
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
351 } else {336 } else {
352- tabRow.focusable = true337+ remoteTarget.StoppedTab = domain.TabSelector{}
353- tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)338+ remoteTarget.LiveTabID = tab.TabID
354 }339 }
355- tabRow.dim = session.RemoteActivation == RemoteUnavailable340+ // 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
356 }345 }
357- if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {346+ if !tabRow.hasRemoteTarget {
358- tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true347+ tabRow.focusable = false
348+ tabRow.selectable = false
349+ } else {
350+ tabRow.focusable = true
351+ tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
359 }352 }
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
361 }357 }
358+ rows = append(rows, tabRow)
362 }359 }
363 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {360 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
364 return nil361 return nil
M internal/usecase/picker/model_test.go +32 -97
@@ -740,107 +740,42 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
740740 }
741741 }
742742
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- }
759-
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)
775- }
776-}
777-
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-}
743+func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) {
744+ for _, test := range []struct {
745+ name string
746+ viewStopped bool
747+ targetStopped bool
748+ }{
749+ {name: "down target overrides live presentation flag", targetStopped: true},
750+ {name: "live target overrides stopped presentation flag", viewStopped: true},
751+ } {
752+ t.Run(test.name, func(t *testing.T) {
753+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: domain.SessionLifecycleID{1}, DisplayOrigin: "arch"}
754+ remoteTarget := domain.RemoteSessionTarget{
755+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: key.LifecycleID,
756+ SessionName: "work", LiveTabID: "tab-1", Stopped: test.targetStopped,
757+ }
758+ activation := RemoteAttach
759+ if test.targetStopped {
760+ remoteTarget.LiveTabID = ""
761+ remoteTarget.StoppedTab = domain.NewStableTabSelector("tab-1")
762+ activation = RemoteRestart
763+ }
764+ model := New([]SessionView{{
765+ ID: key.ID(), Name: key.Display(), Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}},
766+ RemoteKey: &key, RemoteTarget: &remoteTarget, RemoteActivation: activation,
767+ Stopped: test.viewStopped,
768+ }}, SelectionConfig{Mode: SelectNavigationTab})
805769
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)
770+ selected, ok := model.Selected()
771+ require.True(t, ok)
772+ require.Equal(t, test.targetStopped, selected.Stopped)
773+ require.NotNil(t, selected.RemoteTarget)
774+ require.Equal(t, test.targetStopped, selected.RemoteTarget.Stopped)
775+ })
821776 }
822777 }
823778
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-
844779 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
845780 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
846781 local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
@@ -740,107 +740,42 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
740 }740 }
741 }741 }
742 742
743-func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {743+func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) {
744- key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}744+ for _, test := range []struct {
745- availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}745+ name string
746- sessions := make([]SessionView, 0, len(availability))746+ viewStopped bool
747- for _, state := range availability {747+ targetStopped bool
748- sessions = append(sessions, SessionView{748+ }{
749- ID: key.ID(),749+ {name: "down target overrides live presentation flag", targetStopped: true},
750- Name: "presentation text is not routing data",750+ {name: "live target overrides stopped presentation flag", viewStopped: true},
751- Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}},751+ } {
752- RemoteKey: &key,752+ t.Run(test.name, func(t *testing.T) {
753- RemoteAvailability: state,753+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: domain.SessionLifecycleID{1}, DisplayOrigin: "arch"}
754- RemoteDetail: "remote state detail",754+ remoteTarget := domain.RemoteSessionTarget{
755- RemoteActivation: RemoteUnavailable,755+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: key.LifecycleID,
756- ConnectOnly: true,756+ SessionName: "work", LiveTabID: "tab-1", Stopped: test.targetStopped,
757- })757+ }
758- }758+ activation := RemoteAttach
759-759+ if test.targetStopped {
760- model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})760+ remoteTarget.LiveTabID = ""
761- require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")761+ remoteTarget.StoppedTab = domain.NewStableTabSelector("tab-1")
762- for i, pickerRow := range model.rows {762+ activation = RemoteRestart
763- require.Equal(t, rowSession, pickerRow.kind)763+ }
764- require.Equal(t, key, pickerRow.remoteKey)764+ model := New([]SessionView{{
765- require.True(t, pickerRow.hasRemoteKey)765+ ID: key.ID(), Name: key.Display(), Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}},
766- require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])766+ RemoteKey: &key, RemoteTarget: &remoteTarget, RemoteActivation: activation,
767- require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])767+ Stopped: test.viewStopped,
768- require.Equal(t, "@mule (remote state detail)", pickerRow.detail)768+ }}, SelectionConfig{Mode: SelectNavigationTab})
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)
775- }
776-}
777-
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 769
806-func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {770+ selected, ok := model.Selected()
807- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}771+ require.True(t, ok)
808- model := New([]SessionView{{772+ require.Equal(t, test.targetStopped, selected.Stopped)
809- ID: key.ID(),773+ require.NotNil(t, selected.RemoteTarget)
810- Name: key.Display(),774+ require.Equal(t, test.targetStopped, selected.RemoteTarget.Stopped)
811- Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}},775+ })
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)
821 }776 }
822 }777 }
823 778
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-
844 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {779 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
845 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}780 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
846 local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}781 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 {
@@ -930,11 +930,11 @@ func targetEndpoint(endpoint string) string {
930 return endpoint930 return endpoint
931 }931 }
932 932
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) {
934 return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)934 return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
935 }935 }
936 936
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) {
938 deadline, cancel := context.WithTimeout(ctx, 10*time.Second)938 deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
939 defer cancel()939 defer cancel()
940 for {940 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