Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/agent-cdp/skills/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions packages/agent-cdp/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
Expand Down Expand Up @@ -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<IpcResponse> => {
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<IpcResponse> => {
Expand Down
80 changes: 80 additions & 0 deletions packages/agent-cdp/src/__tests__/js-memory.test.ts
Original file line number Diff line number Diff line change
@@ -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:");
});
});
7 changes: 5 additions & 2 deletions packages/agent-cdp/src/cli/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,12 @@ export function registerMemoryCommands(program: Command, deps: CliDeps): void {
console.log(formatJsMemoryTrend(data as Parameters<typeof formatJsMemoryTrend>[0], getVerbose(command)));
});

usage.command("leak-signal").action(async (_options, command) => {
usage.command("leak-signal").option("--since <sampleId>").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<typeof formatJsMemoryLeakSignal>[0], getVerbose(command)));
});
}
2 changes: 1 addition & 1 deletion packages/agent-cdp/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-cdp/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
16 changes: 15 additions & 1 deletion packages/agent-cdp/src/js-memory/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,18 +212,32 @@ 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("");
lines.push("Evidence:");
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");
}
6 changes: 4 additions & 2 deletions packages/agent-cdp/src/js-memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}

Expand Down
Loading
Loading