diff --git a/packages/agent-cdp/skills/core.md b/packages/agent-cdp/skills/core.md index 79610c9..4e7a84c 100644 --- a/packages/agent-cdp/skills/core.md +++ b/packages/agent-cdp/skills/core.md @@ -177,11 +177,10 @@ agent-cdp memory usage list [--limit N] [--offset N] # list all samples agent-cdp memory usage summary # overall stats agent-cdp memory usage diff --base SAMPLE_ID --compare SAMPLE_ID agent-cdp memory usage trend [--limit N] # usage over time -agent-cdp memory usage leak-signal # heuristic leak indicator +agent-cdp memory usage leak-signal [--since SAMPLE_ID] # heuristic leak indicator for one bounded sample window ``` -Use `memory usage` for quick "is heap growing?" checks. Use `memory snapshot` for -deep object-level analysis. +Use `memory usage` for quick "is heap growing?" checks. Prefer a bounded workflow such as baseline -> action -> GC -> `leak-signal --since SAMPLE_ID` so old samples do not contaminate the result. Use `memory snapshot` for deep object-level analysis. ## JS allocation profiler diff --git a/packages/agent-cdp/src/__tests__/cli.test.ts b/packages/agent-cdp/src/__tests__/cli.test.ts index 2a49148..563471c 100644 --- a/packages/agent-cdp/src/__tests__/cli.test.ts +++ b/packages/agent-cdp/src/__tests__/cli.test.ts @@ -13,6 +13,7 @@ describe("cli", () => { expect(usage()).toContain("network list [--session ID] [--limit N] [--offset N]"); expect(usage()).toContain("memory snapshot capture [--name NAME] [--gc] [--file PATH]"); expect(usage()).toContain("memory usage summary"); + expect(usage()).toContain("memory usage leak-signal [--since SAMPLE_ID]"); expect(usage()).toContain("memory allocation hotspots [--session ID] [--limit N] [--offset N]"); expect(usage()).toContain("memory allocation-timeline summary [--session ID]"); expect(usage()).toContain("profile cpu hotspots [--session ID] [--limit N] [--offset N] [--sort selfMs|totalMs] [--min-self-ms N] [--min-total-ms N] [--include-runtime]"); @@ -167,6 +168,43 @@ describe("cli", () => { logSpy.mockRestore(); }); + it("dispatches js-memory leak-signal with a bounded window", async () => { + const ensureDaemonMock = vi.fn().mockResolvedValue(undefined); + const sendCommandMock = vi.fn(async (command: IpcCommand): Promise => { + if (command.type === "js-memory-leak-signal") { + return { + ok: true, + data: { + suspicionScore: 0, + level: "none", + confidence: "low", + sampleCount: 2, + scope: "bounded", + windowStartSampleId: "jm_10", + windowEndSampleId: "jm_11", + evidence: [], + qualityNotes: [], + caveat: "heuristic", + }, + }; + } + + throw new Error(`Unexpected command: ${command.type}`); + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const program = createProgram({ + ensureDaemon: ensureDaemonMock, + sendCommand: sendCommandMock, + stopDaemon: vi.fn(), + }); + + await program.parseAsync(["memory", "usage", "leak-signal", "--since", "jm_10"], { from: "user" }); + + expect(sendCommandMock).toHaveBeenCalledWith({ type: "js-memory-leak-signal", sinceSampleId: "jm_10" }); + + logSpy.mockRestore(); + }); + it("dispatches CPU hotspot filters through commander", async () => { const ensureDaemonMock = vi.fn().mockResolvedValue(undefined); const sendCommandMock = vi.fn(async (command: IpcCommand): Promise => { diff --git a/packages/agent-cdp/src/__tests__/js-memory.test.ts b/packages/agent-cdp/src/__tests__/js-memory.test.ts new file mode 100644 index 0000000..b4ae777 --- /dev/null +++ b/packages/agent-cdp/src/__tests__/js-memory.test.ts @@ -0,0 +1,80 @@ +import { formatJsMemoryLeakSignal } from "../js-memory/formatters.js"; +import { queryLeakSignal } from "../js-memory/query.js"; +import type { JsMemorySample } from "../js-memory/types.js"; + +function sample( + sampleId: string, + usedJSHeapSizeMb: number, + options: { label?: string; collectGarbageRequested?: boolean; timestamp?: number } = {}, +): JsMemorySample { + const derivedTimestamp = Number(sampleId.replace(/\D/g, "")) || 1; + + return { + sampleId, + label: options.label, + timestamp: options.timestamp ?? derivedTimestamp, + usedJSHeapSize: usedJSHeapSizeMb * 1024 * 1024, + totalJSHeapSize: usedJSHeapSizeMb * 1024 * 1024, + jsHeapSizeLimit: 64 * 1024 * 1024, + source: "Runtime.getHeapUsage", + collectGarbageRequested: options.collectGarbageRequested ?? false, + }; +} + +describe("js-memory leak signal", () => { + it("uses post-GC retained growth as the main leak signal", () => { + const result = queryLeakSignal( + [sample("jm_1", 10), sample("jm_2", 24, { label: "action" }), sample("jm_3", 21, { collectGarbageRequested: true })], + { scoped: true }, + ); + + expect(result.level).toBe("medium"); + expect(result.confidence).toBe("medium"); + expect(result.scope).toBe("bounded"); + expect(result.evidence.join(" ")).toContain("Post-GC checkpoint jm_3 is +11.0 MB vs baseline"); + expect(result.qualityNotes).toContain("Only 3 samples in this window; leak confidence is limited."); + }); + + it("calls out mixed full-history windows in compact output", () => { + const result = queryLeakSignal([sample("jm_1", 10), sample("jm_2", 13), sample("jm_3", 11), sample("jm_4", 14)], { + scoped: false, + }); + + expect(result.confidence).toBe("low"); + expect(result.qualityNotes[0]).toContain("Mixed workflows can skew the signal"); + expect(formatJsMemoryLeakSignal(result)).toContain("scope:full-history"); + expect(formatJsMemoryLeakSignal(result)).toContain("note: This result spans all stored samples"); + }); + + it("keeps mixed full-history windows low-confidence even with GC checkpoints", () => { + const result = queryLeakSignal( + [ + sample("jm_1", 7), + sample("jm_2", 8, { collectGarbageRequested: true }), + sample("jm_3", 6, { collectGarbageRequested: true }), + sample("jm_4", 12), + sample("jm_5", 6.7, { collectGarbageRequested: true }), + ], + { scoped: false }, + ); + + expect(result.level).toBe("none"); + expect(result.confidence).toBe("low"); + expect(result.qualityNotes[0]).toContain("Mixed workflows can skew the signal"); + }); + + it("reports too-few-samples as low-confidence evidence", () => { + const result = queryLeakSignal([sample("jm_1", 10)], { scoped: true }); + + expect(result.level).toBe("none"); + expect(result.confidence).toBe("low"); + expect(result.qualityNotes[0]).toContain("bounded baseline and follow-up sample"); + }); + + it("formats verbose output with confidence and quality notes", () => { + const result = queryLeakSignal([sample("jm_1", 10), sample("jm_2", 12)], { scoped: false }); + + expect(formatJsMemoryLeakSignal(result, true)).toContain("Confidence:"); + expect(formatJsMemoryLeakSignal(result, true)).toContain("Quality notes:"); + }); +}); diff --git a/packages/agent-cdp/src/cli/commands/memory.ts b/packages/agent-cdp/src/cli/commands/memory.ts index 9385ee0..adfe3b5 100644 --- a/packages/agent-cdp/src/cli/commands/memory.ts +++ b/packages/agent-cdp/src/cli/commands/memory.ts @@ -148,9 +148,12 @@ export function registerMemoryCommands(program: Command, deps: CliDeps): void { console.log(formatJsMemoryTrend(data as Parameters[0], getVerbose(command))); }); - usage.command("leak-signal").action(async (_options, command) => { + usage.command("leak-signal").option("--since ").action(async (options: { since?: string }, command) => { await deps.ensureDaemon(); - const data = unwrapResponse(await deps.sendCommand({ type: "js-memory-leak-signal" }), "Failed to get JS memory leak signal"); + const data = unwrapResponse( + await deps.sendCommand({ type: "js-memory-leak-signal", sinceSampleId: options.since }), + "Failed to get JS memory leak signal", + ); console.log(formatJsMemoryLeakSignal(data as Parameters[0], getVerbose(command))); }); } diff --git a/packages/agent-cdp/src/cli/help.ts b/packages/agent-cdp/src/cli/help.ts index f1d9ba7..92b256b 100644 --- a/packages/agent-cdp/src/cli/help.ts +++ b/packages/agent-cdp/src/cli/help.ts @@ -68,7 +68,7 @@ Memory Usage: memory usage summary memory usage diff --base SAMPLE_ID --compare SAMPLE_ID memory usage trend [--limit N] - memory usage leak-signal + memory usage leak-signal [--since SAMPLE_ID] Memory Allocation: memory allocation start [--name NAME] [--interval BYTES] [--stack-depth N] [--include-major-gc] [--include-minor-gc] diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index 0b716d9..1a85cc8 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -681,7 +681,7 @@ class Daemon { } if (command.type === "js-memory-leak-signal") { - return { ok: true, data: this.jsHeapUsageMonitor.getLeakSignal() }; + return { ok: true, data: this.jsHeapUsageMonitor.getLeakSignal(command.sinceSampleId) }; } const status: StatusInfo = { diff --git a/packages/agent-cdp/src/js-memory/formatters.ts b/packages/agent-cdp/src/js-memory/formatters.ts index 9c5256f..8e3c98b 100644 --- a/packages/agent-cdp/src/js-memory/formatters.ts +++ b/packages/agent-cdp/src/js-memory/formatters.ts @@ -212,6 +212,8 @@ export function formatJsMemoryLeakSignal(result: JsMemoryLeakSignalResult, verbo const lines: string[] = []; lines.push(`JS Memory Leak Signal:`); lines.push(` Level: ${result.level.toUpperCase()} (score: ${result.suspicionScore})`); + lines.push(` Confidence: ${result.confidence.toUpperCase()}`); + lines.push(` Scope: ${result.scope === "bounded" ? "bounded window" : "full daemon history"} (${result.sampleCount} sample${result.sampleCount === 1 ? "" : "s"}, ${result.windowStartSampleId ?? "n/a"} -> ${result.windowEndSampleId ?? "n/a"})`); if (result.evidence.length > 0) { lines.push(""); @@ -219,11 +221,23 @@ export function formatJsMemoryLeakSignal(result: JsMemoryLeakSignalResult, verbo for (const e of result.evidence) lines.push(` - ${e}`); } + if (result.qualityNotes.length > 0) { + lines.push(""); + lines.push("Quality notes:"); + for (const note of result.qualityNotes) lines.push(` - ${note}`); + } + lines.push(""); lines.push(`Caveat: ${result.caveat}`); return lines.join("\n"); } - return `${result.level.toUpperCase()} score:${result.suspicionScore}`; + const lines = [ + `${result.level.toUpperCase()} confidence:${result.confidence} score:${result.suspicionScore} scope:${result.scope} samples:${result.sampleCount}`, + ]; + if (result.qualityNotes.length > 0) { + lines.push(`note: ${result.qualityNotes[0]}`); + } + return lines.join("\n"); } diff --git a/packages/agent-cdp/src/js-memory/index.ts b/packages/agent-cdp/src/js-memory/index.ts index c1b376e..f4cfdd5 100644 --- a/packages/agent-cdp/src/js-memory/index.ts +++ b/packages/agent-cdp/src/js-memory/index.ts @@ -44,8 +44,10 @@ export class JsHeapUsageMonitor { return queryTrend(this.store.all(), limit); } - getLeakSignal(): JsMemoryLeakSignalResult { - return queryLeakSignal(this.store.all()); + getLeakSignal(sinceSampleId?: string): JsMemoryLeakSignalResult { + return queryLeakSignal(this.store.allSince(sinceSampleId), { + scoped: sinceSampleId !== undefined, + }); } } diff --git a/packages/agent-cdp/src/js-memory/query.ts b/packages/agent-cdp/src/js-memory/query.ts index c885599..f584770 100644 --- a/packages/agent-cdp/src/js-memory/query.ts +++ b/packages/agent-cdp/src/js-memory/query.ts @@ -10,6 +10,12 @@ import type { JsMemoryTrendResult, } from "./types.js"; +const MB = 1024 * 1024; + +function formatMb(bytes: number): string { + return `${(bytes / MB).toFixed(1)} MB`; +} + function toResult(sample: JsMemorySample): JsMemorySampleResult { return { sampleId: sample.sampleId, @@ -173,72 +179,129 @@ export function queryTrend(samples: JsMemorySample[], limit = 50): JsMemoryTrend }; } -export function queryLeakSignal(samples: JsMemorySample[]): JsMemoryLeakSignalResult { - if (samples.length < 2) { +export function queryLeakSignal( + samples: JsMemorySample[], + options: { scoped?: boolean } = {}, +): JsMemoryLeakSignalResult { + const sampleCount = samples.length; + const scope: JsMemoryLeakSignalResult["scope"] = options.scoped ? "bounded" : "full-history"; + const windowStartSampleId = samples[0]?.sampleId ?? null; + const windowEndSampleId = samples.at(-1)?.sampleId ?? null; + + if (sampleCount < 2) { return { suspicionScore: 0, level: "none", - evidence: ["Not enough samples to compute a trend (need at least 2)."], - caveat: "This is a heuristic signal, not proof of a leak.", + confidence: "low", + sampleCount, + scope, + windowStartSampleId, + windowEndSampleId, + evidence: ["Need at least two checkpoints to compare heap growth."], + qualityNotes: ["Capture a bounded baseline and follow-up sample before using leak-signal."], + caveat: "This is a heuristic signal, not proof of a leak. Use heap snapshots for confirmation.", }; } const evidence: string[] = []; + const qualityNotes: string[] = []; let score = 0; + let confidenceScore = 0; + + if (options.scoped) { + confidenceScore += 1; + } else { + qualityNotes.push("This result spans all stored samples in the daemon. Mixed workflows can skew the signal; rerun with --since SAMPLE_ID for one bounded check."); + } + + if (sampleCount >= 4) { + confidenceScore += 1; + } else { + qualityNotes.push(`Only ${sampleCount} sample${sampleCount === 1 ? "" : "s"} in this window; leak confidence is limited.`); + } const { slope, monotoneUp } = computeSlope(samples); + const baseline = samples[0]; + const latest = samples.at(-1)!; + const peak = samples.reduce((max, sample) => (sample.usedJSHeapSize > max.usedJSHeapSize ? sample : max), samples[0]); + const totalGrowthBytes = latest.usedJSHeapSize - baseline.usedJSHeapSize; + + evidence.push( + `Window ${windowStartSampleId} -> ${windowEndSampleId}: baseline ${formatMb(baseline.usedJSHeapSize)}, peak ${formatMb(peak.usedJSHeapSize)}, latest ${formatMb(latest.usedJSHeapSize)}.`, + ); + + const postGcSample = [...samples].reverse().find((sample) => sample.collectGarbageRequested); + + if (postGcSample) { + confidenceScore += 2; + const retainedAfterGc = postGcSample.usedJSHeapSize - baseline.usedJSHeapSize; + const recoveryFromPeak = peak.usedJSHeapSize - postGcSample.usedJSHeapSize; + const peakGrowth = peak.usedJSHeapSize - baseline.usedJSHeapSize; + const retainedShare = peakGrowth > 0 ? retainedAfterGc / peakGrowth : 0; + + evidence.push( + `Post-GC checkpoint ${postGcSample.sampleId} is ${retainedAfterGc >= 0 ? "+" : ""}${formatMb(retainedAfterGc)} vs baseline after ${recoveryFromPeak >= 0 ? "+" : ""}${formatMb(recoveryFromPeak)} of recovery from the peak.`, + ); + + if (retainedAfterGc >= 20 * MB && retainedShare >= 0.6) { + score += 4; + evidence.push("Most peak growth remains after a GC-assisted checkpoint, which is a strong retention signal."); + } else if (retainedAfterGc >= 8 * MB && retainedShare >= 0.5) { + score += 3; + evidence.push("A large share of the peak growth remains after GC, which is consistent with retained objects."); + } else if (retainedAfterGc >= 3 * MB && retainedShare >= 0.35) { + score += 1; + evidence.push("Some post-GC growth remains above baseline, but the retained floor is modest."); + } else { + evidence.push("The heap recovered close to baseline after GC, which weakens the leak signal."); + } + } else { + qualityNotes.push("No GC-assisted checkpoint in this window; the signal is trend-based and lower confidence."); + } if (monotoneUp) { - score += 3; - evidence.push(`usedJSHeapSize increased monotonically across all ${samples.length} samples.`); + score += postGcSample ? 1 : 2; + evidence.push(`usedJSHeapSize increased monotonically across all ${sampleCount} samples.`); } else if (slope === "increasing") { - score += 2; + score += postGcSample ? 1 : 2; evidence.push("usedJSHeapSize shows a predominantly increasing trend."); + } else if (slope === "oscillating") { + qualityNotes.push("Samples oscillate instead of following a clean progression, which lowers confidence."); } - const first = samples[0].usedJSHeapSize; - const last = samples.at(-1)!.usedJSHeapSize; - const growthMb = (last - first) / (1024 * 1024); - const growthPct = first > 0 ? ((last - first) / first) * 100 : 0; - - if (growthMb > 50) { - score += 3; - evidence.push(`Total growth exceeds 50 MB (${growthMb.toFixed(1)} MB).`); - } else if (growthMb > 10) { - score += 2; - evidence.push(`Total growth exceeds 10 MB (${growthMb.toFixed(1)} MB).`); - } else if (growthPct > 50) { - score += 1; - evidence.push(`Total growth is ${growthPct.toFixed(1)}% of the initial heap size.`); - } + const growthMb = totalGrowthBytes / MB; + const growthPct = baseline.usedJSHeapSize > 0 ? (totalGrowthBytes / baseline.usedJSHeapSize) * 100 : 0; - const gcSamples = samples.filter((s) => s.collectGarbageRequested); - if (gcSamples.length > 0) { - const gcIndices = gcSamples.map((s) => samples.indexOf(s)); - const poorRecovery = gcIndices.some((idx) => { - if (idx === 0) return false; - const beforeGc = samples[idx - 1].usedJSHeapSize; - const afterGc = samples[idx].usedJSHeapSize; - return afterGc > beforeGc * 0.9; - }); - if (poorRecovery) { - score += 2; - evidence.push("Heap did not recover significantly after GC-assisted samples."); - } + if (!postGcSample && growthMb > 10) { + score += 1; + evidence.push(`Total growth exceeds 10 MB (${growthMb.toFixed(1)} MB), but without a post-GC checkpoint this is only a weak trend signal.`); + } else if (postGcSample && growthMb > 10) { + score += 1; + evidence.push(`Latest heap is still ${growthPct.toFixed(1)}% above the baseline.`); } const level: JsMemoryLeakSignalResult["level"] = score >= 5 ? "high" : score >= 3 ? "medium" : score >= 1 ? "low" : "none"; + const effectiveConfidenceScore = options.scoped ? confidenceScore : Math.min(confidenceScore, 1); + const confidence: JsMemoryLeakSignalResult["confidence"] = + effectiveConfidenceScore >= 4 ? "high" : effectiveConfidenceScore >= 2 ? "medium" : "low"; + if (evidence.length === 0) { - evidence.push("No significant growth pattern detected."); + evidence.push("No strong retained-growth pattern detected in this window."); } return { suspicionScore: score, level, + confidence, + sampleCount, + scope, + windowStartSampleId, + windowEndSampleId, evidence, + qualityNotes, caveat: - "This is a heuristic signal based on heap usage trends, not proof of a memory leak. Use heap snapshots for confirmation.", + "This is a heuristic signal based on heap usage checkpoints, not proof of a memory leak. Use heap snapshots for confirmation.", }; } diff --git a/packages/agent-cdp/src/js-memory/store.ts b/packages/agent-cdp/src/js-memory/store.ts index e6d2083..1e4173d 100644 --- a/packages/agent-cdp/src/js-memory/store.ts +++ b/packages/agent-cdp/src/js-memory/store.ts @@ -28,6 +28,19 @@ export class JsMemoryStore { return [...this.samples]; } + allSince(sampleId?: string): JsMemorySample[] { + if (!sampleId) { + return this.all(); + } + + const index = this.samples.findIndex((sample) => sample.sampleId === sampleId); + if (index === -1) { + throw new Error(`Sample ${sampleId} not found`); + } + + return this.samples.slice(index); + } + count(): number { return this.samples.length; } diff --git a/packages/agent-cdp/src/js-memory/types.ts b/packages/agent-cdp/src/js-memory/types.ts index 48a165e..a683860 100644 --- a/packages/agent-cdp/src/js-memory/types.ts +++ b/packages/agent-cdp/src/js-memory/types.ts @@ -72,6 +72,12 @@ export interface JsMemoryTrendResult { export interface JsMemoryLeakSignalResult { suspicionScore: number; level: "none" | "low" | "medium" | "high"; + confidence: "low" | "medium" | "high"; + sampleCount: number; + scope: "full-history" | "bounded"; + windowStartSampleId: string | null; + windowEndSampleId: string | null; evidence: string[]; + qualityNotes: string[]; caveat: string; } diff --git a/packages/agent-cdp/src/types.ts b/packages/agent-cdp/src/types.ts index d037f49..d5d37b5 100644 --- a/packages/agent-cdp/src/types.ts +++ b/packages/agent-cdp/src/types.ts @@ -201,7 +201,7 @@ export type IpcCommand = | { type: "js-memory-summary" } | { type: "js-memory-diff"; baseSampleId: string; compareSampleId: string } | { type: "js-memory-trend"; limit?: number } - | { type: "js-memory-leak-signal" }; + | { type: "js-memory-leak-signal"; sinceSampleId?: string }; export interface IpcResponse { ok: boolean;