Files
AgentHub/server/lib/session-liveness.js
T
nntrivi2001 ca821008e2 perf: v2.5.10 — ready for 10+ concurrent users, leaner locally
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated
browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max
2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server
RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli).

Server
- /api/analytics: the events-table scans run in the read worker
  (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes
  maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args
- /api/workflows/skill-usage: +event_type and a tool_name range instead of
  LIKE, so it uses idx_events_tool (784 ms → a few ms)
- idx_events_session_type_tool (session_id, event_type, tool_name,
  created_at) replaces idx_events_session_type: covering reads for session
  stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB
  cache_size and 256 MB mmap_size on both connections
- WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every
  DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable
  connection; a failed checkpoint restores the automatic one (hook COMMIT p95
  109 ms → 15 ms at 100 hooks/s)
- tmux: one list-sessions answers a whole GET /api/lanes and the heal beat
  (liveSessions), shared for 1 s and cleared by this process's new/kill —
  GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat
  skips tmux when nothing runs; attach options chained into 2 spawns
- hooks: TurnDuration/APIError deduped against one read per hook instead of a
  query per transcript entry (23 ms/hook on a 763-turn session); one lane
  scan per hook; hourly upserts skip rows that did not grow; frames are sent
  after the transaction commits (and after the pane binding); new_event
  carries its row id; a split pane's (slot 1–9) hooks no longer rebind the
  lane's session_id
- watchdog liveness probe runs ps asynchronously; transcript newline scan via
  indexOf; startup subagent backfill fingerprints the files it already checked
- pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB)
- static: hashed assets served from their pre-compressed .br/.gz sibling

Client
- hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes,
  lane cards, git header, changes panel, settings); no per-tab hourly
  /updates/check (the server already checks)
- refetch storms throttled (workspace tree/session list 3 s, /api/run only on
  a run-key change, notification pane lookup only when shown); WS reconnect
  jitter; terminal retry backoff 1.5 → 30 s, stops on 1008
- heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame;
  GET in-flight dedupe in request()
- lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi
  (only while enabled); only the active locale loads; Workspace chunk
  prefetched on a /workspace deep link
- Sagi: no work while disabled, pupil via CSS variables, memo avatar
- build: .br/.gz siblings, sourcemap hidden; service worker no longer caches
  /assets/ (cache v3 drops the old one)

Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook
exports, so a suite run from a linked worktree's pre-commit can no longer
commit into the real repository or set core.bare.

Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README,
docs/plans/perf/README.md (audit, checklist, results).
2026-10-07 13:24:15 +07:00

