summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 14:24:58 +0900
committerAdam Malczewski <[email protected]>2026-06-28 14:24:58 +0900
commit841e776635d2a93371f302f0617e729626a69fe5 (patch)
treec416925f6730a35f2560451ca7c6edaf41e8b1e4 /packages/session-orchestrator/src
parent71c635f7d8ee01a2b23d5ddfdfc4bff043980052 (diff)
parent414080e271ea44df0a7affc154b62e39b51a11a0 (diff)
downloaddispatch-841e776635d2a93371f302f0617e729626a69fe5.tar.gz
dispatch-841e776635d2a93371f302f0617e729626a69fe5.zip
Merge branch 'dev' into feature/workspace-star
Diffstat (limited to 'packages/session-orchestrator/src')
-rw-r--r--packages/session-orchestrator/src/extension.ts13
-rw-r--r--packages/session-orchestrator/src/index.ts3
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts138
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts53
-rw-r--r--packages/session-orchestrator/src/pure.test.ts71
-rw-r--r--packages/session-orchestrator/src/pure.ts69
6 files changed, 346 insertions, 1 deletions
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index 783d894..777feaa 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -65,6 +65,19 @@ export function activate(host: HostAPI): void {
logger: host.logger,
now: () => Date.now(),
emit: (hook, payload) => host.emit(hook, payload),
+ // Injected process.memoryUsage() sampler — the production edge. Tests
+ // inject a fake to assert per-turn before/after telemetry. Wired in the
+ // shell (like `now: () => Date.now()`); pure decision logic is untouched.
+ sampleMemory: () => {
+ const m = process.memoryUsage();
+ return {
+ rss: m.rss,
+ heapUsed: m.heapUsed,
+ heapTotal: m.heapTotal,
+ external: m.external,
+ arrayBuffers: m.arrayBuffers,
+ };
+ },
resolveQueue: () => {
// Lazily resolve the message-queue service. Returns undefined when the
// extension isn't loaded (feature degrades off) — checked via the
diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts
index cc41fa8..3d3e816 100644
--- a/packages/session-orchestrator/src/index.ts
+++ b/packages/session-orchestrator/src/index.ts
@@ -39,6 +39,9 @@ export {
defaultDispatchPolicy,
delayFor,
generateTurnId,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
RETRY_BUDGET_MS,
RETRY_SCHEDULE_MS,
RETRY_TAIL_MS,
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 076ad51..400ca0b 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -4,7 +4,10 @@ import type {
AgentEvent,
ChatMessage,
EventHookDescriptor,
+ LogDeps,
Logger,
+ LogRecord,
+ LogSink,
ProviderContract,
ProviderEvent,
ProviderStreamOptions,
@@ -15,7 +18,7 @@ import type {
ToolContract,
TurnMetrics,
} from "@dispatch/kernel";
-import { runTurn } from "@dispatch/kernel";
+import { createLogger, runTurn } from "@dispatch/kernel";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { describe, expect, it } from "vitest";
import {
@@ -27,6 +30,7 @@ import {
type TurnLifecyclePayload,
type WarmCompletedPayload,
} from "./orchestrator.js";
+import type { MemorySample } from "./pure.js";
import type { ToolAssembly } from "./tools-filter.js";
function createInMemoryStore(): ConversationStore & {
@@ -3980,3 +3984,135 @@ describe("system prompt: compaction flow", () => {
expect(capturedSystemPrompt?.startsWith("RECONSTRUCTED")).toBe(false);
});
});
+
+describe("per-turn memory telemetry", () => {
+ function capturingLogger(): { logger: Logger; records: LogRecord[] } {
+ let id = 0;
+ const deps: LogDeps = { now: () => 1000 + id++, newId: () => `id-${id++}` };
+ const records: LogRecord[] = [];
+ const sink: LogSink = { emit: (r) => records.push(r) };
+ return { logger: createLogger({ extensionId: "session-orchestrator" }, sink, deps), records };
+ }
+
+ it("logs before/after samples around the stream, tagged with conversationId + turnId", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+ const { logger, records } = capturingLogger();
+
+ const samples: MemorySample[] = [
+ { rss: 100, heapUsed: 10, heapTotal: 20, external: 1, arrayBuffers: 0 },
+ { rss: 250, heapUsed: 30, heapTotal: 20, external: 1, arrayBuffers: 5 },
+ ];
+ let sampleIdx = 0;
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ logger,
+ sampleMemory: () => samples[sampleIdx++] as MemorySample,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-mem",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ const beforeLogs = records.filter((r) => r.kind === "log" && r.msg === "memory:turn:before");
+ const afterLogs = records.filter((r) => r.kind === "log" && r.msg === "memory:turn:after");
+ expect(beforeLogs).toHaveLength(1);
+ expect(afterLogs).toHaveLength(1);
+
+ // Both samples carry the turn's conversationId + turnId correlation.
+ const turnId = captured[0]?.turnId;
+ expect(turnId).toMatch(/^turn-/);
+ const before = beforeLogs[0] as Extract<LogRecord, { kind: "log" }>;
+ const after = afterLogs[0] as Extract<LogRecord, { kind: "log" }>;
+ expect(before.conversationId).toBe("conv-mem");
+ expect(before.turnId).toBe(turnId);
+ expect(after.conversationId).toBe("conv-mem");
+ expect(after.turnId).toBe(turnId);
+
+ // Before sample carries absolute MB values.
+ expect(before.attributes?.rssMB).toBe(0); // 100 bytes rounds to 0 MB
+ // After sample carries absolute + delta (delta rss = 150 bytes → 0 MB).
+ expect(after.attributes?.rssMB).toBe(0);
+ // deltaRssMB is the rounded delta (150 bytes → 0 MB).
+ expect(after.attributes).toHaveProperty("deltaRssMB");
+ });
+
+ it("emits no memory logs when sampleMemory is not injected (degrades off)", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+ const { logger, records } = capturingLogger();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ logger,
+ // sampleMemory intentionally omitted
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-mem",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ const memLogs = records.filter(
+ (r) => r.kind === "log" && typeof r.msg === "string" && r.msg.startsWith("memory:turn"),
+ );
+ expect(memLogs).toHaveLength(0);
+ });
+
+ it("getActiveConversationCount tracks in-flight turns", async () => {
+ const store = createInMemoryStore();
+ const result: RunTurnResult = {
+ messages: [{ role: "assistant", chunks: [{ type: "text", text: "ok" }] }],
+ usage: { inputTokens: 1, outputTokens: 1 },
+ finishReason: "stop",
+ };
+ // A runTurn that blocks until the test releases it — keeps the turn
+ // active so getActiveConversationCount reflects an in-flight turn.
+ let release: () => void = () => {};
+ const blocked = new Promise<void>((resolve) => {
+ release = resolve;
+ });
+ const blockingRunTurn = async (_input: RunTurnInput): Promise<RunTurnResult> => {
+ await blocked;
+ return result;
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: blockingRunTurn,
+ });
+
+ expect(orchestrator.getActiveConversationCount()).toBe(0);
+ const done = orchestrator.handleMessage({
+ conversationId: "conv-active",
+ text: "hi",
+ onEvent: () => {},
+ });
+ // Give the detached turn a tick to register as active.
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(orchestrator.getActiveConversationCount()).toBe(1);
+
+ release();
+ await done;
+ expect(orchestrator.getActiveConversationCount()).toBe(0);
+ });
+});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 4c6a673..aaf418a 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -30,6 +30,9 @@ import {
defaultDispatchPolicy,
delayFor,
generateTurnId,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
resolveModelName,
resolveReasoningEffort,
} from "./pure.js";
@@ -331,6 +334,13 @@ export interface SessionOrchestrator {
subscribe(conversationId: string, listener: TurnEventListener): () => void;
isActive(conversationId: string): boolean;
/**
+ * The number of conversations currently driving a turn (in the
+ * `activeConversations` set). Used by host-bin's periodic memory telemetry
+ * to tag each RSS sample with the active-conversation count, so growth can
+ * be attributed to the streaming/turn path vs an idle baseline.
+ */
+ getActiveConversationCount(): number;
+ /**
* Explicitly close a conversation (the user closed its tab — distinct from a
* socket disconnect, which never touches the turn): aborts any in-flight turn
* (the kernel finishes with `finishReason: "aborted"`, partial messages are
@@ -431,6 +441,16 @@ export interface SessionOrchestratorDeps {
readonly logger?: Logger;
/** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */
readonly now?: () => number;
+ /**
+ * Optional process.memoryUsage() sampler, injected for testability. When
+ * present, the orchestrator captures a sample immediately before and after
+ * each turn's stream completes (`deps.runTurn`) and logs the per-turn delta
+ * tagged with conversationId + turnId — correlating RSS growth with the
+ * streaming path (the prime leak suspect). When absent (undefined), no
+ * per-turn memory telemetry is emitted (feature degrades off cleanly).
+ * Pure decision logic stays unchanged; this is additive observability.
+ */
+ readonly sampleMemory?: () => MemorySample;
/** Emit a lifecycle event hook to subscribers. Injected from host. */
readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
}
@@ -886,6 +906,18 @@ export function createSessionOrchestrator(
// FE to syncTail during generation (CR-6).
await deps.conversationStore.append(conversationId, [userMsg]);
+ // Per-turn memory telemetry: capture a sample immediately BEFORE the
+ // stream starts. Paired with the post-stream sample below, this
+ // measures the streaming path's memory footprint per turn (the prime
+ // leak suspect — AI-SDK streaming buffers + per-turn message arrays).
+ // Tagged with conversationId + turnId via the turnLogger's correlation
+ // context. Additive observability only — does not alter the stream.
+ const sampleMem = deps.sampleMemory;
+ const memBefore = sampleMem?.();
+ if (memBefore !== undefined) {
+ turnLogger?.debug("memory:turn:before", memorySampleAttributes(memBefore));
+ }
+
let stepsPersisted = false;
const result = await deps.runTurn({
...opts,
@@ -899,6 +931,23 @@ export function createSessionOrchestrator(
},
});
+ // Per-turn memory telemetry: capture a sample immediately AFTER the
+ // stream completes and log the per-turn delta vs `memBefore`. A
+ // positive rss delta on a sealed turn flags memory retained by the
+ // streaming path (the leak we are localizing). No I/O beyond the
+ // injected sampler; pure delta computation via memoryDelta(). The
+ // delta attributes carry a `delta` prefix so the absolute "after"
+ // values and the per-turn delta coexist without key collision.
+ if (memBefore !== undefined) {
+ const memAfter = sampleMem?.();
+ if (memAfter !== undefined) {
+ turnLogger?.info("memory:turn:after", {
+ ...memorySampleAttributes(memAfter),
+ ...memorySampleAttributes(memoryDelta(memBefore, memAfter), "delta"),
+ });
+ }
+ }
+
// Fallback: if onStepComplete was never called (e.g., a fake
// runTurn in tests), persist all result messages as a batch.
if (!stepsPersisted && result.messages.length > 0) {
@@ -1042,6 +1091,10 @@ export function createSessionOrchestrator(
return activeTurns.has(conversationId);
},
+ getActiveConversationCount() {
+ return activeConversations.size;
+ },
+
closeConversation(conversationId) {
const turn = activeTurns.get(conversationId);
const abortedTurn = turn !== undefined;
diff --git a/packages/session-orchestrator/src/pure.test.ts b/packages/session-orchestrator/src/pure.test.ts
index 7a574f1..d4fe84a 100644
--- a/packages/session-orchestrator/src/pure.test.ts
+++ b/packages/session-orchestrator/src/pure.test.ts
@@ -6,6 +6,9 @@ import {
defaultDispatchPolicy,
delayFor,
generateTurnId,
+ type MemorySample,
+ memoryDelta,
+ memorySampleAttributes,
RETRY_BUDGET_MS,
RETRY_SCHEDULE_MS,
RETRY_TAIL_MS,
@@ -194,3 +197,71 @@ describe("retry backoff schedule (delayFor)", () => {
expect(schedule.slice(8).every((d) => d === RETRY_TAIL_MS)).toBe(true);
});
});
+
+describe("memorySampleAttributes", () => {
+ const sample: MemorySample = {
+ rss: 100 * 1024 * 1024, // 100 MB
+ heapUsed: 40 * 1024 * 1024,
+ heapTotal: 60 * 1024 * 1024,
+ external: 5 * 1024 * 1024,
+ arrayBuffers: 2 * 1024 * 1024,
+ };
+
+ it("formats fields as rounded MB with no prefix", () => {
+ const attrs = memorySampleAttributes(sample);
+ expect(attrs).toEqual({
+ rssMB: 100,
+ heapUsedMB: 40,
+ heapTotalMB: 60,
+ externalMB: 5,
+ arrayBuffersMB: 2,
+ });
+ });
+
+ it("namespaces keys with the given prefix", () => {
+ const attrs = memorySampleAttributes(sample, "delta");
+ expect(attrs).toEqual({
+ deltaRssMB: 100,
+ deltaHeapUsedMB: 40,
+ deltaHeapTotalMB: 60,
+ deltaExternalMB: 5,
+ deltaArrayBuffersMB: 2,
+ });
+ });
+
+ it("rounds fractional MB", () => {
+ const attrs = memorySampleAttributes({ ...sample, rss: 100.6 * 1024 * 1024 });
+ expect(attrs.rssMB).toBe(101);
+ });
+});
+
+describe("memoryDelta", () => {
+ const before: MemorySample = {
+ rss: 200 * 1024 * 1024,
+ heapUsed: 100 * 1024 * 1024,
+ heapTotal: 150 * 1024 * 1024,
+ external: 10 * 1024 * 1024,
+ arrayBuffers: 4 * 1024 * 1024,
+ };
+ const after: MemorySample = {
+ rss: 350 * 1024 * 1024,
+ heapUsed: 120 * 1024 * 1024,
+ heapTotal: 150 * 1024 * 1024,
+ external: 10 * 1024 * 1024,
+ arrayBuffers: 8 * 1024 * 1024,
+ };
+
+ it("computes signed after - before per field", () => {
+ const delta = memoryDelta(before, after);
+ expect(delta.rss).toBe(150 * 1024 * 1024);
+ expect(delta.heapUsed).toBe(20 * 1024 * 1024);
+ expect(delta.heapTotal).toBe(0);
+ expect(delta.external).toBe(0);
+ expect(delta.arrayBuffers).toBe(4 * 1024 * 1024);
+ });
+
+ it("is negative when memory dropped", () => {
+ const delta = memoryDelta(after, before);
+ expect(delta.rss).toBe(-150 * 1024 * 1024);
+ });
+});
diff --git a/packages/session-orchestrator/src/pure.ts b/packages/session-orchestrator/src/pure.ts
index 0d2068f..489b140 100644
--- a/packages/session-orchestrator/src/pure.ts
+++ b/packages/session-orchestrator/src/pure.ts
@@ -1,4 +1,5 @@
import type {
+ Attributes,
ChatMessage,
Chunk,
ImageInput,
@@ -134,3 +135,71 @@ export function defaultDispatchPolicy(): ToolDispatchPolicy {
export function generateTurnId(): string {
return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
+
+// ── Memory telemetry (leak localization) ────────────────────────────────────
+//
+// Pure helpers for process.memoryUsage() sampling. The orchestrator owns the
+// sample SHAPE (this type) so its per-turn sampling and the host-bin periodic
+// timer share one contract without a cross-package import of an
+// implementation — host-bin imports this type, the orchestrator never imports
+// host-bin. Pure: inputs → attributes/delta, no I/O, no clock.
+
+/**
+ * A snapshot of process.memoryUsage() at one instant. Mirrors the subset of
+ * Node/Bun's MemoryUsage we log for leak localization (rss, heapUsed,
+ * heapTotal, external, arrayBuffers). Owned here so the orchestrator's
+ * per-turn sampling and the host-bin periodic timer agree on the shape.
+ */
+export interface MemorySample {
+ readonly rss: number;
+ readonly heapUsed: number;
+ readonly heapTotal: number;
+ readonly external: number;
+ readonly arrayBuffers: number;
+}
+
+const BYTES_PER_MB = 1024 * 1024;
+
+function mb(bytes: number): number {
+ return Math.round(bytes / BYTES_PER_MB);
+}
+
+/**
+ * Pure: format a {@link MemorySample} as flat logger {@link Attributes}
+ * (values in MB, rounded). Flat scalars are serializable (D3) and queryable
+ * (D9) in the journal. No I/O.
+ *
+ * Pass a `prefix` to namespace the keys — e.g. `memorySampleAttributes(delta,
+ * "delta")` yields `deltaRssMB`, so an "after" log can carry both the absolute
+ * sample (`rssMB`) and the per-turn delta (`deltaRssMB`) without key collision.
+ * The first letter of each field is capitalized after the prefix for
+ * readability (`deltaRssMB`, not `deltarssMB`).
+ */
+export function memorySampleAttributes(sample: MemorySample, prefix?: string): Attributes {
+ const p = prefix === undefined ? "" : prefix;
+ const cap = (s: string): string =>
+ s.length === 0 ? s : `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`;
+ const field = (name: string): string => (p.length === 0 ? name : `${p}${cap(name)}`);
+ return {
+ [field("rssMB")]: mb(sample.rss),
+ [field("heapUsedMB")]: mb(sample.heapUsed),
+ [field("heapTotalMB")]: mb(sample.heapTotal),
+ [field("externalMB")]: mb(sample.external),
+ [field("arrayBuffersMB")]: mb(sample.arrayBuffers),
+ };
+}
+
+/**
+ * Pure: compute the signed per-field delta `after - before`. A positive
+ * `rss` delta on a sealed turn flags memory retained by the streaming path
+ * (the prime leak suspect). No I/O.
+ */
+export function memoryDelta(before: MemorySample, after: MemorySample): MemorySample {
+ return {
+ rss: after.rss - before.rss,
+ heapUsed: after.heapUsed - before.heapUsed,
+ heapTotal: after.heapTotal - before.heapTotal,
+ external: after.external - before.external,
+ arrayBuffers: after.arrayBuffers - before.arrayBuffers,
+ };
+}