From 8b284e85e6ac2d2255a8998c1b370c1417c6895e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 10 Sep 2026 18:35:07 +0200 Subject: [PATCH 1/2] (remote): keep descriptors on a failed refresh, let a manual refresh force reconnection A transient ssh timeout used to wipe the remote descriptor cache, so every remote session read as non-attachable for the whole backoff window while the tmux sessions were alive, and refreshHostNow/refreshNow honoured the backoff unconditionally, leaving a manual refresh powerless during that window. Descriptors now survive a failed cycle; a forced refresh (sidebar button, or a per-host action next to the host dot) ignores backoff and restarts the watch channel for the affected host. --- .ai/contexts/session-cache.md | 49 +++++++++- main.js | 44 ++++++++- preload.js | 1 + public/app.js | 5 + public/sidebar.js | 40 +++++++- public/style.css | 40 ++++++++ remote-index.js | 28 ++++-- ...ate-remote-attachable-local-status.test.js | 6 +- test/dom-setup.js | 5 + ...om-sidebar-remote-host-refresh-btn.test.js | 82 ++++++++++++++++ test/remote-index.test.js | 98 ++++++++++++++++++- 11 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 test/dom-sidebar-remote-host-refresh-btn.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 8207ca1b..21e750b0 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -176,9 +176,35 @@ the exact remote command, and the mutation proofs are in the first failure and each change of tier (`onHostFailure` in `remote-index.js`), not every attempt, so the same field incident would have produced roughly 5 lines instead of 226. Per-host state is readable via - `getRemoteHostState(alias)` (mirrors `getRemoteSessions(alias)`); no GUI - reads it yet. Proven in `test/remote-index.test.js` with an injected - `now()` clock — no real timers, no `setTimeout` waits. + `getRemoteHostState(alias)` (mirrors `getRemoteSessions(alias)`); the sidebar + reads its `nextAttemptAt` for the host dot's error tooltip (below). Proven in + `test/remote-index.test.js` with an injected `now()` clock — no real timers, + no `setTimeout` waits. + +- **A manual refresh means "I know the host is back": it ignores the backoff + instead of waiting it out (issue #252).** Field incident 2026-09-10 17:41: a + single transient ssh timeout put a host in backoff, and because + `refreshHostNow`/`refreshNow` honoured `nextAttemptAt` unconditionally, the + sidebar refresh button and a per-host reconnect action were both powerless + for the whole ≥300 s window even though the tmux sessions were alive. + `refreshHostNow(alias, { force: true })` and `refreshNow({ force: true })` + now reset `failures`/`nextAttemptAt` to nominal (`lastError` is left alone — + it only clears on an actual success, or gets overwritten by a fresh failure) + and run the transport immediately regardless of `nextAttemptAt`. The + automatic callers — the periodic timer's `refreshNow()` and the watch + channel's `onRemoteWatchEvent` → `refreshHostNow(alias)` in `main.js` — call + both functions with no options, so `force` defaults to `false` and the + backoff keeps applying exactly as before. IPC `remote-hosts-refresh` (all + enabled hosts) and `remote-host-refresh` (one alias, `main.js`) both force; + after the refresh, both also restart that alias's watch channel + (`remoteWatcher.stop` then the same `start(alias, onRemoteWatchEvent, + onRemoteWatchActivity)` `syncRemoteWatchers()` uses, factored into + `startWatcherForHost`/`restartWatcherForAlias` so the callback wiring is + never duplicated) — this also clears a channel stuck on the + `SWITCHBOARD-NO-INOTIFYWAIT` marker, since `remoteWatcher.start()` resets + `unwatchable`. Proven in `test/remote-index.test.js`: `force` bypassing the + backoff and clearing it on success, and the automatic (non-forced) path + still honouring it, both on an injected clock. - **The mirror is indexed off the main thread.** `workers/scan-projects.js` takes `folderPrefix` and a `folders` subset in `workerData`, and @@ -374,6 +400,23 @@ untouched. cannot grow unboundedly across host-list edits. Attach now exists off this data (issue #221, below); capacity tiers and a liveness badge in the UI (#218, #212) still don't. + **Corrected 2026-09-10 (issue #252): only a SUCCESSFUL cycle replaces this + map.** `refreshHost()`'s failure path used to also do `remoteSessions.set(alias, + [])`, so one transient ssh timeout wiped every live descriptor and + `annotateRemoteAttachable` (`main.js`) then found none — every remote + session read as non-attachable for the whole backoff window even though + the tmux sessions were alive (field incident 2026-09-10 17:41). The wipe is + removed from both `refreshNow()`'s and `refreshHostNow()`'s catch blocks; + `getRemoteSessions(alias)` on a failed cycle now returns the previous + `sessions` list unchanged, the previous `at`, and the fresh `error` from + `hostBackoff` — the host dot already renders `error` as "unreachable", so + the UI signal is unchanged, only the underlying data survives. Attach + itself is the honest failure mode for a genuinely dead host: it tries the + stale descriptor and the ssh call inside it fails. A host that is + disabled or removed is still pruned by `pruneUnknownAliases()`, unaffected + by this change. Proven by `test/remote-index.test.js` ("getRemoteSessions + keeps the last known descriptors, not wiped, after a cycle where sync() + throws"). - **Remote hosts — meta.json sidecars (issue #244).** A subagent's agent type lives in a sidecar `agent-.meta.json` next to its transcript, read by `readSubagentMeta()` (`read-session-file.js`). `LIST_COMMAND`'s diff --git a/main.js b/main.js index 7a48f9ec..ee9a5cd1 100644 --- a/main.js +++ b/main.js @@ -496,6 +496,10 @@ function onRemoteWatchActivity(alias, rel) { } } +function startWatcherForHost(host) { + remoteWatcher.start(host.alias, onRemoteWatchEvent, onRemoteWatchActivity); +} + function syncRemoteWatchers() { const declared = enabledHosts((getSetting('global') || {}).remoteHosts); const wanted = new Set(declared.map(h => h.alias)); @@ -503,11 +507,24 @@ function syncRemoteWatchers() { if (!wanted.has(alias)) remoteWatcher.stop(alias); } for (const host of declared) { - if (!remoteWatcher.isRunning(host.alias)) remoteWatcher.start(host.alias, onRemoteWatchEvent, onRemoteWatchActivity); + if (!remoteWatcher.isRunning(host.alias)) startWatcherForHost(host); } watchedAliases = wanted; } +// A manual reconnect (issue #252) restarts the watch channel for one alias so +// a channel killed by a network blip or the SWITCHBOARD-NO-INOTIFYWAIT marker +// comes back without a settings round-trip — see .ai/contexts/session-cache.md +// ("Remote hosts backoff" — manual reconnect). +function restartWatcherForAlias(alias) { + const declared = enabledHosts((getSetting('global') || {}).remoteHosts); + const host = declared.find(h => h.alias === alias); + if (!host) return; + remoteWatcher.stop(alias); + startWatcherForHost(host); + watchedAliases.add(alias); +} + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") const remoteAttachAdapter = createTmuxAttachAdapter({ spawnPty: (file, args, ptyOpts) => spawnPty(file, args, { ...ptyOpts, cwd: os.homedir(), env: cleanPtyEnv }), @@ -522,7 +539,8 @@ function annotateRemoteAttachable(projects) { function hostInfo(alias) { if (!hostInfoByAlias.has(alias)) { const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias); - hostInfoByAlias.set(alias, { at, error, byId: new Map(sessions.map(d => [d.sessionId, d])) }); + const { nextAttemptAt } = remoteIndexer.getRemoteHostState(alias); + hostInfoByAlias.set(alias, { at, error, nextAttemptAt, byId: new Map(sessions.map(d => [d.sessionId, d])) }); } return hostInfoByAlias.get(alias); } @@ -531,6 +549,7 @@ function annotateRemoteAttachable(projects) { const info = hostInfo(project.remoteAlias); project.remoteHostAt = info.at; project.remoteHostError = info.error; + project.remoteHostNextAttemptAt = info.nextAttemptAt || null; } for (const session of project.sessions) { if (session.remoteAlias) { @@ -1521,9 +1540,28 @@ ipcMain.handle('remote-hosts-apply', () => { } }); +// A manual refresh means "I know the host is back, reconnect now": it ignores +// backoff and restarts the watch channel per enabled host — see +// .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252). ipcMain.handle('remote-hosts-refresh', async () => { try { - return { ok: true, ...(await remoteIndexer.refreshNow()) }; + const result = await remoteIndexer.refreshNow({ force: true }); + for (const host of enabledHosts((getSetting('global') || {}).remoteHosts)) { + restartWatcherForAlias(host.alias); + } + return { ok: true, ...result }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +// Same as above, narrowed to one alias — the per-host reconnect action next +// to a remote project header's host dot. +ipcMain.handle('remote-host-refresh', async (_event, alias) => { + try { + const result = await remoteIndexer.refreshHostNow(alias, { force: true }); + restartWatcherForAlias(alias); + return { ok: true, ...result }; } catch (err) { return { ok: false, error: err.message }; } diff --git a/preload.js b/preload.js index 70a54450..821287b3 100644 --- a/preload.js +++ b/preload.js @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', { getEffectiveSettings: (projectPath) => ipcRenderer.invoke('get-effective-settings', projectPath), remoteHostsApply: () => ipcRenderer.invoke('remote-hosts-apply'), remoteHostsRefresh: () => ipcRenderer.invoke('remote-hosts-refresh'), + remoteHostRefresh: (alias) => ipcRenderer.invoke('remote-host-refresh', alias), getScheduleCreatorCommand: () => ipcRenderer.invoke('get-schedule-creator-command'), createScheduleSession: (projectPath) => ipcRenderer.invoke('create-schedule-session', projectPath), runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath), diff --git a/public/app.js b/public/app.js index f5bb847a..8133be8b 100644 --- a/public/app.js +++ b/public/app.js @@ -720,6 +720,11 @@ async function triggerRebuildAndSearch() { if (rebuildInFlight) return; rebuildInFlight = true; if (searchRefreshBtn) searchRefreshBtn.classList.add('spinning'); + // Fire-and-forget: a manual refresh also reconnects every enabled remote + // host (issue #252), but that must never block the local reindex below. + window.api.remoteHostsRefresh().catch((err) => { + console.error('remote hosts refresh failed', err); + }); try { await window.api.rebuildCache(); } catch {} diff --git a/public/sidebar.js b/public/sidebar.js index 8a0b9f94..ec2dad9f 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -32,6 +32,17 @@ function formatStatusAge(epochMs) { return d + 'd ago'; } +// The next automatic retry, phrased for a tooltip — issue #252. null when the +// host isn't backing off (never failed, or a manual reconnect just reset it). +function formatNextAttemptIn(epochMs) { + if (!Number.isFinite(epochMs)) return null; + const deltaMs = epochMs - Date.now(); + if (deltaMs <= 0) return null; + const s = Math.ceil(deltaMs / 1000); + if (s < 60) return s + 's'; + return Math.ceil(s / 60) + 'm'; +} + // Three states a remote host's project header can carry (issue #212): the last // sync cycle failed (host unreachable, reason visible), the host has never // been read yet, or it was read successfully and genuinely has no live @@ -39,10 +50,12 @@ function formatStatusAge(epochMs) { function remoteHostState(project) { if (project.remoteHostError) { const age = formatStatusAge(project.remoteHostAt); + const nextIn = formatNextAttemptIn(project.remoteHostNextAttemptAt); return { cls: 'remote-host-error', detail: 'host unreachable: ' + project.remoteHostError - + (age ? ' (last confirmed ' + age + ')' : ', never confirmed'), + + (age ? ' (last confirmed ' + age + ')' : ', never confirmed') + + (nextIn ? '; next automatic attempt in ' + nextIn : ''), }; } if (!Number.isFinite(project.remoteHostAt)) { @@ -781,6 +794,15 @@ function renderProjects(projects, resort) { hostDot.className = 'session-status-dot remote-host-dot ' + state.cls; hostDot.title = state.detail; header.querySelector('.project-name').after(hostDot); + + // Manual reconnect (issue #252): ignores backoff, restarts the watch + // channel for this alias — see .ai/contexts/session-cache.md + // ("Remote hosts backoff" — manual reconnect). + const hostRefreshBtn = document.createElement('button'); + hostRefreshBtn.className = 'remote-host-refresh-btn'; + hostRefreshBtn.title = 'Reconnect ' + project.remoteAlias + ' now'; + hostRefreshBtn.innerHTML = ''; + hostDot.after(hostRefreshBtn); } const scheduleBtn = document.createElement('button'); @@ -1015,6 +1037,20 @@ function rebindSidebarEvents(projects) { loadProjects(); }; } + const hostRefreshBtn = header.querySelector('.remote-host-refresh-btn'); + if (hostRefreshBtn) { + hostRefreshBtn.onclick = async (e) => { + e.stopPropagation(); + const hostDot = header.querySelector('.remote-host-dot'); + if (hostDot) hostDot.className = 'session-status-dot remote-host-dot remote-host-connecting'; + try { + await window.api.remoteHostRefresh(project.remoteAlias); + } catch (err) { + console.error('remote host refresh failed for ' + project.remoteAlias, err); + } + loadProjects(); + }; + } const remapBtn = header.querySelector('.project-remap-btn'); if (remapBtn) { remapBtn.onclick = async (e) => { @@ -1032,7 +1068,7 @@ function rebindSidebarEvents(projects) { }; } header.onclick = (e) => { - if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn') || e.target.closest('.project-remap-btn')) return; + if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn') || e.target.closest('.project-remap-btn') || e.target.closest('.remote-host-refresh-btn')) return; header.classList.toggle('collapsed'); }; } diff --git a/public/style.css b/public/style.css index 2245ecba..954c9bbb 100644 --- a/public/style.css +++ b/public/style.css @@ -4167,6 +4167,46 @@ body { display: flex; flex-direction: column; } .remote-host-dot.remote-host-unknown { background: #f5be3c; } .remote-host-dot.remote-host-empty { background: rgba(255,255,255,0.18); } .remote-host-dot.remote-host-live { background: #3ecf5a; } +/* Manual reconnect in flight (issue #252) — same grey as "never synced", + pulsed so it reads as active rather than unknown. */ +.remote-host-dot.remote-host-connecting { + background: #f5be3c; + animation: pulse-dot 1s steps(4) infinite; +} + +/* Project header manual reconnect button, next to the remote host dot */ +.remote-host-refresh-btn { + background: transparent; + border: none; + color: #7a7a90; + width: 16px; + height: 16px; + margin: 0 0 0 2px; + border-radius: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 0; + vertical-align: middle; + transition: all 0.15s; + opacity: 0; +} + +.remote-host-refresh-btn svg { + display: block; +} + +.project-header:hover .remote-host-refresh-btn { + opacity: 0.7; +} + +.remote-host-refresh-btn:hover { + opacity: 1; + background: rgba(255,255,255,0.04); + color: #b0b0c4; +} .remote-hosts-list { margin: 8px 0; diff --git a/remote-index.js b/remote-index.js index 30d0449f..eb3c94f6 100644 --- a/remote-index.js +++ b/remote-index.js @@ -199,7 +199,8 @@ function createRemoteIndexer(ctx) { return toScan.size > 0; } - async function refreshNow() { + // see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252) + async function refreshNow({ force = false } = {}) { if (stopped || inFlight) return { skipped: true }; const list = hosts(); publishRoots(list); @@ -214,13 +215,19 @@ function createRemoteIndexer(ctx) { if (stopped) break; if (hostInFlight.has(host.alias)) continue; const state = backoffState(host.alias); - if (now() < state.nextAttemptAt) continue; // still backing off: no attempt, no log, no ssh + if (force) { + state.failures = 0; + state.nextAttemptAt = 0; + } else if (now() < state.nextAttemptAt) { + continue; // still backing off: no attempt, no log, no ssh + } try { if (await refreshHost(host)) changed = true; onHostSuccess(host.alias); remoteSessionsAt.set(host.alias, now()); } catch (err) { - remoteSessions.set(host.alias, []); + // A failed cycle keeps the last known descriptors — see + // .ai/contexts/session-cache.md ("Remote hosts — freshness contract"). errors.push({ alias: host.alias, error: err.message }); onHostFailure(host.alias, err, intervalMs); } @@ -233,13 +240,19 @@ function createRemoteIndexer(ctx) { return { skipped: false, hosts: list.length, changed, errors }; } - // see .ai/contexts/session-cache.md ("Remote hosts — watch channel") - async function refreshHostNow(alias) { + // see .ai/contexts/session-cache.md ("Remote hosts — watch channel" and, + // for `force`, "Remote hosts backoff" — manual reconnect, issue #252) + async function refreshHostNow(alias, { force = false } = {}) { if (stopped || inFlight || hostInFlight.has(alias)) return { skipped: true }; const host = hosts().find(h => h.alias === alias); if (!host) return { skipped: true }; const state = backoffState(alias); - if (now() < state.nextAttemptAt) return { skipped: true }; + if (force) { + state.failures = 0; + state.nextAttemptAt = 0; + } else if (now() < state.nextAttemptAt) { + return { skipped: true }; + } const intervalMs = normalizeRefreshMs(ctx.getRefreshMs ? ctx.getRefreshMs() : undefined); hostInFlight.add(alias); let changed = false; @@ -249,7 +262,8 @@ function createRemoteIndexer(ctx) { onHostSuccess(alias); remoteSessionsAt.set(alias, now()); } catch (err) { - remoteSessions.set(alias, []); + // A failed cycle keeps the last known descriptors — see + // .ai/contexts/session-cache.md ("Remote hosts — freshness contract"). onHostFailure(alias, err, intervalMs); error = err.message; } finally { diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index 05b42c72..b30e949e 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -39,7 +39,10 @@ function makeAnnotate(mocks) { source + '\nreturn annotateRemoteAttachable;' ); return factory( - mocks.remoteIndexer || { getRemoteSessions: () => ({ sessions: [], at: null, error: null }) }, + mocks.remoteIndexer || { + getRemoteSessions: () => ({ sessions: [], at: null, error: null }), + getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + }, mocks.remoteAttachAdapter || { supports: () => false }, mocks.remoteActivityTracker || { activeAt: () => null }, mocks.cliSessionState || { getStatus: () => undefined } @@ -90,6 +93,7 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip at: 111, error: null, }), + getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), }, remoteAttachAdapter: { supports: () => true }, cliSessionState: { getStatus: () => { throw new Error('must not be called for a remote session'); } }, diff --git a/test/dom-setup.js b/test/dom-setup.js index e0f276c7..0a9e2028 100644 --- a/test/dom-setup.js +++ b/test/dom-setup.js @@ -51,6 +51,11 @@ function setupSidebarDom() { const apiTarget = { onSubagentSpawned: (cb) => { apiTarget._subagentSpawnedCb = cb; }, onSubagentCompleted: (cb) => { apiTarget._subagentCompletedCb = cb; }, + // Manual remote reconnect (issue #252) — explicit defaults so a test that + // doesn't care about these calls still gets a resolved promise; a test + // that does override them per-call, the same way archiveSession etc. do. + remoteHostsRefresh: () => Promise.resolve({ ok: true }), + remoteHostRefresh: () => Promise.resolve({ ok: true }), }; window.api = new Proxy(apiTarget, { get(target, prop) { diff --git a/test/dom-sidebar-remote-host-refresh-btn.test.js b/test/dom-sidebar-remote-host-refresh-btn.test.js new file mode 100644 index 00000000..a3a5c796 --- /dev/null +++ b/test/dom-sidebar-remote-host-refresh-btn.test.js @@ -0,0 +1,82 @@ +// Issue #252 — a remote project group header carries a manual reconnect +// action next to the host dot: it targets only that alias, flips the dot to +// a "connecting" state immediately (before ssh returns), and asks for a +// fresh render once the IPC call settles. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function remoteProject(overrides) { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + remoteHostAt: Date.now(), + remoteHostError: null, + sessions: [], + ...overrides, + }); +} + +test('clicking the remote host refresh action targets only its own alias and flips the dot to connecting', async () => { + const ctx = setupSidebarDom(); + try { + const calls = []; + let resolveRefresh; + ctx.window.api.remoteHostRefresh = (alias) => { + calls.push(alias); + return new Promise((resolve) => { resolveRefresh = resolve; }); + }; + let loadCount = 0; + ctx.window.loadProjects = () => { loadCount++; }; + + ctx.sidebar.renderProjects([remoteProject()], true); + + const fId = ctx.sidebar.folderId('/srv/supervision'); + const header = ctx.document.getElementById('ph-' + fId); + const refreshBtn = header.querySelector('.remote-host-refresh-btn'); + assert.ok(refreshBtn, 'a remote project header must carry a reconnect action'); + + const clickPromise = refreshBtn.onclick({ stopPropagation: () => {} }); + + assert.deepEqual(calls, ['planificator'], 'the action must target the project host alias only'); + const dot = header.querySelector('.remote-host-dot'); + assert.ok(dot.classList.contains('remote-host-connecting'), + 'the dot must flip to connecting immediately, before ssh returns'); + + resolveRefresh({ ok: true }); + await clickPromise; + + assert.equal(loadCount, 1, 'a fresh get-projects re-render is requested once the refresh settles'); + } finally { ctx.destroy(); } +}); + +test('the reconnect action never bubbles into the header collapse toggle', () => { + const ctx = setupSidebarDom(); + try { + ctx.window.api.remoteHostRefresh = () => new Promise(() => {}); // never resolves in this test + ctx.sidebar.renderProjects([remoteProject()], true); + + const fId = ctx.sidebar.folderId('/srv/supervision'); + const header = ctx.document.getElementById('ph-' + fId); + const refreshBtn = header.querySelector('.remote-host-refresh-btn'); + + let stopped = false; + refreshBtn.onclick({ stopPropagation: () => { stopped = true; } }); + + assert.equal(stopped, true, 'the click must stop propagation before reaching the header toggle'); + assert.equal(header.classList.contains('collapsed'), false, 'the reconnect action must not collapse the group'); + } finally { ctx.destroy(); } +}); + +test('a local project group carries no remote host refresh action', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([makeSampleProject()], true); + const fId = ctx.sidebar.folderId('/home/dev/myproj'); + const header = ctx.document.getElementById('ph-' + fId); + assert.equal(header.querySelector('.remote-host-refresh-btn'), null); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index 98ad543b..266a2771 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -186,7 +186,12 @@ test('getRemoteSessions surfaces per-host session descriptors from the same sync } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); -test('getRemoteSessions is cleared, not left stale, after a cycle where sync() throws', async () => { +// Issue #252, field incident 2026-09-10 17:41: a transient ssh timeout used to +// wipe the descriptor list, so every remote session read as non-attachable +// for the whole backoff window while the tmux sessions were alive. A failed +// cycle now keeps the last known descriptors; only a successful inventory +// replaces them. +test('getRemoteSessions keeps the last known descriptors, not wiped, after a cycle where sync() throws', async () => { const dataDir = tmp('idx-sessions-stale'); try { let cycle = 0; @@ -219,8 +224,8 @@ test('getRemoteSessions is cleared, not left stale, after a cycle where sync() t const r2 = await indexer.refreshNow(); assert.equal(r2.errors.length, 1, 'the second cycle must be reported as failed'); const afterFailure = indexer.getRemoteSessions('planificator'); - assert.deepEqual(afterFailure.sessions, [], - 'a failed cycle must not keep reporting hours-old sessions as live'); + assert.deepEqual(afterFailure.sessions, [{ pid: 1, sessionId: 'still-alive' }], + 'a failed cycle must keep the last known descriptors — the tmux session is still alive, attach must stay possible'); assert.match(afterFailure.error, /timed out/, 'the failure reason must survive on the accessor so the UI can distinguish it from a genuinely idle host'); assert.equal(afterFailure.at, afterSuccess.at, @@ -639,3 +644,90 @@ test('refreshHostNow and the periodic cycle never overlap on the same host', asy assert.equal(maxInflight, 1, 'the two paths never ran the transport for the same host concurrently'); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + +// Issue #252 — a manual reconnect means "I know the host is back": it must +// ignore the backoff and actually run the ssh inventory, unlike the automatic +// path (`onRemoteWatchEvent` in main.js), which keeps calling refreshHostNow +// without `force` and must keep respecting it. +test('refreshHostNow({force:true}) ignores backoff, runs the transport, and clears it on success', async () => { + const dataDir = tmp('idx-hostnow-force'); + try { + const clock = fakeClock(0); + let shouldFail = true; + let attempts = 0; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'dead' }], + getRefreshMs: () => 60_000, + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + now: clock, + sync: async () => { + attempts++; + if (shouldFail) throw new Error('ssh: connect to host dead port 22: timed out'); + return { fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, changedFolders: new Set() }; + }, + }); + + await indexer.refreshHostNow('dead'); + assert.equal(attempts, 1); + assert.ok(indexer.getRemoteHostState('dead').nextAttemptAt > clock(), 'the host must now be backing off'); + + // The automatic (non-forced) path — what onRemoteWatchEvent keeps calling + // — must still be skipped while backing off. + const skipped = await indexer.refreshHostNow('dead'); + assert.equal(attempts, 1, 'the automatic path must still respect the backoff'); + assert.equal(skipped.skipped, true); + + // A manual reconnect ignores the backoff and runs the inventory now. + shouldFail = false; + const forced = await indexer.refreshHostNow('dead', { force: true }); + assert.equal(attempts, 2, 'force must actually run the transport, not skip'); + assert.equal(forced.skipped, false); + const recovered = indexer.getRemoteHostState('dead'); + assert.equal(recovered.consecutiveFailures, 0, 'a forced success clears the backoff'); + assert.equal(recovered.nextAttemptAt, 0, 'no artificial delay is left behind'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('refreshNow({force:true}) ignores backoff for every host and resets it on success', async () => { + const dataDir = tmp('idx-refreshall-force'); + try { + const clock = fakeClock(0); + let shouldFail = true; + let attempts = 0; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'dead' }], + getRefreshMs: () => 60_000, + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + now: clock, + sync: async () => { + attempts++; + if (shouldFail) throw new Error('ssh: connect to host dead port 22: timed out'); + return { fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, changedFolders: new Set() }; + }, + }); + + await indexer.refreshNow(); + assert.equal(attempts, 1); + assert.ok(indexer.getRemoteHostState('dead').nextAttemptAt > clock()); + + const skipped = await indexer.refreshNow(); + assert.equal(attempts, 1, 'the automatic refreshNow (periodic timer) must still respect the backoff'); + assert.deepEqual(skipped.errors, []); + + shouldFail = false; + const forced = await indexer.refreshNow({ force: true }); + assert.equal(attempts, 2, 'force must run the transport for a backing-off host'); + assert.deepEqual(forced.errors, []); + const recovered = indexer.getRemoteHostState('dead'); + assert.equal(recovered.consecutiveFailures, 0); + assert.equal(recovered.nextAttemptAt, 0); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); From 0618e0e7687e9d6b6e565d1376a3d81459fa7268 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 10 Sep 2026 18:39:12 +0200 Subject: [PATCH 2/2] chore: trim reconnect comments to doc pointers --- main.js | 11 ++--------- public/sidebar.js | 7 ++----- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/main.js b/main.js index ee9a5cd1..e47b2277 100644 --- a/main.js +++ b/main.js @@ -512,10 +512,7 @@ function syncRemoteWatchers() { watchedAliases = wanted; } -// A manual reconnect (issue #252) restarts the watch channel for one alias so -// a channel killed by a network blip or the SWITCHBOARD-NO-INOTIFYWAIT marker -// comes back without a settings round-trip — see .ai/contexts/session-cache.md -// ("Remote hosts backoff" — manual reconnect). +// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252) function restartWatcherForAlias(alias) { const declared = enabledHosts((getSetting('global') || {}).remoteHosts); const host = declared.find(h => h.alias === alias); @@ -1540,9 +1537,7 @@ ipcMain.handle('remote-hosts-apply', () => { } }); -// A manual refresh means "I know the host is back, reconnect now": it ignores -// backoff and restarts the watch channel per enabled host — see -// .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252). +// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252) ipcMain.handle('remote-hosts-refresh', async () => { try { const result = await remoteIndexer.refreshNow({ force: true }); @@ -1555,8 +1550,6 @@ ipcMain.handle('remote-hosts-refresh', async () => { } }); -// Same as above, narrowed to one alias — the per-host reconnect action next -// to a remote project header's host dot. ipcMain.handle('remote-host-refresh', async (_event, alias) => { try { const result = await remoteIndexer.refreshHostNow(alias, { force: true }); diff --git a/public/sidebar.js b/public/sidebar.js index ec2dad9f..14d1f64c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -32,8 +32,7 @@ function formatStatusAge(epochMs) { return d + 'd ago'; } -// The next automatic retry, phrased for a tooltip — issue #252. null when the -// host isn't backing off (never failed, or a manual reconnect just reset it). +// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252) function formatNextAttemptIn(epochMs) { if (!Number.isFinite(epochMs)) return null; const deltaMs = epochMs - Date.now(); @@ -795,9 +794,7 @@ function renderProjects(projects, resort) { hostDot.title = state.detail; header.querySelector('.project-name').after(hostDot); - // Manual reconnect (issue #252): ignores backoff, restarts the watch - // channel for this alias — see .ai/contexts/session-cache.md - // ("Remote hosts backoff" — manual reconnect). + // see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252) const hostRefreshBtn = document.createElement('button'); hostRefreshBtn.className = 'remote-host-refresh-btn'; hostRefreshBtn.title = 'Reconnect ' + project.remoteAlias + ' now';