mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
perf(ui): avoid redundant session list reads during activity (#177328)
* perf(ui): avoid redundant session list reads during activity Keep certified roster updates on the event path, preserve row bases through protocol-owned gap recovery, and share initial child-window reads across views. Retain authoritative membership, count, catalog, and explicit refresh behavior. * perf(ui): reconcile ancestors outside loaded roster windows Resolve matching ancestor references from the existing connection row cache without advancing wire baselines. Exclude old owned ancestors only below both bounded windows, retaining authoritative reads for uncertain membership. Add a real Gateway replay with per-mutation delivery and final row-fact checks. * test(ui): make sustained ancestor replay reproducible * build(types): include UI contracts in benchmark typechecks * test(ui): require rendered rows in ancestor replay * fix(bench): preserve JSON parse overloads in timing wrapper * test(ui): match Gateway child receipts in churn fixture * fix(ui): resolve ancestor references without session IDs
This commit is contained in:
1 parent
3155a8f82b
commit
11c31139b9
24 files changed
+1259
-95
No files matched your search
@@ -117,7 +117,7 @@ Sidebar live narration pauses while the browser tab is hidden and resumes from c
|
||||
|
||||
**My sessions** and specific-owner selections list that owner’s sessions across configured agents, independently of the chat or workspace you are viewing. Other agent-scoped sidebar lists refresh immediately when you switch agents, even while the selected chat is still loading. Automatic startup and reconnect refreshes continue to wait for that chat to become ready.
|
||||
|
||||
Owner-filtered sidebars apply live updates to loaded sessions while ownership and list membership remain unchanged. Ownership changes, new sessions, and broad invalidations still refresh the authoritative list. A completed refresh absorbs any older scheduled fallback read.
|
||||
Sidebars apply live updates to loaded sessions without periodic list reads while ownership and list membership remain unchanged. Ownership changes, new sessions, incomplete event coverage, and broad invalidations still refresh the authoritative list. Matching views share the same managed query, and refreshes reuse retained row revisions when available.
|
||||
|
||||
In **My sessions**, choosing an agent from the switcher resumes that agent's newest loaded active conversation from your list, or opens its Home when none is loaded. Agent unread indicators and Home activity use the same current list; Home controls keep their separate main-session behavior.
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// --cpu-profile: source-mapped reload/live/streaming tables and per-run/median cpu-profile.json.
|
||||
// --cpu-profile-compare <baseline cpu-profile.json>: profile and diff against that report.
|
||||
// Sampling inflates timings: never compare profiled runs with unprofiled runs.
|
||||
// --session-event-duration-ms 135000 --session-event-ancestor-refs extends the native churn.
|
||||
// Pair built bundles with --fixture-now-ms and --live-start-at-ms (one run per process).
|
||||
// OPENCLAW_CONTROL_UI_BUILD_ID supplies the matching identity outside a Git checkout.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -230,11 +233,17 @@ const { values } = parseArgs({
|
||||
"sidebar-list-churn": { type: "boolean", default: false },
|
||||
"sidebar-live-churn": { type: "boolean", default: false },
|
||||
"session-event-churn": { type: "boolean", default: false },
|
||||
"session-event-duration-ms": { type: "string", default: "30000" },
|
||||
"session-event-ancestor-refs": { type: "boolean", default: false },
|
||||
"fixture-now-ms": { type: "string" },
|
||||
"live-start-at-ms": { type: "string" },
|
||||
"duplicate-boot-reads": { type: "boolean", default: false },
|
||||
"closed-menu-members": { type: "boolean", default: false },
|
||||
"capture-ms": { type: "string", default: "0" },
|
||||
"sidebar-row-transfer": { type: "string", default: "full" },
|
||||
"bundle-dir": { type: "string" },
|
||||
port: { type: "string" },
|
||||
"sandbox-port": { type: "string" },
|
||||
runs: { type: "string", default: "3" },
|
||||
"latency-ms": { type: "string", default: "35" },
|
||||
"frame-gap-ms": { type: "string", default: "4" },
|
||||
@@ -257,6 +266,9 @@ if (values["sidebar-list-churn"] && values.untraced) {
|
||||
if (values["sidebar-live-churn"] && (!values["live-events"] || values["sidebar-list-churn"])) {
|
||||
throw new Error("sidebar-live-churn requires live-events and excludes sidebar-list-churn");
|
||||
}
|
||||
if (values["session-event-ancestor-refs"] && !values["session-event-churn"]) {
|
||||
throw new Error("session-event-ancestor-refs requires session-event-churn");
|
||||
}
|
||||
const sidebarRowTransfer = values["sidebar-row-transfer"];
|
||||
if (!["full", "delta", "interleaved"].includes(sidebarRowTransfer)) {
|
||||
throw new Error("Expected sidebar-row-transfer full, delta or interleaved");
|
||||
@@ -281,6 +293,27 @@ const runs = Number(values.runs);
|
||||
const latencyMs = Number(values["latency-ms"]);
|
||||
const frameGapMs = Number(values["frame-gap-ms"]);
|
||||
const captureMs = Number(values["capture-ms"]);
|
||||
const sessionEventDurationMs = Number(values["session-event-duration-ms"]);
|
||||
const fixtureNowMs =
|
||||
values["fixture-now-ms"] === undefined ? undefined : Number(values["fixture-now-ms"]);
|
||||
const liveStartAtMs =
|
||||
values["live-start-at-ms"] === undefined ? undefined : Number(values["live-start-at-ms"]);
|
||||
const port = values.port === undefined ? undefined : Number(values.port);
|
||||
const sandboxPort =
|
||||
values["sandbox-port"] === undefined ? undefined : Number(values["sandbox-port"]);
|
||||
if (
|
||||
(port !== undefined || sandboxPort !== undefined) &&
|
||||
(!values["bundle-dir"] ||
|
||||
port === sandboxPort ||
|
||||
[port, sandboxPort].some(
|
||||
(value) =>
|
||||
value === undefined || !Number.isInteger(value) || value < 40_000 || value > 65_535,
|
||||
))
|
||||
) {
|
||||
throw new Error(
|
||||
"Explicit port and sandbox-port require a built bundle and distinct ports from 40000 to 65535",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(runs) ||
|
||||
runs < 1 ||
|
||||
@@ -290,33 +323,51 @@ if (
|
||||
!Number.isFinite(frameGapMs) ||
|
||||
frameGapMs < 0 ||
|
||||
!Number.isFinite(captureMs) ||
|
||||
captureMs < 0
|
||||
captureMs < 0 ||
|
||||
!Number.isSafeInteger(sessionEventDurationMs) ||
|
||||
sessionEventDurationMs < 250 ||
|
||||
sessionEventDurationMs % 125 !== 0 ||
|
||||
[fixtureNowMs, liveStartAtMs].some(
|
||||
(value) => value !== undefined && (!Number.isSafeInteger(value) || value < 0),
|
||||
)
|
||||
) {
|
||||
throw new Error("Expected 1–20 runs and nonnegative finite wire delays and capture duration");
|
||||
throw new Error(
|
||||
"Expected 1–20 runs, nonnegative wire delays/timestamps, and session duration in 125ms steps (at least 250ms)",
|
||||
);
|
||||
}
|
||||
if (sidebarRowTransfer === "interleaved" && (runs < 14 || runs % 2 !== 0)) {
|
||||
throw new Error("Interleaved sidebar transfer requires an even run count of at least 14");
|
||||
}
|
||||
if (liveStartAtMs !== undefined && (runs !== 1 || !values["live-events"])) {
|
||||
throw new Error("live-start-at-ms requires one live-events run");
|
||||
}
|
||||
const artifactDir = path.resolve(values.output, new Date().toISOString().replaceAll(":", "-"));
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
const dirty = execFileSync("git", ["status", "--porcelain"], { encoding: "utf8" });
|
||||
const explicitBuildId = process.env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim();
|
||||
const head = explicitBuildId
|
||||
? null
|
||||
: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
const buildId = explicitBuildId ?? head!;
|
||||
const dirty = explicitBuildId
|
||||
? null
|
||||
: execFileSync("git", ["status", "--porcelain"], { encoding: "utf8" });
|
||||
const bundleDir = values["bundle-dir"]
|
||||
? path.resolve(values["bundle-dir"])
|
||||
: path.join(artifactDir, "ui");
|
||||
const server = values["bundle-dir"]
|
||||
? await startBuiltControlUiE2eServer(bundleDir)
|
||||
: await startProductionControlUiE2eServer(bundleDir, head);
|
||||
? await startBuiltControlUiE2eServer(bundleDir, undefined, port)
|
||||
: await startProductionControlUiE2eServer(bundleDir, buildId);
|
||||
const sandbox = createSandboxHostHttpServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sandbox.once("error", reject);
|
||||
sandbox.listen(0, "127.0.0.1", resolve);
|
||||
sandbox.listen(sandboxPort ?? 0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = sandbox.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Sandbox did not bind");
|
||||
}
|
||||
const fixture = createControlUiTeamScaleFixture({
|
||||
now: fixtureNowMs,
|
||||
baseUrl: server.baseUrl,
|
||||
sandboxUrl: buildSandboxHostPath({ blockDescendantFrames: true }),
|
||||
sandboxPort: address.port,
|
||||
@@ -324,6 +375,8 @@ const fixture = createControlUiTeamScaleFixture({
|
||||
sidebarListChurn: values["sidebar-list-churn"],
|
||||
sidebarLiveChurn: values["sidebar-live-churn"],
|
||||
sessionEventChurn: values["session-event-churn"],
|
||||
sessionEventDurationMs,
|
||||
sessionEventAncestorRefs: values["session-event-ancestor-refs"],
|
||||
duplicateBootReads: values["duplicate-boot-reads"],
|
||||
closedMenuMembers: values["closed-menu-members"],
|
||||
largeSnapshot: values["large-snapshot"],
|
||||
@@ -339,7 +392,7 @@ const results = [];
|
||||
try {
|
||||
const prepared = await prepareControlUiMockGatewayScenario({
|
||||
...fixture.scenario,
|
||||
serverBuildId: head,
|
||||
serverBuildId: buildId,
|
||||
serverVersion: JSON.parse(await readFile("package.json", "utf8")).version,
|
||||
});
|
||||
browser = await chromium.launch({
|
||||
@@ -364,7 +417,7 @@ try {
|
||||
await context.exposeBinding("__teamScaleList", (_source, params: Record<string, unknown>) => {
|
||||
const { result, previousRows } = sidebarLists.list(params);
|
||||
const response =
|
||||
params.rowMode === "sidebar"
|
||||
params.rowMode === "sidebar" || params.ifRowsRevision !== undefined
|
||||
? {
|
||||
...result,
|
||||
...shareSidebarSessionRows(
|
||||
@@ -456,9 +509,10 @@ try {
|
||||
key: controlUiBundledSettingsStorageKey(server.baseUrl),
|
||||
pins: fixture.sidebarEntries,
|
||||
mode:
|
||||
values["sidebar-list-churn"] ||
|
||||
values["sidebar-live-churn"] ||
|
||||
values["session-event-churn"]
|
||||
!values["session-event-ancestor-refs"] &&
|
||||
(values["sidebar-list-churn"] ||
|
||||
values["sidebar-live-churn"] ||
|
||||
values["session-event-churn"])
|
||||
? "roster"
|
||||
: "chip",
|
||||
},
|
||||
@@ -471,6 +525,13 @@ try {
|
||||
async function settle() {
|
||||
try {
|
||||
await page.locator(".chat-pane__header").first().waitFor();
|
||||
if (values["session-event-ancestor-refs"]) {
|
||||
await page
|
||||
.locator(
|
||||
`.sidebar-recent-session[data-session-key="${fixture.scenario.sessions![2]!.key}"]`,
|
||||
)
|
||||
.waitFor({ state: "visible" });
|
||||
}
|
||||
await page
|
||||
.locator('.sidebar-rail [data-sidebar-entry^="session:"]')
|
||||
.nth(29)
|
||||
@@ -523,7 +584,9 @@ try {
|
||||
try {
|
||||
await page.locator("#sidebar-session-owner-title").click();
|
||||
await page
|
||||
.locator(`.sidebar-session-owner-filter [role="option"][data-value="involving-me"]`)
|
||||
.locator(
|
||||
`.sidebar-session-owner-filter [role="option"][data-value="${values["session-event-ancestor-refs"] ? "all" : "involving-me"}"]`,
|
||||
)
|
||||
.click();
|
||||
} catch (error) {
|
||||
await writeFile(
|
||||
@@ -843,6 +906,15 @@ try {
|
||||
],
|
||||
);
|
||||
if (values["live-events"]) {
|
||||
if (liveStartAtMs !== undefined) {
|
||||
const remainingMs = liveStartAtMs - Date.now();
|
||||
if (remainingMs < 0) {
|
||||
throw new Error("Missed paired live-start barrier");
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.max(0, remainingMs));
|
||||
});
|
||||
}
|
||||
const before = await readLivePresentation();
|
||||
await cdp.send("Performance.enable");
|
||||
const cpuBefore = (await cdp.send("Performance.getMetrics")).metrics;
|
||||
@@ -871,7 +943,7 @@ try {
|
||||
await page.waitForFunction(
|
||||
(count) => Reflect.get(window, "__coldLoad").readLive().sentEvents === count,
|
||||
fixture.liveEvents.length,
|
||||
{ timeout: 35_000 },
|
||||
{ timeout: Math.max(35_000, fixture.liveEvents.at(-1)!.atMs + 5_000) },
|
||||
);
|
||||
const plannedWindowMs =
|
||||
fixture.liveEvents.at(-1)!.atMs + (values["sidebar-list-churn"] ? 16_000 : 750);
|
||||
@@ -923,6 +995,26 @@ try {
|
||||
JSON.stringify({ traceEvents: liveTrace }),
|
||||
);
|
||||
const after = await readLivePresentation();
|
||||
const ancestorLink = page.locator(
|
||||
`.sidebar-rail [data-sidebar-entry="session:${fixture.selectedSessionKey}"] a`,
|
||||
);
|
||||
const ancestorBaseline = values["session-event-ancestor-refs"]
|
||||
? {
|
||||
text: await ancestorLink.getAttribute("aria-label"),
|
||||
visible: await ancestorLink.isVisible(),
|
||||
socketCount: await page.evaluate(() =>
|
||||
Reflect.get(window, "openclawControlUiE2eGateway").socketCount(),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
if (
|
||||
ancestorBaseline &&
|
||||
(!ancestorBaseline.text?.includes("Synthetic ancestor baseline verified") ||
|
||||
!ancestorBaseline.visible ||
|
||||
ancestorBaseline.socketCount !== 1)
|
||||
) {
|
||||
throw new Error("Ancestor-reference replay lost its raw baseline or rendered outcome");
|
||||
}
|
||||
const liveMetrics = await page.evaluate(() => Reflect.get(window, "__coldLoad").readLive());
|
||||
// Independent coordinators drift; pair request starts within half their minimum cooldown.
|
||||
const pairedRefreshes = values["sidebar-list-churn"]
|
||||
@@ -946,6 +1038,7 @@ try {
|
||||
const responseTasks = summarizeResponseTasks(liveTrace);
|
||||
live = {
|
||||
...liveMetrics,
|
||||
ancestorBaseline,
|
||||
milliseconds: Object.fromEntries(
|
||||
["TaskDuration", "ScriptDuration", "LayoutDuration", "RecalcStyleDuration"].map(
|
||||
(name) => [
|
||||
@@ -959,16 +1052,17 @@ try {
|
||||
head,
|
||||
sidebarRowTransfer: values["sidebar-list-churn"] ? runTransfer : undefined,
|
||||
sidebarLists: sidebarReads,
|
||||
sidebarWindows: sidebarReads
|
||||
? summarizeSidebarWindows(
|
||||
liveMetrics.rpcs,
|
||||
liveMetrics.rpcOffset,
|
||||
sidebarReads,
|
||||
responseTasks,
|
||||
activeStreamMs,
|
||||
liveMetrics.elapsedMs,
|
||||
)
|
||||
: undefined,
|
||||
sidebarWindows:
|
||||
sidebarReads && !values.untraced
|
||||
? summarizeSidebarWindows(
|
||||
liveMetrics.rpcs,
|
||||
liveMetrics.rpcOffset,
|
||||
sidebarReads,
|
||||
responseTasks,
|
||||
activeStreamMs,
|
||||
liveMetrics.elapsedMs,
|
||||
)
|
||||
: undefined,
|
||||
pairedRefreshes,
|
||||
pairingMeasurement:
|
||||
"Conditional request starts after six seconds, paired within half the five-second minimum cooldown; response arrival skew is separate.",
|
||||
@@ -1080,6 +1174,7 @@ try {
|
||||
JSON.stringify(
|
||||
{
|
||||
head,
|
||||
buildId,
|
||||
dirty,
|
||||
bundleDir,
|
||||
fixture: fixture.counts,
|
||||
@@ -1098,6 +1193,12 @@ try {
|
||||
latencyMs,
|
||||
frameGapMs,
|
||||
captureMs,
|
||||
sessionEventDurationMs,
|
||||
sessionEventAncestorRefs: values["session-event-ancestor-refs"],
|
||||
fixtureNowMs,
|
||||
liveStartAtMs,
|
||||
port,
|
||||
sandboxPort,
|
||||
results,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -68,17 +68,19 @@ await withOpenClawTestState({ label: "session-write-hold" }, async (state) => {
|
||||
}
|
||||
},
|
||||
});
|
||||
JSON.parse = (...args: Parameters<typeof parse>) => {
|
||||
const start = performance.now();
|
||||
const held = holdStart !== undefined;
|
||||
try {
|
||||
return parse(...args);
|
||||
} finally {
|
||||
if (held) {
|
||||
jsonInHoldMs += performance.now() - start;
|
||||
JSON.parse = new Proxy(parse, {
|
||||
apply(target, receiver, args) {
|
||||
const start = performance.now();
|
||||
const held = holdStart !== undefined;
|
||||
try {
|
||||
return Reflect.apply(target, receiver, args);
|
||||
} finally {
|
||||
if (held) {
|
||||
jsonInHoldMs += performance.now() - start;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
holdMs = 0;
|
||||
|
||||
@@ -1007,3 +1007,47 @@ both legacy and delta readers still receive real changes. CPU and RSS cover the
|
||||
workload in the shared Gateway/client process; they are context, not full-browser
|
||||
or production capacity measurements. This script models a reproducible lifecycle
|
||||
pattern, not a claim that all observed viewer traffic is redundant.
|
||||
|
||||
## Measure session roster refreshes during row churn
|
||||
|
||||
`session-roster-churn-measure.ts` starts an isolated loopback Gateway, creates 270
|
||||
synthetic conversations through the real RPC, and drives the Control UI session
|
||||
capability through a WebSocket transport adapter. It observes the general,
|
||||
owner, involving-me, and compact child queries plus the primary roster. The
|
||||
sidebar uses its 200-row window; the ten warmed hot rows receive one patch per
|
||||
second. Each run lasts 135 seconds by default, crossing both minute fallback
|
||||
windows:
|
||||
|
||||
```sh
|
||||
node --import ./scripts/tsx.mjs scripts/perf/session-roster-churn-measure.ts \
|
||||
--state-dir /path/to/new-synthetic-state --port 41471 \
|
||||
--sessions 270 --duration 135 --output /path/to/roster-report.json
|
||||
```
|
||||
|
||||
The state directory must be new. `--session-owner` selects an alternate module
|
||||
exporting `createSessionCapability` and `GatewaySessionRowEvents` for controlled
|
||||
owner and decoder comparisons; otherwise the current source is used. `--start-at` gates activity after setup and rejects
|
||||
an already missed shared deadline. `--membership-churn` also creates and archives
|
||||
sessions every thirty patches, keeping necessary membership reads separate from
|
||||
the steady held-row workload. Run the same scenario on both sides, with the host's
|
||||
exclusive measurement lock and alternating CPU halves. Fixture preparation and
|
||||
builds must use the host's preparation CPU allocation.
|
||||
|
||||
The report records list caller/query parameters, response JSON bytes, conditional
|
||||
row bases, preceding session event, event counts, CPU, and Node event-loop delay.
|
||||
It rejects missing row-event deliveries, decoder gaps, empty observed queries,
|
||||
RPC failures, or final membership, label, status, active-run, model, or agent-status
|
||||
note differences from authoritative list reads. Counts exclude setup and final
|
||||
verification. Bytes are response payload
|
||||
JSON, excluding WebSocket framing and compression. The process contains both the
|
||||
Gateway and session owner, so CPU and event-loop delay do not isolate either.
|
||||
This exercises the real session owner and transport, not browser DOM rendering,
|
||||
Long Task blocking, or reconnection.
|
||||
|
||||
Add `--model-turns 2 --mock-port 41474` for a separate correctness phase after the
|
||||
measured row workload. It enables the bundled OpenAI plugin with synthetic-only
|
||||
credentials and a loopback SSE fixture, changes between two configured models,
|
||||
starts real agent turns, and waits for success. The report must observe each
|
||||
turn's start and settlement events and each loopback inference request. Model
|
||||
and catalog traffic is reported separately and is excluded from the healthy-row
|
||||
rate comparison. The default run starts no model provider.
|
||||
@@ -0,0 +1,614 @@
|
||||
// Real Gateway transport plus the Control UI's session owner, with optional loopback model turns.
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { monitorEventLoopDelay } from "node:perf_hooks";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { buildDeviceAuthPayloadV3 } from "../../packages/gateway-client/src/device-auth.ts";
|
||||
import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.ts";
|
||||
import { isRecord } from "../../packages/normalization-core/src/record-coerce.ts";
|
||||
import {
|
||||
publicKeyRawBase64UrlFromEd25519Pem,
|
||||
signEd25519Payload,
|
||||
} from "../../src/infra/ed25519-signature.ts";
|
||||
import { GatewaySessionRowEvents } from "../../ui/src/api/gateway-session-row-events.ts";
|
||||
import type {
|
||||
GatewayBrowserClient,
|
||||
GatewayEventFrame,
|
||||
GatewayHelloOk,
|
||||
} from "../../ui/src/api/gateway.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../../ui/src/api/types.ts";
|
||||
import type {
|
||||
SessionCapability,
|
||||
SessionGateway,
|
||||
SessionListScope,
|
||||
} from "../../ui/src/lib/sessions/session-capability.ts";
|
||||
import { buildSessionListParams } from "../../ui/src/lib/sessions/session-requests.ts";
|
||||
import { applyMockOpenAiModelConfig } from "../e2e/lib/fixtures/mock-openai-config.mjs";
|
||||
import { readBody } from "../e2e/lib/mock-openai-http.mjs";
|
||||
import { BASE_GATEWAY_BENCH_CONFIG } from "../lib/gateway-bench-runtime.ts";
|
||||
import { createGatewayWsClient } from "../lib/gateway-ws-client.ts";
|
||||
import { parseStrictIntegerOption } from "../lib/strict-integer-option.ts";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"state-dir": { type: "string" },
|
||||
output: { type: "string" },
|
||||
port: { type: "string", default: "41471" },
|
||||
sessions: { type: "string", default: "270" },
|
||||
duration: { type: "string", default: "135" },
|
||||
"interval-ms": { type: "string", default: "1000" },
|
||||
"start-at": { type: "string" },
|
||||
"session-owner": { type: "string" },
|
||||
"model-turns": { type: "string", default: "0" },
|
||||
"mock-port": { type: "string", default: "41474" },
|
||||
"membership-churn": { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
const integer = (raw: string, label: string, min: number) =>
|
||||
parseStrictIntegerOption({ raw, label, min, fallback: min });
|
||||
assert(values["state-dir"] && values.output, "--state-dir and --output are required");
|
||||
const port = integer(values.port, "--port", 40000);
|
||||
assert(port <= 65535);
|
||||
const modelTurns = integer(values["model-turns"], "--model-turns", 0);
|
||||
const mockPort = integer(values["mock-port"], "--mock-port", 40000);
|
||||
assert(mockPort <= 65535 && mockPort !== port);
|
||||
const count = integer(values.sessions, "--sessions", 20);
|
||||
const durationMs = integer(values.duration, "--duration", 1) * 1000;
|
||||
const intervalMs = integer(values["interval-ms"], "--interval-ms", 100);
|
||||
const root = path.resolve(values["state-dir"]);
|
||||
mkdirSync(root); // A run never adopts existing state.
|
||||
mkdirSync(path.join(root, "tmp"));
|
||||
const configPath = path.join(root, "openclaw.json");
|
||||
const config = {
|
||||
...BASE_GATEWAY_BENCH_CONFIG,
|
||||
gateway: {
|
||||
...BASE_GATEWAY_BENCH_CONFIG.gateway,
|
||||
controlUi: { enabled: false, allowedOrigins: [`http://127.0.0.1:${port}`] },
|
||||
},
|
||||
agents: {
|
||||
defaults: { model: { primary: "openai/gpt-5.6-luna" }, heartbeat: { every: "0m" } },
|
||||
entries: { main: { workspace: path.join(root, "workspace") } },
|
||||
},
|
||||
plugins: { enabled: false },
|
||||
cron: { enabled: false },
|
||||
};
|
||||
if (modelTurns) {
|
||||
applyMockOpenAiModelConfig(config, {
|
||||
mockPort,
|
||||
modelRef: "openai/gpt-5.6-luna",
|
||||
utilityModelRef: "openai/gpt-5.6-sol",
|
||||
});
|
||||
Object.assign(config.plugins, {
|
||||
enabled: true,
|
||||
allow: ["openai"],
|
||||
slots: { memory: "none" },
|
||||
load: { paths: [path.resolve("extensions/openai")] },
|
||||
});
|
||||
process.env.OPENAI_API_KEY = "synthetic-loopback-only";
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify(config));
|
||||
Object.assign(process.env, {
|
||||
OPENCLAW_HOME: root,
|
||||
OPENCLAW_STATE_DIR: path.join(root, "state"),
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
TMPDIR: path.join(root, "tmp"),
|
||||
});
|
||||
const { startGatewayServer } = await import("../../src/gateway/server.js");
|
||||
const server = await startGatewayServer(port, { bind: "loopback", controlUiEnabled: false });
|
||||
const owner = (
|
||||
values["session-owner"]
|
||||
? await import(pathToFileURL(path.resolve(values["session-owner"])).href)
|
||||
: {
|
||||
...(await import("../../ui/src/lib/sessions/index.ts")),
|
||||
GatewaySessionRowEvents,
|
||||
}
|
||||
) as {
|
||||
createSessionCapability(
|
||||
gateway: SessionGateway,
|
||||
selection: { state: { selectedId: string }; subscribe(): () => void },
|
||||
): SessionCapability;
|
||||
GatewaySessionRowEvents: typeof GatewaySessionRowEvents;
|
||||
};
|
||||
const eventListeners = new Set<(event: GatewayEventFrame) => void>();
|
||||
const decoder = new owner.GatewaySessionRowEvents();
|
||||
const challenge = Promise.withResolvers<{ nonce: string; ts: number }>();
|
||||
let phase = "setup";
|
||||
let latestEvent: Record<string, unknown> | null = null;
|
||||
let eventCount = 0;
|
||||
const pendingPatches = new Set<string>();
|
||||
const modelEvents: Array<Record<string, unknown>> = [];
|
||||
let modelRequests = 0;
|
||||
const modelOutcomes: unknown[] = [];
|
||||
let eventBytes = 0;
|
||||
let decodeGaps = 0;
|
||||
const eventReasons: Record<string, number> = {};
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
const failures: string[] = [];
|
||||
let capability: SessionCapability | undefined;
|
||||
const observations: Array<{ dispose(): void }> = [];
|
||||
const peer = createGatewayWsClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
onEvent(event) {
|
||||
if (
|
||||
event.event === "connect.challenge" &&
|
||||
isRecord(event.payload) &&
|
||||
typeof event.payload.nonce === "string" &&
|
||||
typeof event.payload.ts === "number"
|
||||
) {
|
||||
challenge.resolve({ nonce: event.payload.nonce, ts: event.payload.ts });
|
||||
}
|
||||
const projected = decoder.project(event);
|
||||
if (projected.missingBaseline) {
|
||||
decodeGaps++;
|
||||
return;
|
||||
}
|
||||
if (event.event === "sessions.changed") {
|
||||
const payload = isRecord(projected.event.payload) ? projected.event.payload : {};
|
||||
latestEvent = {
|
||||
at: Date.now(),
|
||||
reason: payload.reason,
|
||||
sessionKey: payload.sessionKey,
|
||||
revision: payload.revision,
|
||||
event: event.event,
|
||||
};
|
||||
const row = isRecord(payload.session) ? payload.session : {};
|
||||
if (isRecord(row.agentStatus)) {
|
||||
pendingPatches.delete(JSON.stringify([payload.sessionKey, row.agentStatus.note]));
|
||||
}
|
||||
if (phase === "model-correctness") {
|
||||
modelEvents.push({ ...latestEvent, phase: payload.phase });
|
||||
}
|
||||
if (phase === "activity") {
|
||||
eventCount++;
|
||||
eventBytes += Buffer.byteLength(JSON.stringify(event));
|
||||
const reason = String(payload.reason);
|
||||
eventReasons[reason] = (eventReasons[reason] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const listener of eventListeners) {
|
||||
listener(projected.event);
|
||||
}
|
||||
},
|
||||
});
|
||||
async function rpc(method: string, params: unknown) {
|
||||
const result = await peer.request(method, params, 60_000);
|
||||
assert(result.ok, `${method}: ${JSON.stringify(result.error)}`);
|
||||
return result.payload;
|
||||
}
|
||||
const mock = modelTurns
|
||||
? createServer((req, res) => {
|
||||
void (async () => {
|
||||
assert.equal(req.url, "/v1/responses");
|
||||
await readBody(req);
|
||||
modelRequests++;
|
||||
const item = {
|
||||
type: "message",
|
||||
id: `msg_${modelRequests}`,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Synthetic roster answer.", annotations: [] }],
|
||||
};
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const send = (event: unknown) => res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
send({
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { ...item, status: "in_progress", content: [] },
|
||||
});
|
||||
await delay(250);
|
||||
send({
|
||||
type: "response.output_text.delta",
|
||||
item_id: item.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: "Synthetic roster answer.",
|
||||
});
|
||||
send({ type: "response.output_item.done", output_index: 0, item });
|
||||
send({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp_${modelRequests}`,
|
||||
status: "completed",
|
||||
output: [item],
|
||||
usage: { input_tokens: 11, output_tokens: 4, total_tokens: 15 },
|
||||
},
|
||||
});
|
||||
res.end("data: [DONE]\n\n");
|
||||
})().catch((error: unknown) => {
|
||||
failures.push(String(error));
|
||||
res.destroy();
|
||||
});
|
||||
})
|
||||
: null;
|
||||
try {
|
||||
if (mock) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mock.once("error", reject);
|
||||
mock.listen(mockPort, "127.0.0.1", resolve);
|
||||
});
|
||||
}
|
||||
await peer.waitOpen();
|
||||
const timeout = setTimeout(
|
||||
() => challenge.reject(new Error("connect challenge timeout")),
|
||||
12_000,
|
||||
);
|
||||
const { nonce, ts } = await challenge.promise.finally(() => clearTimeout(timeout));
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const publicKey = publicKeyRawBase64UrlFromEd25519Pem(
|
||||
keys.publicKey.export({ type: "spki", format: "pem" }),
|
||||
);
|
||||
const deviceId = createHash("sha256").update(Buffer.from(publicKey, "base64url")).digest("hex");
|
||||
const identity = {
|
||||
id: "openclaw-control-ui",
|
||||
version: "dev",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
instanceId: "synthetic-roster",
|
||||
};
|
||||
const scopes = ["operator.admin"];
|
||||
const signed = buildDeviceAuthPayloadV3({
|
||||
deviceId,
|
||||
clientId: identity.id,
|
||||
clientMode: identity.mode,
|
||||
platform: identity.platform,
|
||||
role: "operator",
|
||||
scopes,
|
||||
signedAtMs: ts,
|
||||
nonce,
|
||||
});
|
||||
const hello = (await rpc("connect", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: identity,
|
||||
role: "operator",
|
||||
scopes,
|
||||
device: {
|
||||
id: deviceId,
|
||||
publicKey,
|
||||
signedAt: ts,
|
||||
nonce,
|
||||
signature: signEd25519Payload(
|
||||
keys.privateKey.export({ type: "pkcs8", format: "pem" }),
|
||||
signed,
|
||||
),
|
||||
},
|
||||
caps: ["session-row-deltas", "session-event-field-refs"],
|
||||
})) as GatewayHelloOk;
|
||||
const self = await rpc("users.self", {});
|
||||
assert(isRecord(self) && isRecord(self.profile) && typeof self.profile.id === "string");
|
||||
const profileId = self.profile.id;
|
||||
await rpc("sessions.create", {
|
||||
key: "agent:main:main",
|
||||
visibility: "shared",
|
||||
displayName: "Synthetic parent",
|
||||
});
|
||||
const sessionKeys = Array.from({ length: count }, (_, i) => `agent:main:roster-${i}`);
|
||||
const sessionIds = new Map<string, string>();
|
||||
for (const [index, key] of sessionKeys.entries()) {
|
||||
const created = await rpc("sessions.create", {
|
||||
key,
|
||||
visibility: "shared",
|
||||
displayName: `Synthetic session ${index}`,
|
||||
...(index > count - 11 ? { parentSessionKey: sessionKeys[0] } : {}),
|
||||
});
|
||||
assert(isRecord(created) && typeof created.sessionId === "string");
|
||||
sessionIds.set(key, created.sessionId);
|
||||
}
|
||||
for (const key of sessionKeys.slice(0, 10)) {
|
||||
await rpc("sessions.patch", { key, statusNote: "Synthetic warm row", ttlMinutes: 5 });
|
||||
}
|
||||
console.log(JSON.stringify({ phase: "fixture-ready", sessions: count + 1 }));
|
||||
const request = async (method: string, params?: Record<string, unknown>) => {
|
||||
const startedAt = Date.now();
|
||||
const call =
|
||||
method === "sessions.list"
|
||||
? {
|
||||
phase,
|
||||
startedAt,
|
||||
params,
|
||||
precedingEvent: latestEvent,
|
||||
hasBase: typeof params?.ifRowsRevision === "string" && params.ifRowsRevision.length > 0,
|
||||
caller: params?.spawnedBy
|
||||
? "compact-children"
|
||||
: params?.ownerId
|
||||
? "sidebar-owner"
|
||||
: params?.involvingMe
|
||||
? "sidebar-involving"
|
||||
: params?.includeOwnerSessionCounts
|
||||
? "owner-counts"
|
||||
: params?.source === "sidebar"
|
||||
? "sidebar-general"
|
||||
: "chat-pane",
|
||||
}
|
||||
: null;
|
||||
try {
|
||||
const response = await rpc(method, params);
|
||||
if (call) {
|
||||
calls.push({
|
||||
...call,
|
||||
ms: Date.now() - startedAt,
|
||||
bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
responseKind:
|
||||
isRecord(response) && response.rowsUnchanged
|
||||
? "unchanged"
|
||||
: isRecord(response) && response.rowsDelta
|
||||
? "delta"
|
||||
: "full",
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
failures.push(String(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const gatewayListeners = new Set<(snapshot: SessionGateway["snapshot"]) => void>();
|
||||
const gateway: SessionGateway = {
|
||||
snapshot: {
|
||||
client: { request } as GatewayBrowserClient,
|
||||
phase: "connected",
|
||||
hello,
|
||||
assistantAgentId: "main",
|
||||
sessionKey: sessionKeys[0],
|
||||
selfUser: { id: profileId },
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
gatewayListeners.add(listener);
|
||||
return () => {
|
||||
gatewayListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
subscribeEvents: (listener) => {
|
||||
eventListeners.add(listener);
|
||||
return () => {
|
||||
eventListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
capability = owner.createSessionCapability(gateway, {
|
||||
state: { selectedId: "main" },
|
||||
subscribe: () => () => {},
|
||||
});
|
||||
for (const listener of gatewayListeners) {
|
||||
listener(gateway.snapshot);
|
||||
}
|
||||
const common = {
|
||||
source: "sidebar",
|
||||
agentId: "main",
|
||||
rowMode: "sidebar",
|
||||
limit: 200,
|
||||
ownerFirst: true,
|
||||
includeDerivedTitles: true,
|
||||
includeLastMessage: true,
|
||||
} as const;
|
||||
const queries: Array<{ name: string; query: SessionListScope }> = [
|
||||
{ name: "sidebar-general", query: common },
|
||||
{
|
||||
name: "sidebar-owner",
|
||||
query: { ...common, ownerFirst: undefined, agentId: undefined, ownerId: profileId },
|
||||
},
|
||||
{
|
||||
name: "sidebar-involving",
|
||||
query: { ...common, ownerFirst: undefined, involvingMe: true },
|
||||
},
|
||||
{
|
||||
name: "compact-children",
|
||||
query: {
|
||||
...common,
|
||||
source: "chat-pane",
|
||||
rowMode: "compact",
|
||||
ownerFirst: false,
|
||||
includeDerivedTitles: false,
|
||||
includeLastMessage: false,
|
||||
spawnedBy: sessionKeys[0],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const { query } of queries) {
|
||||
const observation = capability.observeList(query, () => {});
|
||||
observations.push(observation);
|
||||
await observation.refresh();
|
||||
assert(
|
||||
(capability.listSnapshot(query).result?.sessions.length ?? 0) > 0,
|
||||
"every observed query must contain fixture rows",
|
||||
);
|
||||
}
|
||||
// The primary chat roster is a separate consumer, and must share the same owner.
|
||||
await capability.refresh({ ...common, force: true });
|
||||
await delay(1000);
|
||||
console.log(JSON.stringify({ phase: "observers-ready", calls: calls.length }));
|
||||
if (values["start-at"]) {
|
||||
const startAt = integer(values["start-at"], "--start-at", 1);
|
||||
assert(startAt > Date.now(), "pair start deadline must follow Gateway preparation");
|
||||
await delay(startAt - Date.now());
|
||||
}
|
||||
const loop = monitorEventLoopDelay({ resolution: 10 });
|
||||
loop.enable();
|
||||
const cpu = process.cpuUsage();
|
||||
const started = performance.now();
|
||||
phase = "activity";
|
||||
const operations = Math.floor(durationMs / intervalMs);
|
||||
for (let round = 0; round < operations; round++) {
|
||||
await delay(Math.max(0, started + round * intervalMs - performance.now()));
|
||||
const key = sessionKeys[round % 10]!;
|
||||
pendingPatches.add(JSON.stringify([key, `Synthetic phase ${round}`]));
|
||||
await rpc("sessions.patch", {
|
||||
key,
|
||||
statusNote: `Synthetic phase ${round}`,
|
||||
ttlMinutes: 5,
|
||||
...(round % 3 === 0 ? { label: `Synthetic label ${round}` } : {}),
|
||||
});
|
||||
if (values["membership-churn"] && round > 0 && round % 30 === 0) {
|
||||
await rpc("sessions.create", {
|
||||
key: `agent:main:roster-new-${round}`,
|
||||
visibility: "shared",
|
||||
displayName: `Synthetic new session ${round}`,
|
||||
});
|
||||
const archivedKey = sessionKeys[count - 15 + ((round / 30) % 4)]!;
|
||||
await rpc("sessions.patch", {
|
||||
key: archivedKey,
|
||||
expectedSessionId: sessionIds.get(archivedKey),
|
||||
archived: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await delay(Math.max(0, started + durationMs - performance.now()));
|
||||
console.log(JSON.stringify({ phase: "activity-complete", eventCount }));
|
||||
phase = "drain";
|
||||
const elapsedMs = performance.now() - started;
|
||||
const used = process.cpuUsage(cpu);
|
||||
loop.disable();
|
||||
if (modelTurns) {
|
||||
phase = "model-correctness";
|
||||
for (let turn = 0; turn < modelTurns; turn++) {
|
||||
const key = sessionKeys[turn % 10]!;
|
||||
await rpc("sessions.patch", {
|
||||
key,
|
||||
model: turn % 2 ? "openai/gpt-5.6-sol" : "openai/gpt-5.6-luna",
|
||||
});
|
||||
const runId = randomUUID();
|
||||
await rpc("agent", {
|
||||
sessionKey: key,
|
||||
message: "Reply with one synthetic acknowledgement.",
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
const outcome = await rpc("agent.wait", { runId, timeoutMs: 60_000 });
|
||||
modelOutcomes.push(outcome);
|
||||
assert(
|
||||
isRecord(outcome) && outcome.status === "ok",
|
||||
`synthetic turn failed: ${JSON.stringify(outcome)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await delay(20_000); // Allow one paced membership refresh to settle before comparing authority.
|
||||
const correctness: Array<Record<string, unknown>> = [];
|
||||
phase = "verification";
|
||||
const compareRows = async (
|
||||
name: string,
|
||||
held: SessionsListResult | null,
|
||||
query: SessionListScope,
|
||||
) => {
|
||||
assert(held, `${name} must retain a list result`);
|
||||
const authoritative = (await rpc(
|
||||
"sessions.list",
|
||||
buildSessionListParams(query),
|
||||
)) as SessionsListResult;
|
||||
// Sidebar rows omit model and terminal status, even for explicit model overrides.
|
||||
const fullRows = (await rpc("sessions.list", {
|
||||
...buildSessionListParams(query),
|
||||
rowMode: undefined,
|
||||
})) as SessionsListResult;
|
||||
const fullByKey = new Map(fullRows.sessions.map((row) => [row.key, row]));
|
||||
const facts = (row: GatewaySessionRow, defaults: SessionsListResult["defaults"]) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
status: row.status,
|
||||
hasActiveRun: row.hasActiveRun === true,
|
||||
modelProvider: row.modelProvider ?? defaults.modelProvider,
|
||||
model: row.model ?? defaults.model,
|
||||
note: row.agentStatus?.note,
|
||||
});
|
||||
const actual = new Map(held.sessions.map((row) => [row.key, facts(row, held.defaults)]));
|
||||
const expected = new Map(
|
||||
authoritative.sessions.map((row) => {
|
||||
const full = fullByKey.get(row.key);
|
||||
assert(full, `full authority must include ${name} row ${row.key}`);
|
||||
return [row.key, facts(full, fullRows.defaults)];
|
||||
}),
|
||||
);
|
||||
const rowKeys = [...new Set([...actual.keys(), ...expected.keys()])].toSorted();
|
||||
const mismatches = rowKeys
|
||||
.filter((key) => JSON.stringify(actual.get(key)) !== JSON.stringify(expected.get(key)))
|
||||
.map((key) => ({ key, actual: actual.get(key), expected: expected.get(key) }));
|
||||
correctness.push({
|
||||
name,
|
||||
held: actual.size,
|
||||
authoritative: expected.size,
|
||||
equal: mismatches.length === 0,
|
||||
mismatches,
|
||||
});
|
||||
};
|
||||
for (const { name, query } of queries) {
|
||||
const snapshot = capability.listSnapshot(query);
|
||||
assert.equal(snapshot.error, null, `${name} observer error`);
|
||||
await compareRows(name, snapshot.result, query);
|
||||
}
|
||||
await compareRows("primary", capability.state.result, common);
|
||||
const measuredCalls = calls.filter((call) => call.phase === "activity");
|
||||
const report = {
|
||||
boundary:
|
||||
"real Gateway + real Control UI session capability over WebSocket; no browser renderer",
|
||||
sessionOwner: values["session-owner"] ?? "current source",
|
||||
count,
|
||||
operations,
|
||||
elapsedMs,
|
||||
membershipChurn: values["membership-churn"],
|
||||
eventCount,
|
||||
missingPatches: [...pendingPatches],
|
||||
eventBytes,
|
||||
eventReasons,
|
||||
decodeGaps,
|
||||
callsPerMinute: (measuredCalls.length * 60_000) / elapsedMs,
|
||||
listCalls: measuredCalls.length,
|
||||
listBytes: measuredCalls.reduce((sum, call) => sum + Number(call.bytes), 0),
|
||||
listBytesPerMinute:
|
||||
(measuredCalls.reduce((sum, call) => sum + Number(call.bytes), 0) * 60_000) / elapsedMs,
|
||||
cpuMs: (used.user + used.system) / 1000,
|
||||
nodeEventLoopP95Ms: loop.percentile(95) / 1e6,
|
||||
nodeEventLoopMaxMs: loop.max / 1e6,
|
||||
correctness,
|
||||
failures,
|
||||
calls,
|
||||
modelTurns,
|
||||
modelRequests,
|
||||
modelOutcomes,
|
||||
modelEvents,
|
||||
gaps: [
|
||||
"No browser DOM rendering or native Long Task blocking measurement",
|
||||
...(modelTurns ? [] : ["No model turns: running/settled lifecycles are not exercised"]),
|
||||
],
|
||||
};
|
||||
writeFileSync(values.output, JSON.stringify(report, null, 2) + "\n");
|
||||
console.log(JSON.stringify({ ...report, calls: undefined }));
|
||||
if (modelTurns) {
|
||||
assert(modelRequests >= modelTurns, "each synthetic turn must reach the loopback provider");
|
||||
assert(
|
||||
modelEvents.filter((event) => event.phase === "start").length >= modelTurns,
|
||||
"missing run-start observations",
|
||||
);
|
||||
assert(
|
||||
modelEvents.filter((event) => event.phase === "end").length >= modelTurns,
|
||||
"missing run-settled observations",
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
pendingPatches.size,
|
||||
0,
|
||||
"every workload patch must be observed through real session events",
|
||||
);
|
||||
assert.equal(decodeGaps, 0, "real event row baselines must remain valid");
|
||||
assert.equal(failures.length, 0, failures.join("\n"));
|
||||
assert(
|
||||
correctness.every((item) => item.equal),
|
||||
"final row membership or facts differ from authority",
|
||||
);
|
||||
} finally {
|
||||
for (const observation of observations) {
|
||||
observation.dispose();
|
||||
}
|
||||
capability?.dispose();
|
||||
peer.close();
|
||||
await server.close();
|
||||
if (mock?.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mock.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
"compilerOptions": {
|
||||
// Lifecycle hooks and compatibility shims must stay executable by plain Node.
|
||||
"allowJs": true,
|
||||
// Benchmarks exercise the real Control UI owners and their Solid dependencies.
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@solidjs/web",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"tsBuildInfoFile": ".artifacts/tsgo-cache/scripts.tsbuildinfo"
|
||||
@@ -11,7 +14,7 @@
|
||||
"scripts/**/*",
|
||||
"src/**/*.d.ts",
|
||||
"packages/**/*.d.ts",
|
||||
"ui/src/i18n/virtual-locale.d.ts"
|
||||
"ui/src/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
+2
-2
@@ -22,13 +22,13 @@ imports inside their route's `component` loader.
|
||||
|
||||
- `src/lib/sessions/session-list-query.ts` owns held-window reconciliation. Lifecycle, participants, placement, patch/send/steer, run-start/settlement/capacity, title snapshots may skip list reads only with unchanged membership, lineage, pin/owner/archive facts + nondecreasing recency. Tree events: complete access-scoped `ancestorSessions` + any `ancestorSessionRefs`; references need held row's admitted content revision. Preserve each row's generation + field receipts. Certified nested rows own facts; only explicit null clearing receipts may fill omissions from envelope.
|
||||
- Authoritative refreshes required: unknown rows, incomplete ancestor coverage, broad/catalog changes, Gateway-owned filters, failed reads, uncertain owner-prefix boundaries. Overlapping reads recheck later row receipts against their accepted membership; covered facts merge without trailing reads. Events never create filtered membership; only complete unfiltered active-only Current Work windows may admit certified full active rows below.
|
||||
- Sidebar windows may exclude certified rows below a full recency window; owner-first overflow crossing into the shared page still needs authority. Involving-me members need current viewer ownership or participant receipts and explicit non-hidden state; bounded participants cannot prove non-membership or admit unseen members.
|
||||
- Sidebar windows may exclude certified rows below a full recency window; owned rows must also fall below the bounded owner prefix. Owner-first overflow crossing into the shared page still needs authority. The connection's decoded row cache expands matching ancestor references without advancing wire baselines; evicted or mismatched references retain authoritative refresh behavior. Involving-me members need current viewer ownership or participant receipts and explicit non-hidden state; bounded participants cannot prove non-membership or admit unseen members.
|
||||
- Compact rows + bounded source attribution. Enrichment flags/global-or-unknown inclusion preserve membership when kind unchanged; dashboard filters need matching `hasBoard`/`boardFace` receipts. Gallery pagination extends shared managed window; load full settings from descriptors on demand. Child membership: Gateway retention owner's `childOwnerSessionKeys`. Parent-only events must certify complete child window; unheld ancestors need explicit exclusion or admitted reference resolved through connection row provenance.
|
||||
- Cached lineage re-adoption changes presentation, not managed membership. Preserve fresh descriptor/event invalidation. Apply admitted descriptor rows immediately; incomplete ancestor coverage gets one paced coordinator refresh, never read per event.
|
||||
- Unfiltered History/Current Work: apply admitted rows without list refetches. History's shared row-provenance owner retains field clocks, recap updates, clearing receipts; returned lists alone own membership. `excludeSubagents` ignores key-proven child exclusions only with complete access-scoped ancestor coverage; held parents/certified references still update. Missing held parents/supplied unheld ancestors need authority; named unheld parents absent from certified coverage may be invisible. Merge live facts on refresh, never replay/coalesce History packets. Missing initial membership needs catch-up; query/connection changes reset provenance. History: forward activity clocks required; preserve omitted recap enrichment; person/search filters retain authoritative refreshes.
|
||||
- Current Work: coalesce only consecutive full active snapshots of one generation, retaining first receipt/latest tail. Partial events, references, terminal snapshots, other identities = FIFO barriers. Complete window below limit may admit certified active row with real session ID/kind, sampled after accepted list + generation retirement/liveness clocks; preserve Gateway cron-run exclusion. Truncated/full windows, conflicting generations, partial unknown rows, ambiguous settlement, incomplete-window removals need authority.
|
||||
- Current Work: apply ancestor snapshots/references via shared reconciler; pending references retain prerequisite full-row receipts. Controller caps generation-coalesced fence records at 1,000; reconciler owns independent retirement, liveness-observation, generation-authority facts. Only retirement prunes stale returned rows. Fresh certified full rows may resolve older clocked liveness; unclocked observations/conflicting generations need list read. Query/connection changes clear records. Authoritative lists clear uncertainty + covered retirements before pending replay, preserving later retirements. Saturation blocks unseen admission until authoritative read restores bounded state.
|
||||
- Healthy applied row traffic retains one fallback refresh after at least 60 seconds, including whole-query people/pulse facets. `src/lib/sessions/event-refresh-coordinator.ts` owns pacing + pending/trailing invalidation: sample four-to-five-second collection window once when armed; events cannot postpone it. After automatic refresh: wait three times its duration, bounded to five–15 seconds.
|
||||
- Healthy applied roster row traffic does not schedule a fallback list read. Activity retains its 60-second fallback for whole-query people/pulse facets. `src/lib/sessions/event-refresh-coordinator.ts` owns pacing + pending/trailing invalidation: sample four-to-five-second collection window once when armed; events cannot postpone it. After automatic refresh: wait three times its duration, bounded to five–15 seconds.
|
||||
- Accepted local creates refresh observed list membership independently of primary reads/bootstrap. Remote create events use coordinator collection/backoff with other invalidations; the Gateway still owns owner/involvement/search filtering. Retain visibility, bootstrap admission, and queued navigation ownership.
|
||||
- Explicit refresh, filter/agent changes, reconnects, foreground replacement bypass backoff + absorb pending invalidation. Recheck visibility/current intent after background admission; hidden pages retain one catch-up until visible.
|
||||
|
||||
|
||||
@@ -111,6 +111,106 @@ describe("session row event ingress", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["parent-session", undefined])(
|
||||
"expands certified ancestor references with session ID %s without changing the raw delta baseline",
|
||||
(sessionId) => {
|
||||
const h = harness();
|
||||
const parent = {
|
||||
key: "parent",
|
||||
agentId: "work",
|
||||
...(sessionId === undefined ? {} : { sessionId }),
|
||||
ancestorRevision: "parent-revision",
|
||||
snapshotAt: 100,
|
||||
label: "Original",
|
||||
};
|
||||
h.send({ ancestorSessions: [full(parent)] });
|
||||
expect(
|
||||
h.send({
|
||||
session: full({ key: "child", label: "Child" }),
|
||||
sessionFieldRefs: [1],
|
||||
ancestorSessions: [],
|
||||
ancestorSessionRefs: [
|
||||
{
|
||||
key: parent.key,
|
||||
agentId: parent.agentId,
|
||||
...(sessionId === undefined ? {} : { sessionId }),
|
||||
revision: parent.ancestorRevision,
|
||||
snapshotAt: 200,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
event({
|
||||
session: { key: "child", label: "Child" },
|
||||
label: "Child",
|
||||
ancestorSessions: [{ ...parent, snapshotAt: 200 }],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
h.send({
|
||||
ancestorSessions: [
|
||||
{
|
||||
key: parent.key,
|
||||
agentId: parent.agentId,
|
||||
base: 1,
|
||||
version: 2,
|
||||
set: { label: "Updated" },
|
||||
remove: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(event({ ancestorSessions: [{ ...parent, label: "Updated" }] }));
|
||||
expect(h.reconnect).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves unresolved ancestor references without touching the wire row LRU", () => {
|
||||
const h = harness();
|
||||
const parent = {
|
||||
key: "parent",
|
||||
agentId: "work",
|
||||
sessionId: "parent-session",
|
||||
ancestorRevision: "parent-revision",
|
||||
snapshotAt: 100,
|
||||
};
|
||||
const reference = {
|
||||
key: parent.key,
|
||||
agentId: parent.agentId,
|
||||
sessionId: parent.sessionId,
|
||||
revision: parent.ancestorRevision,
|
||||
snapshotAt: 200,
|
||||
};
|
||||
const unresolved = [
|
||||
{ ...reference, key: "other-parent" },
|
||||
{ ...reference, agentId: "other-agent" },
|
||||
{ ...reference, sessionId: "other-generation" },
|
||||
{ ...reference, sessionId: undefined },
|
||||
{ ...reference, revision: "other-revision" },
|
||||
];
|
||||
h.send({
|
||||
ancestorSessions: [
|
||||
full(parent),
|
||||
...Array.from({ length: SESSION_ROW_DELTA_MAX_ENTRIES - 1 }, (_, index) =>
|
||||
full({ key: `row-${index}` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
h.send({ ancestorSessions: [], ancestorSessionRefs: [...unresolved, reference] }),
|
||||
).toEqual(
|
||||
event({
|
||||
ancestorSessions: [{ ...parent, snapshotAt: 200 }],
|
||||
ancestorSessionRefs: unresolved,
|
||||
}),
|
||||
);
|
||||
h.send({ session: full({ key: "new-row" }) });
|
||||
const unavailable = { ancestorSessions: [], ancestorSessionRefs: [reference] };
|
||||
expect(h.send(unavailable)).toEqual(event(unavailable));
|
||||
h.events.clear();
|
||||
expect(h.send(unavailable)).toEqual(event(unavailable));
|
||||
expect(h.reconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a divergent frame atomically and asks to reconnect once across listeners", () => {
|
||||
const h = harness();
|
||||
h.send({ session: full({ key: "child", runtimeMs: 10 }) });
|
||||
|
||||
@@ -149,6 +149,41 @@ export class GatewaySessionRowEvents {
|
||||
? ancestors.map((row) => decodeRow(row, transaction))
|
||||
: ancestors;
|
||||
let envelope = payload;
|
||||
if (Array.isArray(ancestorSessions) && Array.isArray(payload.ancestorSessionRefs)) {
|
||||
const unresolved: unknown[] = [];
|
||||
for (const value of payload.ancestorSessionRefs) {
|
||||
const reference = asNullableRecord(value);
|
||||
const retained =
|
||||
reference &&
|
||||
typeof reference.key === "string" &&
|
||||
(reference.agentId === undefined || typeof reference.agentId === "string")
|
||||
? transaction.get(sessionRowIdentity(reference.key, reference.agentId))?.row
|
||||
: undefined;
|
||||
if (
|
||||
!reference ||
|
||||
!retained ||
|
||||
(reference.sessionId !== undefined && typeof reference.sessionId !== "string") ||
|
||||
reference.sessionId !== retained.sessionId ||
|
||||
typeof reference.revision !== "string" ||
|
||||
reference.revision !== retained.ancestorRevision ||
|
||||
typeof reference.snapshotAt !== "number" ||
|
||||
!Number.isFinite(reference.snapshotAt)
|
||||
) {
|
||||
unresolved.push(value);
|
||||
continue;
|
||||
}
|
||||
// References certify content, but never change the wire delta baseline or its LRU.
|
||||
ancestorSessions.push({ ...retained, snapshotAt: reference.snapshotAt });
|
||||
}
|
||||
if (unresolved.length !== payload.ancestorSessionRefs.length) {
|
||||
envelope = { ...payload };
|
||||
if (unresolved.length) {
|
||||
envelope.ancestorSessionRefs = unresolved;
|
||||
} else {
|
||||
delete envelope.ancestorSessionRefs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(payload, "sessionFieldRefs")) {
|
||||
const row = asNullableRecord(session);
|
||||
if (!row || !Array.isArray(payload.sessionFieldRefs)) {
|
||||
@@ -163,7 +198,7 @@ export class GatewaySessionRowEvents {
|
||||
}
|
||||
restored[field] = row[field];
|
||||
}
|
||||
envelope = { ...payload, ...restored };
|
||||
envelope = { ...envelope, ...restored };
|
||||
delete envelope.sessionFieldRefs;
|
||||
}
|
||||
transaction.commit();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setAvatarGatewayOrigin } from "../lib/identity-avatar-context.ts";
|
||||
import { resolveAvatar } from "../lib/identity-avatar.ts";
|
||||
import { invalidateModelCatalogCache } from "../lib/model-catalog-cache.ts";
|
||||
import { loadModelCatalog } from "../lib/model-catalog-store.ts";
|
||||
import { requestSessionList } from "../lib/sessions/session-requests.ts";
|
||||
import {
|
||||
createGatewayEvent,
|
||||
createGatewayStoreTestStore as createStore,
|
||||
@@ -401,13 +402,17 @@ describe("createApplicationGateway connection ownership", () => {
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"discards a gapped frame when recovery replaces its client (observer=%s)",
|
||||
(observer) => {
|
||||
"retains row bases through gap recovery unless an observer replaces the client (observer=%s)",
|
||||
async (observer) => {
|
||||
const listener = vi.fn();
|
||||
gateway.subscribeEventLog(() => {});
|
||||
gateway.subscribeEvents(listener);
|
||||
connect();
|
||||
const stale = current();
|
||||
const row = { key: "agent:main:held", kind: "direct" as const };
|
||||
stale.request.mockResolvedValue({ sessions: [row], rowsRevision: "held-revision" });
|
||||
const query = { rowMode: "sidebar" as const };
|
||||
await requestSessionList(gateway.snapshot.client!, query, () => true);
|
||||
if (observer) {
|
||||
gateway.subscribe((snapshot) => {
|
||||
if (snapshot.lastError?.startsWith("event gap detected")) {
|
||||
@@ -415,12 +420,31 @@ describe("createApplicationGateway connection ownership", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
// The protocol invokes onGap before onEvent for the same received frame.
|
||||
// The protocol drops the gapped frame and owns socket reconnection.
|
||||
stale.opts.onGap?.({ expected: 2, received: 5 });
|
||||
stale.opts.onEvent?.(createGatewayEvent("stale.gap", { stale: true }, 5));
|
||||
expect(clients).toHaveLength(observer ? 2 : 1);
|
||||
if (observer) {
|
||||
stale.opts.onEvent?.(createGatewayEvent("stale.gap", { stale: true }, 5));
|
||||
} else {
|
||||
stale.opts.onClose?.({ code: 4000, reason: "event sequence gap", willRetry: true });
|
||||
stale.opts.onHello?.(HELLO);
|
||||
stale.request.mockResolvedValue({
|
||||
sessions: [],
|
||||
rowsRevision: "held-revision",
|
||||
rowsUnchanged: true,
|
||||
});
|
||||
expect(await requestSessionList(gateway.snapshot.client!, query, () => true)).toMatchObject(
|
||||
{
|
||||
sessions: [row],
|
||||
},
|
||||
);
|
||||
expect(stale.request).toHaveBeenLastCalledWith(
|
||||
"sessions.list",
|
||||
expect.objectContaining({ ifRowsRevision: "delta:held-revision" }),
|
||||
);
|
||||
}
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(gateway.eventLog).toEqual([]);
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(current().started).toBe(1);
|
||||
expect(gateway.snapshot.client).toBe(current());
|
||||
const activeEvent = createGatewayEvent("fresh.event", { active: true }, 6);
|
||||
|
||||
@@ -612,9 +612,8 @@ export function createApplicationGateway(
|
||||
lastErrorCode: null,
|
||||
lastErrorAuthReason: null,
|
||||
});
|
||||
if (isCurrentClient(nextClient)) {
|
||||
connect();
|
||||
}
|
||||
// The protocol reconnects its socket after dropping the gapped frame.
|
||||
// Keep the client so conditional reads retain their raw row bases.
|
||||
},
|
||||
onEvent: createGatewayEventObserver({
|
||||
isAttached: () => client === nextClient,
|
||||
|
||||
@@ -56,7 +56,7 @@ const suite = createControlUiE2eSuite({
|
||||
});
|
||||
|
||||
suite.define(() => {
|
||||
it("applies real row events in the page and sidebar before a one-minute fallback", async () => {
|
||||
it("applies real row events in the page and sidebar without periodic list reads", async () => {
|
||||
if (!instance) {
|
||||
throw new Error("Gateway fixture is not running");
|
||||
}
|
||||
@@ -168,13 +168,11 @@ suite.define(() => {
|
||||
}
|
||||
await page.clock.runFor(56_999 - 4 * eventRenderMs);
|
||||
expect(lists).toEqual(before);
|
||||
// Later rows cannot postpone the first row's minute fallback + collection window.
|
||||
await page.clock.runFor(5_001);
|
||||
await expect.poll(() => pageReads().length).toBe(pageBefore + 1);
|
||||
await expect.poll(() => responses.has(pageReads().at(-1)!.id)).toBe(true);
|
||||
expect(lists).toEqual(before);
|
||||
await roster.getByText("Trailing rate update 2", { exact: true }).waitFor();
|
||||
await expect.poll(() => selected.textContent()).toContain("Trailing rate update 2");
|
||||
expect(primaryReads()).toHaveLength(primaryBefore + 1);
|
||||
expect(primaryReads()).toHaveLength(primaryBefore);
|
||||
expect(lists.every(({ params }) => params.source)).toBe(true);
|
||||
expect(pageReads().every(({ params }) => params.rowMode === "compact")).toBe(true);
|
||||
expect(primaryReads().every(({ params }) => params.rowMode === "sidebar")).toBe(true);
|
||||
@@ -191,9 +189,9 @@ suite.define(() => {
|
||||
// A relevant successor proves the quiet interval did not retire the subscription.
|
||||
await patch(key, "Relevant after unrelated");
|
||||
await page.clock.runFor(5_000);
|
||||
expect(pageReads()).toHaveLength(pageBefore + 1);
|
||||
expect(pageReads()).toHaveLength(pageBefore);
|
||||
await roster.getByText("Relevant after unrelated", { exact: true }).waitFor();
|
||||
expect(primaryReads()).toHaveLength(primaryBefore + 1);
|
||||
expect(primaryReads()).toHaveLength(primaryBefore);
|
||||
await page.clock.resume();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -299,19 +299,20 @@ describe("session list requests", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("queues a reentrant observed refresh behind the current request", async () => {
|
||||
it("queues a reentrant observed refresh of a loaded list behind the current request", async () => {
|
||||
const firstResponse = createDeferred<SessionsListResult>();
|
||||
const secondResponse = createDeferred<SessionsListResult>();
|
||||
const secondStarted = createDeferred();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(listResult(["agent:writer:seed"]))
|
||||
.mockImplementationOnce(async () => firstResponse.promise)
|
||||
.mockImplementationOnce(async () => {
|
||||
secondStarted.resolve();
|
||||
return secondResponse.promise;
|
||||
});
|
||||
const { sessions } = sessionHarness(request);
|
||||
let requestedAgain = false;
|
||||
let requestedAgain = true;
|
||||
let reentrant: Promise<void> | undefined;
|
||||
let observedKeys: string[] = [];
|
||||
const observation = sessions.observeList({ agentId: "writer", limit: 2 }, (snapshot) => {
|
||||
@@ -321,6 +322,9 @@ describe("session list requests", () => {
|
||||
reentrant = observation.refresh();
|
||||
}
|
||||
});
|
||||
await observation.refresh();
|
||||
request.mockClear();
|
||||
requestedAgain = false;
|
||||
const initial = observation.refresh();
|
||||
|
||||
onTestFinished(async () => {
|
||||
|
||||
@@ -8,6 +8,66 @@ import {
|
||||
sessionsResult,
|
||||
} from "./session-capability.test-support.ts";
|
||||
import type { SessionListOptions, SessionListSnapshot } from "./session-capability.ts";
|
||||
import { canApplySessionListSnapshot } from "./session-list-query.ts";
|
||||
|
||||
it.each([
|
||||
[200, 60, true],
|
||||
[200, 59, false],
|
||||
[2, 2, true],
|
||||
[2, 1, false],
|
||||
] as const)(
|
||||
"excludes an old owned ancestor only behind the full owner prefix (limit=%i, owned=%i)",
|
||||
(limit, owned, expected) => {
|
||||
const actor = (id: string) => ({
|
||||
type: "human" as const,
|
||||
id,
|
||||
identity: { type: "profile" as const, id },
|
||||
});
|
||||
const rows: GatewaySessionRow[] = Array.from({ length: limit }, (_, index) => ({
|
||||
key: `agent:main:child-${index}`,
|
||||
sessionId: `child-${index}`,
|
||||
kind: "direct",
|
||||
updatedAt: 1_000 - index,
|
||||
owner: { actor: actor(index < owned ? "viewer" : "other") },
|
||||
parentSessionKey: "agent:main:main",
|
||||
}));
|
||||
const parent: GatewaySessionRow = {
|
||||
key: "agent:main:main",
|
||||
sessionId: "parent",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
owner: { actor: actor("viewer") },
|
||||
};
|
||||
const result = {
|
||||
...sessionsResult(rows, 2_000),
|
||||
hasMore: true,
|
||||
nextOffset: limit,
|
||||
};
|
||||
const event = {
|
||||
reason: "patch",
|
||||
session: { ...rows[0], updatedAt: 2_001 },
|
||||
ancestorSessions: [parent],
|
||||
};
|
||||
const query = { agentId: "main", limit, ownerFirst: true };
|
||||
expect(
|
||||
canApplySessionListSnapshot(result, event, query, "updatedAt", undefined, "viewer"),
|
||||
).toBe(expected);
|
||||
// A parent that becomes recent may enter the shared page even with a full owner prefix.
|
||||
expect(
|
||||
canApplySessionListSnapshot(
|
||||
result,
|
||||
{
|
||||
...event,
|
||||
ancestorSessions: [{ ...parent, updatedAt: 2_002 }],
|
||||
},
|
||||
query,
|
||||
"updatedAt",
|
||||
undefined,
|
||||
"viewer",
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
describe("session list replacement options", () => {
|
||||
it("keeps deliberate dock-inclusive queries separate from the ordinary roster", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { sessionActivityTimestamp } from "../../../../src/shared/session-activity-timestamp.js";
|
||||
import { SESSIONS_LIST_OWNER_LIMIT } from "../../../../src/shared/session-list-limits.js";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import type { createSessionEventRefreshCoordinator } from "./event-refresh-coordinator.ts";
|
||||
import { compareSessionRowsByUpdatedAt, sessionMatchesArchivedFilter } from "./navigation.ts";
|
||||
@@ -180,24 +181,30 @@ export function canApplySessionListSnapshot(
|
||||
}
|
||||
const windowSize =
|
||||
result.nextOffset ?? result.limitApplied ?? options.limit ?? DEFAULT_SESSION_LIST_QUERY.limit;
|
||||
if (
|
||||
excluded &&
|
||||
result.hasMore === true &&
|
||||
sortBy === "updatedAt" &&
|
||||
(options.ownerFirst !== true ||
|
||||
(viewerProfileId &&
|
||||
!(
|
||||
excluded.owner?.actor.identity?.type === "profile" &&
|
||||
excluded.owner.actor.identity.id === viewerProfileId
|
||||
))) &&
|
||||
result.sessions.filter(
|
||||
if (excluded && result.hasMore === true && sortBy === "updatedAt") {
|
||||
const newerRows = result.sessions.filter(
|
||||
(row) =>
|
||||
sessionMatchesArchivedFilter(row, options.archivedFilter ?? "active") &&
|
||||
compareSessionRowsByUpdatedAt(row, excluded) < 0,
|
||||
).length >= windowSize
|
||||
) {
|
||||
covered = true;
|
||||
continue;
|
||||
);
|
||||
if (
|
||||
newerRows.length >= windowSize &&
|
||||
(options.ownerFirst !== true ||
|
||||
(viewerProfileId &&
|
||||
(!(
|
||||
excluded.owner?.actor.identity?.type === "profile" &&
|
||||
excluded.owner.actor.identity.id === viewerProfileId
|
||||
) ||
|
||||
// Owned ancestors below both bounded windows cannot enter either page.
|
||||
newerRows.filter(
|
||||
(row) =>
|
||||
row.owner?.actor.identity?.type === "profile" &&
|
||||
row.owner.actor.identity.id === viewerProfileId,
|
||||
).length >= Math.min(windowSize, SESSIONS_LIST_OWNER_LIMIT))))
|
||||
) {
|
||||
covered = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const next = reconcileParsedSessionChangedRow(existing, parsedSnapshot, {
|
||||
resultAgentId: options.agentId,
|
||||
|
||||
@@ -129,6 +129,8 @@ describe("session roster event traffic", () => {
|
||||
updatedAt: 100_001 + index,
|
||||
snapshotAt: Date.now() + 1,
|
||||
totalTokens: index + 1,
|
||||
hasActiveRun: index % 20 < 10,
|
||||
status: index % 20 < 10 ? "running" : "done",
|
||||
unread: hot % 2 === 0,
|
||||
lastReadAt: hot % 2 === 0 ? 98_000 : 100_001 + index,
|
||||
};
|
||||
@@ -139,10 +141,8 @@ describe("session roster event traffic", () => {
|
||||
expect(bytes).toEqual(bootBytes);
|
||||
}
|
||||
}
|
||||
expect(reads).toEqual({ shared: bootReads.shared + 1, personal: bootReads.personal + 1 });
|
||||
for (const scope of ["shared", "personal"] as const) {
|
||||
expect(bytes[scope] - bootBytes[scope]).toBeLessThan(2 * bootBytes[scope]);
|
||||
}
|
||||
expect(reads).toEqual(bootReads);
|
||||
expect(bytes).toEqual(bootBytes);
|
||||
for (const query of [shared, personal]) {
|
||||
const held = listRows(query) ?? [];
|
||||
expect(held.slice(0, 10).map((row) => row.key)).toEqual(
|
||||
@@ -159,7 +159,7 @@ describe("session roster event traffic", () => {
|
||||
lastReadAt: 101_200,
|
||||
});
|
||||
}
|
||||
// Explicit reads absorb the minute fallback before exercising membership changes.
|
||||
// Explicit reads still reconcile both windows before exercising membership changes.
|
||||
const fresh = [
|
||||
sessions.refresh({ ...shared, force: true }),
|
||||
sessions.refreshList({ ...personal, force: true }),
|
||||
@@ -770,7 +770,7 @@ describe("session roster event traffic", () => {
|
||||
}
|
||||
await advance(1_000);
|
||||
}
|
||||
await advance(54_999);
|
||||
await advance(125_000);
|
||||
expect(summaryRequest).toHaveBeenCalledOnce();
|
||||
counts.running = 1;
|
||||
emitEvent(
|
||||
|
||||
@@ -31,6 +31,33 @@ function resultFor(agentId: string, ts: number) {
|
||||
}
|
||||
|
||||
describe("session roster refresh", () => {
|
||||
it("shares the initial child list across sidebar and chat observers", async () => {
|
||||
const initial = createDeferred<SessionsListResult>();
|
||||
const row = { key: "agent:main:child", kind: "direct" as const };
|
||||
const result = sessionsResult([row]);
|
||||
const request = vi.fn().mockReturnValueOnce(initial.promise).mockResolvedValue(result);
|
||||
const { sessions, watch, resolveOnCleanup } = rosterFixture(request);
|
||||
resolveOnCleanup([initial, result]);
|
||||
const query = { agentId: "main", spawnedBy: "agent:main:parent" };
|
||||
const sidebar = vi.fn();
|
||||
const chat = vi.fn();
|
||||
const first = sessions.observeList({ ...query, source: "sidebar" }, sidebar);
|
||||
const second = sessions.observeList({ ...query, source: "chat-pane" }, chat);
|
||||
watch(() => first.dispose());
|
||||
watch(() => second.dispose());
|
||||
const reads = [first.refresh(), second.refresh()];
|
||||
initial.resolve(result);
|
||||
await Promise.all(reads);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
for (const listener of [sidebar, chat]) {
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ result, loading: false }),
|
||||
);
|
||||
}
|
||||
await second.refresh();
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses conditional sidebar rows while accepting fresh facets and query replacements", async () => {
|
||||
const row = {
|
||||
key: "agent:main:one",
|
||||
|
||||
@@ -563,7 +563,10 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
|
||||
observeList: (scope: SessionListScope, listener: (snapshot: SessionListSnapshot) => void) => {
|
||||
const entry = managedList(scope);
|
||||
return primaryWindows.observe(entry, listener, pageActive, host.connection, () =>
|
||||
refreshManagedList(entry, { append: false, invalidated: true }),
|
||||
refreshManagedList(
|
||||
entry,
|
||||
entry.snapshot.result ? { append: false, invalidated: true } : { append: false },
|
||||
),
|
||||
);
|
||||
},
|
||||
refreshList(this: void, options: SessionRefreshOptions = {}): Promise<void> {
|
||||
@@ -668,22 +671,14 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
|
||||
(entry) => affected?.has(entry) ?? matchesAgent(entry.query.agentId),
|
||||
lastListOptions,
|
||||
);
|
||||
if (options.primarySnapshotApplied) {
|
||||
eventRefreshCoordinator.scheduleFallback();
|
||||
} else if (options.affectsPrimary ?? matchesAgent(lastListOptions.agentId)) {
|
||||
if (
|
||||
!options.primarySnapshotApplied &&
|
||||
(options.affectsPrimary ?? matchesAgent(lastListOptions.agentId))
|
||||
) {
|
||||
eventRefreshCoordinator.schedule();
|
||||
}
|
||||
if (affected) {
|
||||
scheduleManagedLists((entry) => affected.has(entry));
|
||||
for (const entry of managedLists.values()) {
|
||||
if (
|
||||
!affected.has(entry) &&
|
||||
entry.listeners.size > 0 &&
|
||||
matchesAgent(entry.query.agentId)
|
||||
) {
|
||||
entry.coordinator.scheduleFallback();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidateManagedLists(options.agentId);
|
||||
}
|
||||
|
||||
@@ -472,6 +472,10 @@ function installColdLoadProbes(
|
||||
readLive() {
|
||||
return {
|
||||
elapsedMs: (liveFinishedAt || performance.now()) - liveStartedAt,
|
||||
longTasks: longTasks.filter(
|
||||
(task) =>
|
||||
task.start >= liveStartedAt && (!liveFinishedAt || task.start <= liveFinishedAt),
|
||||
),
|
||||
rpcs: rpcs.slice(liveRpcOffset),
|
||||
rpcOffset: liveRpcOffset,
|
||||
sentEvents: [...liveEvents.values()].reduce((sum, count) => sum + count, 0),
|
||||
|
||||
@@ -798,12 +798,12 @@ async function runProductionControlUiBuild(
|
||||
export async function startBuiltControlUiE2eServer(
|
||||
outDir: string,
|
||||
bootstrapConfig?: Record<string, unknown>,
|
||||
port?: number,
|
||||
): Promise<ControlUiE2eProductionServer> {
|
||||
const [{ preview }, { default: controlUiViteConfig }] = await Promise.all([
|
||||
import("vite"),
|
||||
import("../../vite.config.ts"),
|
||||
]);
|
||||
const port = await resolveAvailableLoopbackPort();
|
||||
const sharedConfig = createBundledControlUiE2eConfig(controlUiViteConfig, outDir);
|
||||
const publication = createControlUiE2eBuildPublication(outDir);
|
||||
const server = await preview({
|
||||
@@ -816,7 +816,7 @@ export async function startBuiltControlUiE2eServer(
|
||||
],
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
port: port ?? (await resolveAvailableLoopbackPort()),
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -317,15 +317,17 @@ export function prepareTeamScaleSessionOwnership(
|
||||
export function createTeamScaleSessionEvents(
|
||||
sessions: readonly GatewaySessionRow[],
|
||||
now: number,
|
||||
options: { durationMs?: number; ancestorSessionKey?: string } = {},
|
||||
): TeamScaleLiveEvent[] {
|
||||
const liveEvents: TeamScaleLiveEvent[] = [];
|
||||
for (let index = 0; index < 240; index++) {
|
||||
const durationMs = options.durationMs ?? 30_000;
|
||||
for (let index = 0; index < durationMs / 125; index++) {
|
||||
const atMs = (index + 1) * 125;
|
||||
const row = {
|
||||
...sessions[index % 24]!,
|
||||
updatedAt: now + atMs,
|
||||
snapshotAt: now + atMs,
|
||||
lastMessagePreview: `Synthetic worker ${(index % 24) + 1}: completed checkpoint ${Math.floor(index / 24) + 1}.`,
|
||||
lastMessagePreview: `Synthetic worker ${(index % 24) + 1}: completed checkpoint ${Math.floor((index % 240) / 24) + 1}.`,
|
||||
};
|
||||
liveEvents.push({
|
||||
atMs,
|
||||
@@ -342,6 +344,90 @@ export function createTeamScaleSessionEvents(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (options.ancestorSessionKey) {
|
||||
const source = sessions.find((row) => row.key === options.ancestorSessionKey);
|
||||
const children = sessions.filter((row) => row.spawnedBy === options.ancestorSessionKey);
|
||||
if (!source || !children.length) {
|
||||
throw new Error("Ancestor-reference churn requires an existing parent and children");
|
||||
}
|
||||
const parent = { ...source, ancestorRevision: "team-scale-parent-v1" };
|
||||
for (let index = 1; index < liveEvents.length; index += 2) {
|
||||
const atMs = liveEvents[index]!.atMs;
|
||||
const ts = now + atMs;
|
||||
const row = {
|
||||
...children[Math.floor(index / 2) % children.length]!,
|
||||
updatedAt: ts,
|
||||
snapshotAt: ts,
|
||||
lastMessagePreview: `Synthetic child checkpoint ${index}`,
|
||||
};
|
||||
const initial = index === 1;
|
||||
liveEvents[index] = {
|
||||
atMs,
|
||||
kind: initial
|
||||
? "sessions.changed.child-parent-full"
|
||||
: "sessions.changed.child-parent-reference",
|
||||
event: "sessions.changed",
|
||||
sessionMutation: { upsert: initial ? [row, parent] : [row] },
|
||||
payload: {
|
||||
...row,
|
||||
sessionKey: row.key,
|
||||
reason: "agent.run.updated",
|
||||
ts,
|
||||
session: row,
|
||||
ancestorSessions: initial
|
||||
? [{ key: parent.key, agentId: parent.agentId, version: 1, full: parent }]
|
||||
: [],
|
||||
...(initial
|
||||
? {}
|
||||
: {
|
||||
ancestorSessionRefs: [
|
||||
{
|
||||
key: parent.key,
|
||||
agentId: parent.agentId,
|
||||
sessionId: parent.sessionId,
|
||||
revision: parent.ancestorRevision,
|
||||
snapshotAt: ts,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
// References must preserve raw version 1 so a later wire delta still decodes.
|
||||
const atMs = durationMs + 125;
|
||||
const ts = now + atMs;
|
||||
const changed = {
|
||||
label: "Synthetic ancestor baseline verified",
|
||||
displayName: "Synthetic ancestor baseline verified",
|
||||
derivedTitle: "Synthetic ancestor baseline verified",
|
||||
updatedAt: ts,
|
||||
snapshotAt: ts,
|
||||
ancestorRevision: "team-scale-parent-v2",
|
||||
};
|
||||
liveEvents.push({
|
||||
atMs,
|
||||
kind: "sessions.changed.parent-raw-baseline-check",
|
||||
event: "sessions.changed",
|
||||
sessionMutation: { upsert: [{ ...parent, ...changed }] },
|
||||
payload: {
|
||||
sessionKey: parent.key,
|
||||
agentId: parent.agentId,
|
||||
sessionId: parent.sessionId,
|
||||
reason: "patch",
|
||||
ts,
|
||||
updatedAt: ts,
|
||||
ancestorSessions: [],
|
||||
session: {
|
||||
key: parent.key,
|
||||
agentId: parent.agentId,
|
||||
version: 2,
|
||||
base: 1,
|
||||
set: changed,
|
||||
remove: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return liveEvents;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CronJobSchema } from "../../../packages/gateway-protocol/src/schema/cron.ts";
|
||||
import { GatewaySessionRowEvents } from "../api/gateway-session-row-events.ts";
|
||||
import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import { canApplySessionListSnapshot } from "../lib/sessions/session-list-query.ts";
|
||||
import { createTeamScaleSidebarListOwner } from "./control-ui-sidebar-list-fixture.ts";
|
||||
import { createControlUiTeamScaleFixture } from "./control-ui-team-scale-fixture.ts";
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
|
||||
describe("Team-scale Gateway fixture", () => {
|
||||
it("sustains certified ancestor churn past the minute fallback and preserves the raw delta baseline", () => {
|
||||
const fixture = createControlUiTeamScaleFixture({
|
||||
now,
|
||||
sessionEventChurn: true,
|
||||
sessionEventDurationMs: 135_000,
|
||||
sessionEventAncestorRefs: true,
|
||||
});
|
||||
expect(fixture.liveEvents).toHaveLength(1081);
|
||||
expect(fixture.liveEvents[0]?.atMs).toBe(125);
|
||||
expect(fixture.liveEvents.at(-1)?.atMs).toBe(135_125);
|
||||
const childQuery = { spawnedBy: "agent:main:main", limit: 100 };
|
||||
const children = createTeamScaleSidebarListOwner(fixture.sidebarListInput).list({
|
||||
...childQuery,
|
||||
rowMode: "compact",
|
||||
}).result;
|
||||
expect(canApplySessionListSnapshot(children, fixture.liveEvents[0]!.payload, childQuery)).toBe(
|
||||
true,
|
||||
);
|
||||
const decoder = new GatewaySessionRowEvents();
|
||||
let references = 0;
|
||||
for (const event of fixture.liveEvents) {
|
||||
const projected = decoder.project(event);
|
||||
expect(projected.missingBaseline).toBe(false);
|
||||
if (event.kind === "sessions.changed.child-parent-reference") {
|
||||
references++;
|
||||
expect(projected.event.payload).not.toHaveProperty("ancestorSessionRefs");
|
||||
expect(projected.event.payload).toMatchObject({
|
||||
ancestorSessions: [
|
||||
{
|
||||
key: fixture.selectedSessionKey,
|
||||
ancestorRevision: "team-scale-parent-v1",
|
||||
snapshotAt: now + event.atMs,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (event.kind === "sessions.changed.parent-raw-baseline-check") {
|
||||
expect(projected.event.payload).toMatchObject({
|
||||
session: {
|
||||
key: fixture.selectedSessionKey,
|
||||
derivedTitle: "Synthetic ancestor baseline verified",
|
||||
snapshotAt: now + 135_125,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(references).toBe(539);
|
||||
});
|
||||
|
||||
it("pages session lists using pin placement, stable key ties, and the requested activity clock", () => {
|
||||
const fixture = createControlUiTeamScaleFixture({ now });
|
||||
const rows: GatewaySessionRow[] = [
|
||||
|
||||
@@ -67,6 +67,8 @@ type FixtureOptions = {
|
||||
sidebarListChurn?: boolean;
|
||||
sidebarLiveChurn?: boolean;
|
||||
sessionEventChurn?: boolean;
|
||||
sessionEventDurationMs?: number;
|
||||
sessionEventAncestorRefs?: boolean;
|
||||
duplicateBootReads?: boolean;
|
||||
closedMenuMembers?: boolean;
|
||||
largeSnapshot?: boolean;
|
||||
@@ -591,7 +593,14 @@ export function createControlUiTeamScaleFixture(options: FixtureOptions = {}) {
|
||||
}
|
||||
}
|
||||
if (options.sessionEventChurn) {
|
||||
liveEvents.splice(0, liveEvents.length, ...createTeamScaleSessionEvents(sessions, now));
|
||||
liveEvents.splice(
|
||||
0,
|
||||
liveEvents.length,
|
||||
...createTeamScaleSessionEvents(sessions, now, {
|
||||
durationMs: options.sessionEventDurationMs,
|
||||
ancestorSessionKey: options.sessionEventAncestorRefs ? selectedSessionKey : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {
|
||||
scenario,
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createTeamScaleSessionInventory({
|
||||
: `agent:main:${pinned ? "dashboard" : "chat"}:00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
...(index === 0 ? { isMain: true } : {}),
|
||||
sessionId: `team-session-${index + 1}`,
|
||||
childOwnerSessionKeys: [],
|
||||
agentId: "main",
|
||||
kind: "direct",
|
||||
label: pinned ? `Project dashboard ${index - 269}` : `Project discussion ${index + 1}`,
|
||||
|
||||
Reference in new issue
Block a user