@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ // Round-2/4 review ③ — stale session sweeper tests.
2
+ //
3
+ // Drives the real sweepStaleSessions against isolated in-process
4
+ // SQLite (COMMHUB_DB=/tmp/...). Pins behaviour the read-path
5
+ // regression (per-request UPDATE) would re-introduce if anyone
6
+ // undoes the fix:
7
+ //
8
+ // 1. fresh sessions stay non-offline regardless of how many
9
+ // times the sweeper runs;
10
+ // 2. stale sessions get marked offline exactly once (idempotent);
11
+ // 3. global sweep crosses tenant boundaries — a stale agent in
12
+ // network B is marked offline even when no one in net B is
13
+ // polling /api/status;
14
+ // 4. env-var cutoff is respected and bad values fall back silently.
15
+ //
16
+ // Run with:
17
+ // COMMHUB_DB=/tmp/stale-test.db bun test src/stale-sweeper.test.ts
18
+
19
+ import { describe, expect, test, beforeEach } from "bun:test";
20
+ import { db } from "./db.js";
21
+ import { sweepStaleSessions, staleCutoffMinutes } from "./stale-sweeper.js";
22
+
23
+ function insertSession(opts: {
24
+ alias: string;
25
+ status?: string;
26
+ minutesAgo: number;
27
+ network_id?: string;
28
+ }): void {
29
+ const id = `r_${opts.alias}`;
30
+ db.run(
31
+ `INSERT OR REPLACE INTO sessions
32
+ (resume_id, alias, status, network_id, updated_at, registered_at)
33
+ VALUES (?1, ?2, ?3, ?4, datetime('now', ?5), datetime('now', ?5))`,
34
+ [id, opts.alias, opts.status ?? "idle", opts.network_id ?? "default", `-${opts.minutesAgo} minutes`]
35
+ );
36
+ }
37
+
38
+ function getStatus(alias: string): string | null {
39
+ const row = db.get<{ status: string }>(
40
+ "SELECT status FROM sessions WHERE alias = ?1",
41
+ alias
42
+ );
43
+ return row?.status ?? null;
44
+ }
45
+
46
+ beforeEach(() => {
47
+ db.run("DELETE FROM sessions");
48
+ });
49
+
50
+ describe("sweepStaleSessions — fresh agents not touched", () => {
51
+ test("agent updated < cutoff stays at its current status", () => {
52
+ insertSession({ alias: "live-1", status: "idle", minutesAgo: 1 });
53
+ insertSession({ alias: "live-2", status: "working", minutesAgo: 5 });
54
+
55
+ sweepStaleSessions(10);
56
+
57
+ expect(getStatus("live-1")).toBe("idle");
58
+ expect(getStatus("live-2")).toBe("working");
59
+ });
60
+
61
+ test("multiple sweeps on quiet hub are pure no-ops (no write fired)", () => {
62
+ insertSession({ alias: "live", status: "idle", minutesAgo: 1 });
63
+
64
+ const r1 = sweepStaleSessions(10);
65
+ const r2 = sweepStaleSessions(10);
66
+ const r3 = sweepStaleSessions(10);
67
+
68
+ expect(r1.markedOffline).toBe(0);
69
+ expect(r2.markedOffline).toBe(0);
70
+ expect(r3.markedOffline).toBe(0);
71
+ expect(getStatus("live")).toBe("idle");
72
+ });
73
+ });
74
+
75
+ describe("sweepStaleSessions — stale agents marked offline", () => {
76
+ test("agent updated > cutoff gets marked offline exactly once", () => {
77
+ insertSession({ alias: "stale", status: "idle", minutesAgo: 30 });
78
+
79
+ const r1 = sweepStaleSessions(10);
80
+ expect(r1.markedOffline).toBe(1);
81
+ expect(getStatus("stale")).toBe("offline");
82
+
83
+ // Second sweep does nothing because the row is already offline
84
+ // (the predicate is `status != 'offline'`).
85
+ const r2 = sweepStaleSessions(10);
86
+ expect(r2.markedOffline).toBe(0);
87
+ expect(getStatus("stale")).toBe("offline");
88
+ });
89
+
90
+ test("already-offline stale row not re-marked (write avoided)", () => {
91
+ insertSession({ alias: "old-offline", status: "offline", minutesAgo: 60 });
92
+
93
+ const r = sweepStaleSessions(10);
94
+
95
+ expect(r.markedOffline).toBe(0);
96
+ expect(getStatus("old-offline")).toBe("offline");
97
+ });
98
+ });
99
+
100
+ describe("sweepStaleSessions — global across tenants (the read-path fix)", () => {
101
+ test("stale row in network B is marked offline even with no net-B polling", () => {
102
+ // Pre-fix: GET /api/status from net-A would only UPDATE rows in
103
+ // net-A's scope. A stale agent in net-B would stay non-offline
104
+ // until someone polled net-B. After the fix, the global sweeper
105
+ // is tenant-blind on the maintenance write.
106
+ insertSession({ alias: "stale-a", status: "idle", minutesAgo: 30, network_id: "net-A" });
107
+ insertSession({ alias: "stale-b", status: "idle", minutesAgo: 30, network_id: "net-B" });
108
+ insertSession({ alias: "stale-c", status: "idle", minutesAgo: 30, network_id: null as any });
109
+
110
+ const r = sweepStaleSessions(10);
111
+
112
+ expect(r.markedOffline).toBe(3);
113
+ expect(getStatus("stale-a")).toBe("offline");
114
+ expect(getStatus("stale-b")).toBe("offline");
115
+ expect(getStatus("stale-c")).toBe("offline");
116
+ });
117
+ });
118
+
119
+ describe("sweepStaleSessions — cutoff boundary + env", () => {
120
+ test("exactly-at-cutoff agent treated as stale (< strict, not <=)", () => {
121
+ // Just past the cutoff window.
122
+ insertSession({ alias: "just-stale", status: "idle", minutesAgo: 11 });
123
+ sweepStaleSessions(10);
124
+ expect(getStatus("just-stale")).toBe("offline");
125
+ });
126
+
127
+ test("just-under-cutoff agent treated as live", () => {
128
+ insertSession({ alias: "just-live", status: "working", minutesAgo: 9 });
129
+ sweepStaleSessions(10);
130
+ expect(getStatus("just-live")).toBe("working");
131
+ });
132
+
133
+ test("env COMMHUB_STALE_CUTOFF_MINUTES overrides default", () => {
134
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "3";
135
+ expect(staleCutoffMinutes()).toBe(3);
136
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
137
+ });
138
+
139
+ test("env invalid value falls back to default 10 (no operator footgun)", () => {
140
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "not-a-number";
141
+ expect(staleCutoffMinutes()).toBe(10);
142
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
143
+
144
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "0";
145
+ expect(staleCutoffMinutes()).toBe(10);
146
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
147
+
148
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "-5";
149
+ expect(staleCutoffMinutes()).toBe(10);
150
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
151
+ });
152
+ });
153
+
154
+ describe("sweepStaleSessions — read-path regression guard (pin the fix)", () => {
155
+ // This is the load-bearing pin: if anyone adds back a per-request
156
+ // UPDATE in /api/status / /api/servers / /api/server-detail-* /
157
+ // get_all_status, this test plus the absence of those UPDATEs in
158
+ // the source should make the regression visible. Since this is a
159
+ // unit-level test of the sweeper itself, the regression-prevention
160
+ // role is mostly social — the comments in this file + the comments
161
+ // in index.ts and tools.ts at the former UPDATE sites tell future
162
+ // editors "do not put the UPDATE back; use the sweeper."
163
+ test("after sweep, sessions row has the offline status persisted (single source of truth)", () => {
164
+ insertSession({ alias: "x", status: "working", minutesAgo: 30 });
165
+ const before = sweepStaleSessions(10);
166
+ expect(before.markedOffline).toBe(1);
167
+
168
+ // Re-read directly — no extra UPDATE needed by a caller.
169
+ const status = getStatus("x");
170
+ expect(status).toBe("offline");
171
+
172
+ // 100 simulated read-path lookups don't fire any maintenance writes.
173
+ for (let i = 0; i < 100; i++) {
174
+ const row = db.get<{ status: string }>(
175
+ "SELECT status FROM sessions WHERE alias = ?1",
176
+ "x"
177
+ );
178
+ expect(row?.status).toBe("offline");
179
+ }
180
+ });
181
+ });
@@ -0,0 +1,114 @@
1
+ // Round-2/4 review ③ — read-path write amplification fix.
2
+ //
3
+ // Before this module: GET /api/status, GET /api/servers, GET
4
+ // /api/server-health/:host, and the MCP `get_all_status` tool each
5
+ // fired:
6
+ //
7
+ // UPDATE sessions SET status = 'offline'
8
+ // WHERE updated_at < ?1 AND status != 'offline'
9
+ //
10
+ // on every call. With the dashboard polling /api/status every few
11
+ // seconds × N tabs × M users, that's a high-frequency WRITE on a
12
+ // heavily-read table just to maintain a derived field. Wait events
13
+ // stack up (WAL fsync, page locks) and the writes are 99% no-ops.
14
+ //
15
+ // Fix: move the stale-marking off the read path into a single
16
+ // background timer. Reads now SELECT only; the timer runs every 60s
17
+ // and applies the same UPDATE GLOBALLY (no per-network scope — stale
18
+ // is stale regardless of which tenant is asking). Worst-case delay
19
+ // between an agent crashing and going `offline` in the dashboard:
20
+ //
21
+ // 10min stale cutoff + 60s sweep = up to ~11min
22
+ //
23
+ // (unchanged from before the fix for any reader that wasn't actively
24
+ // polling).
25
+ //
26
+ // Same pattern as `src/retention.ts` for shutdown (clearInterval in
27
+ // the graceful shutdown handler).
28
+
29
+ import { db } from "./db.js";
30
+
31
+ export type StaleSweepResult = {
32
+ startedAt: string;
33
+ durationMs: number;
34
+ markedOffline: number;
35
+ staleCutoffMinutes: number;
36
+ };
37
+
38
+ // Stale window: an agent that hasn't reported in this many minutes is
39
+ // considered offline. Matches the pre-fix per-request cutoff so the
40
+ // observable behaviour from the dashboard's perspective is unchanged.
41
+ const DEFAULT_STALE_MINUTES = 10;
42
+ const DEFAULT_SWEEP_MS = 60 * 1000;
43
+
44
+ export function staleCutoffMinutes(): number {
45
+ const raw = process.env.COMMHUB_STALE_CUTOFF_MINUTES;
46
+ if (raw === undefined || raw === "") return DEFAULT_STALE_MINUTES;
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_STALE_MINUTES;
49
+ return n;
50
+ }
51
+
52
+ function sqliteCutoff(minutes: number): string {
53
+ return new Date(Date.now() - minutes * 60 * 1000)
54
+ .toISOString()
55
+ .replace("T", " ")
56
+ .slice(0, 19);
57
+ }
58
+
59
+ // Single GLOBAL UPDATE — no per-network filtering. The pre-fix per-
60
+ // request stale marker WAS scoped (each REST call only saw its own
61
+ // network's stale rows), but the semantics of "this row hasn't been
62
+ // heard from in 10min" is global. A network that no one's looking at
63
+ // still wants its agents marked offline so an admin scanning all
64
+ // networks gets consistent state.
65
+ export function sweepStaleSessions(cutoffMinutes = staleCutoffMinutes()): StaleSweepResult {
66
+ const startedAt = new Date().toISOString();
67
+ const t0 = Date.now();
68
+ let markedOffline = 0;
69
+ try {
70
+ const cutoff = sqliteCutoff(cutoffMinutes);
71
+ const res = db.run(
72
+ "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'",
73
+ [cutoff],
74
+ );
75
+ markedOffline = res.changes ?? 0;
76
+ } catch (e: any) {
77
+ console.log(`[commhub stale-sweep] failed: ${e?.message ?? e}`);
78
+ }
79
+ return {
80
+ startedAt,
81
+ durationMs: Date.now() - t0,
82
+ markedOffline,
83
+ staleCutoffMinutes: cutoffMinutes,
84
+ };
85
+ }
86
+
87
+ export function startStaleSessionSweeper(
88
+ intervalMs?: number,
89
+ ): ReturnType<typeof setInterval> {
90
+ const env = process.env.COMMHUB_STALE_SWEEP_SECONDS;
91
+ const envMs = env && Number.isFinite(Number(env)) && Number(env) > 0
92
+ ? Number(env) * 1000
93
+ : undefined;
94
+ const ms = intervalMs ?? envMs ?? DEFAULT_SWEEP_MS;
95
+ const sweep = () => {
96
+ try {
97
+ const r = sweepStaleSessions();
98
+ // Log only when something actually changed — quiet hubs stay quiet.
99
+ if (r.markedOffline > 0) {
100
+ console.log(
101
+ `[commhub stale-sweep] marked ${r.markedOffline} session(s) offline in ${r.durationMs}ms ` +
102
+ `(cutoff=${r.staleCutoffMinutes}min)`,
103
+ );
104
+ }
105
+ } catch (e: any) {
106
+ console.log(`[commhub stale-sweep] top-level failed: ${e?.message ?? e}`);
107
+ }
108
+ };
109
+ // Fire once at boot via setImmediate so the very-first /api/status
110
+ // after server start gets fresh stale state without blocking
111
+ // startup.
112
+ setImmediate(sweep);
113
+ return setInterval(sweep, ms);
114
+ }