From 95d2b5da4b3611b1c386cd6b214677ae408dfee2 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 19:04:05 +0200 Subject: [PATCH] plugin: benign-reconnect overflow-notice fix (maxAssignedSeq gate), null-id reconnect timer clear; rsync: mkdir before early exit --- deploy/rsync-pi-agent.sh | 6 ++++-- plugin/lvmh-agent.ts | 44 +++++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/deploy/rsync-pi-agent.sh b/deploy/rsync-pi-agent.sh index 6700f35..d946af0 100755 --- a/deploy/rsync-pi-agent.sh +++ b/deploy/rsync-pi-agent.sh @@ -6,12 +6,14 @@ set -euo pipefail SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}" DEST="$(dirname "$0")/../docker/pi-agent" +# DEST must exist even without dotfiles: the worker Dockerfile COPYs it +# unconditionally (a missing dir would fail the build). +mkdir -p "$DEST" + if [ ! -f "$SRC/settings.json" ]; then echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2 exit 0 fi - -mkdir -p "$DEST" rsync -a --delete \ --exclude 'auth.json' \ --exclude 'models.json' \ diff --git a/plugin/lvmh-agent.ts b/plugin/lvmh-agent.ts index 88bdb0f..7b59167 100644 --- a/plugin/lvmh-agent.ts +++ b/plugin/lvmh-agent.ts @@ -93,6 +93,7 @@ interface SessionSnapshot { /** Per-session seq counters survive reconnects and instance rebinds in-process. */ const seqCounters: Map = new Map(); +const maxAssignedSeq: Map = new Map(); const sessionStartTs: Map = new Map(); const logPath: string = path.join(os.homedir(), ".pi", "lvmh-agent.log"); @@ -110,6 +111,11 @@ function log(message: unknown): void { function nextSeq(sessionId: string): number { const n: number = (seqCounters.get(sessionId) ?? 0) + 1; seqCounters.set(sessionId, n); + // High-water mark of seqs THIS process produced — distinguishes a benign + // reconnect (daemon lastSeq <= maxAssigned: frames were delivered by us) + // from a foreign high-water after a plugin restart (real gaps). + const seen = maxAssignedSeq.get(sessionId) ?? 0; + if (n > seen) maxAssignedSeq.set(sessionId, n); return n; } @@ -487,18 +493,26 @@ export default function (pi: ExtensionAPI): void { const counter: number = seqCounters.get(currentSessionId) ?? 0; if (counter <= lastSeq) seqCounters.set(currentSessionId, lastSeq); } - // Events already covered by the daemon's lastSeq are silently absent - // from both replay and queue below — count them so the buffer_overflow - // notice reflects every gap, not just cap drops. - const covered: number = - replayBuf.filter((f) => f.seq <= lastSeq).length + - sendQueue.filter((f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq) - .length; - if (covered > 0) { - droppedEvents += covered; - log( - `welcome lastSeq=${lastSeq} covers ${covered} in-flight events; flagging`, - ); + // Gap accounting: only a daemon lastSeq ABOVE anything this process ever + // assigned indicates real gaps (plugin restart against a daemon that has + // history). lastSeq <= maxAssigned means the daemon acked frames we sent + // ourselves — benign reconnect, no notice. replayBuf alone holds every + // persisted-kind frame (sendQueue copies are a subset), so counting it + // once cannot double-count. + const maxAssigned: number = + currentSessionId !== null + ? (maxAssignedSeq.get(currentSessionId) ?? 0) + : 0; + if (lastSeq > maxAssigned) { + const covered: number = replayBuf.filter( + (f) => f.seq <= lastSeq, + ).length; + if (covered > 0) { + droppedEvents += covered; + log( + `welcome lastSeq=${lastSeq} > maxAssigned=${maxAssigned}: ${covered} replayed frames never acknowledged; flagging`, + ); + } } // Replay persisted events the daemon is missing, before queued live // frames. Persisted-kind frames still sitting in the queue are dropped @@ -637,6 +651,12 @@ export default function (pi: ExtensionAPI): void { // attributing this session's events to the previous session id. currentSessionId = null; handleDisconnect(); + try { + if (reconnectTimer !== null) clearTimeout(reconnectTimer); + } catch { + // clearable timers never throw in practice + } + reconnectTimer = null; return; } const assignedId = process.env[ENV_SESSION_ID];