plugin: benign-reconnect overflow-notice fix (maxAssignedSeq gate), null-id reconnect timer clear; rsync: mkdir before early exit

This commit is contained in:
Raphael Westphal
2026-08-18 19:04:05 +02:00
parent 6aac763563
commit 95d2b5da4b
2 changed files with 36 additions and 14 deletions
+4 -2
View File
@@ -6,12 +6,14 @@ set -euo pipefail
SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}" SRC="${LVMH_PI_AGENT_DIR:-$HOME/.dotfiles/pi/agent}"
DEST="$(dirname "$0")/../docker/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 if [ ! -f "$SRC/settings.json" ]; then
echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2 echo "rsync-pi-agent: $SRC/settings.json not found — skipping dotfiles sync" >&2
exit 0 exit 0
fi fi
mkdir -p "$DEST"
rsync -a --delete \ rsync -a --delete \
--exclude 'auth.json' \ --exclude 'auth.json' \
--exclude 'models.json' \ --exclude 'models.json' \
+28 -8
View File
@@ -93,6 +93,7 @@ interface SessionSnapshot {
/** Per-session seq counters survive reconnects and instance rebinds in-process. */ /** Per-session seq counters survive reconnects and instance rebinds in-process. */
const seqCounters: Map<string, number> = new Map(); const seqCounters: Map<string, number> = new Map();
const maxAssignedSeq: Map<string, number> = new Map();
const sessionStartTs: Map<string, number> = new Map(); const sessionStartTs: Map<string, number> = new Map();
const logPath: string = path.join(os.homedir(), ".pi", "lvmh-agent.log"); 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 { function nextSeq(sessionId: string): number {
const n: number = (seqCounters.get(sessionId) ?? 0) + 1; const n: number = (seqCounters.get(sessionId) ?? 0) + 1;
seqCounters.set(sessionId, n); 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; return n;
} }
@@ -487,19 +493,27 @@ export default function (pi: ExtensionAPI): void {
const counter: number = seqCounters.get(currentSessionId) ?? 0; const counter: number = seqCounters.get(currentSessionId) ?? 0;
if (counter <= lastSeq) seqCounters.set(currentSessionId, lastSeq); if (counter <= lastSeq) seqCounters.set(currentSessionId, lastSeq);
} }
// Events already covered by the daemon's lastSeq are silently absent // Gap accounting: only a daemon lastSeq ABOVE anything this process ever
// from both replay and queue below — count them so the buffer_overflow // assigned indicates real gaps (plugin restart against a daemon that has
// notice reflects every gap, not just cap drops. // history). lastSeq <= maxAssigned means the daemon acked frames we sent
const covered: number = // ourselves — benign reconnect, no notice. replayBuf alone holds every
replayBuf.filter((f) => f.seq <= lastSeq).length + // persisted-kind frame (sendQueue copies are a subset), so counting it
sendQueue.filter((f) => !TRANSIENT_TYPES.has(f.type) && f.seq <= lastSeq) // once cannot double-count.
.length; 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) { if (covered > 0) {
droppedEvents += covered; droppedEvents += covered;
log( log(
`welcome lastSeq=${lastSeq} covers ${covered} in-flight events; flagging`, `welcome lastSeq=${lastSeq} > maxAssigned=${maxAssigned}: ${covered} replayed frames never acknowledged; flagging`,
); );
} }
}
// Replay persisted events the daemon is missing, before queued live // Replay persisted events the daemon is missing, before queued live
// frames. Persisted-kind frames still sitting in the queue are dropped // frames. Persisted-kind frames still sitting in the queue are dropped
// here: they are already in replayBuf, so sending both would duplicate // here: they are already in replayBuf, so sending both would duplicate
@@ -637,6 +651,12 @@ export default function (pi: ExtensionAPI): void {
// attributing this session's events to the previous session id. // attributing this session's events to the previous session id.
currentSessionId = null; currentSessionId = null;
handleDisconnect(); handleDisconnect();
try {
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
} catch {
// clearable timers never throw in practice
}
reconnectTimer = null;
return; return;
} }
const assignedId = process.env[ENV_SESSION_ID]; const assignedId = process.env[ENV_SESSION_ID];