148 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Process-liveness probe for Claude Code sessions. Answers "could any
* running `claude` CLI process own this session?" by listing live claude
* processes and their working directories. Used by the hooks watchdog to
* reap sessions whose SessionEnd hook was lost because the dashboard was not
* running when the user quit (e.g. Ctrl+C while the server was down) — the
* only signal that a session ended is that hook, so a missed one previously
* left the session stuck in Waiting until the 3 h stale sweep.
*
* Fail-safe by design: whenever the probe cannot produce a trustworthy
* answer it reports `available: false` and the caller must change nothing.
* That covers Windows (no probe implementation), containers (host processes
* are invisible, so an empty process list would be a lie), missing `ps` /
* `lsof` binaries, and the DASHBOARD_LIVENESS_PROBE=0 escape hatch for
* setups where hooks arrive from another machine.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile, execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const { isInsideContainer } = require("../../scripts/install-hooks");
let execImpl = execFileSync;
/** Test seam — same pattern as tmux.js / desktop-env.js. */
function __setExecImpl(fn) {
execImpl = fn || execFileSync;
}
const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
/**
* True when a `ps` args string is a Claude Code CLI process. Matches the
* bare binary (`claude`, `/usr/local/bin/claude`) and interpreter-launched
* shims (`node /path/to/claude`, `bun /path/to/claude`). The basename must
* be exactly "claude" so lookalikes (claude-mem, Claude.app's `Claude`
* binary, this project's own processes) never match.
*/
function isClaudeCommand(args) {
if (typeof args !== "string") return false;
const tokens = args.trim().split(/\s+/);
if (tokens.length === 0 || !tokens[0]) return false;
if (path.basename(tokens[0]) === "claude") return true;
const interpreter = path.basename(tokens[0]);
if ((interpreter === "node" || interpreter === "bun") && tokens[1]) {
return path.basename(tokens[1]) === "claude";
}
return false;
}
/** True when the probe is explicitly disabled via env. */
function probeDisabledByEnv() {
const raw = (process.env.DASHBOARD_LIVENESS_PROBE || "").trim().toLowerCase();
return raw === "0" || raw === "false" || raw === "no" || raw === "off";
}
/**
* Enumerate the working directories of every live `claude` CLI process.
*
* @returns {{ available: boolean, cwds: Set<string> }} `available: false`
* means "no trustworthy answer — do not act"; an `available: true` result
* with an empty set genuinely means no claude process is running.
*/
function probeLiveCwds() {
if (probeBlocked()) return UNAVAILABLE();
let psOut;
try {
psOut = execImpl("ps", PS_ARGS, PS_OPTS);
} catch {
return UNAVAILABLE();
}
return cwdsFromPs(psOut);
}
/**
* `probeLiveCwds` with `ps` run asynchronously — what the watchdog's 15 s
* beat uses, so the spawn (24–300 ms inside a large server process) never
* blocks the event loop. Same answer shape; resolves, never rejects.
*
* @returns {Promise<{ available: boolean, cwds: Set<string> }>}
*/
function probeLiveCwdsAsync() {
if (probeBlocked()) return Promise.resolve(UNAVAILABLE());
return new Promise((resolve) => {
execFile("ps", PS_ARGS, PS_OPTS, (err, out) => {
try {
resolve(err ? UNAVAILABLE() : cwdsFromPs(out));
} catch {
resolve(UNAVAILABLE());
}
});
});
}
const PS_ARGS = ["-Ao", "pid=,args="];
const PS_OPTS = { encoding: "utf8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 };
function probeBlocked() {
return probeDisabledByEnv() || process.platform === "win32" || isInsideContainer();
}
/** The cwds of the `claude` processes in a `ps -Ao pid=,args=` listing. */
function cwdsFromPs(psOut) {
const pids = [];
for (const line of psOut.split("\n")) {
const m = line.match(/^\s*(\d+)\s+(.*)$/);
if (m && isClaudeCommand(m[2])) pids.push(m[1]);
}
const cwds = new Set();
if (pids.length === 0) return { available: true, cwds };
if (process.platform === "linux") {
// /proc is authoritative and needs no external binary.
for (const pid of pids) {
try {
cwds.add(path.resolve(fs.readlinkSync(`/proc/${pid}/cwd`)));
} catch {
/* process exited between ps and readlink — skip */
}
}
return { available: true, cwds };
}
// macOS (and other BSD-likes): resolve each pid's cwd via lsof. `-Fn`
// machine format emits `p<pid>` / `f cwd` / `n<path>` records.
let lsofOut;
try {
lsofOut = execImpl("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
encoding: "utf8",
timeout: 10_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (err) {
// lsof exits non-zero when SOME of the pids vanished between ps and
// lsof but still prints records for the rest — keep that partial
// output. No stdout at all (binary missing, hard failure) → no answer.
lsofOut = err && typeof err.stdout === "string" && err.stdout ? err.stdout : null;
if (lsofOut === null) return UNAVAILABLE();
}
for (const line of lsofOut.split("\n")) {
if (line.startsWith("n") && line.length > 1) cwds.add(path.resolve(line.slice(1)));
}
return { available: true, cwds };
}
module.exports = { probeLiveCwds, probeLiveCwdsAsync, isClaudeCommand, __setExecImpl };