From ef8c4e7ced9deb262818042547220ab55db0eb8a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 10 Sep 2026 02:17:35 +0200 Subject: [PATCH 1/2] feat(remote): light a pip while a remote session is writing remote-watch.js already parses every transcript-write inotify event but threw the rel path away. Wire a second, uncoalesced callback (onActivity) through it so a per-session last-write timestamp reaches the sidebar in near real time, without paying for an ssh refresh per event the way the existing coalesced onEvent does. main.js tracks last-write times per alias+session (remote-activity.js, throttled to 1 IPC message/s/session) and seeds session.remoteActiveAt on every rebuild so a fresh launch paints correctly. The renderer (remote-activity-ui.js, mirroring the session-activity.js split so it can be evaluated standalone in jsdom) lights a dedicated dot next to the existing status dot and clears it 20s after the last sighting. --- eslint.config.js | 1 + main.js | 16 ++- preload.js | 3 + public/app.js | 1 + public/index.html | 1 + public/remote-activity-ui.js | 38 +++++ public/sidebar.js | 14 ++ public/style.css | 16 +++ remote-activity.js | 54 +++++++ remote-watch.js | 6 +- test/dom-sidebar-remote-activity-pip.test.js | 82 +++++++++++ test/remote-activity-ui.test.js | 140 +++++++++++++++++++ test/remote-activity.test.js | 80 +++++++++++ test/remote-watch.test.js | 48 +++++++ 14 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 public/remote-activity-ui.js create mode 100644 remote-activity.js create mode 100644 test/dom-sidebar-remote-activity-pip.test.js create mode 100644 test/remote-activity-ui.test.js create mode 100644 test/remote-activity.test.js diff --git a/eslint.config.js b/eslint.config.js index 7901729c..c7358ff8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -145,6 +145,7 @@ const rendererCrossFileGlobals = { reconcileBusyState: 'readonly', currentActivitySeq: 'readonly', forgetActivitySeq: 'readonly', + pruneRemoteActivityTimers: 'readonly', // Third-party renderer libs loaded as + diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js new file mode 100644 index 00000000..be5c4e81 --- /dev/null +++ b/public/remote-activity-ui.js @@ -0,0 +1,38 @@ +// See .ai/contexts/session-cache.md ("Remote hosts — activity pip"). + +const PIP_DECAY_MS = 20000; +const remoteActivityDecayTimers = new Map(); + +function remoteActivityDotFor(sessionId) { + const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); + return item ? item.querySelector('.remote-activity-dot') : null; +} + +function clearRemoteActivityTimer(sessionId) { + const t = remoteActivityDecayTimers.get(sessionId); + if (t) { + clearTimeout(t); + remoteActivityDecayTimers.delete(sessionId); + } +} + +function pruneRemoteActivityTimers() { + for (const sessionId of remoteActivityDecayTimers.keys()) { + if (!remoteActivityDotFor(sessionId)) clearRemoteActivityTimer(sessionId); + } +} + +function onRemoteActivityEvent(payload) { + const sessionId = payload && payload.sessionId; + if (typeof sessionId !== 'string' || !sessionId) return; + const dot = remoteActivityDotFor(sessionId); + if (dot) dot.classList.add('active'); + clearRemoteActivityTimer(sessionId); + remoteActivityDecayTimers.set(sessionId, setTimeout(() => { + remoteActivityDecayTimers.delete(sessionId); + const el = remoteActivityDotFor(sessionId); + if (el) el.classList.remove('active'); + }, PIP_DECAY_MS)); +} + +window.api.onRemoteActivity(onRemoteActivityEvent); diff --git a/public/sidebar.js b/public/sidebar.js index 278c27c4..a169baeb 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -9,6 +9,9 @@ // showNewSessionPopover, openSettingsViewer, showResumeSessionDialog, // showJsonlViewer, forkSession, openSession, loadProjects (app.js/dialogs.js) +// see .ai/contexts/session-cache.md ("Remote hosts — activity pip") +const REMOTE_ACTIVITY_DECAY_MS = 20000; + function slugId(slug) { return 'slug-' + slug.replace(/[^a-zA-Z0-9_-]/g, '_'); } @@ -1299,6 +1302,16 @@ function buildSessionItem(session) { const dot = document.createElement('span'); dot.className = 'session-status-dot' + (activePtyIds.has(session.sessionId) ? ' running' : ''); + // see .ai/contexts/session-cache.md ("Remote hosts — activity pip") + let activityDot = null; + if (session.remoteAlias) { + activityDot = document.createElement('span'); + const isActive = Number.isFinite(session.remoteActiveAt) && + (Date.now() - session.remoteActiveAt) < REMOTE_ACTIVITY_DECAY_MS; + activityDot.className = 'session-status-dot remote-activity-dot' + (isActive ? ' active' : ''); + activityDot.title = 'Remote session is writing its transcript'; + } + // Info block const info = document.createElement('div'); info.className = 'session-info'; @@ -1395,6 +1408,7 @@ function buildSessionItem(session) { row.appendChild(pin); row.appendChild(dot); + if (activityDot) row.appendChild(activityDot); row.appendChild(info); row.appendChild(actions); item.appendChild(row); diff --git a/public/style.css b/public/style.css index d07b5167..c83ddc16 100644 --- a/public/style.css +++ b/public/style.css @@ -969,6 +969,22 @@ body { display: flex; flex-direction: column; } background: #3ecf5a; } +/* see .ai/contexts/session-cache.md ("Remote hosts — activity pip") */ +.remote-activity-dot { + background: transparent; + margin-left: -4px; +} + +.remote-activity-dot.active { + background: #b388ff; + animation: remote-activity-pulse 1s ease-in-out infinite; +} + +@keyframes remote-activity-pulse { + 0%, 100% { opacity: 0.45; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1.2); } +} + /* ---- CLI busy spinner (braille spinner detected) ---- */ /* needs-attention takes precedence — when both are set, the attention indicator shows */ /* Braille spinner via content keyframes — see docs/decisions/0002 */ diff --git a/remote-activity.js b/remote-activity.js new file mode 100644 index 00000000..da25b5bf --- /dev/null +++ b/remote-activity.js @@ -0,0 +1,54 @@ +// see .ai/contexts/session-cache.md ("Remote hosts — activity pip") +'use strict'; + +const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DEFAULT_DECAY_MS = 20000; +const DEFAULT_IPC_MIN_MS = 1000; + +function sessionIdFromRel(rel) { + const base = (typeof rel === 'string' ? rel : '').split('/').pop() || ''; + const sessionId = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base; + return SESSION_ID_RE.test(sessionId) ? sessionId : null; +} + +function createRemoteActivityTracker(opts = {}) { + const decayMs = opts.decayMs || DEFAULT_DECAY_MS; + const ipcMinMs = opts.ipcMinMs || DEFAULT_IPC_MIN_MS; + const now = opts.now || Date.now; + + const seenAt = new Map(); + const ipcAt = new Map(); + + function key(alias, sessionId) { + return alias + ' ' + sessionId; + } + + function prune(t) { + for (const [k, at] of seenAt) { + if (t - at > decayMs) seenAt.delete(k); + } + } + + function record(alias, rel) { + const sessionId = sessionIdFromRel(rel); + if (!sessionId) return null; + const t = now(); + const k = key(alias, sessionId); + seenAt.set(k, t); + prune(t); + const lastIpc = ipcAt.has(k) ? ipcAt.get(k) : -Infinity; + if (t - lastIpc < ipcMinMs) return null; + ipcAt.set(k, t); + return { alias, sessionId, at: t }; + } + + function activeAt(alias, sessionId) { + prune(now()); + const k = key(alias, sessionId); + return seenAt.has(k) ? seenAt.get(k) : null; + } + + return { record, activeAt }; +} + +module.exports = { createRemoteActivityTracker, sessionIdFromRel, SESSION_ID_RE }; diff --git a/remote-watch.js b/remote-watch.js index 72285f19..e0b6252c 100644 --- a/remote-watch.js +++ b/remote-watch.js @@ -86,6 +86,7 @@ function createRemoteWatcher(opts = {}) { } const parsed = parseWatchLine(line); if (!parsed) return; + if (parsed.kind === 'project' && s.onActivity) s.onActivity(s.alias, parsed.rel); emitCoalesced(s, parsed.kind); } @@ -140,7 +141,7 @@ function createRemoteWatcher(opts = {}) { if (!s) { s = { alias, child: null, buf: '', stopped: true, unwatchable: false, - failures: 0, spawnedAt: 0, restartTimer: null, onEvent: null, + failures: 0, spawnedAt: 0, restartTimer: null, onEvent: null, onActivity: null, cooldown: { project: false, session: false }, pending: { project: false, session: false }, }; @@ -149,7 +150,7 @@ function createRemoteWatcher(opts = {}) { return s; } - function start(alias, onEvent) { + function start(alias, onEvent, onActivity) { if (typeof alias !== 'string' || !alias || typeof onEvent !== 'function') return; const s = getState(alias); if (!s.stopped && !s.unwatchable) return; @@ -157,6 +158,7 @@ function createRemoteWatcher(opts = {}) { s.unwatchable = false; s.failures = 0; s.onEvent = onEvent; + s.onActivity = typeof onActivity === 'function' ? onActivity : null; spawnChild(s); } diff --git a/test/dom-sidebar-remote-activity-pip.test.js b/test/dom-sidebar-remote-activity-pip.test.js new file mode 100644 index 00000000..bd5fe13e --- /dev/null +++ b/test/dom-sidebar-remote-activity-pip.test.js @@ -0,0 +1,82 @@ +// Issue #242: a rebuilt sidebar (or a fresh launch) must paint the remote +// activity pip from session.remoteActiveAt without waiting for the next +// live remote-activity IPC message — see .ai/contexts/session-cache.md +// ("Remote hosts — activity pip"). The live-update path itself is covered +// by test/remote-activity-ui.test.js. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function remoteProject(session) { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + sessions: [session], + }); +} + +test('a session active within the decay window paints the pip lit on first render', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'remote-active', summary: 'live now', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', remoteActiveAt: Date.now() - 5000, + }; + ctx.sidebar.renderProjects([remoteProject(session)], true); + + const dot = ctx.document.querySelector('#si-remote-active .remote-activity-dot'); + assert.ok(dot, 'a remote session must carry the activity pip element'); + assert.ok(dot.classList.contains('active'), 'a sighting 5s ago is still inside the 20s decay window'); + } finally { ctx.destroy(); } +}); + +test('a session last active past the decay window renders the pip off', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'remote-stale', summary: 'quiet now', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', remoteActiveAt: Date.now() - 60000, + }; + ctx.sidebar.renderProjects([remoteProject(session)], true); + + const dot = ctx.document.querySelector('#si-remote-stale .remote-activity-dot'); + assert.ok(dot); + assert.ok(!dot.classList.contains('active'), 'a sighting a minute ago is well past the 20s decay window'); + } finally { ctx.destroy(); } +}); + +test('a session with no remoteActiveAt at all renders the pip off, not crashing on undefined', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'remote-never', summary: 'never seen writing', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', + }; + ctx.sidebar.renderProjects([remoteProject(session)], true); + + const dot = ctx.document.querySelector('#si-remote-never .remote-activity-dot'); + assert.ok(dot); + assert.ok(!dot.classList.contains('active')); + } finally { ctx.destroy(); } +}); + +test('a local session carries no activity pip at all', () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject({ + sessions: [{ + sessionId: 'local-1', summary: 'local work', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 2, + }], + }); + ctx.sidebar.renderProjects([project], true); + + assert.equal(ctx.document.querySelector('#si-local-1 .remote-activity-dot'), null); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-activity-ui.test.js b/test/remote-activity-ui.test.js new file mode 100644 index 00000000..b0ea4f15 --- /dev/null +++ b/test/remote-activity-ui.test.js @@ -0,0 +1,140 @@ +// Tests for public/remote-activity-ui.js — the sidebar's remote +// transcript-write pip. Evaluated standalone in jsdom, same technique as +// test/session-activity.test.js: this file was split out precisely so it can +// be exercised without the rest of app.js (which builds ViewerPanel/xterm at +// module scope and cannot be eval'd in isolation). +// +// setTimeout/clearTimeout are stubbed on the jsdom window so the 20s decay +// is driven by hand instead of a real wall-clock wait. + +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const SRC = path.join(__dirname, '..', 'public', 'remote-activity-ui.js'); + +function setup(sessionIds = ['s1']) { + const items = sessionIds + .map(id => `
`) + .join(''); + const dom = new JSDOM(`${items}`, + { url: 'http://localhost/', runScripts: 'outside-only' }); + const { window } = dom; + + let onRemoteActivityCb = null; + Object.defineProperty(window, 'api', { + value: { onRemoteActivity: (cb) => { onRemoteActivityCb = cb; } }, + writable: true, configurable: true, + }); + + const scheduled = []; + let nextId = 1; + Object.defineProperty(window, 'setTimeout', { + value: (fn, ms) => { + const handle = { id: nextId++, fn, ms, cleared: false }; + scheduled.push(handle); + return handle.id; + }, + writable: true, configurable: true, + }); + Object.defineProperty(window, 'clearTimeout', { + value: (id) => { + const h = scheduled.find(s => s.id === id); + if (h) h.cleared = true; + }, + writable: true, configurable: true, + }); + + const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: SRC }); + + const read = (expr) => vm.runInContext(expr, ctx); + + return { + window, + document: window.document, + item: (id) => window.document.querySelector(`.session-item[data-session-id="${id}"]`), + dot: (id) => window.document.querySelector(`.session-item[data-session-id="${id}"] .remote-activity-dot`), + emit: (payload) => onRemoteActivityCb(payload), + scheduled, + pending: () => scheduled.filter(h => !h.cleared), + pruneRemoteActivityTimers: read('pruneRemoteActivityTimers'), + remoteActivityDecayTimers: read('remoteActivityDecayTimers'), + destroy: () => window.close(), + }; +} + +test('a remote-activity event marks the matching row active', () => { + const t = setup(['s1']); + assert.ok(!t.dot('s1').classList.contains('active'), 'precondition: pip starts off'); + + t.emit({ alias: 'vps', sessionId: 's1', at: Date.now() }); + + assert.ok(t.dot('s1').classList.contains('active'), 'the pip must light up on activity'); + t.destroy(); +}); + +test('a payload with no sessionId is ignored', () => { + const t = setup(['s1']); + t.emit({ alias: 'vps' }); + t.emit(null); + t.emit(undefined); + + assert.ok(!t.dot('s1').classList.contains('active')); + assert.equal(t.scheduled.length, 0, 'a malformed payload must not schedule a decay timer either'); + t.destroy(); +}); + +test('the pip clears once the decay timer fires, and not before', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1' }); + assert.ok(t.dot('s1').classList.contains('active')); + + const timer = t.pending()[0]; + assert.ok(timer, 'a decay timer must be scheduled'); + assert.equal(timer.ms, 20000, 'the decay window is 20s'); + + // Before the timer fires, the pip stays lit. + assert.ok(t.dot('s1').classList.contains('active')); + + timer.fn(); // simulate the 20s elapsing + assert.ok(!t.dot('s1').classList.contains('active'), 'the pip must clear once the decay window elapses'); + t.destroy(); +}); + +test('a second event before decay resets the timer instead of stacking one', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1' }); + const first = t.pending()[0]; + + t.emit({ sessionId: 's1' }); + + assert.equal(first.cleared, true, 'the earlier timer must be cancelled, not left to also fire'); + assert.equal(t.pending().length, 1, 'exactly one live decay timer per session'); + + // Firing the (now-cancelled) first timer's callback would be a stale fire + // in the real world — clearTimeout prevents that from ever happening — but + // confirm the surviving timer is the one that actually clears the pip. + const second = t.pending()[0]; + second.fn(); + assert.ok(!t.dot('s1').classList.contains('active')); + t.destroy(); +}); + +test('pruneRemoteActivityTimers cancels a timer whose row no longer exists', () => { + const t = setup(['s1', 's2']); + t.emit({ sessionId: 's1' }); + t.emit({ sessionId: 's2' }); + assert.equal(t.pending().length, 2); + + t.item('s2').remove(); // e.g. the session was archived out of the sidebar + t.pruneRemoteActivityTimers(); + + assert.equal(t.remoteActivityDecayTimers.has('s2'), false, 'the orphaned timer entry must be dropped'); + assert.equal(t.remoteActivityDecayTimers.has('s1'), true, 'a timer for a row that still exists must survive the sweep'); + t.destroy(); +}); diff --git a/test/remote-activity.test.js b/test/remote-activity.test.js new file mode 100644 index 00000000..1c625bd6 --- /dev/null +++ b/test/remote-activity.test.js @@ -0,0 +1,80 @@ +'use strict'; + +// remote-activity.js: the uncoalesced transcript-write signal that feeds the +// sidebar's activity pip. Properties proven here (issue #242, see +// .ai/contexts/session-cache.md, "Remote hosts — activity pip"): +// 1. A rel that doesn't decode to a plausible session id is dropped. +// 2. A first sighting of a session is always forwarded. +// 3. A second sighting inside the throttle window is swallowed, not forwarded. +// 4. Once the throttle window has passed, the next sighting is forwarded again. +// 5. activeAt reports null once a session has been silent past the decay window. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createRemoteActivityTracker, sessionIdFromRel } = require('../remote-activity'); +const { REMOTE_PROJECTS_REL } = require('../remote-transport'); + +const UUID = '11111111-1111-4111-8111-111111111111'; + +function clock(startAt = 1000) { + let t = startAt; + return { now: () => t, advance: (ms) => { t += ms; } }; +} + +test('sessionIdFromRel accepts a bare session transcript, rejects a subagent leg', () => { + assert.equal(sessionIdFromRel(`-srv-a/${UUID}.jsonl`), UUID); + assert.equal(sessionIdFromRel(`-srv-a/${UUID}/subagents/agent-7.jsonl`), null, + 'the basename here is "agent-7", not a session id — must not be guessed at'); + assert.equal(sessionIdFromRel('-srv-a/not-a-uuid.jsonl'), null); + assert.equal(sessionIdFromRel(''), null); + assert.equal(sessionIdFromRel(undefined), null); +}); + +test('record() drops a malformed rel instead of forwarding a guess', () => { + const tracker = createRemoteActivityTracker({ now: clock().now }); + assert.equal(tracker.record('vps', `${REMOTE_PROJECTS_REL}/-srv-a/agent-7.jsonl`), null); + assert.equal(tracker.activeAt('vps', 'agent-7'), null, 'nothing must have been recorded for the rejected id'); +}); + +test('record() forwards the first sighting of a session', () => { + const c = clock(5000); + const tracker = createRemoteActivityTracker({ now: c.now }); + const result = tracker.record('vps', `-srv-a/${UUID}.jsonl`); + assert.deepEqual(result, { alias: 'vps', sessionId: UUID, at: 5000 }); +}); + +test('record() throttles a second sighting inside the 1s window, but keeps activeAt fresh', () => { + const c = clock(0); + const tracker = createRemoteActivityTracker({ now: c.now, ipcMinMs: 1000 }); + const rel = `-srv-a/${UUID}.jsonl`; + + assert.ok(tracker.record('vps', rel), 'first sighting always forwards'); + c.advance(400); + assert.equal(tracker.record('vps', rel), null, 'a sighting inside the throttle window must not forward again'); + // The suppressed sighting still counts as activity for the decay clock. + assert.equal(tracker.activeAt('vps', UUID), 400); + + c.advance(700); // total 1100ms since the first forward + const third = tracker.record('vps', rel); + assert.ok(third, 'once ipcMinMs has elapsed since the last forward, the next sighting forwards again'); + assert.equal(third.at, 1100); +}); + +test('activeAt reports null once a session has gone silent past the decay window', () => { + const c = clock(0); + const tracker = createRemoteActivityTracker({ now: c.now, decayMs: 20000 }); + tracker.record('vps', `-srv-a/${UUID}.jsonl`); + + c.advance(19999); + assert.equal(tracker.activeAt('vps', UUID), 0, 'still inside the decay window'); + + c.advance(2); // 20001ms since the sighting + assert.equal(tracker.activeAt('vps', UUID), null, 'past the decay window, the pip must clear'); +}); + +test('two hosts never share activity state for the same session id', () => { + const tracker = createRemoteActivityTracker({ now: clock(0).now }); + tracker.record('vps-a', `-srv-a/${UUID}.jsonl`); + assert.equal(tracker.activeAt('vps-b', UUID), null, 'alias must be part of the key, not just the session id'); +}); diff --git a/test/remote-watch.test.js b/test/remote-watch.test.js index 90ac7863..5317ad00 100644 --- a/test/remote-watch.test.js +++ b/test/remote-watch.test.js @@ -128,6 +128,54 @@ test('a burst of same-kind events collapses to far fewer callbacks than events', assert.ok(events.every(e => e.kind === 'project')); }); +test('onActivity fires once per project event, uncoalesced — not collapsed like onEvent', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const events = []; + const activity = []; + + watcher.start('vps', (alias, kind) => events.push({ alias, kind }), (alias, rel) => activity.push({ alias, rel })); + const { child } = spawn.calls[0]; + const rel = `${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl`; + for (let i = 0; i < 19; i++) child.stdout.push(`P|${rel}\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(activity.length, 19, 'onActivity must fire once per raw line, unlike the coalesced onEvent'); + assert.ok(events.length <= 2, 'onEvent must still coalesce the same burst'); + assert.ok(activity.every(a => a.alias === 'vps' && a.rel === '-srv-a/session.jsonl')); +}); + +test('a session-kind event never reaches onActivity', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const activity = []; + + watcher.start('vps', () => {}, (alias, rel) => activity.push({ alias, rel })); + const { child } = spawn.calls[0]; + child.stdout.push(`S|${REMOTE_SESSIONS_REL}/1234.json\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(activity, [], 'a session descriptor event must not be mistaken for transcript activity'); +}); + +test('start() works with no onActivity supplied — the callback is optional', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + + watcher.start('vps', () => {}); + const { child } = spawn.calls[0]; + child.stdout.push(`P|${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(watcher.isRunning('vps'), true, 'a project event with no onActivity wired must not throw'); +}); + test('project and session events are distinguishable in the callback', async () => { const spawn = spawnRecorder(); const timers = fakeTimers(); From bc5e329c4c698946079f9c4efd20d7821c0ceac1 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 10 Sep 2026 02:23:35 +0200 Subject: [PATCH 2/2] fix(remote): prune the activity throttle map, not only the sightings The per-session IPC throttle kept one entry per session it had ever seen, for the life of the process, while the sightings map was pruned. Both now age out of the same decay window. --- remote-activity.js | 9 ++++++++- test/remote-activity.test.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/remote-activity.js b/remote-activity.js index da25b5bf..22cb5a6b 100644 --- a/remote-activity.js +++ b/remote-activity.js @@ -27,6 +27,9 @@ function createRemoteActivityTracker(opts = {}) { for (const [k, at] of seenAt) { if (t - at > decayMs) seenAt.delete(k); } + for (const [k, at] of ipcAt) { + if (t - at > decayMs) ipcAt.delete(k); + } } function record(alias, rel) { @@ -48,7 +51,11 @@ function createRemoteActivityTracker(opts = {}) { return seenAt.has(k) ? seenAt.get(k) : null; } - return { record, activeAt }; + function stats() { + return { seen: seenAt.size, ipc: ipcAt.size }; + } + + return { record, activeAt, stats }; } module.exports = { createRemoteActivityTracker, sessionIdFromRel, SESSION_ID_RE }; diff --git a/test/remote-activity.test.js b/test/remote-activity.test.js index 1c625bd6..a353b8bd 100644 --- a/test/remote-activity.test.js +++ b/test/remote-activity.test.js @@ -78,3 +78,21 @@ test('two hosts never share activity state for the same session id', () => { tracker.record('vps-a', `-srv-a/${UUID}.jsonl`); assert.equal(tracker.activeAt('vps-b', UUID), null, 'alias must be part of the key, not just the session id'); }); + +test('neither map grows without bound: both are pruned past the decay window', () => { + let t = 1000; + const tracker = createRemoteActivityTracker({ now: () => t, decayMs: 20000, ipcMinMs: 1000 }); + for (let i = 0; i < 50; i++) { + tracker.record('vps', `-srv-a/${String(i).padStart(8, '0')}-1111-2222-3333-444444444444.jsonl`); + t += 10; + } + const filled = tracker.stats(); + assert.equal(filled.seen, 50); + assert.equal(filled.ipc, 50, 'every distinct session took an IPC slot'); + + t += 30000; + tracker.record('vps', '-srv-a/99999999-1111-2222-3333-444444444444.jsonl'); + const pruned = tracker.stats(); + assert.equal(pruned.seen, 1, 'stale sightings must be dropped'); + assert.equal(pruned.ipc, 1, 'stale throttle entries must be dropped too, or the map leaks one entry per session seen'); +});