From c02dbdf0e53e082f8e7f100ae04309d415a8a450 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:37:30 +0200 Subject: [PATCH 1/2] (remote): show a remote host's capability tier and disable what it cannot do A host profile computed from what each refresh already knows (synced, last error, live descriptors) names the highest tier reached among observe, liveness, inject, attach and launch, and the reason each tier above it is missing. The host dot's tooltip shows it. While the last refresh failed, Stop is disabled and an attachable row opens its transcript, each with the reason as title, instead of failing at the click. First slice: no new probe, no inject affordance (#219), no launch tier. Refs #218 --- .ai/contexts/session-cache.md | 21 ++++ CHANGELOG.md | 6 ++ docs/remote-hosts.md | 8 ++ main.js | 14 ++- public/app.js | 5 + public/sidebar.js | 24 ++++- public/stop-session-ui.js | 2 +- remote-host-profile.js | 68 +++++++++++++ remote-index.js | 8 ++ ...ate-remote-attachable-local-status.test.js | 57 ++++++++++- test/dom-sidebar-remote-host-tier.test.js | 99 +++++++++++++++++++ test/remote-host-profile.test.js | 86 ++++++++++++++++ test/remote-index.test.js | 31 ++++++ test/stop-session-ui.test.js | 6 ++ 14 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 remote-host-profile.js create mode 100644 test/dom-sidebar-remote-host-tier.test.js create mode 100644 test/remote-host-profile.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index a02d197d..4ec20e10 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -744,6 +744,27 @@ created the `.jsonl`; a manual host refresh did not help. rel path, not its own, because `readSubagentMeta()` in the transcript's row is what actually needs re-deriving. +### Remote hosts — capability tiers (issue #218, first slice) + +`remote-host-profile.js` is a pure function: `computeHostProfile({ at, error, descriptors })` returns +`{ tier, tiers, missing }`, the highest of `observe < liveness < inject < attach < launch` that is +available plus, for every tier above it, the reason it is not. The indexer's `getRemoteHostProfile(alias)` +feeds it the last cycle's own data (`at`, `error`, live descriptors), so there is no probe and no extra ssh. + +- `none`: never synced, or the last cycle failed. A failed `find ~/.claude/projects` fails the whole + cycle, so an unreadable projects directory and an unreachable host are not told apart; the ssh error is the reason. +- `liveness`: at least one live descriptor. `inject`: a live descriptor with a `messagingSocketPath` that is a POSIX + absolute path. `attach`: a live descriptor naming a tmux pane with a valid pid (the adapter's own test). + The tiers are independent requirements: the reported tier is the highest available one, not the highest contiguous one. +- `launch` is never available: starting a session from here is not implemented. +- A tier that needs a live session reads as missing on an idle host; that is "nothing to read it from", not "unsupported". +- `annotateRemoteAttachable` (main.js) puts the profile on the project (`remoteHostProfile`) and, when the host is `none`, + sets `remoteAttachable: false` plus `remoteAttachBlocked` / `remoteStopBlocked` (the reason) on the session. The + renderer only shows the strings: the tooltip of the host dot, the disabled Stop button, the row and badge titles. +- The new-session button was already disabled for every remote host; it is unchanged. +- Not done: the probe for what the descriptors cannot tell (multiplexer installed but no session in it, `inotifywait`), + the inject affordance (issue #219), the launch tier. + ## Remote hosts — tmux attach (issue #221) `open-terminal` no longer refuses every remote session outright. When diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..12b7fb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### New +- A remote host's project header now shows what the host supports: hover its status dot to see the highest capability reached (observe, liveness, inject, attach) and, for each one above it, why it is missing. (#218) + +### Changed +- On a remote host whose last refresh failed, the Stop button is disabled and a row that would have attached opens its transcript, each saying why in its tooltip, instead of failing when clicked. (#218) + ## v0.0.86 — 2026-10-01 ### New diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 909e4193..d43c3b57 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -120,6 +120,14 @@ The project header carries a dot for the host's state, with a tooltip: | red | *host unreachable: <error>*, when it was last confirmed, and when the next attempt is | | amber, pulsing | a reconnect in progress | +Hovering the dot also lists the host's capability: the highest of observe, +liveness, inject, attach and launch that its last refresh could confirm, and for +each one above it why it is missing (for example no live session names a tmux +pane). A tier that needs a live session reads as missing on an idle host. While +the last refresh failed, the **Stop** button of the host's live sessions is +disabled and a row that would attach opens its transcript, each with the reason +in its tooltip. + A failing host is retried with a doubling delay, up to 30 minutes, and never dropped; one success resets it. **Reconnect** on the header retries at once and restarts the watch connection. The sidebar's global refresh retries every host. diff --git a/main.js b/main.js index 795ca5e8..62078fb1 100644 --- a/main.js +++ b/main.js @@ -80,6 +80,7 @@ const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); const { createTmuxAttachAdapter } = require('./remote-attach'); const { createRemoteStopAdapter } = require('./remote-stop'); +const { isTierAvailable, tierReason } = require('./remote-host-profile'); const { createGitChangesRunner } = require('./git-changes-runner'); const gitChangesTarget = require('./git-changes-target'); const terminalPathTarget = require('./terminal-path-target'); @@ -561,7 +562,8 @@ function annotateRemoteAttachable(projects) { if (!hostInfoByAlias.has(alias)) { const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias); const { nextAttemptAt } = remoteIndexer.getRemoteHostState(alias); - hostInfoByAlias.set(alias, { at, error, nextAttemptAt, byId: new Map(sessions.map(d => [d.sessionId, d])) }); + const profile = remoteIndexer.getRemoteHostProfile(alias); + hostInfoByAlias.set(alias, { at, error, nextAttemptAt, profile, byId: new Map(sessions.map(d => [d.sessionId, d])) }); } return hostInfoByAlias.get(alias); } @@ -571,11 +573,17 @@ function annotateRemoteAttachable(projects) { project.remoteHostAt = info.at; project.remoteHostError = info.error; project.remoteHostNextAttemptAt = info.nextAttemptAt || null; + project.remoteHostProfile = info.profile; } for (const session of project.sessions) { if (session.remoteAlias) { - const descriptor = hostInfo(session.remoteAlias).byId.get(session.sessionId); - session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + const info = hostInfo(session.remoteAlias); + const descriptor = info.byId.get(session.sessionId); + const hostBlocked = isTierAvailable(info.profile, 'observe') ? null : tierReason(info.profile, 'observe'); + const supportsAttach = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + session.remoteAttachable = supportsAttach && !hostBlocked; + session.remoteAttachBlocked = supportsAttach ? hostBlocked : null; + session.remoteStopBlocked = descriptor ? hostBlocked : null; session.status = descriptor ? (descriptor.status || null) : null; session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId); diff --git a/public/app.js b/public/app.js index 7f656e99..5992377e 100644 --- a/public/app.js +++ b/public/app.js @@ -855,6 +855,11 @@ async function triggerRebuildAndSearch() { // btn (optional): the clicked control, flashed on failure instead of alert() — see sidebar.js's session-delete-btn async function confirmAndStopSession(sessionId, btn) { const plan = resolveSessionStop(sessionMap.get(sessionId)); + if (plan.blocked) { + if (btn && typeof window.flashButtonText === 'function') window.flashButtonText(btn, 'Unavailable', 1500); + console.error('[stop-session]', plan.blocked); + return; + } if (!confirm(plan.confirmText)) return; const result = plan.remote ? await window.api.remoteStopSession(plan.alias, sessionId) diff --git a/public/sidebar.js b/public/sidebar.js index 3a448323..59490c94 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -56,7 +56,19 @@ function formatNextAttemptIn(epochMs) { // sync cycle failed (host unreachable, reason visible), the host has never // been read yet, or it was read successfully and genuinely has no live // session right now. See .ai/contexts/session-cache.md. +function remoteHostTierLines(profile) { + if (!profile || typeof profile.tier !== 'string' || !Array.isArray(profile.missing)) return ''; + return '\nCapability: ' + profile.tier + + profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join(''); +} + function remoteHostState(project) { + const state = remoteHostStatus(project); + state.detail += remoteHostTierLines(project.remoteHostProfile); + return state; +} + +function remoteHostStatus(project) { if (project.remoteHostError) { const age = formatStatusAge(project.remoteHostAt); const nextIn = formatNextAttemptIn(project.remoteHostNextAttemptAt); @@ -1233,7 +1245,9 @@ function rebindSidebarEvents(projects) { // see .ai/contexts/session-cache.md ("Remote SSH hosts") if (session.remoteAlias && !session.remoteAttachable) { - item.title = 'Not currently attachable — opening the transcript instead'; + item.title = session.remoteAttachBlocked + ? 'Attach unavailable: ' + session.remoteAttachBlocked + ' — opening the transcript instead' + : 'Not currently attachable — opening the transcript instead'; } item.onclick = () => { @@ -1265,6 +1279,10 @@ function rebindSidebarEvents(projects) { } const stopBtn = item.querySelector('.session-stop-btn'); + if (stopBtn && session.remoteStopBlocked) { + stopBtn.disabled = true; + stopBtn.title = 'Stop unavailable: ' + session.remoteStopBlocked; + } if (stopBtn) { stopBtn.onclick = (e) => { e.stopPropagation(); @@ -1452,7 +1470,9 @@ function buildSessionItem(session) { badge.className = 'remote-badge'; badge.title = session.remoteAttachable ? 'Live session on ' + session.remoteAlias + ' — click to attach' - : 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript'; + : session.remoteAttachBlocked + ? 'Session on ' + session.remoteAlias + ' — attach unavailable: ' + session.remoteAttachBlocked + ', click to read its transcript' + : 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript'; badge.textContent = session.remoteAlias; summaryEl.prepend(badge); } diff --git a/public/stop-session-ui.js b/public/stop-session-ui.js index 32ec02ff..a2aeaa1a 100644 --- a/public/stop-session-ui.js +++ b/public/stop-session-ui.js @@ -4,7 +4,7 @@ function resolveSessionStop(session) { const alias = session && session.remoteAlias; if (alias) { - return { remote: true, alias, confirmText: `Stop this session on ${alias}?` }; + return { remote: true, alias, confirmText: `Stop this session on ${alias}?`, blocked: session.remoteStopBlocked || null }; } return { remote: false, alias: null, confirmText: 'Stop this session?' }; } diff --git a/remote-host-profile.js b/remote-host-profile.js new file mode 100644 index 00000000..6b7506c9 --- /dev/null +++ b/remote-host-profile.js @@ -0,0 +1,68 @@ +// see .ai/contexts/session-cache.md ("Remote hosts — capability tiers") +'use strict'; + +const { parseTmuxField, isValidPid } = require('./remote-attach'); + +const TIERS = ['observe', 'liveness', 'inject', 'attach', 'launch']; + +function hasPosixSocketPath(descriptor) { + const p = descriptor && descriptor.messagingSocketPath; + return typeof p === 'string' && p.startsWith('/') && !p.includes('\0') && !p.includes('\\'); +} + +function namesTmuxPane(descriptor) { + return !!(descriptor && parseTmuxField(descriptor.tmux) && isValidPid(descriptor.pid)); +} + +/** + * Highest capability tier of a host, from what its last refresh cycle knew. + * input: { at: epoch ms of the last successful cycle | null, + * error: last cycle's error message | null, + * descriptors: live session descriptors of that cycle } + * -> { tier: 'none' | a TIERS name, + * tiers: [{ tier, available, reason }], + * missing: [{ tier, reason }] for every tier above `tier` } + */ +function computeHostProfile(input) { + const { at, error } = input || {}; + const descriptors = Array.isArray(input && input.descriptors) ? input.descriptors.filter(d => d && typeof d === 'object') : []; + + let blocked = null; + if (error) blocked = `last refresh of this host failed: ${error}`; + else if (!Number.isFinite(at)) blocked = 'not yet synced with this host'; + + const reasons = {}; + if (blocked) { + for (const tier of TIERS) reasons[tier] = blocked; + } else { + reasons.observe = null; + reasons.liveness = descriptors.length > 0 ? null + : 'no live session descriptor under ~/.claude/sessions on this host (one appears while a session runs)'; + reasons.inject = descriptors.some(hasPosixSocketPath) ? null + : 'no live session reports a messagingSocketPath on a POSIX path'; + reasons.attach = descriptors.some(namesTmuxPane) ? null + : 'no live session names a tmux pane in its descriptor (start it inside tmux)'; + reasons.launch = 'new sessions cannot be started from here; they must be started on the host'; + } + + const tiers = TIERS.map(tier => ({ tier, available: reasons[tier] === null, reason: reasons[tier] })); + let top = -1; + tiers.forEach((t, i) => { if (t.available) top = i; }); + return { + tier: top === -1 ? 'none' : TIERS[top], + tiers, + missing: tiers.slice(top + 1).map(t => ({ tier: t.tier, reason: t.reason })), + }; +} + +function isTierAvailable(profile, tier) { + const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === tier); + return !!(hit && hit.available); +} + +function tierReason(profile, tier) { + const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === tier); + return hit && hit.reason ? hit.reason : null; +} + +module.exports = { computeHostProfile, isTierAvailable, tierReason, TIERS }; diff --git a/remote-index.js b/remote-index.js index 13b83f95..628b256f 100644 --- a/remote-index.js +++ b/remote-index.js @@ -12,6 +12,7 @@ const { } = require('./remote-hosts'); const { syncMirror } = require('./remote-mirror'); const { encodeProjectPath } = require('./encode-project-path'); +const { computeHostProfile } = require('./remote-host-profile'); const NOOP_LOG = { info() {}, warn() {}, error() {} }; @@ -346,6 +347,12 @@ function createRemoteIndexer(ctx) { }; } + // see .ai/contexts/session-cache.md ("Remote hosts — capability tiers") + function getRemoteHostProfile(alias) { + const { sessions, at, error } = getRemoteSessions(alias); + return computeHostProfile({ at, error, descriptors: sessions }); + } + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") function getPlaceholderSessions(alias) { const list = remoteSessions.get(alias) || []; @@ -386,6 +393,7 @@ function createRemoteIndexer(ctx) { start, stop, dispose, restart, refreshNow, refreshHostNow, isRunning: () => timer !== null, getRemoteSessions, + getRemoteHostProfile, getPlaceholderSessions, getAllPlaceholderSessions, findSessionAlias, diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index b30e949e..e32c30e4 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -15,6 +15,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { computeHostProfile, isTierAvailable, tierReason } = require('../remote-host-profile'); + const root = path.join(__dirname, '..'); function extractAnnotateRemoteAttachableSource() { @@ -36,16 +38,20 @@ function makeAnnotate(mocks) { const source = extractAnnotateRemoteAttachableSource(); const factory = new Function( 'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState', + 'isTierAvailable', 'tierReason', source + '\nreturn annotateRemoteAttachable;' ); return factory( mocks.remoteIndexer || { getRemoteSessions: () => ({ sessions: [], at: null, error: null }), getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: null, error: null, descriptors: [] }), }, mocks.remoteAttachAdapter || { supports: () => false }, mocks.remoteActivityTracker || { activeAt: () => null }, - mocks.cliSessionState || { getStatus: () => undefined } + mocks.cliSessionState || { getStatus: () => undefined }, + isTierAvailable, + tierReason ); } @@ -94,6 +100,7 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip error: null, }), getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 111, error: null, descriptors: [{ pid: 4, sessionId: 'remote-1' }] }), }, remoteAttachAdapter: { supports: () => true }, cliSessionState: { getStatus: () => { throw new Error('must not be called for a remote session'); } }, @@ -111,3 +118,51 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip assert.equal(projects[0].sessions[0].statusUpdatedAt, 999); assert.equal(projects[0].sessions[0].remoteAttachable, true); }); + +function failedHostIndexer(error) { + return { + getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error }), + getRemoteHostState: () => ({ consecutiveFailures: 1, lastError: error, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 111, error, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }), + }; +} + +function remoteProjects() { + return [{ + projectPath: '/srv/proj', + remoteAlias: 'planificator', + sessions: [{ sessionId: 'remote-1', remoteAlias: 'planificator' }], + }]; +} + +test('a session on a host whose last refresh failed is not attachable and carries the reason for attach and stop', () => { + const annotate = makeAnnotate({ + remoteIndexer: failedHostIndexer('connect timed out'), + remoteAttachAdapter: { supports: () => true }, + }); + const projects = remoteProjects(); + annotate(projects); + const session = projects[0].sessions[0]; + assert.equal(session.remoteAttachable, false); + assert.match(session.remoteAttachBlocked, /connect timed out/); + assert.match(session.remoteStopBlocked, /connect timed out/); + assert.equal(projects[0].remoteHostProfile.tier, 'none'); +}); + +test('a session on a healthy host carries no blocking reason and the project carries the host profile', () => { + const annotate = makeAnnotate({ + remoteIndexer: { + getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error: null }), + getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: 111, error: null, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }), + }, + remoteAttachAdapter: { supports: () => true }, + }); + const projects = remoteProjects(); + annotate(projects); + const session = projects[0].sessions[0]; + assert.equal(session.remoteAttachable, true); + assert.equal(session.remoteAttachBlocked, null); + assert.equal(session.remoteStopBlocked, null); + assert.equal(projects[0].remoteHostProfile.tier, 'attach'); +}); diff --git a/test/dom-sidebar-remote-host-tier.test.js b/test/dom-sidebar-remote-host-tier.test.js new file mode 100644 index 00000000..db6b3854 --- /dev/null +++ b/test/dom-sidebar-remote-host-tier.test.js @@ -0,0 +1,99 @@ +// Issue #218 — the host's capability tier is shown on its project header +// and every affordance above the tier is disabled with the reason as title. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); +const { computeHostProfile } = require('../remote-host-profile'); + +const SESSION = { + sessionId: 'remote-1', + summary: 'ripcord protocol', + modified: '2026-09-06T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 4, + projectPath: '/srv/supervision', + remoteAlias: 'planificator', + remoteDescriptorSeen: true, +}; + +function project(profile, session) { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + remoteHostAt: Date.parse('2026-10-01T10:00:00Z'), + remoteHostError: null, + remoteHostProfile: profile, + sessions: [session], + }); +} + +function header(ctx) { + return ctx.document.getElementById('ph-' + ctx.sidebar.folderId('/srv/supervision')); +} + +test('the host dot title names the tier and the reason of every tier above it', () => { + const ctx = setupSidebarDom(); + try { + const profile = computeHostProfile({ at: Date.now(), error: null, descriptors: [{ pid: 5, sessionId: 'a' }] }); + ctx.sidebar.renderProjects([project(profile, SESSION)], true); + const title = header(ctx).querySelector('.remote-host-dot').title; + assert.match(title, /Capability: liveness/); + assert.match(title, /inject unavailable: no live session reports a messagingSocketPath/); + assert.match(title, /attach unavailable: no live session names a tmux pane/); + assert.match(title, /launch unavailable/); + } finally { ctx.destroy(); } +}); + +test('a host with no profile keeps the plain status title', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([project(undefined, SESSION)], true); + assert.doesNotMatch(header(ctx).querySelector('.remote-host-dot').title, /Capability/); + } finally { ctx.destroy(); } +}); + +test('a stop button blocked by the host tier is disabled and its title is the reason', () => { + const ctx = setupSidebarDom(); + try { + const blocked = { ...SESSION, remoteStopBlocked: 'last refresh of this host failed: connect timed out' }; + ctx.window.sessionMap.set(blocked.sessionId, blocked); + ctx.sidebar.renderProjects([project(undefined, blocked)], true); + const stopBtn = ctx.document.getElementById('si-remote-1').querySelector('.session-stop-btn'); + assert.equal(stopBtn.disabled, true); + assert.match(stopBtn.title, /connect timed out/); + } finally { ctx.destroy(); } +}); + +test('a stop button with no block stays enabled', () => { + const ctx = setupSidebarDom(); + try { + ctx.window.sessionMap.set(SESSION.sessionId, SESSION); + ctx.sidebar.renderProjects([project(undefined, SESSION)], true); + const stopBtn = ctx.document.getElementById('si-remote-1').querySelector('.session-stop-btn'); + assert.equal(stopBtn.disabled, false); + assert.equal(stopBtn.title, 'Stop session'); + } finally { ctx.destroy(); } +}); + +test('a row whose attach is blocked says why in its title and badge, and opens the transcript', () => { + const ctx = setupSidebarDom(); + try { + const blocked = { ...SESSION, remoteAttachable: false, remoteAttachBlocked: 'last refresh of this host failed: connect timed out' }; + ctx.window.sessionMap.set(blocked.sessionId, blocked); + ctx.sidebar.renderProjects([project(undefined, blocked)], true); + const viewed = []; + const opened = []; + ctx.window.showJsonlViewer = (s) => viewed.push(s.sessionId); + ctx.window.openSession = (s) => opened.push(s.sessionId); + const item = ctx.document.getElementById('si-remote-1'); + assert.match(item.title, /connect timed out/); + assert.match(item.querySelector('.remote-badge').title, /connect timed out/); + item.onclick(); + assert.deepEqual(viewed, ['remote-1']); + assert.deepEqual(opened, []); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-host-profile.test.js b/test/remote-host-profile.test.js new file mode 100644 index 00000000..dc5ddaf3 --- /dev/null +++ b/test/remote-host-profile.test.js @@ -0,0 +1,86 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { computeHostProfile, TIERS } = require('../remote-host-profile'); + +const AT = Date.parse('2026-10-01T10:00:00Z'); +const tmuxDescriptor = { pid: 101, sessionId: 'a', tmux: 'main:@0.%0' }; +const socketDescriptor = { pid: 102, sessionId: 'b', messagingSocketPath: '/run/user/1000/claude-102.sock' }; + +function reasonFor(profile, tier) { + const hit = profile.missing.find(m => m.tier === tier); + return hit ? hit.reason : undefined; +} + +test('the tiers are ordered observe < liveness < inject < attach < launch', () => { + assert.deepEqual(TIERS, ['observe', 'liveness', 'inject', 'attach', 'launch']); +}); + +test('a host never synced has no tier and every tier says it was not synced', () => { + const profile = computeHostProfile({ at: null, error: null, descriptors: [] }); + assert.equal(profile.tier, 'none'); + assert.deepEqual(profile.missing.map(m => m.tier), TIERS); + assert.match(reasonFor(profile, 'observe'), /not yet synced/); +}); + +test('a host whose last cycle failed has no tier and the ssh error is the reason of every tier', () => { + const profile = computeHostProfile({ at: AT, error: 'connect timed out', descriptors: [tmuxDescriptor] }); + assert.equal(profile.tier, 'none'); + for (const tier of TIERS) assert.match(reasonFor(profile, tier), /connect timed out/); +}); + +test('a synced host with no live descriptor stops at observe and says why liveness is missing', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [] }); + assert.equal(profile.tier, 'observe'); + assert.deepEqual(profile.missing.map(m => m.tier), ['liveness', 'inject', 'attach', 'launch']); + assert.match(reasonFor(profile, 'liveness'), /no live session descriptor/); +}); + +test('a live descriptor without socket or multiplexer reaches liveness only', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [{ pid: 7, sessionId: 'x' }] }); + assert.equal(profile.tier, 'liveness'); + assert.match(reasonFor(profile, 'inject'), /messagingSocketPath/); + assert.match(reasonFor(profile, 'attach'), /tmux/); +}); + +test('a live descriptor naming a POSIX messagingSocketPath makes inject available', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [socketDescriptor] }); + assert.equal(profile.tier, 'inject'); + assert.deepEqual(profile.missing.map(m => m.tier), ['attach', 'launch']); +}); + +test('a messagingSocketPath that is not a POSIX absolute path does not count', () => { + for (const bad of ['\\\\.\\pipe\\claude-1', 'relative/sock', '', 42, null]) { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [{ pid: 9, sessionId: 'x', messagingSocketPath: bad }] }); + assert.equal(profile.tier, 'liveness', JSON.stringify(bad)); + } +}); + +test('a descriptor naming a tmux pane makes attach available even without a socket', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor] }); + assert.equal(profile.tier, 'attach'); + assert.deepEqual(profile.missing.map(m => m.tier), ['launch']); +}); + +test('the highest tier wins when several sessions bring different capabilities', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [socketDescriptor, tmuxDescriptor] }); + assert.equal(profile.tier, 'attach'); + assert.equal(profile.tiers.find(t => t.tier === 'inject').available, true); +}); + +test('a tmux descriptor with an invalid pid does not count', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [{ pid: 0, sessionId: 'x', tmux: 'main:@0.%0' }] }); + assert.equal(profile.tier, 'liveness'); +}); + +test('launch is never available in this build and says to start the session on the host', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor, socketDescriptor] }); + assert.match(reasonFor(profile, 'launch'), /started on the host/); +}); + +test('garbage input never throws and yields no tier', () => { + assert.equal(computeHostProfile(undefined).tier, 'none'); + assert.equal(computeHostProfile({ at: AT, error: null, descriptors: 'nope' }).tier, 'observe'); + assert.equal(computeHostProfile({ at: AT, error: null, descriptors: [null, 3] }).tier, 'observe'); +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index ba6ac7bd..d1051c93 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -888,3 +888,34 @@ test('refreshNow({force:true}) ignores backoff for every host and resets it on s assert.equal(recovered.nextAttemptAt, 0); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + +test('getRemoteHostProfile follows the last cycle: never synced, then live tmux session, then a failure', async () => { + const dataDir = tmp('idx-profile'); + try { + const clock = fakeClock(1_000); + let outcome = 'tmux'; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'box' }], + getRefreshMs: () => 60_000, + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + now: clock, + sync: async () => { + if (outcome === 'fail') throw new Error('connect timed out'); + return { changedFolders: [], sessions: [{ pid: 5, sessionId: 's', tmux: 'main:@0.%0' }] }; + }, + }); + + assert.equal(indexer.getRemoteHostProfile('box').tier, 'none', 'never synced'); + await indexer.refreshNow(); + assert.equal(indexer.getRemoteHostProfile('box').tier, 'attach'); + outcome = 'fail'; + await indexer.refreshNow({ force: true }); + const failed = indexer.getRemoteHostProfile('box'); + assert.equal(failed.tier, 'none'); + assert.match(failed.missing[0].reason, /connect timed out/); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); diff --git a/test/stop-session-ui.test.js b/test/stop-session-ui.test.js index e3ac224c..3cd9e4cc 100644 --- a/test/stop-session-ui.test.js +++ b/test/stop-session-ui.test.js @@ -43,3 +43,9 @@ test('isRemoteSessionAlive: falls back to remoteDescriptorSeen when no adapter s assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: false }), false); assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps' }), false, 'missing field must not read as alive'); }); + +test('a remote session blocked by its host tier resolves to a plan carrying the reason', () => { + const plan = resolveSessionStop({ sessionId: 's1', remoteAlias: 'vps', remoteStopBlocked: 'host unreachable' }); + assert.equal(plan.blocked, 'host unreachable'); + assert.equal(resolveSessionStop({ sessionId: 's1', remoteAlias: 'vps' }).blocked, null); +}); From 672875f0b90ae187693f724b66508455fe04514b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 22:01:28 +0200 Subject: [PATCH 2/2] (remote): block attach only after three failed refreshes and never block stop A single transient poll failure disabled Stop and attach although both run their own ssh and the last descriptors are kept on failure. Stop is no longer gated by the profile; attach opens the transcript only after three consecutive failed refreshes, the tooltip still stating the last error. Refs #218 --- .ai/contexts/session-cache.md | 8 +++-- CHANGELOG.md | 2 +- docs/remote-hosts.md | 7 ++--- main.js | 9 +++--- public/app.js | 5 ---- public/sidebar.js | 4 --- public/stop-session-ui.js | 2 +- remote-host-profile.js | 13 ++++----- ...ate-remote-attachable-local-status.test.js | 29 +++++++++++++------ test/dom-sidebar-remote-host-tier.test.js | 23 --------------- test/remote-host-profile.test.js | 16 +++++++++- test/stop-session-ui.test.js | 6 ---- 12 files changed, 54 insertions(+), 70 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 4ec20e10..034c2093 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -758,9 +758,11 @@ feeds it the last cycle's own data (`at`, `error`, live descriptors), so there i The tiers are independent requirements: the reported tier is the highest available one, not the highest contiguous one. - `launch` is never available: starting a session from here is not implemented. - A tier that needs a live session reads as missing on an idle host; that is "nothing to read it from", not "unsupported". -- `annotateRemoteAttachable` (main.js) puts the profile on the project (`remoteHostProfile`) and, when the host is `none`, - sets `remoteAttachable: false` plus `remoteAttachBlocked` / `remoteStopBlocked` (the reason) on the session. The - renderer only shows the strings: the tooltip of the host dot, the disabled Stop button, the row and badge titles. +- `annotateRemoteAttachable` (main.js) puts the profile on the project (`remoteHostProfile`). After 3 consecutive failed + cycles (`attachBlockReason`), it sets `remoteAttachable: false` plus `remoteAttachBlocked` (the last error) on the + session: a single transient poll failure blocks nothing, and the descriptors of the last good cycle are kept. + Stop is never blocked, it runs its own ssh. The renderer only shows the strings: the host dot's tooltip (which states + the last error from the first failure), the row and badge titles. - The new-session button was already disabled for every remote host; it is unchanged. - Not done: the probe for what the descriptors cannot tell (multiplexer installed but no session in it, `inotifywait`), the inject affordance (issue #219), the launch tier. diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b7fb5b..0e498f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - A remote host's project header now shows what the host supports: hover its status dot to see the highest capability reached (observe, liveness, inject, attach) and, for each one above it, why it is missing. (#218) ### Changed -- On a remote host whose last refresh failed, the Stop button is disabled and a row that would have attached opens its transcript, each saying why in its tooltip, instead of failing when clicked. (#218) +- After three failed refreshes of a remote host in a row, a row that would have attached opens its transcript and says why in its tooltip, instead of failing when clicked. Stop is never disabled: it runs its own ssh. (#218) ## v0.0.86 — 2026-10-01 diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index d43c3b57..29ceac2a 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -123,10 +123,9 @@ The project header carries a dot for the host's state, with a tooltip: Hovering the dot also lists the host's capability: the highest of observe, liveness, inject, attach and launch that its last refresh could confirm, and for each one above it why it is missing (for example no live session names a tmux -pane). A tier that needs a live session reads as missing on an idle host. While -the last refresh failed, the **Stop** button of the host's live sessions is -disabled and a row that would attach opens its transcript, each with the reason -in its tooltip. +pane). A tier that needs a live session reads as missing on an idle host. After +three failed refreshes in a row, a row that would attach opens its transcript, +with the reason in its tooltip; **Stop** is never disabled, it runs its own ssh. A failing host is retried with a doubling delay, up to 30 minutes, and never dropped; one success resets it. **Reconnect** on the header retries at once and diff --git a/main.js b/main.js index 62078fb1..ef2f619e 100644 --- a/main.js +++ b/main.js @@ -80,7 +80,7 @@ const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); const { createTmuxAttachAdapter } = require('./remote-attach'); const { createRemoteStopAdapter } = require('./remote-stop'); -const { isTierAvailable, tierReason } = require('./remote-host-profile'); +const { attachBlockReason } = require('./remote-host-profile'); const { createGitChangesRunner } = require('./git-changes-runner'); const gitChangesTarget = require('./git-changes-target'); const terminalPathTarget = require('./terminal-path-target'); @@ -561,9 +561,9 @@ function annotateRemoteAttachable(projects) { function hostInfo(alias) { if (!hostInfoByAlias.has(alias)) { const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias); - const { nextAttemptAt } = remoteIndexer.getRemoteHostState(alias); + const { nextAttemptAt, consecutiveFailures } = remoteIndexer.getRemoteHostState(alias); const profile = remoteIndexer.getRemoteHostProfile(alias); - hostInfoByAlias.set(alias, { at, error, nextAttemptAt, profile, byId: new Map(sessions.map(d => [d.sessionId, d])) }); + hostInfoByAlias.set(alias, { at, error, nextAttemptAt, consecutiveFailures, profile, byId: new Map(sessions.map(d => [d.sessionId, d])) }); } return hostInfoByAlias.get(alias); } @@ -579,11 +579,10 @@ function annotateRemoteAttachable(projects) { if (session.remoteAlias) { const info = hostInfo(session.remoteAlias); const descriptor = info.byId.get(session.sessionId); - const hostBlocked = isTierAvailable(info.profile, 'observe') ? null : tierReason(info.profile, 'observe'); + const hostBlocked = attachBlockReason(info.profile, info.consecutiveFailures); const supportsAttach = !!(descriptor && remoteAttachAdapter.supports(descriptor)); session.remoteAttachable = supportsAttach && !hostBlocked; session.remoteAttachBlocked = supportsAttach ? hostBlocked : null; - session.remoteStopBlocked = descriptor ? hostBlocked : null; session.status = descriptor ? (descriptor.status || null) : null; session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId); diff --git a/public/app.js b/public/app.js index 5992377e..7f656e99 100644 --- a/public/app.js +++ b/public/app.js @@ -855,11 +855,6 @@ async function triggerRebuildAndSearch() { // btn (optional): the clicked control, flashed on failure instead of alert() — see sidebar.js's session-delete-btn async function confirmAndStopSession(sessionId, btn) { const plan = resolveSessionStop(sessionMap.get(sessionId)); - if (plan.blocked) { - if (btn && typeof window.flashButtonText === 'function') window.flashButtonText(btn, 'Unavailable', 1500); - console.error('[stop-session]', plan.blocked); - return; - } if (!confirm(plan.confirmText)) return; const result = plan.remote ? await window.api.remoteStopSession(plan.alias, sessionId) diff --git a/public/sidebar.js b/public/sidebar.js index 59490c94..ce96db37 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1279,10 +1279,6 @@ function rebindSidebarEvents(projects) { } const stopBtn = item.querySelector('.session-stop-btn'); - if (stopBtn && session.remoteStopBlocked) { - stopBtn.disabled = true; - stopBtn.title = 'Stop unavailable: ' + session.remoteStopBlocked; - } if (stopBtn) { stopBtn.onclick = (e) => { e.stopPropagation(); diff --git a/public/stop-session-ui.js b/public/stop-session-ui.js index a2aeaa1a..32ec02ff 100644 --- a/public/stop-session-ui.js +++ b/public/stop-session-ui.js @@ -4,7 +4,7 @@ function resolveSessionStop(session) { const alias = session && session.remoteAlias; if (alias) { - return { remote: true, alias, confirmText: `Stop this session on ${alias}?`, blocked: session.remoteStopBlocked || null }; + return { remote: true, alias, confirmText: `Stop this session on ${alias}?` }; } return { remote: false, alias: null, confirmText: 'Stop this session?' }; } diff --git a/remote-host-profile.js b/remote-host-profile.js index 6b7506c9..dba99fc2 100644 --- a/remote-host-profile.js +++ b/remote-host-profile.js @@ -3,6 +3,7 @@ const { parseTmuxField, isValidPid } = require('./remote-attach'); +const ATTACH_BLOCK_AFTER_FAILURES = 3; const TIERS = ['observe', 'liveness', 'inject', 'attach', 'launch']; function hasPosixSocketPath(descriptor) { @@ -55,14 +56,10 @@ function computeHostProfile(input) { }; } -function isTierAvailable(profile, tier) { - const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === tier); - return !!(hit && hit.available); -} - -function tierReason(profile, tier) { - const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === tier); +function attachBlockReason(profile, consecutiveFailures) { + if (!(consecutiveFailures >= ATTACH_BLOCK_AFTER_FAILURES)) return null; + const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === 'observe'); return hit && hit.reason ? hit.reason : null; } -module.exports = { computeHostProfile, isTierAvailable, tierReason, TIERS }; +module.exports = { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS }; diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index e32c30e4..b11ed4dd 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -15,7 +15,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { computeHostProfile, isTierAvailable, tierReason } = require('../remote-host-profile'); +const { computeHostProfile, attachBlockReason } = require('../remote-host-profile'); const root = path.join(__dirname, '..'); @@ -38,7 +38,7 @@ function makeAnnotate(mocks) { const source = extractAnnotateRemoteAttachableSource(); const factory = new Function( 'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState', - 'isTierAvailable', 'tierReason', + 'attachBlockReason', source + '\nreturn annotateRemoteAttachable;' ); return factory( @@ -50,8 +50,7 @@ function makeAnnotate(mocks) { mocks.remoteAttachAdapter || { supports: () => false }, mocks.remoteActivityTracker || { activeAt: () => null }, mocks.cliSessionState || { getStatus: () => undefined }, - isTierAvailable, - tierReason + attachBlockReason ); } @@ -119,10 +118,10 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip assert.equal(projects[0].sessions[0].remoteAttachable, true); }); -function failedHostIndexer(error) { +function failedHostIndexer(error, failures = 3) { return { getRemoteSessions: () => ({ sessions: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }], at: 111, error }), - getRemoteHostState: () => ({ consecutiveFailures: 1, lastError: error, nextAttemptAt: 0 }), + getRemoteHostState: () => ({ consecutiveFailures: failures, lastError: error, nextAttemptAt: 0 }), getRemoteHostProfile: () => computeHostProfile({ at: 111, error, descriptors: [{ sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }] }), }; } @@ -135,7 +134,7 @@ function remoteProjects() { }]; } -test('a session on a host whose last refresh failed is not attachable and carries the reason for attach and stop', () => { +test('a session on a host that failed three refreshes in a row is not attachable and carries the reason', () => { const annotate = makeAnnotate({ remoteIndexer: failedHostIndexer('connect timed out'), remoteAttachAdapter: { supports: () => true }, @@ -145,10 +144,23 @@ test('a session on a host whose last refresh failed is not attachable and carrie const session = projects[0].sessions[0]; assert.equal(session.remoteAttachable, false); assert.match(session.remoteAttachBlocked, /connect timed out/); - assert.match(session.remoteStopBlocked, /connect timed out/); + assert.equal(session.remoteStopBlocked, undefined, 'stop runs its own ssh and is never blocked by a poll failure'); assert.equal(projects[0].remoteHostProfile.tier, 'none'); }); +test('a single failed refresh does not block attach, while the project profile still states the error', () => { + const annotate = makeAnnotate({ + remoteIndexer: failedHostIndexer('connect timed out', 1), + remoteAttachAdapter: { supports: () => true }, + }); + const projects = remoteProjects(); + annotate(projects); + const session = projects[0].sessions[0]; + assert.equal(session.remoteAttachable, true); + assert.equal(session.remoteAttachBlocked, null); + assert.match(projects[0].remoteHostProfile.missing[0].reason, /connect timed out/); +}); + test('a session on a healthy host carries no blocking reason and the project carries the host profile', () => { const annotate = makeAnnotate({ remoteIndexer: { @@ -163,6 +175,5 @@ test('a session on a healthy host carries no blocking reason and the project car const session = projects[0].sessions[0]; assert.equal(session.remoteAttachable, true); assert.equal(session.remoteAttachBlocked, null); - assert.equal(session.remoteStopBlocked, null); assert.equal(projects[0].remoteHostProfile.tier, 'attach'); }); diff --git a/test/dom-sidebar-remote-host-tier.test.js b/test/dom-sidebar-remote-host-tier.test.js index db6b3854..04dbb52f 100644 --- a/test/dom-sidebar-remote-host-tier.test.js +++ b/test/dom-sidebar-remote-host-tier.test.js @@ -56,29 +56,6 @@ test('a host with no profile keeps the plain status title', () => { } finally { ctx.destroy(); } }); -test('a stop button blocked by the host tier is disabled and its title is the reason', () => { - const ctx = setupSidebarDom(); - try { - const blocked = { ...SESSION, remoteStopBlocked: 'last refresh of this host failed: connect timed out' }; - ctx.window.sessionMap.set(blocked.sessionId, blocked); - ctx.sidebar.renderProjects([project(undefined, blocked)], true); - const stopBtn = ctx.document.getElementById('si-remote-1').querySelector('.session-stop-btn'); - assert.equal(stopBtn.disabled, true); - assert.match(stopBtn.title, /connect timed out/); - } finally { ctx.destroy(); } -}); - -test('a stop button with no block stays enabled', () => { - const ctx = setupSidebarDom(); - try { - ctx.window.sessionMap.set(SESSION.sessionId, SESSION); - ctx.sidebar.renderProjects([project(undefined, SESSION)], true); - const stopBtn = ctx.document.getElementById('si-remote-1').querySelector('.session-stop-btn'); - assert.equal(stopBtn.disabled, false); - assert.equal(stopBtn.title, 'Stop session'); - } finally { ctx.destroy(); } -}); - test('a row whose attach is blocked says why in its title and badge, and opens the transcript', () => { const ctx = setupSidebarDom(); try { diff --git a/test/remote-host-profile.test.js b/test/remote-host-profile.test.js index dc5ddaf3..1eecbc25 100644 --- a/test/remote-host-profile.test.js +++ b/test/remote-host-profile.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { computeHostProfile, TIERS } = require('../remote-host-profile'); +const { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS } = require('../remote-host-profile'); const AT = Date.parse('2026-10-01T10:00:00Z'); const tmuxDescriptor = { pid: 101, sessionId: 'a', tmux: 'main:@0.%0' }; @@ -84,3 +84,17 @@ test('garbage input never throws and yields no tier', () => { assert.equal(computeHostProfile({ at: AT, error: null, descriptors: 'nope' }).tier, 'observe'); assert.equal(computeHostProfile({ at: AT, error: null, descriptors: [null, 3] }).tier, 'observe'); }); + +test('attach is blocked only from the third consecutive failure, with the last error as reason', () => { + const profile = computeHostProfile({ at: AT, error: 'connect timed out', descriptors: [tmuxDescriptor] }); + assert.equal(ATTACH_BLOCK_AFTER_FAILURES, 3); + assert.equal(attachBlockReason(profile, 1), null); + assert.equal(attachBlockReason(profile, 2), null); + assert.match(attachBlockReason(profile, 3), /connect timed out/); + assert.match(attachBlockReason(profile, 9), /connect timed out/); +}); + +test('attach is never blocked on a host with no error, whatever the failure count', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor] }); + assert.equal(attachBlockReason(profile, 5), null); +}); diff --git a/test/stop-session-ui.test.js b/test/stop-session-ui.test.js index 3cd9e4cc..e3ac224c 100644 --- a/test/stop-session-ui.test.js +++ b/test/stop-session-ui.test.js @@ -43,9 +43,3 @@ test('isRemoteSessionAlive: falls back to remoteDescriptorSeen when no adapter s assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps', remoteDescriptorSeen: false }), false); assert.equal(isRemoteSessionAlive({ sessionId: 's1', remoteAlias: 'vps' }), false, 'missing field must not read as alive'); }); - -test('a remote session blocked by its host tier resolves to a plan carrying the reason', () => { - const plan = resolveSessionStop({ sessionId: 's1', remoteAlias: 'vps', remoteStopBlocked: 'host unreachable' }); - assert.equal(plan.blocked, 'host unreachable'); - assert.equal(resolveSessionStop({ sessionId: 's1', remoteAlias: 'vps' }).blocked, null); -});