* perf(plugins): reuse bundled catalog identities in inventory reads Prepare shipped catalog identities once while retaining live install provenance and independent list arrays. Synthetic Gateway inventory proof measured 58% less allocation and 14% less main-thread CPU per RPC; latency remains inconclusive under shared-host A/A controls. * test(plugins): type inventory benchmark fixture before publication
Team-shaped synthetic state
Plugin policy reads
plugin-policy-read-measure.mjs measures warm Codex config resolution and registered
plugin metadata reads with 40 synthetic entries. Build a bundle from each source
revision, then use the corresponding Codex entry file; the harness extracts the
actual resolver closure rather than maintaining a second implementation.
node scripts/perf/plugin-policy-read-measure.mjs --build /path/to/source /path/to/owner.mjs
node --expose-gc scripts/perf/plugin-policy-read-measure.mjs \
/path/to/owner.mjs /path/to/source/extensions/codex/index.ts \
config timing /path/to/result.json
Use bindings instead of config for primitive, array, and cached method reads
through the real instance-binding owner. That workload uses a synthetic admission
callback and does not measure invocation leases or complete Gateway RPCs. The
config workload checks exact payload identity; both workloads check their results.
Bundles retain external package imports, so run beside the source dependencies.
Use allocation separately to sample allocations including collected objects.
Optional trailing arguments are READY_FILE START_FILE OPERATIONS (default
200,000 operations). Each side publishes readiness after warmup; publish a common
future epoch in milliseconds to the start file. Run concurrent pairs with swapped
interleaved CPUs under the shared quiet-slot guard. Reports include source hashes,
CPU, sampled allocation, per-kind GC totals/maxima, event-loop sample counts, and
per-operation latency averaged over 100-operation batches. These costs isolate the
policy readers; they do not predict whole-Gateway latency or production savings.
System prompt report retention
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/system-prompt-report-retention.ts
retains 200 real reports from distinct synthetic 80-skill catalogs after dropping
the inputs, then releases the reports as a control. It reports forced-GC heap
deltas and a digest of every report. --expect-detached fails if compact reports
retain their large input catalogs; --snapshot <path> captures synthetic heap
parents for independent inspection. Compare at least five alternating baseline
and candidate samples. This owner proof does not predict production savings when
another owner still retains the same catalog.
Use --mode timing --operations 100000 for untraced construction CPU and GC;
--mode allocation separately samples allocation including collected objects.
--owner <path> selects a saved report module (or its bundle). Paired runs use
separate --ready-file paths and a common --start-file containing a future epoch
in milliseconds after both warmups finish. Apply the shared quiet-slot guard,
counterbalanced launch order and swapped interleaved CPU sets. Acceptance:
unchanged report digest, discarded catalog backing storage released, and no
material construction regression under the campaign's paired rules. Timing in
allocation and forced-GC modes is not acceptance evidence.
Replay history preparation
replay-history-measure.ts publishes synthetic user/assistant messages through an
in-memory SessionManager, then repeatedly prepares the retained history with a
new user tail. One tenth of assistant messages require metadata cleanup.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/replay-history-measure.ts \
--stage history --messages 1000 --attempts 5000
Use --stage normalize or --stage images for attribution. Run --allocations
separately; sampled allocation includes collected objects, and its timing is not
acceptance evidence. The report includes input/output hashes, content and message
reuse, CPU, latency, event-loop samples, and per-kind GC totals and maxima.
Presented-message counts describe the input size, not internal content scans.
This measures replay preparation, not complete Gateway turn latency.
Require matching hashes and fewer allocations or CPU per attempt, with no
material regression under the campaign's absolute-delta and sample-count rules.
For concurrent pairs, use distinct --ready-file paths and a shared
--start-file containing a future epoch in milliseconds. Swap CPU placement for
the second pair; run matched A/A controls before interpreting adverse GC results.
Device pairing publication
device-pairing-publication-measure.ts seeds 64 synthetic paired nodes, then
invalidates and rebuilds the shared node snapshot through the real pairing reader
and SQLite worker. This forces publication misses without repeated database
writes. It verifies exact device contents, snapshot reuse, and nested freezing
before and after measurement. It measures the reader/publication boundary, not
Gateway RPC latency or a production publication rate.
node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/device-pairing-publication-measure.ts \
--mode timing --state-dir /path/to/new-state --operations 4000 --devices 64 \
--output /path/to/timing.json
Use separate fresh processes for --mode allocation (sampling includes collected
objects) and --mode promotion. Promotion additionally requires
--trace-gc-nvp --gc-log /path/to/trace.log with stdout redirected to that log.
Timing and allocation reject GC trace flags. The report hashes the selected
publication source and fixture, reports main-thread and process CPU per
publication, latency and event-loop tails with sample counts, and per-kind GC
totals, tails, and maximum pauses. Allocation reports bytes attributed to
inspectPlainData; promotion is bounded to the measured main-isolate window.
The default 200 publications/minute is an explicit allocation projection.
For concurrent pairs, prepare separate state directories, pass distinct
--warm-ready-file and --ready-file paths, and common --warm-start-file and
--start-file paths. After both sides are ready, atomically publish a future epoch
in milliseconds into the corresponding start file. --publication-source selects
a saved sibling copy of the publication owner for same-tree comparisons. Use the
shared quiet-slot lock and guard, counterbalance launch order, and swap the
interleaved CPU sets per pair. Run at least four identical-source A/A pairs of the
same mode before interpreting adverse GC, promotion, or pause results. Keep
maintenance enabled; apply the campaign's paired absolute-delta and sample-count
rules. Traced timing and allocation-profile timing are not acceptance evidence.
Update database generations
Run node --import ./scripts/tsx.mjs scripts/perf/update-doctor-generation-measure.ts <label> all 1 <source-root> serially, alternating baseline and candidate roots
for at least five pairs. Use generations-only instead of all to measure just
the generation owner. The script creates and reuses eight synthetic SQLite files
under .artifacts/update-doctor-generation-measure, totaling about 3 GiB with a
2 GiB largest file. It isolates HOME and state before loading runtime modules.
The measured phases are stopped snapshot capture, Doctor's real database-write admission/settlement capture with no repair, and an instrumented generation read. This is phase proof, not a complete package update or a full Doctor payload scan. Acceptance: unchanged generation receipts and private-image verification, one source hash pass per unchanged identity, and no latency regression in either downtime phase. Report warm median, p95, spread, source/private read bytes, and host load; do not mix cold samples with warm samples.
The read counters cover synchronous source hashing/admission and private-image
hashing; they exclude native copy and SQLite reads. resourceUsage().fsRead is
reported as operations, not bytes, and may be zero on macOS. On Linux the script
also reports /proc/self/io logical reads; child-process CPU and I/O are outside
the parent counters. Keep these measurement limits alongside the results.
SQLite admission worker release
node --expose-gc scripts/perf/sqlite-admission-release-measure.mjs SOURCE_ROOT OWNER_FILE LABEL MODE OPERATIONS [WARMUP] [TOUCHED] [release|install|install-scope] measures the complete admission registry owner against 40 synthetic
databases with 3,000 session scopes each. Save baseline and candidate owner source
files before running; the source root supplies the unchanged singleton, publication
key, and path-normalization dependencies. Native identity operations deliberately
refuse: this is publication proof, not SQLite admission or Gateway throughput.
Use counts 1 to count scope visits and publications, then timing 1000 for CPU,
wall time, and untraced GC. Run allocation 1000 separately for sampled allocation
including collected objects. Compare at least two concurrent pairs on the approved
sandbox, swapping interleaved CPU sets, with synchronized starts and the shared guard.
Acceptance: zero scope visits/publications for an unaffected worker; only affected
records visited otherwise; preserved reference revocation and last-reader retirement;
CPU/allocation improves without a material GC regression under the campaign rules.
The all-records case is the control for changed-state publication overhead.
To separate JIT history from that control, use timing 200 500 40: 500 warmup
releases followed by 200 measured releases affecting all 40 records, without
earlier no-op/single-record phases. Report
absolute deltas and sample counts; this workload does not measure event-loop tails,
promotion, native SQL, or whole-Gateway performance. The measured loop reattaches
synthetic reference cells directly, excluding worker admission and transport costs.
The default operation is release. Use install for reference-only admission
publication, or install-scope to import a fresh scope with each reference.
Both install modes recreate one synthetic reference per affected database before
calling the real registry install; they include that fixture work in the totals.
After a full sweep, reference-only installs should revisit zero scope cells;
scope imports must still publish their new cells and sweep retirements. Compare
zero, one, and all 40 affected databases. The scope-import control includes the
normal receipt-budget pruning as the workload grows.
Retained Buffer pool storage
These synthetic probes keep the production owners live while interleaving small
retained values with disposable pooled allocations. They report distinct backing
stores, logical bytes, and retained backing bytes. Read the runtime's observed
slab size: Buffer.poolSize need not equal the backing allocation's byte length.
Do not assume an 8 KiB pool. Sparse insertion is the amplification case; adjacent
small values can share a slab efficiently.
node --import ./scripts/tsx.mjs scripts/perf/gateway-buffer-pool-retention.ts
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/process-output-pool-retention.ts
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/command-capture-pool-retention.ts
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/audio-queue-pool-retention.ts --site voice
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/audio-queue-pool-retention.ts --site meeting
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/audio-queue-pool-retention.ts --site transcription
Run identical workloads on baseline and candidate. Acceptance is unchanged output bytes and source-copy isolation, no unrelated pooled slabs retained by repaired owners, and collection after their normal release boundary. The Gateway owner probe covers registered list arrays and both ordinary and segmented shared response payloads. Process probes cover live output/poll retention and a real running child command, plus clipped and string captures. Audio probes cover provider-readiness/reconnect queues and a meeting capture transport fixture. Their fixed PCM workloads correspond to 6.4 seconds of voice readiness backlog, about 17 seconds of meeting input, and 20.5 seconds of transcription backlog.
These are retained-storage measurements, not CPU, GC-rate, latency, or production saving estimates. Command allocation attribution uses stack inspection; do not reuse its timing as a throughput result. Audio and process probes support synthetic heap snapshots and release controls; keep snapshots local.
For whole-Gateway attribution, use the synthetic state generator and the
diagnostics.heapSnapshot procedure in the retention runbook.
Keep clients and mock providers alive through completed turns and capture.
Deduplicate backing stores within each owner; different owners can share a slab,
so their totals cannot be added as exclusive savings. Presence publications are
transient: listSystemPresence() creates fresh arrays, and the delivery cache's
weak keys do not keep those arrays alive between publications. Artificially
retaining every roster would manufacture a lifetime absent from this flow.
Retained heap
Use the heap retention runbook to capture a main-isolate snapshot and run the streaming analyzer in aggregate-only mode with bounded host resources. It covers dominators, collections, duplicate strings, and session-key scaling without exporting snapshot payloads.
Main-isolate module census
scripts/gateway-heap-rig.mjs --module-census records each loaded main-isolate
script's URL, hash, UTF-8 source bytes (or separate WebAssembly binary bytes), and
V8 code statistics after seeding and
after the mixed-turn workload. It uses the rig's private child IPC descriptor;
workers are excluded and no inspector network listener is opened. Run the dist
build on the authorized benchmark host with a fresh synthetic root, for example:
node --import ./scripts/tsx.mjs scripts/gateway-heap-rig.mjs \
--root /path/to/fresh-run --minutes 10 --sessions 500 --clients 4 \
--port 41673 --turn-interval-ms 20000 --snapshot-minutes 10 --module-census
Use --coverage in a separate attribution run to record exact function/range
execution during the workload. Coverage can retain code and change optimization;
its timings and code residency are not uninstrumented performance evidence.
Use --idle-minutes 5 without coverage to stop workload traffic while leaving
normal Gateway maintenance active, sample code statistics each minute, and
capture a final idle snapshot. Use --snapshot-minutes none for paired timing
and natural-GC observations without heap exports; it also skips the idle snapshot.
Run retained-size captures separately and serially to avoid concurrent bulk writes.
Census inspectors disconnect after each capture.
Source bytes and V8 code statistics are not dominator-retained bytes: use the
heap snapshot analyzer for retained-size attribution. All fixtures and mock
provider responses are synthetic; this workload does not reproduce a private
production plugin configuration or measure provider-network latency.
For first-use investigations, --turn-count 50 caps the serial turn loop, while
--minutes still bounds the whole observation window. Each turn records setup,
chat (chat.send through agent.wait), and verification/cleanup durations in
phaseMs. Compare matching operation kinds as well as the mixed median.
--shell-turns makes code turns execute and verify a synthetic printf command
through the real shell tool. --gateway-preload /absolute/probe.mjs loads a
diagnostic observer only in the owned Gateway (and its inheriting workers).
--lazy-import-trace records calls and awaited durations for the optional MCP,
config-diagnostic, public-session, and completion-parser imports, plus module
evaluation counts. Records are separated by process and thread under the run's
lazy-import-trace directory. This source-transforming diagnostic preload is not
latency evidence; run ordinary timing pairs separately.
Exercise each feature independently in a fresh process with:
OPENCLAW_LAZY_IMPORT_TRACE_DIR=/path/to/trace \
node --import ./scripts/tsx.mjs --import ./scripts/perf/lazy-import-trace-preload.mjs \
scripts/perf/lazy-import-feature-probe.mjs mcp \
--output /path/to/mcp.json --expect-single-load
Modes are mcp, config, public, and parser. They verify 20 operations per
primary path using real MCP/public HTTP boundaries, invalid config reads, or
deterministic completion chunks. MCP covers cached catalogs, calls, and fresh
session lifecycles (including disposal); public HTTP also checks a conditional GET. They report first-use and subsequent-operation samples; they are source
feature probes, not complete Gateway turns. The count guard rejects repeated
imports/evaluations after first use. Omit the preload and count guard for timing;
use matched baseline/candidate processes and report sample counts and host noise.
Set --iterations N (integer at least 2) to change each path's sample count.
For coordinated warm samples, give both processes the same fresh --barrier-dir DIR
and distinct --side a / --side b values. Before each path's second operation,
the probe exclusively creates <name>-<side>.ready, then polls every 25 ms for
<name>.start containing a future epoch time in milliseconds. The coordinator
waits for both ready files and writes the shared start file; the probes wait until
that time before measuring. Each barrier has a 120-second total wait limit;
existing ready files, malformed or past start times, and filesystem errors other
than a missing start file fail the probe. MCP has separate mcp-catalog,
mcp-call, and mcp-fresh-session barriers. The single conditional public GET
has no barrier. Barrier waits are excluded from operation timings.
Summary identity strings
session-summary-id-retention.ts prepares 10,000 synthetic sessions with observer
digests and restart-recovery sources, 8,000 settled runs, 2,000 archived sessions,
and 40 synthetic plugins. Capture pages through the real roster, completes ten
mock-provider turns across five dashboards, applies label churn, then requests a
main-isolate heap snapshot from the built Gateway. Use fresh state for each side:
node --import ./scripts/tsx.mjs scripts/perf/session-summary-id-retention.ts \
--prepare --state-dir /path/to/new-summary-fixture
node --import ./scripts/tsx.mjs scripts/perf/session-summary-id-retention.ts \
--capture --state-dir /path/to/new-summary-fixture --output /path/to/new-capture
Build the selected revision first. Both commands accept matching --port and
--mock-port values (defaults 41697 and 41698, loopback only); capture accepts
--entry for its built JavaScript entry. Analyze the path in capture.json with
scripts/heap-snapshot-diff.mjs --summary. Compare physical duplicate-string
nodes/bytes and family populations under identical churn; JavaScript equality
does not prove string sharing. Forced-GC snapshots establish retention, not CPU,
GC-throughput, or production-wide savings. Keep snapshots local and synthetic.
Native session actor residency
session-actor-retention-measure.ts opens the real durable actor owner against a
synthetic Team-shape fixture previously warmed with
transcript-broadcast-measure.ts --scenario memory-soak --clients 4. It requires
the resulting child sessions and their real turn-generated skill snapshots.
Stop the Gateway before opening the same fixture with this probe.
node --import ./scripts/tsx.mjs scripts/perf/session-actor-retention-measure.ts \
--state-dir /path/to/synthetic-state --output /path/to/fresh-results \
--reads 1200 --writes 200
Reads invalidate the host replica before fetching the warm native actor. Writes
toggle the group-intro flag in complete on/off rounds. The report includes
operation counts, latency distributions, worker CPU, GC, event-loop sample
counts, and post-GC heap bytes. Maintenance remains enabled. Use separate fixture
copies for concurrent comparisons and --start-at <epoch-ms> after allowing
enough time for both sides to warm up. Never reuse an output directory.
For retention attribution, use --reads 0 --writes 0 --snapshot and analyze the
worker snapshot with heap-snapshot-diff.mjs; collect at least five alternating
samples per side. Keep snapshots outside timed comparisons and on a host
authorized for synthetic heap capture. --baseline-dir selects the existing
worker preload's mirrored source replacement hook; use that hook symmetrically
for both baseline and candidate. Report resident-owner graph bytes separately
from whole-worker bytes, which also include loaded code. Promotion requires a
separate traced pass with its own baseline.
Released session authority
Pending-input timer retention
Run node --expose-gc --import ./scripts/tsx.mjs scripts/perf/pending-input-retention.ts --state-dir /path/to/fresh-synthetic-state --turns 100 --expect-released on the
approved benchmark host. Repeat with 1,000 turns and with --collected for the
aggregate owner. Use at least five alternating baseline/candidate samples per
shape. Each turn stages and settles a real pending input, captures a separately
decoded 40 KiB synthetic session entry in its admission callback, and leaves a
one-hour timer pending in the receipt's async scope. The script reports surviving
entries and retained heap bytes, then clears the timers and verifies collection.
Acceptance: zero settled entries retained with timers still pending, unchanged
late-execution refusal, and zero survivors after timer cleanup on both versions.
This is forced-GC owner retention proof, not a production timer census, allocation,
promotion, latency, or GC-time benchmark. Retained heap includes timers and store
bookkeeping as well as the captured entries.
Session publication authority
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/session-authority-retention.ts --reads 1000 --expect-released measures the session publication owner's payload
lifetime while released sharing, predicate, and generation handles stay reachable.
Each read deserializes identical synthetic row bytes. WeakRefs count surviving
rows after collection; an active handle must still retain its row. Reports include
read counts, serialized payload bytes, survivors, and retained heap delta including
the handles themselves. Use --owner /absolute/path/to/baseline.ts for a saved
owner module beside its original source so relative imports remain canonical.
Run at least five alternating samples at 100 and 1,000 reads on the approved
benchmark host. This is forced-GC owner retention proof, not a latency, allocation,
promotion, or production GC benchmark.
SQLite reply decoding
Add --sqlite-reply-census to a synthetic transcript-broadcast-measure.ts run
with --workload-cpu-profile or --allocations. Each active CPU/heap window writes
<profile>.sqlite-replies.json with reply counts, total/max bytes, framed replies,
and failures grouped by the real SQLite command. It also saves the first successful
ordinary reply per method as <profile>.reply-<method>.bin, limited to 8 MiB per
sample and 32 MiB per capture. Labels are bounded; admission messages are excluded.
The preload retains the original deserializer so command attribution does not
pollute the profile's existing .deserialize.json observer. Counts and samples
are diagnostic evidence; these instrumented runs are not timing gates.
node scripts/perf/sqlite-reply-transfer-measure.mjs <source-root> --shape 16384 --operations 500000 --start-at <epoch-ms> exercises the complete transfer receiver
with prepared synthetic session-entry frames. The source capsule needs the receiver,
SQLite lifecycle errors, and normalization-core record helpers; it needs no
dependency installation. The receiver remains whole; the two required cleanup
functions are extracted between checked unique source anchors before TypeScript
stripping. Complete input source hashes are included in each JSON result.
Shapes are 1024, 16384, 131072, and 9437184 bytes of prompt content;
the last shape protects fragmented-record behavior. Omit --shape to run all four.
Run baseline and candidate in separate processes under the approved host's guard and exclusive slot, with the same start time and swapped interleaved CPUs in the next pair. Each side warms before the synchronized start and verifies the decoded values and record counts. Compare fixed-work process/main-thread CPU, elapsed time, GC totals and maxima, and event-loop distributions with their sample counts. Use the campaign's absolute-delta and control rules for acceptance.
Use --allocations only in separate diagnostic passes. Inspector samples include
collected objects but exclude external backing buffers; they cannot quantify the
full byte-buffer allocation saved by avoiding reassembly. The reported payload
size identifies the copy size for a single-frame record. This is receiver-owner
proof, excluding producer serialization, IPC, SQL, and whole-Gateway latency.
Session-fork snapshots
node --expose-gc scripts/perf/session-fork-retention-measure.mjs <source-root> --mode timing --output /path/to/result.json measures
ordinary turns through the actual stream-selection, historical fork-capture, and
request-encoding owners. The source root needs only the five files listed in the
script; no build or install is needed. Provider transport and unrelated host
services use synthetic adapters. Current sources do no fork capture; historical
sources retain their original capture and publication behavior.
The default fixed workload is 27,000 requests across 200 sessions, with 256 KiB
prepared inputs and 75% HTTP continuation suffixes. That represents one hour of
traffic at 450 requests/minute, compressed into a fixed-work pass. It is not an
elapsed-hour Gateway soak. Fixture strings remain reachable equally on both sides.
--operations, --sessions, --payload-bytes, and --warmup select the workload.
Run separate modes for untraced CPU/latency/GC/event-loop timing, collected-object allocation sampling, JSON/Blob/timer counts, and forced-GC retention. Retention reports heapUsed, external, ArrayBuffers, and RSS separately; the external and ArrayBuffer counters overlap and must not be summed as exact retained storage. Timing never forces collections. There is no promotion or provider-cache claim.
Use concurrent guarded pairs on the benchmark host, counterbalance launch order,
and swap the slot's interleaved CPUs. --warm-ready-file/--warm-start-file and
--ready-file/--start-file provide separate warmup and measurement barriers;
atomically publish a future Unix epoch in milliseconds after both sides are ready. A missed
start rejects the run. Acceptance is zero fork-specific capture/publication/expiry
work on ordinary candidate turns, identical parent request counts and encoded
bytes, reduced allocation/CPU, and removal of the retained snapshot data. Apply
the campaign's absolute GC/loop materiality, sample-count, and matched-control
rules; do not use profiled runs as timing gates.
Private-array copies
node --expose-gc scripts/perf/private-array-copies-measure.mjs <baseline-root> <candidate-root>
measures six alternating samples per side with collected-object heap sampling.
The roots need only the five source files listed in the script; no build or install
is needed. It measures the task-audience result build/sort site, the complete
subagent topology patch owner, and one scope canonicalization (both reader sites
must match). Synthetic inputs cover 1/100/5,000 task rows, 100/5,000 historical runs
per child with absent/shared/distinct memory rows, and reordered or changed scopes.
These are owner/site allocation bytes per call, not whole-Gateway or GC claims.
Output includes source hashes, call counts, allocation sample counts, every sample,
and absolute/relative mean deltas. Run on an approved benchmark host under its
shared guard; equivalent results and reduced allocation are the acceptance rule.
Retained Gateway ownership assertions
gateway-state-owner-measure.ts acquires the real Gateway lock over an existing,
isolated team-shape fixture and repeatedly invokes its captured state owner's
assertCurrent. It retains the normal ownership heartbeat throughout capture.
This measures the owner boundary, not serving-Gateway throughput or workspace
operations; the fixture's session rows are not read by the measured assertions.
node --import ./scripts/tsx.mjs scripts/perf/gateway-state-owner-measure.ts \
--state-dir /path/to/prepared-synthetic-state --output /path/to/run.json \
--operations 2000 --assertions-per-operation 100 --interval-ms 10 \
--warmup-operations 100 --start-file /path/to/pair.start
Prepare separate fixtures before launching either side. After warmup, each side
writes <output>.ready and waits for the initially absent shared start file to
contain a future Unix epoch in milliseconds. The report records both actual
starts and process ages. Keep one exclusive measurement slot and the shared
production guard around the pair, counterbalance launch order, and swap
interleaved CPU placements. Freeze work count and pacing before comparing pairs.
Collect matched A/A controls under the same instrumentation mode before applying
the campaign's GC, promotion, or maximum-pause rejection gates. A noisy control
protocol needs fresh controls at a longer fixed work count.
--owner-dir selects source overlays using their repository-relative paths:
src/infra/gateway-state-owner.ts, src/infra/gateway-lock.ts,
src/infra/gateway-lock-payload.ts, and
src/gateway/worker-environments/local-workspace-projection.ts. Both sides use
the same esbuild transform and canonical module URLs, including when the option
is omitted. The report lists source hashes and which overlays were loaded in
the main isolate; the workspace module is not exercised by this workload.
Untraced runs report process and main-thread CPU per assertion and measured
minute, batch latency tails with counts, and the shared diagnostics' per-kind GC
counts, total time, pause tails/maxima, and event-loop samples. --allocations
adds a separate inspector pass including collected objects and writes a raw heap
profile beside the result. For a separate promotion pass, add --expose-gc --trace-gc-nvp --gc-log /path/to/capture.log and redirect stdout there. Only
trace identification forces GC, before preparation and warmup; no collection is
forced in the measured interval. Traced and sampled timings are attribution,
not untraced cost gates. Per-minute rates use this workload's measured duration,
not a production assertion-rate estimate.
Limited-scope Control UI RPCs
control-ui-forbidden-rpc-measure.ts opens the real Control UI against a synthetic,
isolated Gateway and records the authenticated hello scopes, per-method calls and
FORBIDDEN responses, connection-minutes, page errors, and a viewport screenshot.
Use an existing limited operator fixture with operator.sessions.write, an open
chat, and a transcript link to a session outside the loaded roster. Do not use a
production Gateway or a real browser profile.
node --import ./scripts/tsx.mjs scripts/perf/control-ui-forbidden-rpc-measure.ts \
--url http://127.0.0.1:41629/chat/main/12345678 \
--proxy-user limited@example.test --duration-ms 60000 \
--ready-text "synthetic limited-scope conversation" --show-panels \
--output-dir /path/to/proof --label baseline
The optional proxy user supplies synthetic trusted-proxy headers; configure the
isolated Gateway to trust them. --browser-path selects an installed Chromium.
Repeat on baseline and candidate with the same fixture, duration, and role.
Acceptance is zero denied calls for unavailable surfaces, with allowed chat reads
still succeeding. Inspect screenshots for usable chat and absent unavailable
controls. This is a request-count measurement, not a CPU or latency benchmark.
Main-thread filesystem durability
Use fsync-gateway-entry.ts as the --entry for
transcript-broadcast-measure.ts to log FSYNC_PROBE records containing the main
thread's synchronous fsync stacks, elapsed time, and timestamp. It requires a
generated team-shape fixture. Separate startup records from the measured workload;
the probe does not observe worker fsyncs or kernel-internal SQLite synchronization.
For attachment attribution, set OPENCLAW_PERF_MEDIA_IO=1 to emit MEDIA_IO_PROBE
records for inbound media filesystem calls, including async open, write, sync,
metadata reads, and close. Records contain relative paths, call stacks, and
elapsed time, never file contents. Set OPENCLAW_PERF_FSYNC_DELAY_MS=50 to add the
same synthetic storage delay to inbound file and directory syncs: synchronous
fsync blocks the main thread, while FileHandle.sync waits asynchronously.
FSYNC_PROBE records mark blocking time; MEDIA_IO_PROBE records mark async
elapsed time. Both include the injected delay. Use identical settings on both
sides of a swapped pair; omit both variables for the original synchronous probe.
The injection does not cover worker calls or synchronization inside native code.
fsync-scratch-measure.ts exercises the real container environment-file and
playback cache owners, verifying their output bytes. It injects a fixed delay into
each main-thread fsyncSync call to model slow storage without a loop device or
large disk writes. This is owner-boundary proof, not whole-Gateway throughput.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/fsync-scratch-measure.ts --state-dir /path/to/new-state \
--operations 120 --interval-ms 500 --fsync-delay-ms 50 \
--start-at <epoch-ms> --gc-log /path/to/capture.log > /path/to/capture.log 2>&1
For the baseline, supply --container-owner and --media-owner pointing to saved
baseline modules beside their original sources so relative imports stay intact.
Run concurrent baseline/candidate pairs under one quiet lock and the shared
production guard, swapping interleaved CPU sets for the second pair. Discard any
guard-interrupted pair. Acceptance: exact contents remain valid, synchronous
fsync count and blocked time fall to zero, and CPU, total GC/work, promotion and
eligible event-loop tails do not regress under the campaign's paired-delta rules.
Results include sample counts and maximum pauses; apply the minimum sample and
absolute-delta rules before treating any percentile as a gate.
Owned directory events
owned-directory-events-measure.ts opens real shared directory subscriptions and
replays fixed native callbacks. Setup checks a missing descendant's event filter;
the measured pass delivers 65 direct-root events per batch, with a one-millisecond
yield between batches. It measures observer work, not filesystem or Gateway throughput.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/owned-directory-events-measure.ts \
--state-dir /path/to/new-state --operations 4000 --start-at <epoch-ms> \
--gc-log /path/to/capture.log --output /path/to/result.json > /path/to/capture.log 2>&1
Use --owner /absolute/path/to/owned-directory-observation.baseline.ts to load a
baseline saved beside the current owner. Add --allocations for a separate paired
pass including collected objects. Reports include bytes per event, batch latency,
CPU, GC counts/tails/maxima, promotion, and event-loop tails with sample counts.
Compare concurrent pairs under one quiet lock and the production guard, swapping
interleaved CPU sets for the second pair. Apply the campaign's paired-delta,
absolute-delta, GC-pause, and minimum-sample rules. Notifications must remain exact;
lower allocation must not regress eligible CPU, GC/work, or latency metrics.
Background result lifecycle wait
background-result-wait-measure.ts holds a synthetic session turn open while
committing an automation result through the real controller and transcript
writer. Run baseline and candidate concurrently with separate state directories
and the same --start-at epoch, then swap CPU sets for the second pair:
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/background-result-wait-measure.ts \
--state-dir /path/to/new-state --samples 6 --hold-ms 2000 --start-at <epoch-ms> \
--gc-log /path/to/capture.log > /path/to/capture.log 2>&1
result.json records persistence latency, the existing lifecycleQueueWaitMs
diagnostic, custody after commit, and whether the turn is still running. A
1.1-second completion-publication fixture makes both versions emit their slow
operation diagnostic; it is excluded from persistence latency. This measures
the controller/storage boundary, not model or whole-Gateway latency. Acceptance:
queue wait no longer tracks the held turn, results persist within one second,
and commit completion leaves no mutation custody. CPU, GC pauses, and event-loop
samples are supporting context; apply the campaign's sample-count rules.
Plugin tool invocation scopes
plugin-gateway-scope-measure.ts registers two synthetic plugins through the real
registry API, alternating one tool factory resolution and sixteen prepared tool
executions per synthetic turn. Every callback checks plugin identity, provenance,
and registry, including after an asynchronous suspension. This is a source-owner
measurement; synthetic turns do not represent whole Gateway or model latency.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/plugin-gateway-scope-measure.ts \
--turns 4000 --calls 16 --start-at <allocation-epoch-ms> \
--cpu-start-at <later-cpu-epoch-ms> --output /path/to/result.json \
--gc-log /path/to/capture.log > /path/to/capture.log 2>&1
Run on the approved benchmark host with task-owned HOME, OPENCLAW_HOME, and
TMPDIR. Setup and warmup finish before SCOPE_READY. Allocation and unprofiled
CPU passes have independent shared start deadlines; late preparation fails.
Use concurrent baseline/candidate pairs under one quiet lock and the production
guard, swapping the interleaved CPU sets in the next pair.
The report includes collected-object allocation per invocation and synthetic turn, CPU and turn latency, total GC time, per-kind collection counts and pause tails/maxima, V8 trace promotion, and event-loop delay. Tails include sample counts; apply the campaign's sample-count and absolute/relative paired-delta rules. The default one-millisecond yield per turn permits event-loop sampling and is excluded from turn latency. A separate, unprofiled probe counts async-store frame installs for a synthetic turn and direct/nested calls to an owned plugin instance. Counts include the turn's outer Gateway scope. No instrumentation remains during the allocation or CPU passes. Raw allocation profiles accompany each result.
For complementary transport evidence, transcript-broadcast-measure.ts --scenario plugin.scope loads a synthetic plugin in an isolated Gateway over a team-shape
fixture. Every RPC checks its plugin identity, source, registry, request
client, and scope continuity across an asynchronous suspension using the public
Plugin SDK. The response echoes the request sequence and confirms those checks.
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --entry src/entry.ts --scenario plugin.scope \
--port 41580 --mock-port 41581 --clients 1 --runs 1 --rpc-concurrency 1 \
--warmup-operations 40 --operations 240 --rpc-interval-ms 300 --rounds 1 --diagnostics \
--allocations --trace-gc --start-at <future-epoch-ms> --revision <source-sha>
Repeat without --allocations for unprofiled CPU and natural-GC measurements.
Use one long round for allocation to avoid repeated profiler-stop collections.
The interval paces batches of clients independently of RPC latency; one client at
300 ms targets 200 calls per minute. Omit it for a throughput workload. Report the
observed rate and fixture size. Existing results include validated RPC
counts and latency, allocation and promotion per RPC, main/process CPU, and
Gateway GC/loop diagnostics. Apply the sample-count rules to each round's tails;
increase operations if the loop histogram has fewer than 1,000 observations.
Use the owner workload above for allocation per plugin callback and its separate
unprofiled promotion measurement. Run both workloads in concurrent swapped pairs
under the campaign's guard and CPU-placement rules.
Subagent idle sweep
subagent-sweep-measure.ts exercises the production sweeper with immutable synthetic
registry rows. Run with --runs 50 and --runs 500. The settled workload retains
finished history; mixed uses 70% history, 10% live claimed runs, 10% pending
deliveries, and 10% future archives. Unexpected effects fail the run, and exact
records and their digest must remain unchanged.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/subagent-sweep-measure.ts \
--runs 500 --workload mixed --operations 4000 --yield-ms 1 \
--state-dir /path/to/new-state --start-at <future-epoch-ms> \
--gc-log /path/to/capture.log --output /path/to/result.json > /path/to/capture.log 2>&1
Run on the approved benchmark host with isolated state. Setup and warmup precede
SWEEP_READY. Compare concurrent baseline/candidate pairs under one quiet lock
and the shared guard, then swap interleaved CPU assignments. Discard interrupted
pairs. The report includes sampled allocation per sweep, its projection at the
60-second idle cadence, sweep duration, total GC/work, promotion, and event-loop
tails. Total allocation includes timing and pacing overhead; registry allocation
is reported separately. This is owner-level evidence, not whole-Gateway latency.
Apply the campaign's paired-delta and sample-count rules. Report absolute deltas
when signs disagree or baselines differ by more than 5×, and use a third pair for
an inconclusive gating metric. p95 needs 200 samples per side, p99 needs 1,000;
major GC is context below 50 collections. GC pause-percentile shifts below 1 ms
are context, as are higher tails with at least 20% fewer collections and no rise
in total GC. Always report both maximum pauses; the candidate maximum still
gates above max(50 ms, 2 × baseline maximum).
Subagent maintenance and scoped reads
subagent-maintenance-capture-measure.ts measures the production session
maintenance preservation owner with 6,000 synthetic retained subagent runs and
60 protected sessions. Before measurement it checks live overrides in both
directions, a live-only row, and real worker update/deletion publications after
preservation preparation. The returned protected-key digest must match across
baseline and candidate. This measures the maintenance owner, not Gateway RPCs.
--team-shape instead generates 5,000 sessions and 6,000 retained runs with zero
transcript payload, then installs those runs in the registry and holds 60 child
execution claims. Existing pending deliveries remain protected alongside the
running children. Fixture preparation supplies the real requester/controller
store provenance and persists the active replacements before warming reads.
Fixture generation and these writes are sequential preparation work.
Add --mutate-every 10 to commit an active run replacement every ten operations.
Mutation time and CPU are included in the measured operation.
--flow maintenance is the default. With --team-shape, --flow prompt calls
buildActiveSubagentRuntimeContext and --flow control calls
buildControlledSubagentRunsReadContext, rotating across the parents of the 60
active runs. These two flows compact settled resident rows through the normal
registry owner, so selected reads include durable payload hydration; active and
pending-delivery rows retain their normal full shape. The report counts evicted
resident payloads. Only the selected read and optional committed mutation are timed;
maintenance checks and output digesting remain outside measurement. Read time is
fixed to the fixture's timestamp so recent-history classification and digests
match across processes; synchronization and performance clocks remain real.
The report includes each parent's retained/active/selected context counts,
output size, and digest. Compare outputsSha256 as well as protected-key digests
between baseline and candidate. Prompt output size counts characters; control
size counts selected runs. Control digests omit only the deliberately mutated
delivery attempt counter and normalize the fixture-directory prefix.
Add --concentrated-root for a separately labeled topology stress case. It
reparents all retained registry generations to one generated root, preserving
the 4,000 child sessions and 60 active claims in the default team shape. Ordinary
distributed runs cover roughly six retained generations per active parent;
concentrated runs exercise the full 6,000-record scope. This is synthetic
registry topology stress, not the fixture's original session-parent layout.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/subagent-maintenance-capture-measure.ts \
--state-dir /path/to/new-state --operations 4000 --start-at <future-epoch-ms> \
--gc-log /path/to/capture.log --output /path/to/result.json > /path/to/capture.log 2>&1
Run each side in a fresh process and a new state directory. Setup and warmup
precede CAPTURE_READY; use preparation CPUs until that marker, then assign the
pair's interleaved measurement CPUs before the shared start. Use the campaign's
exclusive lock and production guard, then swap placements for the second pair.
Alternatively, --start-file /path/to/start waits after preparation until that
file contains a common future epoch in milliseconds for both sides.
The default one-millisecond yield between captures permits event-loop sampling;
it is excluded from per-capture latency but included in elapsed time and CPU.
Use --allocations in a separate paired pass for allocation including collected
objects; the default pass measures unprofiled CPU, capture latency, and GC.
For a paced minute use --team-shape --operations 450 --interval-ms 133.
--per-minute 450 projects per-call allocation and CPU at an explicitly assumed
capture rate; it is not a measured production call rate. Main-thread and process
CPU are both recorded. Registry Map.set row writes are counted
in a separate unprofiled selected read. GC reports count, total time, maximum pause,
and tails with sample counts; promotion excludes setup and profiler-stop GC.
Apply the campaign's sample-count and paired-delta rules. RSS high water includes
setup. State and profile files remain in the task-owned directory for inspection
and cleanup.
Session catalog selection
session-catalog-selection-measure.ts invokes the real sessions.catalog.list
handler with a real row projection over 5,000 persisted synthetic sessions split
between two agents. Two synthetic providers consume the shared planning snapshot
and return 16 rows each. Every measured call follows a committed metadata update
and waits for the row owner to settle before reading its order;
--unchanged instead measures repeated calls at one revision. This is in-process
handler evidence, without transport, model calls, or external provider IO.
Add --adopted to exercise the real adopted-session helper over 10,000 sessions
split equally between the agents. One synthetic provider exposes 32 native rows,
with 16 plugin-owned adoption markers per agent. Every request explicitly selects
main; assertions require exactly its 16 adoptions and leave the other agent's
native rows unbound. This mode avoids per-agent summary materialization, reports
adoption calls and source-marker reads, and includes a separate adopted-helper
allocation subtree. adoptedCallsPerMinute is the driven benchmark rate, not a
claim about an idle Gateway's polling cadence. Use --adopted --unchanged to
isolate repeated adoption scans against an unchanged planning snapshot; omit
--unchanged to retain the metadata-update workload.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/session-catalog-selection-measure.ts \
--state-dir /path/to/new-state --operations 4000 --start-at <future-epoch-ms> \
--gc-log /path/to/capture.log --output /path/to/result.json > /path/to/capture.log 2>&1
Run on the approved benchmark host with a new state directory per process. Setup
and warmup precede CATALOG_READY; use preparation CPUs until that marker, then
the pair's interleaved measurement CPUs. Hold one quiet lock and use the shared
production guard; swap placements in the second pair. Add --allocations for a
separate paired allocation pass including objects collected by minor and major GC.
Handler latency excludes the metadata write and row settling; allocation, GC, promotion, CPU, and
elapsed time include the write, settling, response hashing, and the one-millisecond pacing
yield. Selection and catalog allocation subtrees are reported separately.
Compare the initial/final full ordered entry hashes and complete response-sequence
hash across baseline and candidate: no response fields are omitted. Assertions
check both providers ran and saw all rows and the expected selection revisions.
Twenty unchanged warmup calls provide context; use --unchanged --operations 4000
for a full unchanged comparison. GC reports totals per call, collection counts,
maximum pauses and tails; all tails include sample counts. Apply the campaign's
paired-delta, sample-count and absolute GC pause rules. RSS high water includes
setup; retained state and allocation profiles are task-owned cleanup artifacts.
Codex adoption candidate preparation
adoption-candidates-measure.ts calls the production adoption owner with 5,000
synthetic session entries, including 16 valid adoptions. The main measurement
uses --team-shape: the existing generator creates 5,000 persisted sessions,
6,000 retained runs, approvals, cron jobs, and projects, with transcript payloads
disabled. Setup reads the actual stored session entries through the plugin
runtime and uses the first 4,984 alongside the 16 valid adoptions. Generation
runs sequentially on preparation CPUs, before ADOPTION_READY. Without this
flag, the smaller setup uses simple in-memory session rows. The real binding store
and decoder read an in-memory fixture adapter; unexpected mutations fail. Each
list gets a new snapshot revision by default; --unchanged retains the warmed
revision. This measures candidate preparation and binding decoding, without
Gateway transport, SQLite, or native App Server latency.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/adoption-candidates-measure.ts \
--team-shape --state-dir /path/to/new-state --operations 4000 --start-at <future-epoch-ms> \
--gc-log /path/to/capture.log --output /path/to/result.json > /path/to/capture.log 2>&1
Run on the approved benchmark host under the shared guard and one quiet lock,
with concurrent baseline/candidate processes and swapped interleaved CPU sets
in the second pair. ADOPTION_READY follows fixture preparation and warmup.
--owner /path/to/baseline-sibling.ts substitutes an adoption module alongside
its original imports. Add --allocations for separate paired allocation passes
that retain samples of objects collected by minor and major GC.
Every result must exactly match the independently constructed ordered adoption map. Compare result digests across both sides; fixture digests also include the isolated state-directory paths, which differ between runs. The report includes bytes per list for the full capture, owner stacks, and key-parser stacks, along with CPU, latency, GC time per list, promotion, and event-loop delay. All tails include sample counts and GC includes maximum pauses. Result verification and pacing are excluded from list latency but included in aggregate measurements. Apply the campaign's paired-delta, sample-count, and absolute pause rules.
Gateway startup and restart acceptance
post-restart-burst-measure.ts --cold-row-reads measures cold session pages after
a clean restart. Seed two independent, compact team-shaped fixtures sequentially:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /path/to/new-fixture --sessions 5000 --runs 6000 --settled-runs \
--transcript-mib 0 --projects 0 --port 45637
node --import ./scripts/tsx.mjs scripts/perf/post-restart-burst-measure.ts \
--state-dir /path/to/new-fixture --entry src/entry.ts --cold-row-reads \
--clients 70 --seconds 300 --port 45637 --start-at <future-epoch-ms>
This mode pairs synthetic devices and seeds three Board widgets before restarting.
After readiness, every client immediately reads a sidebar page, an agent-roster
page, and the Board. It verifies populated responses and reports boot-to-ready,
first-batch and subsequent latency, page-selection count, CPU, GC, and event-loop
delay with sample counts. No model or external service is called. The fixture
keeps session metadata and retained runs but omits transcript bulk and projects.
Use --row-refresh-owner /path/to/session-row-projection-refresh.ts to compare
an alternate owner in the same source installation; the report verifies its hash.
Run concurrent pairs under one quiet lock and the production guard, with separate
fixtures and ports, then swap interleaved CPU placements. Start deadlines must
allow both preparation phases to finish. Apply the campaign's sample-count and
paired-delta rules; the 70 first-read samples alone cannot qualify p95 or p99.
Add --startup-admission --waves 20 to the cold-row command to attribute the
operator request-start backlog. This uses a fixed 20 batches per client instead
of --seconds. Each batch sends a scoped models.list before an event-only
sessions.subscribe, immediately followed on the same socket by the
sidebar, roster, Board, sessions.resolve, sessions.messages.subscribe, and
keyed chat.startup requests. Batches await all replies and retain the
--poll-ms pacing. There are no artificial handler delays or held preparations.
Each client uses a distinct shared, unarchived fixture session for catalog,
resolution, message subscription, and startup reads; Board uses the common seeded
session. Results record each client's session key and exact request parameters.
The first batch remains separate from later warm traffic in the latency report.
The explicit catalog RPC reproduces the server's catalog-bootstrap admission
order; this does not exercise capability negotiation or models.snapshot delivery.
The admission result contains sample counts, total duration, p50/p90/p95/p99,
and maxima for each method's queue wait, admission, handler, and first response.
Queue wait is the operator RPC start scheduler, while admission also includes
work before handler entry. Existing gateway.chat.startup.* timeline spans are
sampled in memory through the private source loader; they do not enable timeline
file writes. Additional startup-boundary.* samples cover pending-input reads
and optional subagent snapshot preparation across all callers in the measured
window; they are attribution context, not exclusive startup-handler phases.
The difference between handler duration and its observed spans may
include uninstrumented waits; overlapping spans must not be summed as exclusive
time. All responses are checked, including roster pages, event enrollment, session
resolution, message subscription, and startup history page. Diagnostics overflow
or absent startup-handler evidence fails the run. This compact settled fixture
measures reconnect contention, not interrupted agent-turn recovery or large
transcript hydration. Keep identical instrumentation for A/A and A/B runs, use
the campaign's untraced cost gates, and collect promotion separately with
--trace-gc and its own controls.
Use --request-start-owner /path/to/request-start.ts to compare scheduler
revisions in the same installation. The source loader verifies the selected
file's SHA-256 at load time and records it in the result.
Use scripts/bench-gateway-startup.ts and scripts/bench-gateway-restart.ts
against an exact-head build and isolated synthetic state. Record the Node version,
source SHA, workload case, host load, and build command. The default case is the
primary workload; use fiftyPlugins to check plugin scaling when that owner changes.
Keep fresh-process startup and in-process restart separate. Startup must reach
HTTP readiness and successfully serve the benchmark's WebSocket session RPC;
listening alone is insufficient. Restart must complete the supported restart
signal and recover /readyz. Record completion, readiness, session RPC, downtime,
process-tree CPU, peak RSS, and startup/restart trace phases where available.
Before changing production code, count operations and capture a separate CPU profile of startup, including worker threads. Use the frozen SQL workload for boot, restart, and warmed-turn SQL statements, worker requests, and main-thread SQL. Do not compare profiled timings with unprofiled timings, or call moving work to a worker or after readiness a reduction in total work.
For an A/B comparison, interleave at least five baseline and five candidate samples per workload (ABAB), with identical runtime, build mode, fixtures, and probe settings. Report median, p95, and min–max; report fresh startup separately from warm restart. Use the campaign's exclusive timing lock and start each timed sample only below its one-minute load limit. After a one-hour capacity wait, report operation counts and profile evidence with latency explicitly unmeasured.
Accept a change only when it removes measured work and improves median readiness or CPU by at least 5%, without worsening another user-facing median or p95. Investigate smaller adverse differences instead of claiming equivalence from spread. A 20% regression in any cell stops the change. Boot/restart SQL and main SQL must hold or fall; warmed-turn SQL and requests must not increase. Migrations, credential custody, admission, and required integrity checks remain before the operations they protect.
Model prompt statistics
model-prompt-stats-measure.ts measures the diagnostic size owner with synthetic
history containing quotes, controls, Unicode, and lone surrogates. It verifies
the exact pre-provider JSON character counts independently of the cache.
node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/model-prompt-stats-measure.ts --messages 1000 --calls 1000 --ownership message
Run at least five serial interleaved baseline/candidate samples on the approved
benchmark host. Use --ownership content for mutable envelopes with owned content,
message for sealed message roots, or mutable for the unowned control. --owner
accepts an alternate observation module beside the original for baseline comparisons.
The driver counts payload visits and new large-text size records outside profiling,
warms 50 calls, then samples allocations including objects collected by both GCs.
A second pass reports sampled surviving allocations after GC, amortized per call;
this is retained churn, not total resident memory or whole-turn retention. Run
--mode timing separately for unprofiled CPU and elapsed time, then repeat that
mode with --trace-gc-nvp for promotion. Trace lines between TIMING_START and
TIMING_END cover the fixed work count without forced collections; report
collection counts and both maximum pauses. Trace and profile timing are context,
not latency gates. These are owner measurements, not Gateway latency measurements.
Do not infer tail percentiles from five runs.
For default request instrumentation, add --lifecycle with the absolute path to
attempt.model-diagnostic-lifecycle.ts (or its baseline copy beside the original).
This uses the real activity and stability listeners and completes each lifecycle;
there is no provider call or Gateway. Allocation includes the whole lifecycle,
not just the size helper. Use --start-file for synchronized warmed pairs: wait
for both drivers to print WARM_READY, then write a future epoch in milliseconds.
Compare concurrent pairs under the shared guard and quiet lock, swapping
interleaved CPU placements. Timing reports natural GC per kind (counts, total
milliseconds per call, tails and maximum pause) and event-loop sample counts.
Use separate --mode profile pairs for allocation, including collected objects.
The lifetime gate is zero prompt serialization for default listeners, unchanged
size values when requested, and reduced CPU/allocation per call under the
campaign's absolute-delta and sample-count rules.
Model stream observation allocation
model-stream-observation-measure.ts exercises the real model-call diagnostic
wrapper with two synthetic requests per turn, each yielding 100 prebuilt text
deltas and a terminal message. It verifies identical output, interleaved request
identity across three next() resumptions, and exact completed-event byte and
usage fields for interested listeners. It makes no provider network calls.
Run under an isolated HOME, OPENCLAW_HOME, OPENCLAW_STATE_DIR, and TMPDIR.
node --import ./scripts/tsx.mjs scripts/perf/model-stream-observation-measure.ts \
--turns 1000 --chunks 100 --listeners metrics --mode timing \
--output /path/to/synthetic-state/stream-timing.json
Compare --listeners none, metrics (completed/error consumers without prompt
statistics), and full (including prompt statistics). --raw bypasses only the
diagnostic wrapper to attribute its cost; it is not a replacement candidate.
--owner /absolute/path/to/saved-wrapper.ts selects an alternate source owner;
its imports must resolve the corresponding baseline dependencies. Use the same
script, fixture shape, Node version, and listener mode on both revisions, with
counterbalanced paired runs and the campaign's quiet-slot/guard rules.
--start-file waits after warmup for a future Unix epoch in milliseconds.
Untraced --mode timing reports CPU and latency per turn, event-loop tails,
per-kind GC totals/tails/maxima, and sample counts. --mode allocation separately
samples allocated objects including those collected by minor and major GC, and
writes <output>.heapprofile for source/stack attribution. --mode count
separately counts JSON serializations, terminal usage getter reads, provider
next() calls and result reads. Usage reads include both JSON sizing and terminal
observation; these counts are not direct usage-normalization counts. No count
spies run during timing or allocation. The source uses prebuilt event/message
fixtures, so these are observer-boundary measurements including its downstream
work, not Gateway, transport, provider-production, or whole-turn allocation claims.
Session tool surface
session-tool-surface-measure.ts exercises the production tool pin owner with 50
synthetic tools, each containing 20 nested string fields. It checks unchanged
checkpoint counts, pinned descriptions, and current executable bindings. This is
an owner-only workload, not a Gateway RPC or whole-turn measurement.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs scripts/perf/session-tool-surface-measure.ts \
--case warm --calls 2000 --start-at <future-epoch-ms> --output /path/to/result.json
Repeat with --case restored (detached transcript schemas) and --case guidance
(restored schemas with changed sibling guidance). --owner /absolute/path/to/owner.ts
can select a baseline copy beside the production owner without another checkout.
Use concurrent, swapped, interleaved-core pairs under the host's quiet lock and
shared guard; interrupted pairs do not count. Report paired absolute and relative
deltas. Opposing signs or baseline values differing by more than fivefold are
inconclusive; run a third pair when that metric gates and decide on absolute mean.
Timing excludes fixture construction and warmup. Ten-call synchronous batches
yield for 1 ms; event-loop tails include those yields and carry their own sample
counts. p95 needs 200 samples and p99 needs 1,000 to gate. Major GC is context below
50 collections; report total GC and both sides' maximum pauses. Peak RSS is the
process lifetime high-water mark, including imports and fixtures. Add --profile
in a separate invocation for CPU and allocation samples including collected
objects; this mode writes .cpuprofile and .heapprofile beside the JSON report
and ranks serialization/freezing ancestors. Profiled runs supply no timing verdict.
Sum V8 promoted bytes only between MEASURE_START and MEASURE_END in the
unprofiled trace, excluding import, warmup, and forced setup collections.
Diagnostic heap profile sanitization
diagnostic-profile-measure.ts measures the production diagnostics owners on an
approved benchmark host. It does not start a Gateway or exercise RPC admission.
Run the same script against separate baseline and candidate source roots:
node --import ./scripts/tsx.mjs scripts/perf/diagnostic-profile-measure.ts \
--source-root /path/to/checkout --mode sanitize --iterations 5 \
--start-at <future-epoch-ms> --output /path/to/result.json
The sanitizer workload has 65,535 distinct frames in a depth-16 binary tree and 131,072 allocation samples representing exactly 4 GiB. This is sampled allocation volume, not a 4 GiB JSON document or resident heap. It reports output bytes and a SHA-256 digest so baseline and candidate redaction can be compared exactly. Timing and CPU measurements exclude source imports and synthetic tree preparation.
The native heap capture requests 600,000 ms with both collected-object flags and
allocates 4 GiB of synthetic array payload over 28 seconds, exercising the owner's
30-second cap. The native CPU capture retains the production stall flight recorder
while requesting its fixed five-second, 10 ms capture; this models the overlapping
native profiler used by a running Gateway. Repeat with --mode heap and
--mode cpu, each in a fresh process, so sanitizer fixture garbage cannot affect
native capture memory and GC. --mode all is convenient for exploratory runs.
--mode record writes a small native synthetic heap profile and
its sanitized golden output for an equivalence fixture.
Each result includes operation wall time, process CPU, peak RSS, total GC duration and count, major GC duration and count, the maximum measured asynchronous callback and its callback count, and the event-loop delay maximum and sample count. Native heap capture also records V8 node/sample counts, sampled bytes, and inspector stop time by observing the owner's inspector result. Response JSON serialization is included in the measured operation. Callback timing includes GC and host preemption; it is not a profiler-derived CPU duration. No latency percentiles are inferred from the five sanitizer iterations. Major GC remains context-only below 50 collections per side.
Peak RSS is the operating system's process-lifetime high-water mark, including setup and earlier iterations. Compare the last value from each fresh process. It does not use a main-thread timer, which would miss the synchronous baseline's peak while sampling the yielding candidate. The event-loop histogram is primed before timed work so it also observes the first synchronous task.
For acceptance, hold one exclusive quiet-slot lock around concurrent baseline and
candidate processes and use identical --start-at values. Use interleaved physical
cores within that slot, then swap placements for a second pair. Evaluate the mean
of the paired percentage deltas; reject a mean at least 20% adverse or a metric at
least 10% adverse in both pairs. Follow the host's production load, dirty-page, and
event-loop guard before and during every pair. Keep fixture recording and all
preparation outside the measurement CPU allocation.
Session pull-request subscriptions
session-pr-subscriptions-measure.ts runs a real source Gateway in a separate
process, waits for its readiness probe, and uses signed synthetic WebSocket clients and the standard 5,000-session
fixture. Prepare fixtures sequentially outside measurement CPU allocations:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /path/to/new-fixture --sessions 5000 --runs 4000 \
--approvals 0 --cron 0 --projects 0 --transcript-mib 0 --port 41567
node --import ./scripts/tsx.mjs scripts/perf/session-pr-subscriptions-measure.ts \
--root /path/to/new-fixture --output /path/to/results --port 41567 \
--clients 8 --keys 50 --operations 1000 --subscribes 256 --interval-ms 100 \
--start-at <future-epoch-ms>
Each tab watches one active and 49 passive session PR targets. The measured
writer alternates an unrelated session's category through sessions.patch;
--mutation label alternates its label instead. Both use the general session
patch commit path and emit canonical replacement notifications. These commits
exercise the stored-row notification path even when PR
snapshots remain unchanged. The child counts eligible row notifications, samples
all main-isolate allocations (including collected objects), and captures GC.
Default runs omit V8 GC tracing; collect promotion separately with --trace-gc
and compare it only with another traced run. Use --warmup-ms 65000 to declare
an additional fixed warmup before synchronized measurement. A future --start-at
also extends the startup-ready wait beyond its default 180 seconds when needed;
setup and warmup must still finish before that declared start. Controller and
socket-driver allocation is excluded. Notify RPC
latency reports the mutation round trip; notify settlement latency continues
through the native subscription owner's idle barrier. Settlement is an upper
bound including controller and IPC overhead. Each mutation settles before the
next scheduled mutation, and scheduling delay records any workload overruns.
Setup identifies the subscription owner's native work scope
from its direct caller stack, then restores the original tracking method before
measurement. Exactly one scope must be found; connection and ingress lifetimes
are excluded. Native idle barriers run inside the allocation capture and record
settlement duration; failure to settle within 30 seconds fails the run. Cold setup
snapshots are saved separately. After warmup and timed subscriptions, every client
must receive ready snapshots with task receipt fields for all watched keys before
notification measurement. Initial and final captures both wait for a response on
each WebSocket after settlement. Final observations must match these warm initial
snapshots; canonical hashes sort clients and session keys, while raw frames preserve
delivery order for inspection. Snapshot artifacts are saved even if equality fails.
Use separate identical fixtures and ports for both sides. The common
--start-at starts subscribe timing; notification timing starts 30 seconds later.
Add --list-every 8 to interleave a 200-row sidebar list with every eight stored-row
updates. The allocation window includes these requests and reports their latency
separately. Resource snapshots include selection counts by kind and caller; the
run must observe parent selections during setup; the notification window can
eliminate them entirely when lineage is unchanged. This exercises
the retained sidebar and task-PR descendant paths together, without assuming that
their synthetic ratio is the production ratio.
A missed scheduled phase fails the run. Compare concurrent pairs under one
host measurement lock, use interleaved physical cores, and swap sides for the
second pair. Repeat with --clients 1 to distinguish fixed from per-tab costs.
The result includes sample counts for every tail: p95 requires at least 200 and
p99 at least 1,000 observations to gate. Major GC is context below 50 collections
per side; total GC remains the gate. Reject a mean paired regression of at least
20%, or a metric at least 10% worse in both pairs. Inspect family allocation
share and snapshot captures alongside total allocation at the matched workload.
To compare a small owner-only change without another source tree, place candidate
copies of control-ui-session-task-prs.ts, control-ui-session-pr-read.ts,
control-ui-session-pr-subscriptions.ts, session-row-projection.ts,
session-row-projection-revisions.ts, and session-row-scope.ts
beside the originals with a .candidate.ts suffix and pass --owner-suffix candidate.
The child verifies all substitutions loaded; results record the owner hashes
and the optional --revision label.
Async context allocation
async-store-measure.mjs compares Node's native runner, the shared single-copy
runner, the consolidated production context fields, and experimental parent-linked
or indexed carriers. Run on an approved benchmark host with Node 26:
node --import ./scripts/tsx.mjs scripts/perf/async-store-measure.mjs stock 26 200000
node --import ./scripts/tsx.mjs scripts/perf/async-store-measure.mjs single 26 200000
node --import ./scripts/tsx.mjs scripts/perf/async-store-measure.mjs consolidated 26 200000
node --import ./scripts/tsx.mjs scripts/perf/async-store-measure.mjs linked 26 200000
node --import ./scripts/tsx.mjs scripts/perf/async-store-measure.mjs slots 26 200000
Repeat for 1, 8, 10, 20, 26, and 40 outer stores. Each invocation starts in a fresh
process, warms 20,000 entries, samples collected allocations at 512-byte intervals,
reports bytes per entry with allocation sample counts, then measures unprofiled
entry and oldest/middle/newest lookup time separately. The consolidated variant
enters the same owner fields through one frozen record, sharing their values.
nativeFrameBytesPerRun counts samples rooted at Node's AsyncContextFrame
constructor separately from total entry allocation. Pass a final profile path to
retain the raw collected-object profile. V8 may inline that constructor out of
sample stacks; zero native-frame samples yield a null frame-byte estimate,
not evidence of zero frame allocation.
The persistent carriers are experiments, not production context owners. These
mechanism measurements do not establish Gateway GC or latency improvements; use
the concurrent, swapped-core Gateway workload for acceptance.
main-thread-stall-measure.mjs measures the real stall-attribution owner with the
same sampling and warmup protocol. It reports calls, sampled bytes per call,
sample counts, and separately timed calls per second:
node --import ./scripts/tsx.mjs scripts/perf/main-thread-stall-measure.mjs entry 21 50000
node --import ./scripts/tsx.mjs scripts/perf/main-thread-stall-measure.mjs same-name 21 50000
node --import ./scripts/tsx.mjs scripts/perf/main-thread-stall-measure.mjs phase 21 50000
entry establishes an async task; same-name reenters its inherited task;
phase labels synchronous work without an inherited task, as when entering a
stored owner context. Each callback checks its visible task name. Run identical
commands on baseline and candidate; these owner costs do not measure the
frequency of each case in a Gateway workload.
Workboard dashboard reads
workboard-read-measure.ts exercises the real source Gateway and Workboard SQLite
owner over signed synthetic WebSocket connections. Run it on an approved benchmark
host. Prepare each fixture outside the measurement CPU allocation:
node --import ./scripts/tsx.mjs scripts/perf/workboard-read-measure.ts \
--root /path/to/new-fixture --port 41510 --prepare --boards 24 --cards 50
node --import ./scripts/tsx.mjs scripts/perf/workboard-read-measure.ts \
--root /path/to/new-fixture --port 41510 --clients 12 --waves 80 \
--interval-ms 250 --start-at <future-epoch-ms> --output /path/to/result.json
Preparation creates 1,200 synthetic cards through public RPCs; their example PR
URLs cause no GitHub requests. Measurements warm all board pages, then replay
concurrent workboard.boards.list and workboard.cards.list calls, first without
writes and then after one card status change per wave. Every response verifies
card counts, board status aggregates, and changed card status. Reuse an untouched
prepared fixture for each sample; measured status writes remain in its state.
Reports include calls, errors, round-trip and handler p50/p99, response JSON bytes, Gateway process CPU, worker request classes, and observed board aggregate/metadata read commands on the existing worker transport. Handler seconds per 15 minutes are extrapolated from this replay's request rate, not a production forecast. Diagnostic loss, missing handler observations, stale data, and missed scheduled phase starts fail the run. This measures server RPCs, not browser rendering.
For A/B comparisons use separate identical fixtures, ports, and source checkouts.
Hold the host's exclusive measurement lock around each concurrent pair, use the
same --start-at on both sides, and swap CPU halves for the next pair. Both warm
and mutation phases have independent shared start times. Compare paired
differences across at least two pairs; record outside load for context.
Usage metadata bursts
chat-metadata-burst-measure.ts requests sessions.usage on a cold 5,000-session
fixture while ten signed synthetic clients observe the real Gateway. Prepare a
fresh fixture for every sample; the read populates the usage cache:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /path/to/new-fixture --sessions 5000 --runs 0 --approvals 0 \
--cron 0 --projects 0 --transcript-mib 1 --port 41483
node --import ./scripts/tsx.mjs scripts/perf/chat-metadata-burst-measure.ts \
--root /path/to/new-fixture --port 41483 --clients 10 \
--duration 60000 --output /path/to/result.json
Results classify metadata frames, count usage publications and their event-loop ticks, and record main-thread CPU plus synchronous WebSocket send CPU. Send CPU excludes Gateway JSON encoding and asynchronous socket work. Final assertions require all 5,000 usage entries to be cached and every socket to have received the latest committed usage receipt. The fixture uses synthetic model configuration and makes no model calls.
For concurrent A/B pairs, use identical separate fixtures and --start-at with
the same future epoch milliseconds to synchronize Gateway launch. The measurement
starts once each Gateway's sockets connect; waiting for idle would let startup
warm the usage cache and erase the cold workload. The script rejects fixtures with
existing usage rollups and rejects any run where startup publishes usage before
all clients connect, including partially warmed samples.
--metadata-owner /path/to/server-chat-metadata-lifecycle.baseline.ts replaces
only that module; place the baseline beside the original so relative imports
remain valid. Swap the CPU halves for the next pair and compare paired differences.
Codex process inspection
codex-process-inspection-measure.ts starts a real isolated Gateway, touches and
retains --ballast-mib 8192 of fixed memory, then measures CodexAppServerClient.start, durable child
registration, and initialize against a synthetic stdio peer. It verifies the
initialized notification, registration fingerprint, polite shutdown, and deletion
of the registration after each sample. It does not run model turns or full attempt
policy preparation. Run only on the approved Linux benchmark host, using fresh
state and loopback ports for each side:
node --import ./scripts/tsx.mjs scripts/perf/codex-process-inspection-measure.ts \
--state-dir /srv/perf-sandbox/<lane>/state-a --port 41570 \
--samples 200 --ballast-mib 8192 --start-at <future-epoch-ms> \
--output /srv/perf-sandbox/<lane>/result-a.json
Use --snapshot-owner <baseline-sibling.ts> to replace only the process snapshot
owner through a module resolution hook. Reports separate actual main-thread peer
and inspection spawns and their synchronous durations, startup and cleanup time,
CPU, observed RSS, event-loop sample counts, and total/major GC. Actual RSS includes
the 8 GiB ballast plus Gateway/worker heaps; those heaps can shrink after setup, so
the fixture never sizes ballast against transient startup RSS. Setup and one warmup
are excluded; whole-run CPU/GC also include the registration verification reads.
Hold one shared quiet lock around each concurrent pair, give its sides interleaved
CPUs, then swap sides for the second pair. Use the production guard before and
during each pair. Decide on the mean of paired deltas: reject a mean at least 20%
adverse or a metric at least 10% adverse in both pairs. p95 requires 200 samples per
side and p99 requires 1,000; reported eligibility is per side, so both sides must
qualify. Major GC is context-only below 50 collections per side.
Startup with a populated multi-agent fixture
For a bounded multi-agent boot workload, use the opt-in teamBoot case:
node --import ./scripts/tsx.mjs scripts/bench-gateway-startup.ts \
--case teamBoot --fixture-root /path/to/new-fixture --seed-only
node --import ./scripts/tsx.mjs scripts/bench-gateway-startup.ts \
--case teamBoot --fixture-root /path/to/new-fixture \
--runs 1 --warmup 0 --timeout-ms 90000 --start-at <future-epoch-ms> \
--output /path/to/result.json
This fixture has 44 synthetic startup-active plugins, eight local agents, 5,000
shared session entries, and 128 MiB of inert synthetic shared-state payload. It
uses the normal database and session writers and keeps clean-close seals. It
does not provision remote workers or make model/channel calls. Actual database
sizes and inventory are recorded in team-boot-fixture.json and copied into the
result. Seed each side sequentially outside the measurement window. Payload
writes checkpoint every 8 MiB and pause between batches; Linux generation stops
if system dirty pages reach 2 GiB. A failed seed has no completion marker and
must be regenerated in a new directory.
To measure a mix of bundled and external plugins, pass
--bundled-plugin-fixture /path/to/plugins.json to both the seed and run commands.
The file is an OpenClaw config fragment with plugins.allow selecting 1–44
bundled IDs and plugins.entries.<id>.config supplying any required synthetic
configuration. For example, { "plugins": { "allow": ["memory-core", "openai", "google"] } }
uses plugin defaults. Optional models, tools, and channels sections select
the corresponding startup capabilities; enabling a plugin alone does not
override its lazy activation policy. Other root sections and plugins.load
are rejected, preserving the benchmark's state, workspaces, auth, and disabled
background jobs. Each bundled ID replaces one synthetic external plugin,
keeping the total at 44. The fixture disables implicit memory-core activation;
include it in the requested inventory to enable it. Include only synthetic
configuration and credentials, disable scheduled external work, and deny external
network access for the run.
Channels remain suppressed even when configured for plugin activation.
The fixture metadata and result contain the full config fragment and external
count; a run rejects configuration that differs from its seed or a loaded
inventory that is not 44 plugins including every requested bundled ID. Use the
bundled IDs from the target deployment's startup log and build their artifacts
before measuring. Omit the flag to retain the all-synthetic fixture.
To reproduce startup waiting for pending GitHub publication recovery, add
--publication-git-delay-ms 10000 to both the seed and run commands. This keeps
the same plugin and session inventory and binds one existing session to a tiny
local managed worktree with a retained publication request. Its historical
requester authority is absent, so recovery must inspect the local workspace and
then refuse publication without credentials or network access. A fixture-only
git executable delays one exact read-only config command in that worktree,
then delegates to the real Git executable. Other Git commands run normally.
The fixture preserves its explicit PATH so startup does not replace that Git selection.
The sample's publicationRecovery records the delay and start/release epoch
timestamps. Missing or incomplete boundary markers fail the measurement. The
controller waits for that read, initial post-ready database maintenance, and the
last handler-prewarm phase before teardown. CPU and RSS therefore cover the same
startup work on both builds; completionMs includes that work while readyz.ms
and firstSessionRpc.ms measure availability independently.
Use a newly seeded root for each sample: completed requests and boundary markers
make these publication fixtures intentionally single-use. Delays are bounded to
30 seconds and must match the seed. This tests subprocess waiting at the real
publication owner; it does not simulate GitHub response semantics.
Startup results include spawn-to-/readyz and spawn-to-first-successful
sessions.list timings. The teamBoot protocol handshake uses the isolated
Gateway's synthetic shared token so access-policy plugins receive a real owner
identity; other cases retain loopback auth: none. Session RPC probing starts when /healthz
first responds, concurrently with /readyz, and retries startup refusals until
the shared deadline. A healthy HTTP endpoint without a successful session RPC
fails the measurement. The teamBoot case also requires the expected seeded
session; an empty or unrelated roster does not count. startupTraceLines preserves every observed startup
trace line, including repeated spans; startupTrace retains the existing numeric
summary. All benchmark Gateway ports are at least 40000. If the host's ephemeral
port range cannot provide one, the benchmark reports a setup error after bounded
attempts instead of waiting indefinitely.
Samples record teardown duration in shutdownMs and forced process-tree cleanup
in shutdownForced; the summary includes shutdown timing statistics. The
teamBoot case allows 30 seconds for graceful teardown and fails if the child
exits nonzero, dies from a signal, or requires forced cleanup. Other cases keep
their existing teardown policy.
For paired comparisons, prepare separate roots from the same seed inputs and
pass the same future --start-at to both controllers. Use the host's exclusive
measurement lock, pin each side to its assigned 16-core half, swap the halves on
the next pair, and compare each pair's candidate-minus-baseline difference.
Build and seed outside the measurement CPU allocation. Keep every root and
temporary directory inside the task-owned sandbox and remove them after use.
Startup with interrupted subagent runs
The interruptedRegistry case starts a real Gateway against 86 retained runs:
78 completed rows and eight unfinished rows across eight synthetic child agents.
Four unfinished rows remain running, and four are already interrupted with
aborted session state. All rows retain completion expectations and requester-turn
claims, including the delivered, cleanup-complete rows: all 86 are activation
obligations, not cold history. They share eight requester cohorts; the unfinished
rows share two of those cohorts, four per requester. The seed command
commits the fixture in a child writer, then sends that writer SIGKILL while its
SQLite handles remain open. This preserves actual unclean-close/WAL residue;
the run/session records are synthetic, not captured from a predecessor Gateway
or model execution. No models, credentials, or external channels are used.
node --import ./scripts/tsx.mjs scripts/bench-gateway-startup.ts \
--case interruptedRegistry --fixture-root /path/to/new-fixture --seed-only
node --import ./scripts/tsx.mjs scripts/bench-gateway-startup.ts \
--case interruptedRegistry --fixture-root /path/to/new-fixture \
--runs 1 --warmup 0 --timeout-ms 90000 --output /path/to/result.json
The output includes spawn-to-/readyz, spawn-to-Gateway-ready, all activation
trace spans, per-run recovery times, and database bytes. The driver waits for all
eight recovery events and verifies exactly one event per unfinished run, eight
durable terminal error outcomes, all 86 retained rows, and unchanged execution
outcomes for the 78 completed rows after shutdown. Requester claim metadata may
change during settlement. The synthetic rows have no saved operator source, so
this proves orphan and missing-source settlement; it does not exercise successful
operator authority restoration, resuming a model, or external delivery.
Activation subspans can overlap or sum concurrent groups, so compare the outer
activation elapsed time separately from the component totals.
Prepare fresh roots sequentially for each side of every pair; recovery mutates
the fixture. Use the same --start-at <epoch-ms> for concurrent A/B starts under
the benchmark host's exclusive measurement lock, and swap the assigned CPU
halves for the next pair. Build and seed outside the measurement allocation.
Startup with one checking agent
The startup benchmark has an opt-in slowAgentDatabase case. Prepare each side
sequentially before the measurement window, then run one cold startup per fixture:
node --import ./scripts/tsx.mjs scripts/bench-gateway-startup.ts \
--case slowAgentDatabase --fixture-root /path/to/new-fixture --seed-only \
--agent-database-mib 512 --agent-gate-delay-ms 10000
node --import ./scripts/tsx.mjs scripts/bench-gateway-startup.ts \
--case slowAgentDatabase --fixture-root /path/to/new-fixture \
--runs 1 --warmup 0 --timeout-ms 90000 --output /path/to/result.json
The fixture contains five agents with one interrupted subagent each. All runs belong to independent requester sessions owned by the first healthy agent, so one parent's unrelated conversations must recover independently. Four agent databases are small; the fifth contains the requested MiB of synthetic cache payload. Its clean-close seal is removed under offline fixture custody, forcing the real integrity scan. A benchmark-only preload delays that scan by the requested duration, then executes the original SQLite statement. Use zero delay to measure the native scan separately. This simulates a long check without writing the incident's tens of gigabytes onto a shared host.
Results retain /readyz, the integrity gate's start and completion times, and
each agent's recovered session status event. The driver waits for all five
recovery events before teardown and rejects missing observations. No models or
external channels run. Generate a fresh fixture for every sample; recovery
mutates the synthetic records. Use separate roots and identical inputs for
concurrent A/B pairs, with the host's measurement lock and alternating CPU halves.
The case also requires every healthy agent to recover before the slow integrity
gate passes, the slow agent to recover afterward, and zero admission errors.
An unfixed baseline exits nonzero for the ordering failure while still writing
its measurements; distinguish this expected result from setup or missing data.
Use one fixture for local and sandbox performance repros:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /tmp/openclaw-team-shape --launch
The destination must not exist. The generator refuses existing directories and
symlinks instead of overwriting state. Everything it creates lives below that
path: SQLite stores, config, HOME, temporary files, logs, Git repositories and
worktrees. No operator data or credentials are copied. The launcher runs the
repository's pnpm openclaw gateway run with an isolated environment, binds only
loopback (default port 40967), and forwards shutdown signals through the existing
managed-process helper. It does not install or restart a service. Ctrl-C stops
the launcher and Gateway; the fixture remains available for inspection.
The profile team-shape-<port> and its explicit native service names keep dev
build ownership checks separate from the host's default Gateway.
Use a checkout with dependencies installed. The normal dev launcher may build missing/stale runtime artifacts; on hosts that prohibit builds, prepare the checkout on the authorized benchmark host. Generation itself does not need a Gateway build. Never point another running Gateway at a directory being seeded. Package-manager caches are isolated too: Corepack or pnpm version shims may download the pinned package manager into the fresh HOME. For offline launch, put a directly installed copy of the repository's pinned pnpm version on PATH. The launcher intentionally does not reuse an operator's package-manager cache.
Shape and repeatability
Defaults are 5,000 sessions in the main agent, 6,000 retained subagent runs,
200 pending approval recovery records, 100 disabled cron jobs, and 12 registered
projects with committed synthetic Git repositories and real worktrees.
Sessions have 24 synthetic human identities, assigned owners, 1–5 participants, a 70/10/10/10 split of shared/read-only/suggest/draft visibility, pinned rows and 20% archived rows. Activity timestamps span 30 days. Transcript weights use an 80/17/3 percent short/medium/long distribution, with weights 1/16/128. The seed controls both size assignment and synthetic payload content. Retained runs mix successful terminal, failed terminal, pending announcement, and interrupted with completed cleanup. The 60/15/15/10 slot mix applies before older pending generations are settled; only latest generations keep pending announcements. Actual state counts are included in the summary. Twenty percent of sessions are roots, with children distributed across them and repeated runs in time order. Supporting state uses the existing storage owners and schema; the generator changes no product schema.
Use --settled-runs for steady-state measurements that need the retained run
inventory without a subagent restart-recovery backlog. All requested runs remain
present with terminal execution, delivered completion, and completed cleanup;
failed-terminal outcomes, timestamps, and parent/child generations remain intact.
The default mixed lifecycle shape is unchanged. The summary records settledRuns
and the resulting state counts. This flag does not change pending approvals;
use --approvals 0 when that recovery work is also outside the workload.
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /tmp/openclaw-team-shape-large \
--sessions 5000 --runs 6000 --approvals 200 --cron 100 --projects 12 \
--seed 967 --now 1791892800000 --transcript-mib 7000
--transcript-mib is total logical message text, not physical database size.
The default is 64 MiB. Deterministic high-entropy text prevents compression from
turning gigabytes of repeated padding into a tiny database. The persisted size
also includes transcript projections and SQLite overhead: use the reported
agentDatabaseBytes, not the requested MiB, when comparing fixtures. Memory is
bounded by transcript batches rather than total payload size. For tens of GB,
raise this option and verify free disk space first.
For deep histories, add --long-transcript-entries 2000 --long-transcript-sessions 4.
This appends the requested number of alternating user/assistant messages to the
first selected retained sessions, in addition to the weighted transcript text.
Each message contains 1 KiB of synthetic text; each assistant message also carries
a distinct deterministic 16 KiB thinking signature. The selected session count
defaults to four and is capped by --sessions. The combined logical payload,
including conservative signature/envelope allowance, must fit within 1 GiB when
this stress shape is enabled.
--worktree-files 270000 --nested-repository adds an unchanged tracked inventory
of empty files and an untracked committed nested Git repository to the first
registered linked worktree. Other project worktrees retain their small fixture
shape. These flags require --projects greater than zero; the tracked inventory
is capped at 500,000 files. Writes are sequential. On Linux, generation checks
dirty memory every 1,000 files and every 2 MiB transcript batch, pauses at 1 GiB,
and fails at 2 GiB. Generate on the authorized benchmark host and record actual
database size and dirty-memory observations.
The default seed is 967 and the default reference time is 2026-10-13 12:00 UTC.
Pass the same --seed and --now to reproduce logical data. Set --now near the
measurement date when testing retention; a frozen old epoch intentionally makes
rows old. Filesystem paths, database admission metadata and project registration
timestamps are host-dependent, so SQLite files are not byte-identical exports.
team-shape-summary.json records inputs, counts, actual bytes, Node version and
elapsed generation time. Generation failure leaves the partial directory for
diagnosis; remove only that task-owned fixture before retrying.
Pending approvals represent a startup recovery backlog, not live authority. The real Gateway cancels prior-epoch requests on boot. Pending announcements also participate in normal recovery; their next attempt is delayed one day from the reference time. Generate a fresh fixture for each startup comparison. Cron jobs, heartbeats, channels, plugins, browser automation and network discovery are disabled so this workload does not call models or external integrations.
Census native async-context frame copies
async-context-probe.cjs attributes native AsyncLocalStorage frame copies to
store construction sites. Use it only in an isolated synthetic Gateway on the
authorized benchmark host, under that host's resource limits and production
guard. It validates the runtime's private AsyncContextFrame Map representation
at startup and fails clearly if unavailable; Node 26.12 is the campaign runtime.
Load it before Gateway modules so CommonJS and ESM imports receive the tracked
constructor. Existing stores appear as untracked.
For a synthetic channel-and-tool workload, first prepare the private QA runtime as described in the channel-turn benchmark. From the checkout root, create a task-owned entry wrapper and pass it to the harness:
mkdir -p .local/async-context-probe
cat > .local/async-context-probe/entry.mjs <<'EOF'
import { fileURLToPath } from "node:url";
process.env.OPENCLAW_ASYNC_CONTEXT_PROBE_OUT = fileURLToPath(
new URL("./census.json", import.meta.url),
);
await import("../../scripts/perf/async-context-probe.cjs");
const entry = new URL("../../src/entry.ts", import.meta.url);
process.argv[1] = fileURLToPath(entry);
await import(entry.href);
EOF
node --import ./scripts/tsx.mjs scripts/bench-channel-turn.mts \
--entry .local/async-context-probe/entry.mjs --tool-args-chars 4096
Set probe options inside the wrapper because harnesses sanitize child environments.
OPENCLAW_ASYNC_CONTEXT_PROBE_OUT is required and its parent directory must exist.
OPENCLAW_ASYNC_CONTEXT_PROBE_SAMPLE_EVERY defaults to 8 (approximately one in
eight copies; 1 samples all), and OPENCLAW_ASYNC_CONTEXT_PROBE_INTERVAL_MS
defaults to 30000. The JSON file is replaced atomically at startup, each interval,
and normal process exit. Forced termination can lose the final partial interval.
Use separate wrappers/output paths for baseline and candidate; do not add this
preload to NODE_OPTIONS, which would also instrument children and workers.
copies counts native enterWith calls, including restorations: native run
copies twice when changing a value, while runWithAsyncStore copies once. Sampling
uses a fixed pseudorandom sequence to avoid always selecting the restoration half.
samples, frameSizeHistogram, and meanFrameEntries describe the source
frame being copied, including entries whose value is undefined. meanActiveEntries
counts non-undefined values; it makes no claim about their authority or liveness.
owners ranks construction sites by sampled entry presence, with separate active
and undefined counts. Several stores created at one site can appear in one frame,
so entriesPerSample is a mean count, not a presence probability. enteringOwners
records exact copy counts and sampled source-frame weights for the store issuing
each copy. Sort owners by active or undefined to inspect those contributions.
Only code locations and numeric counts are retained; store values are never logged.
This example includes startup plus one cold and ten warm synthetic turns. Record
the harness's completed turns alongside the census; a failed or shortened workload
does not give comparable counts. The same wrapper works with a longer --entry
harness for tools/subagents or with idle-gateway-measure.ts for an offline
team-shape fixture. These are instrumented work counts, not measured frame bytes,
allocation, promotion, GC, or latency. Run allocation profiling and uninstrumented
acceptance pairs separately with matched workloads and runtime versions.
Calibrating paired GC measurements
Before rejecting an allocation change on GC/work, run identical-source A/A controls with the same runtime, fixture, pacing, warmup, capture length, and CPU placement as the candidate comparison. Use at least four independent concurrent pairs, swapping placements each pair. Keep every valid observation and report both sides, absolute and relative paired deltas, and the two-pair gate result for pairs 1–2 and 3–4. Four pairs can expose a false rejection; they cannot precisely estimate the false-positive rate. A guard-interrupted pair is discarded whole.
Use transcript-broadcast-measure.ts --diagnostics without GC tracing,
allocation or CPU profiling for natural-GC timing gates. Each RPC round without
background polling records naturalGc: completed operation count, timing-gate
eligibility, and GC milliseconds per operation split by kind. diagnostics.gateway.gc supplies
collection counts, pause tails, and maxima; eventLoopDelay includes its sample
count. The diagnostic capture also records Node/V8 versions, process age, and
heap-space sizes before and after. Compare young-generation capacity: equal
source does not imply equal nursery size after startup or an idle wait.
Measure promotion separately with --diagnostics --trace-gc and matched A/A
controls. This adds main-isolate gcTrace and promotedBytesPerOperation but marks
timing as ineligible for gates. Node's GC event clock spans its prologue/epilogue
callbacks; V8 prints the trace after stopping its collection-pause clock and
before the epilogue callback. Trace serialization/output can therefore inflate
GC-event and loop timings. Retain those raw observations for attribution; do not
subtract trace overhead or replace total GC cost with the narrower trace-pause
sum. Tracing also forces an initial identification GC before capture, so compare
baseline and candidate within the same mode. See the
Node callback timer
and V8 trace boundary.
The allocation profiler is a separate experiment. Its stop operation forces GC;
the outer diagnostics.gateway window includes that operation. Use allocation
results for bytes/work, never that outer window as evidence of natural GC cost.
For synchronized warm starts, give both drivers the same new --start-file path.
After both print PHASE warmed, atomically publish a future epoch in milliseconds
to that file. It replaces --start-at; missed deadlines fail the run. Keep the
wait short and record the resulting process ages. This aligns workload start,
not maintenance phase: repeating maintenance can be scheduled from the preceding
pass's completion. Initial main-thread and worker heap collections finish before
PHASE warmed; retained-resource baselines therefore precede the barrier.
Keep maintenance enabled and preserve its logs.
For memory-soak, use --diagnostics --retained-profile-ms 0 without allocation,
CPU, or GC-trace flags for natural-GC cost. Its continuous wholeSoakDiagnostics
window includes the paced gaps between turns. Initial retained-memory sampling,
including any requested baseline collection, completes before PHASE warmed;
the final forced sample follows the closed diagnostic window. Results record
initialMemoryCollected, warm-ready/release epochs, completed warmup rounds,
and measured turns. Set --soak-rounds to a fixed work count and choose
--duration-ms to match the declared pacing; the duration can extend the window
after that work. Completed rounds must match both the requested work and the
driver's completed-turn cohorts. Keep the same protocol for controls and candidates.
Raw per-round observations are written to round-<n>.json, with complete results
in result.json and memory samples in memory-samples.jsonl. Progress output
prints bounded counters rather than raw sample arrays. Gateway stdout/stderr
stream directly to files with backpressure; redirect driver output to a file too.
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/fresh-team-shape --entry src/entry.ts \
--scenario plugin.scope --clients 1 --runs 1 --operations 1000 \
--warmup-operations 40 --rpc-interval-ms 132 --rounds 1 \
--port 41580 --mock-port 41582 --diagnostics \
--start-file /path/to/pair-start --revision <source-sha>
--scenario cron.list validates 40 disabled announce/last jobs targeting one
synthetic session, including their full list and delivery previews. Generate
fresh fixtures with --cron 40 --cron-announce-last; use --rpc-interval-ms 1100 to
exercise uncached reads. --scenario board.get prepares and validates three HTML
widgets. sessions.list and plugin.scope retain their existing response checks.
Generate both fixtures sequentially with matching inventory and zero transcript
bulk when testing these metadata reads. Do not reuse the cron fixture across runs.
If A/A crosses the proposed GC or promotion threshold, classify that short-window comparison as inconclusive. Select a longer fixed work count from the pilot, then freeze it before the candidate run and repeat the A/A control. Include several natural major collections per side when major-cycle phase drives the variance, and report counts even when insufficient. Do not stop each side after its own target collection count, force collections inside measurement, discard maintenance-overlapping outliers, trim major pauses, or divide cost by major cycles: each can remove a real frequency-dependent cost. Keep untrimmed GC/work as the primary cost and report pooled absolute cost/work alongside paired deltas. Retain the campaign's tail sample requirements and maximum-pause safeguard. A noisy control does not establish that a candidate is regression-free.
Chat startup reads
transcript-broadcast-measure.ts --scenario chat.startup opens retained synthetic
sessions through the real WebSocket handler. Generate the team-shape fixture
first; --chat-startup-sessions 30 --long-transcript-entries 1000 --long-transcript-sessions 30 gives the first 30 retained sessions deep histories,
deterministic UUID keys, and two interrupted pending inputs each. Half use short
references; half use canonical keys with an explicit openai/gpt-4.1 selection.
The driver selects these targets from the fixture summary. Pending inputs have
no replay metadata, so recovery retains them for display without calling a model.
This exercises retained pending-input reads, not competing live input custody.
Keep total logical fixture size under
the generator's bound and prepare each side sequentially.
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/fresh-team-shape --entry src/entry.ts \
--scenario chat.startup --startup-temperature warm --runs 30 --clients 1 \
--operations 1000 --warmup-operations 40 --rounds 1 --limit 80 \
--port 41659 --mock-port 41660 --diagnostics --start-file /path/to/pair-start
Warm mode first opens every selected target, then repeats the fixed warmup and
measured workload. --runs chooses the number of retained targets (default 20).
Cold mode uses --startup-temperature cold --operations 30 --rounds 1: each
measured operation selects a different retained session; warmup uses the separate
synthetic parent. Cold means the first chat.startup for that session in this
Gateway, not an empty OS page cache or an uninitialized agent database. Every
response must contain the requested session identity, session metadata, messages,
and pending-input page. Overridden targets also require startup command metadata;
a swallowed projection error is not a successful observation. Custom manifests
can require the same check with "requireMetadata": true.
Latencies are in samples[].rpc["chat.startup"], with
counts and raw observations; diagnostics include phase spans and natural GC.
For prepared short-link, pending-input, or overridden-model fixtures, pass
--startup-targets /path/to/targets.json, an array such as:
[
{
"sessionKey": "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001",
"shortId": "12345678",
"minimumMessages": 1,
"minimumPendingInputs": 1
}
]
Omit shortId to exercise canonical keys. Manifest targets must already exist in
the synthetic fixture. The driver does not inject catalog failures; those require
separate preparation and evidence. Cold targets must be unique
and cover every measured operation across all rounds. Use matching manifests and
fixture preparation on both sides. Retain the paired controls, shared guard, CPU
swaps, synchronized start, and separate traced-promotion pass described above.
Thirty cold or 200 warm observations provide descriptive p99 values, not qualified
p99 gates; those require at least 1,000 observations per side.
For one source installation, --startup-source-preload /path/to/owner-preload.mjs
loads an explicit task-owned source hook before Gateway profiling. It requires
--entry src/entry.ts; the report records the preload's path and SHA-256. Preserve
module identities and worker imports in that hook, verify the selected owners,
and give both sides the same loader topology. The driver does not infer source
equivalence from the supplied revision label.
Measure idle Gateway allocation and timer work
idle-gateway-measure.ts connects one real Control UI client to an owned Gateway,
then sends no application requests during the measured window. Use a freshly
generated, offline team-shape fixture for each side; omitting --state-dir
retains the small empty-state workload. --output must name a new artifact
directory. The fixture config is preserved and an isolated measurement config
is written under the output directory.
node --import ./scripts/tsx.mjs scripts/perf/idle-gateway-measure.ts \
--state-dir /srv/perf-sandbox/<lane>/fixture-a \
--output /srv/perf-sandbox/<lane>/idle-a \
--entry src/entry.ts --port 41563 --minutes 3 --settle-seconds 60 \
--start-at <future-epoch-ms> --allocations
Run on the authorized benchmark host under its measurement lock, resource limits
and production guard. Concurrent baseline/candidate runs need separate fixtures,
output directories and loopback ports (40000 or higher), the same --start-at,
and the host's interleaved CPU sets, swapped for the second pair. The start barrier
follows readiness and settling; a missed barrier fails the run. Actual capture
timestamps are recorded so placement and start skew remain visible.
result.json records main-isolate CPU, GC durations and counts, event-loop p99 and
sample count, and V8 promoted bytes for the capture only. --allocations uses the
shared private IPC profiler to include objects collected by minor and major GC;
the saved heap profile and summary include the top allocation sites and estimated
bytes per minute and second. Worker allocations are excluded. --profile captures
a main-isolate CPU profile and its diagnostics over the measured window; promotion
accounting requires a run without --profile. Run CPU and allocation profiling
separately. Neither profiling mode exposes an inspector listener or copies operator
credentials.
Use --timers in a separate attribution run to collect per-timer callback counts,
CPU and worker snapshots. It instruments timer creation and adds periodic IPC
samples, so leave it off for allocation/GC acceptance comparisons. Compare paired
per-minute allocation, promotion and total GC time; retain counts and maximum
pause alongside any tail percentile, applying the campaign's sample-count and
absolute-delta rules rather than treating sparse tails as gates.
Measure clean-close seals across app versions
seal-version-restart.ts measures the real cold-process agent database open,
including its integrity gate, without launching a Gateway. Run it only on the
authorized Linux benchmark host with separate baseline/candidate checkouts and
state directories. Seed the two fixtures sequentially, under the host's
non-measurement CPU allocation:
node --import ./scripts/tsx.mjs scripts/perf/seal-version-restart.ts seed \
--state-dir /srv/perf-sandbox/seal-version/base-state --mib 2048
node --import ./scripts/tsx.mjs scripts/perf/seal-version-restart.ts open \
--state-dir /srv/perf-sandbox/seal-version/base-state \
--refresh-proof --expect-version 2026.10.4 --expect-full-scans 1
The seed refuses existing directories, writes deterministic high-entropy JSON
into the existing cache_entries table in paced 2 MiB transactions, and uses the
database owner's checkpoint and close paths. It pauses above 1 GiB of Linux
Dirty memory and stops at 2 GiB. It never copies a database or runs VACUUM.
The fresh open --refresh-proof process establishes full verification of the
populated database. Subsequent open commands require the completed fixture
marker and a physical agent database of at least 2 GiB.
Before each pair, set only package.json's version to the old value in each
isolated sandbox checkout, then run open --refresh-proof separately to
reseal both fixtures. Change only that package version to the new value and run
the two measured opens concurrently with the same future --start-at <epoch-ms>.
Use --expect-version on every open: OPENCLAW_VERSION does not override the
package version in the seal policy, and a nearby build-info.json can take
precedence. Baseline expects --expect-full-scans 1; candidate expects 0.
Hold /srv/perf-sandbox/.quiet.lock around the concurrent pair. Use A on CPUs
96–111 and B on 112–127, then swap those halves for the next pair; take at least
two pairs and record mpstat -P 96-127 alongside them. Prepare fixtures and
reseal on CPUs 64–95. Compare B minus A within each pair. JSON reports include
effective version, database bytes, seal-match state, full-scan count, integrity
gate milliseconds, and total cold-open milliseconds including seal lookup.
This is cold process admission, not cold OS page cache; it does not measure
Gateway readiness or model/channel startup. Record those limits with results.
Shared writer contention from audit retention
scripts/bench-channel-ingress-writer.ts measures the real audit FIFO, ingress
queue, and interactive machine-state writer against synthetic retained audit rows:
node --import ./scripts/tsx.mjs scripts/bench-channel-ingress-writer.ts \
--state-dir /tmp/writer-bench --seed-only
node --import ./scripts/tsx.mjs scripts/bench-channel-ingress-writer.ts \
--state-dir /tmp/writer-bench --seconds 60 --output /tmp/writer-bench.json
Seed requires a new database. Defaults populate 100,000 execution contexts and
250,000 decision facts, then offer three audit items, one inbound message, one
idle-drain poll, and one interactive write per second. Audit work alternates
identity and decision inserts; this models enabled identity collection at its
retention caps, not the ordinary run/tool-only audit mix. Reports include worker
queue/service timing, ingress and interactive latency, and persisted-work checks.
This exercises storage owners, not Gateway transport, external channels, or a model.
Use --help for smaller fixtures and synchronized concurrent A/B start times.
Prepare fixtures separately from measurement and compare paired differences on
the authorized benchmark host, swapping CPU allocations between pairs.
Measure the real list endpoint
For browser-originated model and cron list traffic, point the headless probe at an already running isolated Gateway with synthetic state and authentication:
node --import ./scripts/tsx.mjs scripts/perf/catalog-polling-measure.ts \
--url http://127.0.0.1:40967/ --duration 60 --output /path/to/catalog-traffic.json
The probe creates a fresh browser context and observes real WebSocket frames
before navigation. It separates cold startup, visible idle, an offline-triggered
reconnect, Cron navigation and idle, and a retained return to Cron. --duration
is seconds per idle phase. Startup and navigation settle for five seconds after
UI readiness. If Chromium's offline mode does not close the socket, the report
marks reconnect unobserved instead of claiming a reconnect measurement. Counts,
JSON frame bytes and rates per authenticated socket-minute exclude payloads,
URLs and authentication. Supply any synthetic token through the normal URL
fragment. The probe builds nothing and neither starts nor stops the Gateway.
Add a real cron workload with --cron-runs 12 --cron-jobs 20 --interval-ms 2000
and optionally --screenshot /path/to/runtime-complete.png. Use a fresh isolated
Gateway with cron enabled and agents.defaults.heartbeat.every set to "0m".
The probe creates disabled synthetic jobs, then forces runs through cron.run;
system-event payloads and disabled heartbeats keep model providers out of the
workload. Every forced run must finish and its event must reach the browser.
A separate configuration-edit phase verifies that a changed job name becomes
visible. Only browser list requests are counted; the controller socket is
excluded, and the created jobs are removed on exit.
For board/chat document loads, use --load-runs 5 --load-path /dashboard --duration 12.
This mode retains synthetic cron.list, models.list, users.self, and sessions.describe parameters,
response bytes, models.snapshot event counts, paint entries, and native Long Task
blocking (the sum of each task's duration above 50 ms). It records the first cold
browser context separately from later documents that retain browser cache and
storage. --phase-seed selects deterministic randomized reload delays; paired
sides must use matching seeds, routes, and --start-at times. Reloads use fixed
slots from that shared start time; a missed slot fails the run. Use
--expect-session <key> to assert the committed session and --open-details for
chat Details. Optional --request-stacks adds caller stacks for diagnosis;
leave it off for paired timing. Use only synthetic requests:
parameters, stacks, and model rows are retained; authentication and other response
payloads are omitted.
Add --reload-census to count every RPC during that load window. This mode keeps
parameter keys and opaque query IDs instead of parameter values; matching query
IDs identify exact duplicates within one document. It also records native Long
Animation Frame blocking and timestamps of changed sidebar row inventories.
With --request-stacks, --asset-dir /path/to/control-ui/assets maps callers
through the matching build's source maps.
The first nonempty inventory and the last inventory change are DOM milestones,
not paint times. Keep caller-stack diagnostics separate from untraced paired
timing, and use matching fixture state, seeds, and observation windows.
--storage-state accepts a synthetic Playwright storage-state file; use
--min-sidebar-rows to reject captures with an empty or collapsed fixture roster.
control-ui-reload-fixture.ts prepares a real Gateway fixture with 5,000 sessions,
6,000 retained runs, 120 sessions assigned to the synthetic viewer, eight personal
pins, seven browser plugins, and a synthetic model catalog. It seeds a dashboard
widget through RPC and writes a Playwright preference file with Coding expanded.
Run preparation sequentially on the benchmark host, with built runtime and UI assets:
node --import ./scripts/tsx.mjs scripts/perf/control-ui-reload-fixture.ts \
--state-dir /path/to/fixture --ui-root /path/to/control-ui --prepare
node --import ./scripts/tsx.mjs scripts/perf/control-ui-reload-fixture.ts \
--state-dir /path/to/fixture --ui-root /path/to/control-ui
The default ports are 41514 (Gateway), 41515 (widget sandbox), and 41516 (proxy).
Use distinct ports and state directories for concurrent sides. Once each fixture
has been seeded, --skip-seed preserves its state on subsequent launches;
--runtime-entry selects a separately built baseline or candidate runtime.
Use the printed route with --storage-state /path/to/fixture/browser-storage.json,
--reload-census --min-sidebar-rows 10 --duration 15. No provider credentials or
model inference are needed. The fixture uses 8 MiB of transcript content rather
than a multi-gigabyte database; this workload measures reload acquisition and rows.
To isolate session activity from model selection, use
--activity-patch-sessions <key-a>,<key-b> with --load-runs 1 against existing
synthetic sessions. This alternates navigation between those conversations and
changes their labels at --interval-ms, without inference or model changes.
It excludes --activity-session and --activity-cron. Catalog observations
retain model rows for before/after option comparisons; use only synthetic
catalogs. Compare warm session-scoped request counts and activity blocking time,
keeping the initial catalogs separate from the activity window.
Add --activity-catalog-refresh to request unchanged catalog renewal from a
separate controller every third activity step. The fixture's live provider returns
the same rows as its static catalog. Controller requests are recorded in activity
and excluded from browser request counts. This isolates browser invalidation after
explicit renewal; it does not prove which production action triggered a renewal.
With --open-details, the final check reopens Details for the selected conversation
after the activity window, then compares its session identity, label, model, status
note, and thinking level with a fresh controller-side descriptor. It records this
as an input-state comparison, then separately checks the visible conversation
header's label. The controller read is excluded
from browser request counts; compare activity requests only between
activityStartedAtMs and activityEndedAtMs. Request traces retain the preceding
event's session key, reason, timestamp, and revision to distinguish event-driven
reads from navigation or idle reads. Use --request-stacks for caller diagnosis,
then omit it for paired timing.
Failed attempts retain the available browser probe under failure, including
failures in the final Details assertions; they are diagnostics, not accepted samples.
Add --activity-row-mix to the two-session patch workload to cycle selected-session
label changes, other-session status changes, selected-session status changes,
sidebar hover, sidebar scrolling, session opening through the sidebar, and opening
the session menu with an owner-submenu hover. The two keys must be visible in the
synthetic sidebar; the reload fixture's session 1 and pinned session 2 work. Use
--reload-census to count every browser RPC. Each action records its following
observation window, sessions.changed events, sessions.describe requests and
session-scoped models.list requests; the report also normalizes the warm window
to requests per tab-minute. An interval contains deferred work from preceding
actions too, so use the retained event timestamps for causal attribution. Closed
menu panel, owner-submenu and avatar inventories are captured at readiness and
before the activity window. The menu step waits for its root panel and the owner
search input, recording their separate automation-to-visible times. With
--screenshot, the first open owner menu is also captured beside the final image;
omit screenshots during paired timing. This mode does not need model inference.
On Linux, pass --gateway-pid <pid> using gatewayPid from the reload fixture's
ready record to capture that Gateway's main-thread CPU during the warm window.
CPU per action and (with --reload-census) CPU per browser RPC include controller
patches and background work; they are workload-normalized costs, not handler
self-time. Keep both sides' fixture, interval, seed and duration identical, and
keep caller-stack recording off for timing runs. The report retains each RPC's
timestamps for latency analysis; report sample counts with any derived tails.
Use --activity-delete-sessions <key-a>,<key-b>,... with --load-runs 1 and
--expect-session <different-visible-key> to isolate unrelated session removals.
Supply one distinct existing synthetic key per interval: 12 keys with
--duration 24 --interval-ms 2000 performs 12 deletions. This mode excludes other activity
modes, preserves the visible conversation, requires each deletion to succeed,
and waits for its keyed sessions.changed event. The retained event trace also
shows any unscoped deletion notification and its following browser requests.
Deleted keys are consumed; use fresh keys or a fresh synthetic fixture per run.
For account service-tier observations, launch the reload fixture with
--service-tier-port <unused-loopback-port> and run the browser probe with
--activity-service-tiers --load-runs 1 --expect-session <existing-key>.
The fixture serves a synthetic OpenAI Responses endpoint returning fast; its
log records only the requested/returned tier and timestamp, never prompt content.
The probe creates two uniquely named sessions using the real openclaw runtime
and openai/gpt-5 model: one requests priority, the other ultrafast. It completes
one warmup turn per session before browser navigation, then alternates real
chat.send turns and waits for successful agent.wait settlement. No inference
credentials or external provider calls are needed. The visible conversation
remains unchanged so catalog/descriptor requests expose cross-session effects.
Use --duration 42 --interval-ms 2000 for 21 measured initiating turns and
--reload-census --gateway-pid <fixture-pid> for browser request counts and
Gateway main-thread CPU. Verify the fixture log contains the expected alternating
priority/ultrafast requests in addition to both warmups; a successful controller
response alone does not prove model execution. Compare total CPU and CPU per
fixed initiating turn, not CPU per remaining browser RPC. This mode excludes
patch, delete, mixed-interaction, catalog-refresh and cron workloads.
For steady model selection with multiple active conversations, use
--activity-run-mix --activity-session agent:main:main --activity-models openai/gpt-5.
Start the reload fixture with
--subagent-seed --service-tier-port <unused-loopback-port> to expose its native
spawn tool and synthetic provider.
The probe asks its synthetic provider to invoke sessions_spawn once,
finds that exact native child by parent and unique label, then warms three
conversations and completes concurrent real turns in each. --activity-cron replaces every fourth cohort
with a forced cron run. --activity-interactions adds separately timed sidebar
hover, model-picker opening, and conversation navigation after each cohort.
Initial catalog/descriptor responses settle before the warm window; the report
retains actual elapsed time and a post-window model/speed-control observation.
Launch the reload fixture with --stream-delay-ms 75 to stream three chunks per
response, and --service-tier-responses fast,default to alternate provider tier
observations while model selections and requested tiers remain fixed. The default
is still a single immediate response returning fast. Compare steady responses
and changing observations separately; these synthetic transitions establish the
invalidation path, not the production frequency of provider tier changes.
For active-session loads, add --activity-session <synthetic-key> and configure
that isolated Gateway with two loopback mock-provider models:
node --import ./scripts/tsx.mjs scripts/perf/catalog-polling-measure.ts \
--url 'http://127.0.0.1:40967/#token=synthetic-token' \
--load-runs 1 --load-path /chat/main --expect-session agent:main:main \
--activity-session agent:main:main --duration 60 --interval-ms 2000 \
--request-stacks --output /path/to/active-chat.json
The driver cycles a model selection, the same selection with a changed label, a
status/pinning/thinking patch, and a real chat turn. Models default to
openai/gpt-5.6-sol,openai/gpt-5.6-luna; override with --activity-models to match
synthetic configuration. Each turn must finish successfully through agent.wait.
Add --activity-cron --open-details to include forced runs of a temporary disabled
cron job while Details is visible; this requires an agent:<agent-id>:main session.
Each cron run must finish within a bounded wait and reach the browser as a matching
finished event before the driver records success.
The driver binds the job to that agent and session and removes it afterward;
cleanup failures remain in the report.
Use /dashboard/main to exercise the same session's board surface.
The activity window begins after readiness and five seconds of settling. Its
fixed action count is floor(duration * 1000 / interval-ms); startup time never
reduces the workload. Reports retain activity start/end, action latency, warm
Long Task blocking, and the event preceding each catalog request. Session event
metadata includes catalog-change flags, model-selection revisions, and lifecycle
phases; message and response bodies are excluded. Caller stacks are diagnostic
only. With --start-at, the active window begins thirty seconds after the
scheduled navigation and fails if readiness misses that deadline. Use one load
per invocation for paired activity measurements.
For paired activity measurements, use --start-at <epoch-ms> with the same future
time on both sides; it gates the runtime window after browser readiness and
rejects a missed deadline. Hold the host's shared measurement lock, use separate
Gateway states and ports on the two 16-core CPU halves, then swap the halves for
the next pair. Compare per-pair request-count differences. This small catalog
fixture covers the browser event trigger, not production database size, model
inference, native clients, or a model-catalog mutation.
Build runtime artifacts on the authorized benchmark host, generate an offline fixture, then let the measurement driver own its loopback Gateway:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-measure.ts \
--state-dir /path/to/team-shape --port 40967 --clients 35 \
--samples 5 --limit 100 --poll-ms 5000 --revision <commit-sha>
This replaces the former external one-socket timing mode. --state-dir must
contain a generated fixture; the driver stops its own child on completion or
interruption. Startup recovery settles for ten seconds before clients connect.
Clients close and reconnect in cohorts of at most 30, below the per-IP pending
handshake budget. After this one reconnect wave, each variant gets its first
full-response cohort in turn, followed by separate blocks of the requested
steady cohorts. The variants are sidebar,
general, compact, explicit profile involvement, the sidebar ownership summary, and
children. Setup creates one shared dashboard parent and four children through
the real RPC, adding five rows to the seeded inventory. Persistent dashboard
lineage keeps this query independent of terminal-run link expiry. Every variant must return nonempty rows;
the owner query must also return nonempty owner counts. Seed --now near the
measurement time so retained child lineage has not expired.
The ownership summary matches sidebar-owner-session-counts.ts: one row,
unarchived state, all human owners, and the sidebar's automation/system exclusions.
The driver measures one variant at a time, with all clients issuing the same
query concurrently and one request in flight per socket. Cohorts begin no faster
than --poll-ms; 35 clients at five seconds produces about 420 list calls per
minute during steady blocks. Requests use the normal conditional/delta revision
handshake. --mutation-ms 1000 adds one synthetic label edit per second through
sessions.patch during steady blocks, including that work in CPU totals. Each
block schedules only the ticks strictly before its nominal final cohort (19
edits for five cohorts at five seconds and one-second mutations), waits up to
five extra seconds for delayed ticks, and rejects an incomplete mutation count.
This keeps the recorded workload independent of response speed. This is a
controlled shared-query workload, not a complete browser scheduler replay.
Results in list-measure-*/result.json include per-variant reconnect/steady
latency p50/p95/p99, response sizes and full/unchanged/delta counts, row work
counts, raw RPC admission/queue observations, and main-thread/process CPU.
The existing private IPC profiler writes CPU profiles and numeric handler-phase
diagnostics alongside the report. Selection/preparation, row projection,
decoration and response phases are separate. Decoration includes delta/fact
sharing and defaults; response CPU includes serialization and transport enqueue.
The existing probes do not split delta encoding from other decoration work.
Handler wall time includes yielded/awaited time and must not be read as CPU.
The profiler affects both sides equally; no inspector listener is exposed.
Use identical fresh fixtures and options for A/B. --start-at <epoch-ms> waits
after readiness and fails if the deadline has already passed, allowing both
sides of a concurrent pair to begin together. Apply the current host policy:
hold the shared measurement lock, place A and B on their separate 16-core halves,
then swap halves for the next pair. Record the exact revision, fixture counts
and physical database bytes; a 64 MiB text fixture preserves roster/run shape
but does not reproduce multi-GB transcript page-cache pressure.
Measure branch reads across idle worker retirement
branch-rotation-measure.ts drives compressed Control UI WebSockets against an
owned loopback Gateway and an offline team-shape fixture:
node --import ./scripts/tsx.mjs scripts/perf/branch-rotation-measure.ts \
--state-dir /path/to/team-shape --output /path/to/new-result-directory \
--entry /path/to/prepared/dist/entry.js --port 41451 \
--clients 40 --cohorts 12 --poll-ms 10000 --revision <commit-sha>
Each viewer reads a distinct shared transcript through sessions.branches.list
and chat.history, alongside sessions.list. Before each cohort, an alternating
verboseLevel patch invalidates the database write-token shortcut without
opening the maintenance reader. A label patch is unsuitable for that purpose:
label-owner discovery itself retains a maintenance connection.
The warm phase uses startup-prewarmed readers. The private benchmark IPC preload then publishes the existing memory-pressure event and waits until all transcript workers have exited before the cold phase. This exercises the real retirement owner without changing production timers or custody rules. After cold traffic, one label patch deliberately recreates the scoped maintenance reader; two more cohorts measure recovery under continued invalidating writes. The report separates the deliberate retirement from request-driven rotations and records lifecycle counters, CPU, memory, request counts, and raw latency samples. First-use warm cohorts are reported separately from steady cohorts.
For paired comparisons, pass the same future --start-at <epoch-ms> to both
drivers. Warm, cold, and recovery phases have shared absolute deadlines; a missed
deadline fails the run. Generate matching fixtures sequentially, hold the shared
measurement lock around each concurrent pair, and swap the two 16-core allocations
between pairs. Metadata-only writes permit matched fixture reuse after a completed
pair. Compare paired differences; keep outside load samples with the results.
This isolates the worker-rotation mechanism; it does not replay model streaming
or establish which historical operation started or ended a production episode.
Measure managed workspace reuse at turn startup
The workspace computation driver's startup scenario calls the managed sandbox
workspace owner again for each sample, including both directions of checkpoint
synchronization. It uses 32 tracked files per directory, changes the requested
number of guest files before each sample, and verifies their canonical contents
and settled recovery state afterward.
node --import ./scripts/tsx.mjs scripts/bench-workspace-computation.ts \
--scenarios startup --sizes 10000 --concurrency 1 --file-bytes 128 \
--changed-files 1 --runs 10 --warmup 1 --output /path/to/report.json
Prepare matching fixtures sequentially with --prepare-root, then measure them
with --reuse-root. Use the shared quiet-slot lock and guard for concurrent
baseline/candidate pairs, swap CPU assignments, and pass the same --start-at
timestamp. The driver reports latency, CPU, RSS, event-loop delay and GC samples.
Initial projection creation happens before measurement: this measures reuse,
not cold container provisioning or chat time to first model request. Use the
Gateway chat workload separately for those end-to-end timings.
Measure history reader construction
session-history-reader-setup-measure.mjs isolates the history reader factory
and, optionally, its exact owner-construction expression from a pinned runtime
source. It exercises two synthetic runners before measuring setup. It does not
measure database admission, workers, history queries, or Gateway latency.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/session-history-reader-setup-measure.mjs \
--owner src/config/sessions/session-transcript-worker-readers.ts \
--runtime-owner src/config/sessions/session-transcript-worker-runtime.ts \
--operations 200000
Use --allocations in a separate pass for sampled allocation, including collected
objects. Unprofiled setup timing, CPU, shared method counts, and retained bytes
are reported separately. For an isolated source capsule, copy the reader and its
session-transcript-worker.types.ts sibling and use --capsule without the source
loader. The capsule replaces three unrelated imports with throwing sentinels;
calling any of them fails the run. The report includes source hashes. Do not use
capsule results as end-to-end history or GC evidence.
Paired runners can share an initially absent --start-file with distinct
--side baseline and --side candidate. Each writes a .ready marker after
warmup; the coordinator then publishes a future epoch in milliseconds to the
start file. Use matched instrumentation, exclusive host admission, and swapped
CPU placement for comparisons. Keep allocation-profile timing separate from
unprofiled timing.
Measure history pages and checkout diffs
Prepare a team-shape fixture offline, then add this workload's small synthetic transcript and Git worktree. Run generation and measurements on the authorized benchmark host:
node --import ./scripts/tsx.mjs scripts/perf/history-diff-measure.ts \
--state-dir /path/to/team-shape --seed
node --import ./scripts/tsx.mjs scripts/perf/history-diff-measure.ts \
--state-dir /path/to/team-shape --port 41072 --clients 4 \
--operations 25 --rounds 5 --revision <commit-sha>
--seed requires exclusive offline custody and refuses a second seed. It adds
one session with 64 completed tool turns (8 KiB, 64 KiB, and 256 KiB tool results),
captures a clean checkout baseline, then changes --tracked-files (default 32)
tracked files and creates --untracked-files (default 32) untracked files.
Add --dirty-baseline when seeding to capture a pre-session edit in each tracked
file before replacing it with the measured edit. The populated baseline overlaps
every tracked response path, exercising baseline fingerprint comparison without
hiding the later edits. This requires at least one tracked file and records the
captured count in baselineFiles.
--unchanged-files (default 0) adds small committed files to increase checkout
size without increasing the diff. Counts are nonnegative; setting both changed
counts to zero measures a clean checkout. For example, seed with
--tracked-files 32 --untracked-files 100 --unchanged-files 6000 to exercise a
larger index and untracked set. Measurement reads the saved counts, so these
options only configure --seed. All repositories, worktrees and data remain inside the fixture.
Add --managed-worktree when seeding to register the checkout as owned by the
measured session and create its accepted local workspace projection with the
actual committed base. This exercises managed-workspace review and registry
resolution before the Git read. The default remains an unregistered Git
worktree. The seed summary records this choice; measurement needs no matching flag.
Measurement never writes the database directly. The driver owns and stops its
loopback Gateway; --entry defaults to dist/entry.js and also accepts a prepared
source entry. It issues small, default, and large chat.history requests, plus
full sessions.diff requests for both all and uncommitted scopes. Summary
requests are excluded. Repeated full requests exercise the existing diff cache
and its checkout-revision validation.
Add --mutate during measurement to rewrite identical bytes to tracked-0.txt
before each diff cohort, including concurrent warmup. This advances filesystem
freshness while preserving the exact response hash and exercises full diff
collection after cache invalidation. It requires a changed tracked file.
Each operation is one concurrent cohort with one request per client. Empty
history, counts that differ from the seed, missing full patches for a nonempty
diff, or RPC failures reject the run. Keep changed-file counts within the
product's diff limits; the driver does not treat truncated inventories as complete.
The default client retains the existing local administrator recipe.
--scoped-reader instead uses a synthetic profile with exactly
operator.sessions.read, exercising per-person session visibility and retained
read authority. The driver writes a separate configuration under the output
directory with loopback-only trusted-proxy authentication, one allowlisted
example.invalid identity, and a read-only role. It supplies those synthetic
proxy headers through a WebSocket client using the Control UI protocol identity;
no browser, real account, external proxy, or credential is involved. The
connection must return the exact narrow scope, and users.self must identify an
individual profile before measurement begins. Profile creation occurs through
the Gateway's normal authentication path.
The first request for each variant is recorded separately as warmup. Then
--warmup-cohorts (default 5) warms every concurrent reader before measurement.
With --start-at <epoch-ms>, those cohorts run within the final 10 seconds before
the shared start, keeping burst readers warm. A missed readiness deadline rejects
the run. Alternatively, --start-file <path> requires an initially absent file
and emits warmed: true after the concurrent warmup. The pair coordinator waits
for both warmed messages, then atomically publishes a future epoch in milliseconds
to the shared file. Each driver reads that deadline and rejects invalid or passed
values. The two start options are mutually exclusive. The report records the
requested start, actual per-round start and first dispatch skew. CPU profiling
and CPU snapshots begin after the barrier, excluding its idle wait.
Measured rounds include raw latency samples, p50/p95/p99, payload bytes, returned row
counts, main-thread/process CPU, CPU profiles and existing worker queue, run,
and transfer diagnostics. Each sample includes payloadSha256, computed after
omitting the top-level absolute root and sessionKey; each round lists its
distinct hashes. Compare matching diff hashes across A/B reports to establish
identical response content despite different fixture roots. Within one run,
every diff response must match its warmup hash because the workload preserves
checkout content, including with --mutate. Payload bytes measure re-encoded JSON in the driver,
not WebSocket frame overhead. Profiles sample the main isolate; worker task
diagnostics provide worker timings. gitSpawns counts actual Git trace2 start
events during each workload round; gitSpawnsPerRpc divides by completed RPCs.
These are Gateway-wide workload-window counts, including any concurrent Git
activity. Trace writes are enabled identically on both comparison sides and
their overhead is included; trace parsing runs in the driver after capture.
Diagnostic captures also include RPC admission/handler/response durations,
session diff stages (scoped retention, resolution, repository access, managed
review base, Git, and synchronous response encoding/socket enqueue), and separate
checkout.revision and checkout.diff worker round trips. These are nested
measurements: do not add a parent duration to its child stages. The stage channel
observes sub-second requests without enabling slow-request logs or recording
session keys, paths, or diff contents.
--output names a new artifact directory (by default below the fixture).
--scenario defaults to all; individual choices are history-small,
history-default, history-large, diff-all-full, and
diff-uncommitted-full. For a concurrent A/B
comparison, select one matching scenario on both sides so faster completion does
not move one side into a different workload. Generate independent, identical
fixtures for each side, with matching client/operation/round counts and the same
--start-at <epoch-ms>. The barrier waits after readiness and warmup, and fails
if the deadline has passed. Hold the shared measurement lock around the pair,
and swap the two CPU halves for the next pair.
Add --allocations to replace CPU sampling with main-isolate allocation sampling
for the same history/diff requests and response checks. Each scenario/round records
clients × operations completed RPCs. Its profile includes sampled bytes per
operation, allocation sites, and separate major/minor/incremental/weak-callback GC
summaries; promotion includes V8 promoted bytes and bytes per operation. The
stdout-only GC log is parsed after the owned Gateway exits and the log is flushed,
excluding workers and GC outside the capture. CPU totals remain available.
Both profiling modes wait for the start barrier before starting capture, excluding
barrier idle time; first-dispatch skew includes profiler setup.
Compare matching modes and shapes. Workers, external
buffers, and controller allocations are outside the main-isolate estimate.
For a history-owner comparison using one source tree and dependency installation,
pass --entry src/entry.ts --history-source /path/to/owner-copies. That directory
must contain chat-history-session-read.ts and chat-history-handler.ts; the
benchmark preload substitutes only those modules and the report records their
SHA-256 hashes. Keep the rest of the source fixed for both sides.
History payload hashes normalize only sessionInfo.snapshotAt to zero and the
fixture root to <fixture>; response content and field order remain intact.
The first normalized response is saved as <scenario>.json for byte comparison.
For a diff-owner comparison, pass --entry src/entry.ts --diff-source /path/to/session-diff.runtime.ts. A child-only preload substitutes that source
at its canonical module URL, including inside Git workers, so relative imports
retain their normal ownership. The report records the source SHA-256 and worker
load receipts; a source that never loads fails the run. Both sides otherwise use
the same source tree and dependency installation. Combine --dirty-baseline
at seed time with --mutate --scenario diff-uncommitted-full during measurement
to exercise repeated baseline filtering. Keep fixture generation sequential and
run simultaneous pairs under the host guard with swapped interleaved CPU sets.
Actor transcript context cloning
transcript-context-clone-measure.ts measures the real actor context seed/read
functions with 64 synthetic 16 KiB messages. Fixture creation flattens message
strings before warmup to match worker-decoded payloads. Each operation seeds one
replica and reads it once. It checks byte-equivalent output and isolation from nested payload,
array, and navigation-map mutations before capture.
node --import ./scripts/tsx.mjs scripts/perf/transcript-context-clone-measure.ts \
--owner-file /path/to/session-actor-transcript-replica.ts --mode exact \
--operations 10000 --interval-ms 1 --start-file /path/to/pair-start \
--output /path/to/result.json
Use the same replacement hook on both sides, prepared fixtures, a synchronized
warm start, and swapped interleaved CPU sets under the shared measurement lock and
guard. --mode window and --mode complete cover the other residency shapes.
--allocations enables a separate collected-object sampling pass; its timing is
ineligible for natural-GC gates. Measure promotion in another separate pass:
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/transcript-context-clone-measure.ts \
--owner-file /path/to/session-actor-transcript-replica.ts --mode exact \
--operations 10000 --interval-ms 1 --start-file /path/to/pair-start \
--trace-log /path/to/trace.log --output /path/to/promotion.json \
> /path/to/trace.log 2>&1
The trace flags require each other, and tracing requires --expose-gc for one
main-isolate identification collection before fixture preparation and warmup.
Capture markers exclude that collection from promotion per seed/read pair.
Use fresh matched A/A controls for this mode; its timing is also ineligible for
natural-GC gates. Untraced timing runs never force a collection. Report bytes per
seed/read pair, per-kind GC, latency and loop sample counts, and both pause maxima. Apply the campaign's A/A
calibration and paired-delta rules. This owner workload does not measure Gateway
turn latency or establish a deployed allocation reduction; use the real
memory-soak turn workload for that evidence.
Measure transcript broadcast fanout
With runtime artifacts built on the authorized benchmark host, seed a fixture
without --launch, then run:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --clients 60 --runs 20 --rounds 5 --chunks 500
The driver starts its own loopback Gateway and a credential-free SSE model
fixture on ports 41033 and 41034. It creates one shared parent and 20 shared child
sessions through sessions.create, then resets their histories through
sessions.reset before each comparison. The full seeded inventory remains;
these small sessions avoid large-history compaction and retained-run announcement
work obscuring broadcast costs. Standard memory search is disabled and both main
and child run concurrency match --runs. Every client subscribes to session-list
events and messages for the same 20 children. Actual agent and agent.wait RPCs
exercise the full turn and transcript publication paths;
each synthetic model answer streams deltas paced 10 ms apart (100 by default;
the example uses 500 to sustain overlap). Each warmup/measured cohort holds its
primary model responses until all requested, uniquely marked turns arrive, with
a 45-second admission timeout, then streams their answers together. Parent
announcements and utility requests receive one short acknowledgement and are
counted separately. A fixed 10-second post-readiness delay allows retained-run
startup recovery to settle before setup. One concurrent round
warms the Gateway before five measured rounds. --port, --mock-port,
--chunks, --chunk-interval-ms (default 10), --clients, --runs, and
--rounds adjust the workload.
Warmup and each measured round drain until no model streams remain and all
subscriber events have been quiet for one second, with a 15-second timeout.
Results under the fixture's broadcast-measure-* directory include Gateway
logs, event counts, peak concurrent model streams, and CPU snapshots from the
Gateway's private benchmark IPC preload. Main-thread CPU excludes the driver
and model fixture. Port conflicts reject before RPCs; readiness requires the
owned Gateway's bind-success log and live IPC. Lost clients, event-sequence gaps,
incomplete per-client transcript/list fanout, or an incorrect primary request or
overlap count fail the measurement. mainThreadMsPerSessionEvent divides by
the validated session.message plus sessions.changed copies per client;
other event counts remain in the report. It includes all Gateway work during the
round, including the drain interval, so it does not isolate broadcast CPU and
CPU per second includes that quiet interval. Compare the same event counts and
concurrency on both revisions, and report the actual database size. For a roughly 5 GiB fixture, seed with
--transcript-mib 1500 rather than the 64 MiB logical-text default. The driver
stops its Gateway and model fixture on completion or interruption and retains
synthetic state for inspection. Never target a running Gateway's state.
For session-event frame comparisons, add --session-row-deltas to negotiate
row patches and --session-envelope-refs to also negotiate
session-event-field-refs (the latter requires row patches). Both are opt-in;
the default client capabilities are unchanged. A suitable starting swarm for
each 16-core half is --clients 38 --runs 8 --rounds 5 --chunks 500.
For a measured capability mix, replace the uniform session capability flags with
--session-capability-clients 10,20,40 --clients 70: the three counts select
legacy, row-delta-only, and row-delta-plus-field-reference clients, respectively,
and must sum to --clients. Zero-sized cohorts are allowed. The driver admits
one client from each nonempty cohort in rotation until its count is exhausted;
--exact-clients selects the first clients in that order. The result records each
cohort's capabilities, total count, and exact-subscription count. Reconnecting
and polling clients retain their assigned capabilities.
Add --roster-body-optional-clients 0,0,40 to advertise
roster-message-bodies-optional only on the first specified number of clients
in each corresponding cohort. Each count must fit within that cohort; this flag
requires --session-capability-clients. Without it, the existing behavior remains:
every client advertises optional bodies when some clients lack exact subscriptions.
The result records rosterBodyOptionalClients per cohort. Exact subscriptions
still follow admission order, so --exact-clients 1 selects the first nonempty
cohort's first client; capability counts do not establish the production subscription mix.
For explicit fresh-session lineage, add --session-event-ancestors 0 or 2.
Both use the same synthetic session.dmScope: "per-channel-peer" setting so
sessions.create does not implicitly parent roots to main. The zero-ancestor
shape creates independent roots; the two-ancestor shape explicitly links each
measured session to the synthetic parent and then to agent:main:main.
Before warmup, sessions.describe verifies every link and the result records
sessionEventAncestors and sessionEventLineage. This mode rejects seeded history
and Activity recaps. Without the flag, existing lineage behavior is unchanged.
Add --exact-clients 1 --activity-summaries to model one transcript subscriber
and 37 roster viewers receiving Activity recap changes. --exact-clients
defaults to all clients; a mixed audience negotiates optional roster message
bodies. Recap mode creates ordinary shared sessions, since recaps exclude
subagents, and requests sessions.activitySummary.ensure after each completed
turn cohort. Measured rounds must observe both recap model requests and recap
change events; the model fixture serves the recaps without external inference.
Add --recap-burst-repeats 20 --recap-burst-interval-ms 50 to replay repeated
Activity ensure requests after that cohort's recap reaches every socket. These
are real sessions.activitySummary.ensure RPCs against unchanged transcripts;
the driver does not invoke publishers or invent state transitions. Each repeat
sweeps the same sessions in batches of 20, sequentially, with a minimum 50 ms
start-to-start interval between sweeps. Repeats default to zero and are capped
at 100; the interval defaults to 50 ms and is capped at 1000 ms. Use identical
parameters for both revisions. No extra agent turns occur inside this phase.
recapPhase separates the turn and initial recap fanout from repeated ensures,
including frame/byte counts by audience, recap model request counts, RPC response
states, timestamps, and repeated-ensure main-thread/process CPU. A zero model
request count during repeated ensures establishes unchanged-source churn rather
than additional inference. Current ensure responses enqueue no recap source work.
If the last ensure reports updating, every socket must receive a current recap
after that sweep started; a receipt retained from the initial phase cannot satisfy
this wait. CPU includes RPC admission, source checks, and the subsequent one-second
event drain. With --cpu-profile, the repeated-ensure phase also saves
a main-isolate profile and reports broadcastSampledMs: inclusive sampled time
under broadcastInternal, including recipient projection, encoding, and sends.
This is a sampling estimate, not a precise socket-only timer. The driver uses
the Control UI's row decoder with negotiated patches/field references, requires
the latest cohort's distinct recap text and current state on every socket, and
compares its timestamp with a final sessions.describe read. recapReceipts
records observed recap transitions. latestValueDelivery.committedToReceivedMs
measures the first current receipt minus the summary's updatedAt clock, including
commit/publication/transport delay; compare its maximum with the desired one-second
budget. finalReceiptAfterLastEnsureMs reports trailing delivery after the last
repeat response, or zero when the already-correct recap remains unchanged. These
prove delivery into the same decoder as the UI, not browser rendering latency.
--list-interval-ms 1000 adds concurrent compact sessions.list polling, one
request at a time per client, and reports successful latency samples and
p50/p95/p99. Invalid or failed list responses reject the run.
Add --poll-seed <seed> to randomize each client's initial poll phase across the
list interval and jitter subsequent intervals uniformly from 0.5 to 1.5 times
that interval. The deterministic sequence is independent per client and round;
use the same seed on both sides of a pair. Polls remain sequential per client,
so a slow response delays its next poll. The report retains initial phases and
all request timestamps for seeded runs. Without this flag, polling retains its
original synchronized start and fixed interval.
Add --diagnostics identically on both sides to capture per-round Gateway and
replay-controller event-loop delay (20 ms sampling), GC pause totals, and bounded
internal operation aggregates without starting CPU or heap profiling. List
samples also retain the response ID, client index, round, start/end timestamps,
and duration; p99Request identifies the nearest-rank round p99. For a multi-round
pair, sort all sessionsList.requests by duration to identify its pooled p99.
These timing records contain no RPC payloads; --capture-responses separately
opts into representative response payloads, as described below. Gateway
metrics cover its main isolate, not worker isolates. Capture adds overhead, so
compare equally instrumented pairs and keep earlier uncaptured runs distinct.
To isolate a stream-start burst, use --stream-start-diagnostics. It captures
main-isolate diagnostics from immediately before turn admission until every
primary mock request has arrived, then separately for the first 500 ms after
the mock releases those responses together. Stopping admission capture and
starting release capture adds two private IPC round trips before release on
both revisions. Each new capture waits 25 ms and then samples the owned child's
CPU counter as a checkpoint, priming the 20 ms loop-delay timer before admission
or release. Capture timestamps include this priming interval. A delayed stop can extend the window; use the reported capture
timestamps and duration. These captures can run alongside whole-round
--diagnostics without CPU sampling. streamStart.phases records controller
epoch timestamps for admission, first/last classified primary request, cohort
release, first primary SSE write, and completion of all turns. Request/write
timestamps are observed by the mock, not by the Gateway's SSE consumer.
Add --cpu-profile for attribution runs. The private IPC profiler samples only
the Gateway main isolate from before admission through release plus 500 ms,
writing stream-start-<round>.cpuprofile and its diagnostics sidecars. The result
includes the 40 hottest functions by sampled self time; raw samples remain for
windowed or caller analysis. Sampling and profile serialization add overhead,
so omit this flag for acceptance timing. --active-work-diagnostics enables the
real diagnostics Prometheus plugin and scrapes its endpoint while all primary
responses remain held. Each round must report exactly --runs running sessions
and zero queued sessions before release; this scrape is included in admission
timing. For example, run on each authorized 16-core measurement half:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --clients 8 --exact-clients 1 \
--runs 80 --rounds 5 --chunks 500 --chunk-interval-ms 10 \
--list-interval-ms 2000 --poll-seed stream-start-1 \
--diagnostics --stream-start-diagnostics --active-work-diagnostics
Compare release-window event-loop max/p99 and pooled list p95 across concurrent pairs, swapping CPU halves and changing the shared poll seed between pairs. Use list request timestamps to identify polls overlapping release; whole-round latency alone can hide a burst. Existing completion, concurrency, event-order, and fanout checks still apply. This fixture uses synthetic model streams; provider latency and external tool execution are outside this comparison.
Use --stream-start-allocations to sample main-isolate allocations, including
collected objects, from before admission through release plus 500 ms. This writes
stream-start-<round>.heapprofile and diagnostics sidecars. It cannot be combined
with whole-round --allocations. Divide sampled bytes by runCount for bytes per
started stream; observedSessionPublications counts session.message and
sessions.changed received by the first viewer during capture. This is a received
publication denominator, so events still in flight at the boundary are excluded.
Sampling and stopping/serializing the profile add overhead; keep these attribution
runs separate from unprofiled acceptance timing. --trace-gc adds V8 GC logging to
the owned Gateway's log only. With --active-work-diagnostics, each successful
held-stream scrape is retained as held-streams-<cohort>.prom for GC counter context.
eventBytes measures the exact received, uncompressed JSON frame bytes,
including the event envelope, before parsing. sessionEventSizes splits session
frame counts and bytes by the existing bounded reason labels and the fixture's
exact-message subscription membership (subscribed or roster). Exact clients
subscribe to the same measured sessions; parent events are roster deliveries.
Per-round representativeFrames retains the largest first-client
sessions.changed frame per bucket as field names and sizes only. Member sizes
include the JSON key, colon, and value, excluding object braces and separating
commas; nested session fields describe the full row or patch's set object.
initialFullFrame includes setup/warmup and estimates a numeric field-reference
encoding using matching envelope/full-row fields. This estimate is a diagnostic,
not a measured candidate result. No message content or row values are retained
in these size reports. Wire compression and transport headers are not measured.
Pass the same future --start-at <epoch-ms> to both sides of a concurrent pair.
The barrier runs after readiness, warmup, and event drain, and fails if its
deadline has passed. Hold the shared measurement lock around each pair, use
independent identical fixtures and ports, then swap the two CPU halves for the
next pair. Keep fixture generation sequential; --transcript-mib 1 retains the
5,000-session roster shape without multi-GB transcript writes when measuring
event envelopes. Record the actual fixture database size.
For completed-attempt retention, use --scenario attempt-retention --clients 1 --runs 10 --rounds 10 --chunks 1 --prompt-kib 128 --heap-snapshot. Each round
resets the ten child session generations, submits deterministic synthetic prompt
text and a 256×128 synthetic noise PNG, executes three real read tools with
16 KiB text results, receives one synthetic output-limit stop,
and completes the resulting transcript continuation. The driver requires one
completed tool batch, output-limit response, and recovered response per child,
and verifies inline images reach all three provider requests. Each provider
response includes a distinct deterministic 16 KiB synthetic reasoning signature;
the driver verifies that tool and recovery continuations replay the exact completed
signature and omit the reasoning-only output-limit signature, as required by the
replay owner. These are
direct child-session agent calls, not model-driven sessions_spawn calls.
Post-GC Gateway heap samples follow every completed cohort, and the optional
final settled.heapsnapshot permits retainer inspection. Session resets exclude
intentionally retained live histories from the trend.
For loaded-session retention after real turns settle, use:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --scenario session-idle-retention \
--sessions 100 --runs 10 --chunks 1 --chunk-interval-ms 0 --prompt-kib 128 \
--duration-ms 360000 --memory-interval-ms 30000 --diagnostics --trace-gc
This warms the runtime with one parent turn, captures a post-GC baseline, and
touches 100 distinct sessions in cohorts of at most ten concurrent turns. Each
turn includes a synthetic prompt, one real read tool, and the reasoning-signature
replay check. The driver keeps one roster-only client and no exact-message
subscriptions. It then idles for --duration-ms without model work or polling,
captures post-idle resources, and reopens every measured session once. --rounds
is ignored. The final partial cohort has its own barrier size.
The report includes post-GC resources after initial work and after reopening,
periodic natural-GC idle samples, idle GC diagnostics/promotion, and each cohort's
first-turn or reopened-turn latency. Reopened timings include the real agent
turn and tool round trip, so they are end-to-end reload cost rather than a
storage-only timer. After reopening, the pre-idle history digest must match the
retained prefix, with a second exact synthetic answer appended. No history reads
warm the workers between the idle wait and the measured reopen.
Each cohort also reports request-to-provider arrival, time held at the synthetic
provider's cohort barrier, and request-to-first assistant text in the Gateway.
The last timestamp comes from the benchmark-only IPC preload, without a message
subscription or retained message payload. It includes the read-tool round trip
and excludes subsequent client delivery. Missing samples remain visible as a
lower count, rather than being counted as zero latency.
The provider barrier waits for the entire cohort: one slow preparation can delay
all of its full-turn timings. These synthetic SSE results do not measure external
provider latency, WebSocket reconnection, or prompt-cache hits. Add --prep-trace
in a separate diagnostic run to capture the existing per-stage preparation logs;
--diagnostics also separates hydration and model-context database reservations.
For long-history reopen measurements, first seed the measured sessions offline:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/fresh-team-shape --scenario session-idle-retention \
--sessions 100 --runs 10 --history-turns 163 --seed-history
Run the measurement command above against that fixture with --history-turns 163.
Seeding appends 163 user/tool-call/tool-result/answer cycles through SessionManager,
plus a header and two settings entries: 655 entries per session. It runs separately
from measurement, refuses an existing seed, and writes sessions sequentially.
The measurement preserves these histories and paginates the final integrity checks.
Without --history-turns, measured sessions start empty; the background roster's
history size does not determine the measured sessions' history depth.
Add --entry src/entry.ts --session-census to count live SessionManager,
AgentSession, and runtime Agent instances at the four forced-GC checkpoints
without writing snapshots. The source-only census imports the same constructors
as the Gateway and uses V8's count-only object query; it retains no instance
references. Bundled entries are rejected because their constructor identity
differs. Counts cover the main isolate only, and the queries force additional
collections, so omit the census from latency/GC acceptance runs.
Add --entry src/entry.ts --fingerprint-retention to attribute retained prompt-cache
diagnostics after the measured reopen. It waits one more idle interval, collects
garbage, verifies that no session runtime objects remain reachable, then reports heap, external,
and array-buffer deltas after clearing the diagnostic owner. This preserves the
diagnostic baseline throughout the measured reopen and keeps attribution's extra
collections outside latency and GC windows. External memory includes array buffers;
do not add those two deltas together.
Add --heap-snapshot in a separate diagnostic pass for initial.heapsnapshot,
post-work.heapsnapshot, post-idle.heapsnapshot, and post-reopen.heapsnapshot
to count retained managers, sessions, agents, and their transcript retainers.
Snapshot capture and forced GC can affect eviction timing; use the recorded
timestamps and unprofiled paired runs for acceptance comparisons. Set the idle
duration beyond the lifecycle's eviction window. Fixtures and all output stay
inside the supplied synthetic state directory.
To reproduce tool-context retention, set OPENCLAW_BENCH_RETAIN_TOOL_CONTEXT=1
for this source-only scenario. The benchmark loader retains one
AsyncLocalStorage.snapshot() per concrete tool invocation, modeling a socket or
timer that outlives the call. BENCH_RETAINED_TOOL_CONTEXTS lines in the Gateway
log report the cumulative capture count. No Agent or SessionManager is captured
directly. Keep this instrumentation identical on both sides and require matching
turn and context counts. Settled managers should collect while the contexts
remain reachable, and reopened sessions must preserve their history digest.
For a same-checkout baseline, set OPENCLAW_BENCH_TOOL_EXECUTION_SOURCE to a saved
packages/agent-core/src/agent-loop-tool-execution.ts; the loader substitutes
only that owner's source and keeps its canonical import identity. Record both
source hashes. These forced-GC counts and snapshots prove owner retention, not
untraced latency, promotion, or GC performance.
For a steady memory soak with real tool execution and attached viewers, use:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --scenario memory-soak \
--clients 8 --runs 4 --chunks 100 --chunk-interval-ms 10 \
--warmup-ms 300000 --duration-ms 3600000 --round-interval-ms 15000 \
--memory-interval-ms 60000 --retained-profile-ms 600000 \
--reset-every-rounds 20 --list-interval-ms 2000 --heap-snapshot
To retain long active histories during a memory soak, seed with
--scenario memory-soak --runs 8 --history-turns 512 --history-last-turns 2500 --history-payload-kib 16 --reset-every-rounds 0 --seed-history, then run with
the same options without --seed-history. The last session contains the larger
turn count. Each seeded turn has a separate deterministic synthetic reasoning
signature and tool result of the requested KiB each; the default zero preserves
the smaller reopen fixture. In this mode, --mixed-rpc history polls select the
active seeded sessions, including the larger last session. Keep fixture writes sequential and record actual
database bytes before choosing the shape on a shared filesystem.
Add --worker-memory in a separate diagnostic pass to record bounded task-kind
memory under transcript-worker-<thread>.jsonl. The private preload observes
the real transcript worker's task transport and samples after JSON parses of at
least 1 KiB, capturing the raw-row plus decoded-object high water. It records
task-start/end heap, peak observed heap, parsed bytes, heap limit, and the count
and final old-generation bytes of major collections during each task. Tasks with
no major collection keep that value absent. Periodic samples separately label
the last observed post-GC baseline. It does not force GC, retain payloads,
or change production worker limits. These are sampled lower bounds, not an exact
allocation total; per-parse sampling adds overhead, so compare equally instrumented
pairs and keep uninstrumented RPC acceptance timing separate.
The same observer records constructor-to-first-task startup milliseconds for each
transcript isolate, including host dispatch delay, with the first task kind. Direct
parent-port pools do not emit a separate ready reply. Add --worker-snapshots only in a separate diagnostic pass
lasting at least 120 seconds. After 90 seconds it captures one idle isolate per
observed history, context, usage, and other task category, serializing snapshots
across workers. Each snapshot-<category>.json records the thread, last task,
before/after memory, and heap-snapshot path. Snapshots force GC and block the
isolate; exclude this pass from latency and GC acceptance. Both sides include
the same TypeScript observer overhead, including its parser source.
For SQLite and model-catalog isolate retention, add --worker-retained-heap.
result.json.workerHeapEvidence records each warmed worker's post-collection heap,
thread CPU, GC totals and per-kind pause tails/maxima, event-loop tails, and final
post-collection heap. Every tail includes its sample count. Missing, retired, or
late-created target workers make the evidence incomplete. Both worker classes
must be present. Promotion still comes from isolate-matched --trace-gc output;
GCProfiler does not expose promoted bytes.
--worker-heap-snapshot additionally captures the largest final isolate of each
class, serially, after all measured windows and profiles have ended. Snapshots
therefore do not affect the completed timing window. Keep these artifacts within
the synthetic sandbox and check disk/dirty-memory budgets before capture.
For paired unprofiled memory soaks without mixed RPCs or fingerprint retention, pass fresh, distinct
--worker-heap-done-file and --worker-heap-peer-done-file paths to each side,
reversing the paths on its peer. Each side publishes completion after its worker
and whole-workload timing windows, then waits for its peer before collecting or
snapshotting. The wait allows at least two minutes or one full elapsed capture,
whichever is longer. Never reuse marker files from an earlier pair.
For source-runtime comparisons in one checkout, --worker-baseline-dir selects
mirrored TypeScript files while retaining their original module URLs; the worker
replies record which replacements were loaded. Pass this flag on both sides with
the same relative-file manifest and each side's source revision, so both register
the same loading hook. Comparing a hooked baseline against an unhooked candidate
confounds source changes with loader behavior. Use --entry src/entry.ts, identical fixtures, fixed --soak-rounds and --duration-ms, and
the campaign's concurrent swapped CPU pairs and production guard. Source-runtime
results include its loaders and are not packaged-worker absolute heap estimates.
For a focused cold-process bounded-hydration diagnostic on that same fixture:
node --import ./scripts/tsx.mjs scripts/perf/transcript-hydration-headroom.ts \
--state-dir /path/to/seeded-fixture --output /path/to/hydration-result \
--revision <measured-commit-sha> --max-mib 64 --rounds 4
This uses the normal hydration owner and actual transcript task worker, checks
that repeated reads return identical results, and leaves a three-second idle
observation after the task burst. It does not establish Gateway throughput.
Use separate matching fixtures, a shared --start-at, the measurement lock,
and swapped CPU halves for baseline/candidate pairs. Follow it with the actual
Gateway swarm and history/list latency comparison.
For a comparison in one source checkout, --baseline-owners substitutes sibling
.baseline.ts copies of session-transcript.worker,
session-accessor.sqlite-history-query, session-memory-entry.worker, and
session-accessor.sqlite-active-context, session-actor-replica, and
session-actor-receipt under src/config/sessions. Create those six copies from
the recorded baseline commit before applying the candidate;
they are temporary sandbox artifacts. Both sides otherwise use the same import
graph, dependencies, and driver. Omit --worker-memory for the uninstrumented
Gateway latency pairs; the baseline loader still selects the pinned owners.
This runs paced cohorts after five minutes of warmup, with one real read tool
and a streamed completion per turn, including the same synthetic reasoning
signature and replay check as attempt-retention. Diagnostics and the Prometheus plugin are
enabled. Warmup runs a fixed number of paced cohorts, rounded up from the warmup
duration, so paired runs do equal work. Session generations reset every twenty
cohorts, restarting that cadence at the first measured cohort, to bound intentional
live transcript growth; --reset-every-rounds 0 keeps history instead. The report
records resets and completed cohorts. It does not exercise
the image/output-limit recovery of attempt-retention. --rounds is ignored;
an admitted cohort drains before the duration ends, so a slow cohort may overrun.
To replay deep foreground histories throughout the soak and across a restart,
seed the existing offline fixture once, then run with the same --runs,
--history-turns, --verify-history-replay, and --mock-port:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --scenario memory-soak \
--runs 16 --history-turns 500 --verify-history-replay --stress-tools \
--reset-every-rounds 0 --seed-history \
--port 45107 --mock-port 45109
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --scenario memory-soak \
--clients 40 --exact-clients 4 --runs 16 --history-turns 500 --verify-history-replay \
--chunks 100 --chunk-interval-ms 50 --warmup-ms 0 --duration-ms 1500000 \
--round-interval-ms 120000 --memory-interval-ms 15000 --retained-profile-ms 0 \
--reset-every-rounds 0 --stress-tools --stress-subagents --mixed-rpc --dashboard-rpc \
--list-interval-ms 5000 --poll-seed deep-history --diagnostics --trace-gc \
--port 45107 --mock-port 45109
Repeat the run command, without seeding again, to restart the owned Gateway with
the same foreground history. Deep soaks use a separate memory-soak-history
session namespace and marker, so earlier short-session measurements can coexist.
Each seed turn appends user, assistant/tool-call, tool-result, and assistant/answer
messages through SessionManager: 500 turns produce 2,003 entries per session,
including its header and settings. Both assistant messages carry distinct
deterministic 4 KiB encrypted reasoning items in valid JSON thinking signatures,
with owner-generated replay metadata bound to the model, mock endpoint, and session.
Every foreground provider request must replay all seeded reasoning items intact,
verified by their deterministic ciphertext hashes rather than optional provider IDs;
each measured cohort reports historicalReplayRequests, and the result records
historyTurns and verifyHistoryReplay. Missing or compacted history fails the run.
This mode rejects --history-last-turns and --history-payload-kib; those options
remain available for the separate bounded-history stress shape above.
This opt-in shape gives the synthetic model a 2,097,152-token context window and
a 2,000,000-token input budget. The existing model-context reader allows eight
serialized bytes per context token, giving the fixture a 16 MB hydration budget.
This leaves room for continued turns across the two 25-minute commands above;
it does not change production limits or exercise a real provider's context capacity.
Repeated campaigns eventually exhaust this finite budget. Keep the replay assertion
and generate a fresh synthetic fixture when that happens; do not count a failed run
as a completed soak. The previous 8 MB fixture budget was exhausted by accumulated
output during an interrupted campaign and its replacement, despite all seeded rows
remaining in durable storage.
Retained background histories and live response signatures keep their independent
sizes. Its mock request-size limit covers the estimated
seeded history plus 64 MiB for continued turns. Seeding rejects an estimate above
1 GiB and checks the existing Linux dirty-memory budget at least every 50 history
turns (2 MiB estimated writes), using sequential appends. Run with
--reset-every-rounds 0; other reset intervals are rejected for deep-history
soaks. Use matching tool options while seeding and running to retain the fixture
worktree as the foreground workspace. The existing idle-retention seed format and
behavior are unchanged.
memory-samples.jsonl records Gateway heap, external, ArrayBuffer and RSS memory
after completed cohorts at the requested interval, including exact timestamps.
It also records main-isolate heap spaces and explicitly requests the existing worker
registry's per-thread memory samples, sample ages, and missing workers. Other benchmark
resource reads omit worker data unless they request it; native preload startup does not
load the Gateway worker registry. Worker samples do not force GC.
Periodic samples use natural GC; initial and final settled samples explicitly
collect garbage. Allocation-churn soaks keep the warmed heap at the initial
sample (initialMemoryCollected: false) instead of forcing a collection before
measurement. Sampling cadence is bounded by cohort duration. The final
window writes retained.heapprofile with both include-collected flags false and
a forced collection before stopping. Its sampledRetainedBytes and
topRetainedSites describe allocations surviving that window, not allocation
churn or all objects retained since startup. --retained-profile-ms 0 disables
this capture. Retained and allocation-churn capture cannot run together. The optional
settled.heapsnapshot supports retainer inspection. A smoke run can use
--warmup-ms 0 --duration-ms 30000 --retained-profile-ms 15000.
For steady-state allocation and promotion attribution, disable the retained
profile and add --allocations --trace-gc --diagnostics. For example, the
following workload has twenty concurrent foreground turns and thirty viewers:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --scenario memory-soak \
--clients 30 --runs 20 --chunks 100 --chunk-interval-ms 10 \
--warmup-ms 300000 --duration-ms 1200000 --round-interval-ms 15000 \
--retained-profile-ms 0 --allocations --trace-gc --diagnostics \
--stress-tools --stress-subagents --mixed-rpc --list-interval-ms 2000 --poll-seed steady \
--revision <measured-commit-sha> --start-at <shared-future-epoch-ms>
--stress-tools enables Code Mode in the synthetic configuration. Each outer
exec calls the real read and shell exec tools; the shell runs pwd in the
fixture's first registered linked worktree and its exit code and output must
match. The outer call uses awaitResults: true so required tool waits stay owned
until completion, including when host load exceeds the inline wait budget.
The provider continuation must contain the fixture text, the successful
code result, and the original reasoning signature. The default remains a direct
read call. In memory-soak mode, stress tools also enables one normal isolated
cron agent job every thirty seconds, with delivery disabled. Setup forces and
verifies one run; measured successful run-history entries are reported separately
as cronRuns. Cleanup removes the job on success, failure, or interruption; if
removal fails, discard the fixture before reuse as directed by the warning.
These background turns contribute resource cost but are excluded
from the foreground-turn denominator. The twenty foreground child sessions are
direct agent calls.
Add --stress-session-store to make each Code Mode cell load the preceding
turn's value and commit a new value. The cell fails on a replay mismatch, including
when a value disappears behind the bounded hydration window. Combine this with
seeded long histories and --reset-every-rounds 0 to measure repeated store
replay on the same sessions. --transcript-hydration-counts observes the source
worker reader and writes HYDRATION_COUNT records in the Gateway log with the
session, full/bounded mode, decoded bytes/events, and caller stack; it never logs
transcript content. Bounded reads return worker-decoded objects and therefore
report zero main-thread decoded bytes. The observer requires --entry src/entry.ts
and fails if the instrumented source shape changes.
For the isolated store owner, alternate source roots with
node --expose-gc --import ./scripts/tsx.mjs --import ./scripts/perf/transcript-hydration-counts-preload.ts scripts/perf/code-mode-store-measure.ts /path/to/source.
This seeds a store value before 256 large messages, then binds six fresh runs
with bounded SessionManagers. It verifies the value twice per run and reports
first-load and same-run cached latency plus sampled main-thread allocation
(including collected objects). Keep the first cold sample separate from the five
warm samples, and use at least five alternating invocations per revision.
--stress-subagents requires memory-soak --stress-tools. Each cohort adds one
dedicated parent that uses Code Mode to call the real sessions_spawn tool with
an isolated native child and completionTarget: "parent", then calls the direct
sessions_yield tool. The mock holds the child until public chat.history shows
a successful persisted yield and all foreground streams have reached their
barrier. The child returns a unique result; the resumed parent must receive it
and persist a unique acknowledgment. Both the child and that parent continuation
must finish successfully through agent.wait. Per-cohort evidence is recorded in
streamStart.subagent. This additional parent/child work contributes resource
cost but does not change the foreground-turn denominator. The hidden child uses
cleanup: "delete"; the dedicated parent resets before each cohort.
--mixed-rpc cycles each viewer through sessions.list, retained-fixture
chat.history, worktree sessions.diff, and a newly opened WebSocket connect.
It requires at least four viewers and a nonzero polling interval. Every method
must return real nonempty data or a successful handshake in every measured
cohort. Diff polls require a known tracked README edit in the actual worktree;
after capture, a second edit must become visible within twelve seconds, and
cleanup restores the fixture file. Connect latency includes WebSocket opening
and the protocol handshake. The report keeps per-method p50/p95/p99/max and raw
latencies in rpcLatencyMs, plus each cohort's foreground end-to-end
streamStart.turnLatencyMs from admission through successful agent.wait.
Add --dashboard-rpc to a memory soak with --mixed-rpc to include board.get,
workboard.boards.list, workboard.cards.list, sessions.github.options,
github.preview, models.list, models.authStatus, sessions.describe, and
controlUi.sessionPullRequests.subscribe in the same viewer polling rotation.
Setup enables the bundled GitHub and Workboard plugins, writes one synthetic HTML
board widget, and creates a synthetic workboard with one card per viewer. Reusing
the fixture paginates its cards, removes surplus cards, and creates missing cards
to preserve that count when the viewer count changes. The GitHub preview uses the real plugin with a
benchmark-only in-process HTTP transport serving one synthetic public issue;
other non-loopback fetches fail. By default no GitHub account is connected, so options and
PR subscriptions exercise disconnected-account reads and subscription admission,
not remote PR discovery or credential refresh. The model catalog uses the local
synthetic provider configuration. Board widgets are read through RPC, without a
browser or renderer.
Dashboard widgets reserve Gateway port + 1 for their sandbox listener, so this
mode defaults the synthetic model to Gateway port + 2. An explicit --mock-port
must avoid both Gateway listeners.
Dashboard polling and its measurements continue between foreground cohorts until
--round-interval-ms, so turn bursts can be paced independently of viewer RPCs.
Memory samples continue during those intervals without forcing collection.
Dashboard mode also creates a dedicated diff session for every additional
registered fixture worktree and rotates sessions.diff polls across them and the
existing parent worktree. Setup runs one real synthetic turn per target to capture
its baseline, then appends a known README line; every poll must return that edit
from the expected root. dashboardRpc.diffWorktrees maps sessions to fixture
paths, and raw polling requests include diffSessionKey for per-checkout latency
attribution in each sample's rpcRequests. coldSetupRpc retains initial connection,
dashboard preflight, and diff-baseline RPC durations separately under the
setup/preflight phase; these precede the main measurement window.
All paths must be linked worktrees inside the fixture. Cleanup restores
every README; the existing final edit-visibility control still uses the first
worktree. Combine this with the fixture's --worktree-files and
--nested-repository options to compare the large/nested checkout with small ones.
Every method must return a successful validated response in each measured cohort;
boards, cards, models, and the described session must be populated. The existing
rpcLatencyMs, RPC response-byte totals, and event frame/byte counters include
these methods and their emitted events. dashboardRpc.dashboardFixtureRequests
proves that GitHub preview reached the synthetic transport. With forty viewers,
--chunks 500 --chunk-interval-ms 10 --list-interval-ms 5000 leaves enough streaming
time for every method's initial staggered poll. Shorter cohorts may require a
shorter polling interval. This option does not add automatic mid-soak restarts;
separate invocations against the same retained fixture start separate Gateways,
and normal setup still resets the foreground sessions.
To measure connected GitHub options, generate the fixture and run the dashboard
soak with --github-options-connected. Generation registers the synthetic
openclaw-fixture/soak origin before recording each repository fingerprint;
existing disconnected fixtures cannot be reused for this mode. The harness gives
the isolated Gateway a synthetic native GitHub token, serves /user, repository
metadata, an empty PR list, and the preview issue entirely in process. Account
verification (/user) cycles through deterministic 300, 600, and 900 ms delays;
other routes retain the existing preview fixture's immediate responses. Delaying
every preview request would exceed its shared two-second budget across serial
visibility checks and item reads. No request reaches GitHub.
This measures shared-account verification and discovery, not personal OAuth refresh.
Setup creates session-owned managed worktrees through sessions.create and uses
their returned roots for the foreground and diff sessions. This mode requires
fresh foreground sessions (--history-turns is unsupported); the fixture's
retained session histories remain part of the dashboard load.
For either account mode, add --entry src/entry.ts --github-options-observe to
record handler, admission, shared identity, native token lookup, credential
verification, target discovery, receipt-read, and response-serialization durations in
dashboardRpc.githubObservation.stages. A benchmark-only loader wraps the source
owners and refuses missing source shapes; production imports remain unchanged.
The nested session-preparation stage covers options admission's session reader,
while publication-session records durable session preparation inside the
publication owner. Use these stages to distinguish admission work from repeated
session reads during target qualification. state-read-source includes source preparation
and the worker query; its nested state-read-query excludes snapshot preparation and
cleanup. Source workers also log GITHUB_RECEIPT_IMPORT for fallback-handler imports
taking at least 10 ms, with thread, command, source module, and epoch timing. These
records distinguish cold command loading from database work without logging request
contents. All compared sides must enable the same observer.
Failed observed runs save their collected stages, upstream timings, and options
observations in github-options-failure.json; this partial snapshot is diagnostic
evidence, not a completed measurement.
These durations overlap, and stale refresh work may finish after its originating
handler. Use request IDs and timestamps to distinguish the critical path from
background work; never add stage totals together. Gateway diagnostics separately
record response bytes and dispatch work.
For simultaneous comparisons using one checkout, place the baseline receipt
owner beside src/gateway/github-publication-shared-read.ts and the baseline
availability owner beside src/gateway/github-publication-availability.ts.
Pass --github-observation-owner <baseline-receipt-file> and
--github-availability-owner <baseline-availability-file> only on A. The result
records their loaded paths and override flags in githubObservation.owner and
githubObservation.availabilityOwner, and rejects a run unless both owners loaded
and the handler was observed. Both sides must
enable identical observation and use separate fixtures, ports, and CPU sets.
When comparing shared-state command routing, also preserve the baseline
src/state/openclaw-state-read-handlers.ts and src/state/github-publication.read.worker.ts
beside their owners and pass their paths with --github-state-handlers and
--github-publication-reader on A. Pair them with the baseline receipt owner so its
original command reaches its original handler; the import records identify the
selected handler module. This keeps both sides on one dependency installation.
Connected options use one additional socket per viewer granted only
operator.sessions.read: broad dashboard grants skip repository qualification.
The connected soak authenticates its synthetic operator through loopback
trusted-proxy handshakes so the normal profile owner supplies a verified identity.
An initial concurrent batch is retained in coldSetupRpc, before preview preflight;
warm polls rotate over every session in dashboardRpc.diffWorktrees. Each polling
record includes githubOptionsSessionKey. Every options response must expose the
synthetic shared account without an unsupported-workspace reason.
dashboardRpc.githubOptionsConnected records the additional socket count,
every upstream route/sequence/delay/elapsed time/status, totals per route, and
options observations by phase, session, latency, stale flag, and account login.
The synthetic account changes login after sixty seconds from its first /user
request; accountRenameObserved reports whether normal refresh made that change
visible. Use a long enough soak to observe it; a short smoke is not freshness proof.
The upstream elapsed times include the injected wait and fetch header resolution;
they are not additive handler self-time. Use Gateway diagnostics for handler time
and the existing RPC byte counts for serialization volume.
--list-profile-reference <reference> applies the person-reference filter to
concurrent list polling. Use a missing hexadecimal prefix such as ffffffff to
exercise resolution across the full visible retained roster; that workload
accepts an empty page and measures reference resolution rather than row rendering.
The ordinary list-polling workload still requires nonempty pages. Add
--capture-responses to a memory soak to capture the seeded roster after the
measured window; person-filtered polling also adds four person-reference
requests to that capture.
Each allocation profile includes collected objects and reports decimal
allocatedMbPerTurn. Allocation soaks keep one continuous sampling window and
report it as wholeSoakAllocation with allocation-soak.heapprofile. These long
captures sample every 512 KiB on average to bound native sample records and the
final JSON export; short allocation experiments retain their 32 KiB interval. V8 forces
a major collection when a sampling profile is read or stopped, so the soak
never reads the profile between cohorts and stops it only after recording the
whole-window GC and event-loop metrics. With --trace-gc or allocation profiling,
the owned Node process runs --trace-gc-nvp. The shared trace reader identifies
the main isolate before Gateway startup and reads promotion between diagnostic
window markers after process exit and log drainage. It supports legacy NVP and
JSON traces. wholeSoakGc reports promoted bytes, decimal promotedMbPerTurn,
and minor/major GC seconds per minute from the diagnostic observer. Missing
capture markers or malformed promotion traces fail the capture. wholeSoakGc and
wholeSoakDiagnostics span all measured cohorts and the idle gaps, stopping
before final forced collection and the edit control. The latter includes
event-loop p99/max and GC duration by kind. The continuous allocation window
also covers the idle gaps, so both measurements include deferred work. Short
non-soak allocation experiments still stop a profile per cohort; their GC
results do not establish natural steady-state GC behavior. Sampling retains
native sample metadata and adds stack-capture overhead; confirm marginal
GC/latency results with a matching soak without allocation profiling.
For allocation work that executes in a worker, add --worker-allocations to
memory-soak --allocations. It uses the existing private inspector channel and
the same collected-object sampling interval in every isolate. The
wholeSoakAllocation.workerManifestPath artifact lists each worker's native
thread ID, registry script identity, inspector title, acknowledged sampling boundaries, completion/error,
and profile path. Live workers absent from the initialized Gateway registry are
marked separately; Gateway-owned worker pools register synchronously. Join native IDs to the existing memory samples' workerHeaps
and workerMemoryMissing script identities before attributing bytes to an owner.
Read successful worker profiles with readGatewayHeapProfile and report main,
owner-worker, all-worker, and combined bytes per completed foreground turn
separately. During profiling, intentional worker termination first exports its
active profiles, waiting at most five seconds for an unresponsive worker.
Unexpected exits, failed exports, or unidentified workers are missing evidence,
never zero allocation. Worker profiling also samples worker resource usage every 100 ms;
enable it identically on both sides of a comparison.
--soak-rounds <n> runs exactly that many measured cohorts instead of admitting
cohorts until the duration deadline. The continuous diagnostics/allocation window
still lasts at least --duration-ms, including any final idle gap. Use equal
cohort counts and session-reset counts when comparing promotion and GC, and size
the workload for at least 200 observations per side for p95 (1,000 for p99).
Run at least twenty measured minutes per side and compare at least two concurrent pairs with swapped CPU halves. Compare B minus A within each pair for allocation, promotion, minor/major GC, loop p99/max, every RPC tail, and turn latency. A lower sampled allocation total alone does not justify a change that raises promotion or GC time. Keep the same profile settings, fixture shape, reset phase, polling seed, and workload options on both revisions.
Use identical fixtures, driver revisions and options for concurrent A/B pairs,
separate state/ports, the existing --start-at barrier after warmup, the shared
measurement lock and swapped sixteen-core halves. Check paired settled-memory
changes alongside raw sample trends; compare the same session-reset phase.
The soak preserves explicitly configured fixture plugin entries; prepare any
synthetic diagnostic listener fixture separately and record its version. It
does not install production-only listeners or copy their operator state.
To start an existing fixture without regenerating it, set HOME and
OPENCLAW_HOME to <state-dir>/home, OPENCLAW_STATE_DIR to <state-dir>,
OPENCLAW_CONFIG_PATH to <state-dir>/openclaw.json, and TMPDIR to
<state-dir>/tmp, then use pnpm openclaw gateway run --bind loopback --port 40967
in a credential-free environment. Also set OPENCLAW_PROFILE=team-shape-40967
and the native selector: OPENCLAW_SYSTEMD_UNIT=openclaw-gateway-team-shape-40967
on Linux, OPENCLAW_LAUNCHD_LABEL=ai.openclaw.team-shape-40967 on macOS, or
OPENCLAW_WINDOWS_TASK_NAME=OpenClaw Gateway (team-shape-40967) on Windows.
Use the corresponding suffix when changing ports. The generated config uses no authentication
and is only suitable for isolated loopback repros.
Measure the post-restart reconnect burst
Build runtime artifacts on the authorized benchmark host and generate a fresh offline team-shape fixture for each revision. Then run:
node --import ./scripts/tsx.mjs scripts/perf/post-restart-burst-measure.ts \
--state-dir /path/to/team-shape --clients 50 --runs 30 --seconds 300
The driver owns a loopback Gateway on port 41058 and a synthetic SSE model on
41059; port collisions fail. It creates 30 shared sessions and starts real
agent turns. Every uniquely marked primary model request remains held until
all 30 arrive (a three-minute deadline). A primary response closing before the
crash fails the run, and all 30 must still be actively held immediately before
SIGKILL. The driver kills only its owned Gateway process group, drains all held
response close callbacks, and restarts against the same synthetic state. After
restart, model requests receive immediate short acknowledgements. Unique
recovery arrivals are reported separately: startup may settle interrupted runs
without a model replay. Utility calls never count as primary arrivals.
The Gateway uses a fake fixture token. Clients use a non-ephemeral UI connection
mode and sign the normal Gateway device challenge with generated Ed25519 keys.
All clients pair through the normal loopback/shared-token admission before the
crash in batches of 32, avoiding the per-IP preauth limit during setup. They
reconnect concurrently with the same device keys after restart; admission refusals
and retries remain part of the measurement. Connect verifies
token authentication, retained read/admin scopes, and a durable owner profile through
users.self, so projects.list exercises the signed-in session/recents phase.
A generated catalog plugin consumes the real Gateway session metadata snapshot
and returns up to 100 synthetic catalog rows; absent/empty catalog results fail.
This measures catalog snapshot preparation, not real provider filesystem scans
or remote discovery latency. All connections share one synthetic owner profile.
As soon as the restarted Gateway becomes ready, 50 clients connect concurrently
and request sidebar sessions.list rows (limit 100), sessions.catalog.list
(limit 100 for the synthetic local host), and projects.list in parallel every five seconds. Each
client waits for its batch before issuing another, bounding outstanding work.
The measurement window starts at restart spawn; requests begun in its first
five minutes are drained even if they finish later. Latency percentiles include
successful replies only; errors, connection retries, and raw timings are retained
so failed requests cannot disappear into the p99. --seconds 900 extends ELU
observation beyond five minutes without changing the first-five-minute summary.
--entry, --port, --mock-port, and --poll-ms adjust execution and load.
Results in post-restart-burst-*/result.json include readiness time, per-RPC p99,
private Gateway main-thread ELU/CPU and loop-delay samples every five seconds,
and time to three consecutive ELU samples below 0.8 after reconnect load begins
(null if unobserved). Raw ELU includes startup; no inspector or profiling RPC is
exposed. Unexpected child
exit, incomplete initial arrivals, missing successful endpoint samples, or
missing client/sample coverage fails the run. Owned children and the model
server stop on completion or interruption. Teardown confirms each child has
exited and its managed process group is dead; unresolved cleanup fails and is
reported even when stopping a child throws. The model server always closes.
Synthetic state and logs remain for
inspection. This reproduces recovery and reconnect/list pressure, rather than
production provider latency or arbitrary plugin load. Never point it at live
state, and apply the sandbox rules below to every Team command.
For warm reconnect pressure without agent turns or a Gateway crash, use:
node --import ./scripts/tsx.mjs scripts/perf/post-restart-burst-measure.ts \
--state-dir /path/to/team-shape --reconnect-waves --clients 40 --waves 10
This mode pairs devices locally, warms all 40 connections with trusted loopback
proxy attribution (203.0.113.50, a documentation address), and verifies negotiated
permessage-deflate. Each of ten waves reconnects 30 clients while the remaining
10 issue bounded sessions.list, sessions.catalog.list, and projects.list
batches, producing 300 measured connection attempts. The reconnect selection
rotates by 30 clients each wave so all 40 participate. --reconnect-count defaults
to the smaller of 30 or one fewer than --clients; it must leave at least one
client polling. Each wave lasts at least --poll-ms (default 5,000 ms); a client
waits for its batch before polling again. The default client count is 40 in this
mode and the default wave count is 10. Setup pairs and warms devices in batches
of at most 32. Restart workload flags cannot be combined with this mode.
For simultaneous baseline/candidate measurements, pass the same future Unix
millisecond timestamp as --start-at; missing that setup deadline fails the run.
Keep each side's state and ports separate, including during device pairing.
result.json retains every measured connection attempt, wall-clock start,
socket-open/challenge timing, connect RPC timing, connection ID, negotiated
extensions, presence row count, errors, list timings, and main-thread samples.
Warm reconnect samples exclude intervals that begin before measurement starts.
Connect RPC latency ends at hello and excludes the subsequent users.self read.
Verbose compact Gateway logs in gateway-reconnect.log retain existing auth,
device, profile, registration, hello, and send phase timings; correlate them with
each attempt's connection ID. Hello is intentionally uncompressed because it
contains device tokens. This workload measures the connect response and concurrent
list traffic; it does not request the detached Control UI model catalog snapshot.
Use the same fixture seed and shape for both revisions; --transcript-mib 1 keeps
5,000-session fixtures small when transcript volume is irrelevant to the flow.
Add --writer-reads --clients 150 to measure read traffic competing with startup
writes. This enables the Prometheus plugin, makes each synthetic catalog request
read its persisted plugin metadata, and adds sessions.github.options for an
owned synthetic session plus agents.list to each client's polling batch. Agent
listing exercises persisted provenance presentation reads. GitHub accounts remain
disconnected; no external publication occurs. At five minutes from restart spawn
(or the requested shorter duration), the driver saves writer-metrics.prom and
includes writer queue wait sums/counts by request class in result.json. These
are cumulative exporter observations since its startup, so work before exporter
subscription is not covered. The execute class groups multiple unclassified
commands; it does not identify an individual caller. Use the same driver options
and fresh fixtures for both revisions.
Add --sqlite-queues --clients 50 --seconds 180 to capture every instrumented
worker queue for the full first three minutes from restart spawn. Combine it
with --writer-reads or --cheap-handlers to include those workloads. A private
preload subscribes to the source diagnostic dispatcher before the built Gateway
entrypoint starts; both use the same process-global dispatcher. It enables
diagnostics before config loading, so capture does not depend on the Prometheus
exporter's later subscription. Only the restarted main isolate is observed.
sqlite-queues.json and result.json.sqliteQueues contain all worker kinds,
per-kind and per-request-class counts, queue depth peaks, summed queued-time and
service-occupancy integrals, completed service duration, observed queue-wait
sums/maxima, and ten-second bins (at most 180 bins for longer runs). Classes are
ranked by their queued-time contribution. queueDepth and active in the final
rows count work still pending or running at the cutoff; their elapsed occupancy
is included, even when they finish after the window. Cancellation before start
is counted separately. Aggregates are bounded to 512 kind/class rows and retain
only the first 2,000 metadata-only events as samples. No payloads, paths,
session IDs or request values are retained. The existing native-task counter
still covers only its original first-120-second window.
The totals sum overlapping worker-kind queues, including SQLite reads and
writes; they are not one FIFO and nested operations can occupy multiple
kinds at once. A kind may itself combine several workers or database owners.
sharedSqlite separately sums the sqlite_writer, sqlite_read, and stateRead
transports named in sharedSqliteKinds; other database-using workers remain
visible in the complete per-kind results rather than being guessed into this subset.
Queued time identifies classes suffering waits, not which class blocked them.
Compare service occupancy and request counts when investigating the blocker;
service occupancy also includes transport and scheduling, not just SQLite CPU.
worktreeCommandDispatchCounts splits the grouped worktrees class by a fixed
allowlist of exact commands. This private probe decodes serialized dispatch
inputs, including nested state-read commands, to count only command names; use
it identically on both revisions.
Events carry wall-clock emission timestamps, while observedMs records
monotonic receipt time from restart spawn. Integrals and the cutoff use emission
timestamps at millisecond resolution, avoiding asynchronous diagnostic-delivery
lag; maxDeliveryDelayMs and maxClockSkewMs expose that limitation. The preload
drains events already queued at the deadline before snapshotting, while excluding
events emitted after it. captureStartedMs records the small process/loader gap
before the listener is installed. Diagnostic drops, inconsistent depth accounting,
row overflow, or clock skew of 100 ms or more mark the capture incomplete and
fail the run after preserving its report. There are no request IDs or causal
links, so this cannot prove individual request ordering across worker owners.
Add --writer-reads --worktree-churn --clients 40 --seconds 180 to compare
worktree traffic after reconnect. The driver saves writer queue count/wait
snapshots at 120 seconds, then creates 12 sessions with real worktrees in a
seeded project concurrently, archives them, and deletes them through the normal
RPCs. After archiving, it waits for the background cleanup receipt (including a
coalesced follow-up pass) before sampling counters and deleting sessions. Each
stage completes before the next and saves another metrics snapshot.
worktreeChurn in result.json retains the snapshots and elapsed times; subtract
adjacent counts/wait sums for each stage. Reconnect polling continues during the
burst. Worktree creation or preserved cleanup failures reject the run. These
histograms start when the exporter subscribes, so they exclude earlier boot
work and describe all concurrent traffic, not just the 12 operations.
Add --cheap-handlers --clients 30 --seconds 120 to measure the first two minutes
of reconnect traffic for push.web.vapidPublicKey, push.web.subscribe,
workboard.cards.list, sessions.github.options, and github.preview, alongside
the existing list requests. This enables the real bundled Workboard and GitHub
plugins. Before the crash it persists the VAPID key, registers one synthetic
subscription per paired device, disables delivery to each subscription, and
creates one Workboard card per client through the real RPCs. Each reconnect sends
sessions.subscribe first without awaiting its response, matching
ui/src/lib/sessions/index.ts and its subscription owner, then issues the measured
batch on that same socket. This preserves subscription head-of-line waits without
a preceding users.self warm read. It is a bounded RPC workload, not a browser
rendering benchmark or an exact replay of every UI scheduler dependency.
firstTwoMinutes reports p50/p99 and errors for requests begun
within 120 seconds of restart spawn; slow requests drain after the window.
GitHub uses its real plugin, host authorization, anonymous reader and cache, with
an Undici mock transport serving one fixed synthetic issue. The preload binds
that transport explicitly to GitHub fetches and allows only loopback fetches
outside the fixture. The run requires an observed
post-restart fixture fetch plus the expected preview payload. This excludes
external GitHub latency, managed-account refresh, avatars and pull-request
decoration. rpcPhaseMetrics and writer-metrics.prom retain the existing
admission, handler, first-response and queue-wait histograms; these are cumulative
since exporter startup and do not cover requests before its subscription. Each
revision needs a fresh fixture and the same driver options.
Add --transcript-reads --clients 50 --seconds 120 to exercise a cold session
memory index during the reconnect wave. Setup leaves memory search disabled;
immediately before restart the driver enables sessions-only FTS memory with
provider: "none". The first reconnecting client invokes the real memory_search
tool once to open its default manager while all clients poll chat.history for
the same interrupted session and workboard.cards.list for their seeded cards.
No embedding service is used. The history target is a small interrupted turn;
the seeded corpus supplies the large session inventory and transcript volume.
This is a cold-index restart, not a fully indexed steady-state comparison.
The private preload counts main-isolate native task submissions across all
workers, grouped by the bounded input kind and history request kind. It
retains no transcript content, paths, session identifiers, or call stacks. The
first 120-second deadline is checked at submission, so a delayed IPC snapshot
does not count later work. nativeTaskInputs, transcriptQueueMetrics,
firstTwoMinutesMainElu, and per-RPC p95 fields provide the comparison. Native
submission counts include work before the diagnostics exporter starts; queue
metrics do not. Record the kind breakdown and verify memory catch-up actually
ran before attributing an improvement to it. Use a fresh fixture for each revision.
Publication transport spawn attribution
publication-spawn-measure.ts measures the real runPublicationCommand transport
against a tiny synthetic Git repository, with 200 synthetic environment entries.
It warms 100 commands, then runs a fixed mix of rev-parse, config, ls-files,
and show reads, checking every result. This isolates transport costs; it does
not replace a Gateway publication-flow measurement.
node --import ./scripts/tsx.mjs scripts/perf/publication-spawn-measure.ts \
--state-dir /path/to/new-synthetic-state --operations 2000 --output timing.json
Use --transport-owner with a baseline sibling of
src/gateway/github-publication-git-transport.ts to compare the same source tree.
Supply a sibling for each side so both register the same resolver hook.
Both sides must use identical fixed work counts (at least 1,000 operations), fresh
state directories, and a shared --start-at epoch in milliseconds. Hold the quiet
slot lock and guard, then swap interleaved CPU placements for the second pair.
Untraced output includes main-thread and process CPU per operation, operation
latency, synchronous native-spawn time, event-loop sample counts/tails, and
per-kind GC counts, total time, percentiles, and maxima. --allocation is a
separate sampled-allocation pass including collected objects; compare it only
against the same mode. No forced GC or pause trimming occurs. Apply the campaign's
absolute-delta and sample-count rules, and collect matched identical-source
controls before interpreting adverse GC results.
Use --cpu-profile separately from --allocation to capture the measured operation
window in publication-spawn.cpuprofile under the state directory; its profiled
timings are attribution evidence, not untraced comparison gates.
Checkout diff command fan-out
checkout-diff-measure.ts exercises the production Git worker or its read-cache
owner against a small synthetic checkout, without a Gateway or model:
node --import ./scripts/tsx.mjs scripts/perf/checkout-diff-measure.ts \
--state-dir /path/to/new-diff-fixture --seed
node --import ./scripts/tsx.mjs scripts/perf/checkout-diff-measure.ts \
--state-dir /path/to/new-diff-fixture --boundary worker --summary --mutate \
--samples 40 --output /path/to/summary-result.json
The seed refuses an existing directory. It creates 100 untracked files,
including binary, large, empty, and symlink cases, three tracked changes, and an
ignored file. --untracked adjusts the count. Omit --summary to measure full
patches. --mutate changes one tracked file before each request; omit it for
unchanged polls. --boundary read uses loadCheckoutDiff, including revision
validation and caching. Use --poll-ms 10050 to cross the former summary cache's
ten-second TTL; immediate repeated reads alone cannot demonstrate that change.
Run identical separate fixtures concurrently with --start-at <epoch-ms> under
the host's exclusive measurement lock, then swap CPU halves for the next pair.
The report includes per-request latency, actual Git process counts from trace2,
command families, payload size, and a SHA-256 of each response with the fixture's
absolute root removed. Compare matching sample hashes across revisions before
attributing speedups. Setup and worker warmup are separate from measured samples;
trace2's small write overhead is included identically on both sides. These are
Git owner timings, not end-to-end sessions.diff RPC or browser latency.
Acceptance for untracked batching is a Git command bound independent of the untracked-file count, byte-identical returned file statistics and patches, and paired p50/p99 that improve or remain within noise for a simplification. An unchanged checkout eligible for revision caching should issue zero Git commands, including after the prior TTL. Compare B minus A within each concurrent pair; use at least two pairs with swapped CPU halves and record outside load for context.
Interactive diff contention
Use the same script's --boundary rpc mode to measure real sessions.diff
WebSocket requests while full-diff requests, host-budgeted read-only Git work,
and external agent-like Git commands compete. Seed each side separately; the
load checkout contains 16 tracked files with 4,096 changed lines each, written
sequentially. This default exceeds the full-patch size guard, so those requests
exercise diff statistics rather than patch generation. All repository data is
synthetic. The maintenance-shaped load
runs diff --numstat through the production child budget; it does not run
maintenance, repack, garbage collection, or change repository configuration.
node --import ./scripts/tsx.mjs scripts/perf/checkout-diff-measure.ts \
--state-dir /path/to/new-rpc-fixture --boundary rpc --seed
node --import ./scripts/tsx.mjs scripts/perf/checkout-diff-measure.ts \
--state-dir /path/to/new-rpc-fixture --boundary rpc --summary --port 41473 \
--tabs 3 --background 8 --maintenance 2 --agents 2 --mutate --observe-git-queues --duration-ms 30000 --poll-ms 250 \
--start-at <epoch-ms> --output /path/to/rpc-result.json
RPC mode returns full diffs by default; add --summary to exercise summary polls.
Each tab polls the same session, so queued duplicate polls exercise the existing
read owner. --mutate writes a tracked file before every polling round; every
response must include the exact current added-line count, which varies by round.
Compare response hashes for matching A/B round numbers. Without mutation, polls exercise unchanged-revision
reuse. Background readers bypass the read cache to represent distinct heavy
read requests. Each external agent iteration executes status, diff statistics,
and the latest commit read; their end-to-end timings are reported separately.
All load loops stay active for the same fixed duration on both revisions;
completed counts and throughput accompany the latency distributions. Work
already admitted at the deadline must settle before the report is written. Use --heavy-files and --heavy-lines at seed time to
change the load without changing the polling checkout.
The report contains RPC p50/p95/p99, independent round counts and each round’s
maximum tab latency, heavy-reader and agent Git distributions,
response hashes, and the existing openclaw.git.operation diagnostics. Queue
fields are included with --observe-git-queues, which uses a benchmark-only
loader hook to extend the existing diagnostics publication identically on both
revisions. It changes observation only, and fails if that publication cannot be
identified. Without the flag, absent measurements remain absent. Exclude startup
and warmup. Compare at least two concurrent pairs under the measurement lock,
swapping CPU halves, separate state directories and loopback ports. The
contention acceptance rule is at least a 60% paired RPC p99 reduction with no
agent Git regression. Inspect command counts and queue waits to distinguish
scheduling changes from reduced Git work.
Add --cold --tabs 40 --poll-ms 5000 to measure reconnect polls against a fresh Gateway process
without warming diff targets or Git workers. Connections are prepared concurrently;
the first 40 diff requests start at --start-at alongside the existing load loops.
Use repeated --poll-state-dir <seeded-fixture> arguments for distinct checkout
roots; each tab stays on tab % checkoutCount, with one outstanding request.
Cold mode requires a positive --poll-ms and at least one tab per checkout.
Each client independently retries computing after 500 ms and waits --poll-ms
after receiving content. Omit --summary for full diffs. For large nested fixtures, seed each
with --boundary rpc --tracked 267000 --untracked 5 --nested-repository sequentially
under the host's preparation CPU allocation. This measures cold process caches,
not cold disk or the WebSocket handshake.
For the reconnect workload, distribute 40 clients across 14 roots: two large
nested fixtures and twelve small fixtures. A five-second interval approaches
480 full-diff requests per minute, plus temporary computing retries; request
latency lowers the steady rate. The observed production rate of about 500 requests
per minute includes all RPCs, so this is a conservative diff stress workload,
not a replay of the production request mix. The UI's full-diff reader retries
computing results; recurring five-second polling belongs to active-run summaries.
Use the report's actual rpcPerMinute and completed counts when comparing sides.
Use --scope uncommitted for working-tree-only polls (the default is all). Add
--poll in cold mode to opt into the nonblocking RPC contract on supporting revisions. When
comparing that contract against an older blocking baseline, pass --poll only
to the candidate and record the mode difference alongside the results.
For a cache-owner-only comparison with one source checkout and dependency install,
place the baseline beside src/infra/git-read-cache.ts and pass its path through
--read-cache-owner on the baseline process only. A benchmark resolver redirects
the production owner's import before Gateway loading; the adjacent location
preserves its relative imports. The report records the override and load count,
and fails if the selected owner was never loaded. Keep state directories and ports
separate even when both processes share source.
Cold mode prohibits --mutate and --missing-cwd. It verifies fixture contents
and all materialized response hashes after measurement instead of prewarming
them. firstRequestLatencyMs includes the initial response for each assigned tab;
firstContentLatencyMs also includes polling until any computing: true response
becomes materialized. The phases.cold and phases.steady fields separately report
RPC latency, computing-response counts, and summed sessions.diff handler seconds.
Their refreshLatencyMs includes all computing retries and actual timer delays from
each refresh's first request to usable content, excluding the recurring poll cadence.
measurementStartedAtUnixMs, contentReadyAtMs, and each refresh's startedAtMs
allow paired runs to use a common steady window: refreshes starting after both sides
received their final initial content, before either nominal deadline. Include the
full completion latency for those refreshes, even when they finish after that window.
Refreshes are tagged by their start phase; individual RPCs are tagged on completion.
Completions are tagged cold until every client has received content and their
diagnostics have drained, then tagged steady. Clients that finish early keep their
own cadence. Cold samples use a per-client sequence; rpcPollSequenceMaxLatencyMs
groups matching ordinals, not synchronized rounds. Warm mode retains its cross-product
polling and synchronized round metrics. A run fails
if any client never receives content or no steady refresh completes; increase
--duration-ms when needed. Compare both first-response and first-content tails,
then the steady distribution, in concurrent pairs with swapped CPU halves.
Large-worktree revision validation
Seed separate fixtures with --boundary rpc --tracked 267000 --untracked 4 to
exercise production-sized indexes and directory trees. Nonzero --tracked
creates a real linked worktree with that many additional unchanged tracked empty
files, arranged in directories of 1,000. The three changed tracked files and four
untracked edge cases remain. Writes are sequential; on Linux, generation checks
Dirty memory every 1,000 files, pauses at 1 GiB, and stops at 2 GiB. Seed one side
at a time under the host's preparation CPU allocation. This bounds content bytes,
but inode metadata still consumes disk space: record actual fixture size and do
not expand the workload on a production-shared volume without checking capacity.
Measure unchanged summary polls with --summary --tabs 3 --background 0 --maintenance 0 --agents 0 --poll-ms 250 --duration-ms 30000. The report separates warmed samples
from startup and records sessions.diff and checkout.diff phase diagnostics by
cache outcome when those are available. Stage milliseconds are overlapping owner
observations, not additive: cache validation includes its worker queue and Git
work. Compare cache-hit and periodic fallback-cache misses separately. Git
operation counts and queue observations remain available on older revisions.
For a revision-validator change, acceptance is at least a 50% paired cache-hit p50 reduction, with no material p95/p99 regression. The explicitly requested histogram may land with neutral timing. Run at least two simultaneous A/B pairs under the host's exclusive lock, swapping 16-core halves between pairs, separate fixtures and loopback ports, matching options and start times. Retain per-pair differences and outside CPU load; do not compare unrelated absolute runs. Two large synthetic worktrees model file/index size and concurrent polling, not the production fleet's full filesystem working set or cold disk behavior.
Add --churn-ms 1000 to simulate an agent saving identical tracked unchanged.txt
content and running git status on the polled checkout, while build output is
created and removed below the ignored directory. This refreshes index stat data
without changing the visible diff. Every unchanged RPC response must match its
warmup hash. After measurement, the script makes a real tracked edit and checks
that every tab receives the new line count, then restores that file. Summary
fallbacks may intentionally retain a result for 10 seconds when no revision is
available. The report separates first-poll visibility (editFirstPollVisible)
from eventual visibility (editVisibilityMs), polling for up to 12 seconds.
It saves the report before asserting eventual visibility. The report
includes churn counts/timings, cache outcomes, and editControlVerified; phase
samples retain diagnostic miss reasons when the revision supports them.
For cache-reuse changes, compare both the 267,000-file fixture and a small
fixture with --summary --tabs 3 --background 0 --maintenance 0 --agents 0 --poll-ms 250 --churn-ms 1000 --duration-ms 30000. Run full-diff comparisons separately by
omitting --summary: oversized summaries retain their existing 10-second
fallback, while full diffs require fresh validation. Require unchanged response
hashes, a passing edit control, and fewer diff collections with a higher cache
hit rate on the repaired path, without a material paired latency regression.
Native Git status used to validate a revision still counts as Git work; cache
hits do not imply zero subprocesses.
Use --missing-cwd with RPC mode and a small fixture to measure a disappeared
session directory. The driver renames the synthetic new/ directory after
session creation, requires not_git on every measured response, restores the
directory, and requires recovery within two seconds. Do not combine it with
mutation, churn, or extra polling fixtures. The report includes
missingRecoveryMs; both revisions must return identical absent-checkout
responses. Compare at least two simultaneous pairs with swapped CPU halves.
RPC reports also include processCpuMsPerRpc, the Gateway process's CPU during
the measured window divided by completed requests. This includes its worker
threads but excludes child Git processes; compare it with command counts and
latency, not as total system CPU.
Add --nested-cwd to launch each session from the fixture's new/ subdirectory.
This exercises checkout discovery through a session's working directory instead
of supplying the repository root. Compare full diffs under --churn-ms 1000
with and without this option; require identical response hashes, immediate edit
visibility, and the same cache eligibility as the root-directory case. The
267,000-file fixture remains a detached linked worktree with a separate Git dir.
At seed time, add --nested-repository to replace one untracked text file with a
small committed agent-clone/ checkout (--untracked must be at least five).
RPC warmup requires that checkout to remain one opaque untracked entry, with no
nested file contents. Add --excludes-file to configure core.excludesFile
pointing outside the checkout and ignore a synthetic file through it. The
config contains 1,000 synthetic branch sections (more than 64 KiB),
matching a repository shared by many agent worktrees. After the
measured RPC window, the driver clears that ignore file, verifies the newly
visible file reaches every tab immediately, and restores the ignore rule.
Measure these scenarios separately with full diffs, for example --tracked 5000 --untracked 10 at seed time and --tabs 3 --background 0 --maintenance 0 --agents 0 --poll-ms 250 --duration-ms 30000 at run time. All unchanged RPC responses must
match warmup; compare their hashes between revisions, require the external-ignore
control to pass, and compare cache hit rate plus paired p50/p99. Use at least two
concurrent pairs per scenario with swapped CPU halves. These small fixtures
reproduce cache eligibility rather than the full fleet's filesystem working set.
For a second scenario with independent revision checks, add
--poll-state-dir <other seeded fixture> on each side. Both isolated Gateways
then poll both existing synthetic worktrees concurrently, using a separate
session per checkout and --tabs peers per session. This option is repeatable,
requires RPC mode, and rejects --mutate: shared checkout contents stay read-only
with optional Git index refresh disabled through GIT_OPTIONAL_LOCKS=0,
while Gateway state and temporary diff indexes remain owned by each side's
--state-dir. The report includes the extra fixture inventories, checkout count,
and each sample's checkout index. This adds multiple-root contention without
generating another large filesystem tree; it still does not model a whole fleet.
Worker module graphs and retained admission facts
worker-module-graphs-measure.mjs exercises the Git, plugin-preparation,
Code Mode, and skill-discovery worker entrypoints through their task protocol.
It records cold and warm script inventories, retained heap after collection,
request latency, worker GC, and worker event-loop delay with sample counts.
Each run owns one isolate. The synthetic fixture has four workspaces with eight
skills each and plugin admission facts for 100 roots and 5,000 files.
node --import ./scripts/tsx.mjs scripts/perf/worker-module-graphs-measure.mjs \
--state-dir /path/to/owned-fixture --output /path/to/result.json \
--worker git-operation --samples 1200 --start-at <epoch-ms> --trace-gc
Other worker names are plugin-generation-preparation, code-mode-node, and
discovery. Use one worker per concurrent baseline/candidate pair so --start-at
synchronizes the measured windows after warmup and diagnostic collection. Missed
start deadlines fail the run; startedAt records the actual request-window start.
Allow enough preparation time for snapshots. Swap CPU placement in the second pair. --baseline-dir loads the original versions of the import/admission
owners listed in the worker helper, retaining candidate URLs for dependency
resolution. Store those files at their repository-relative paths beneath that
directory. --snapshots writes warm heap snapshots outside the timed window.
For plugin-preparation retention comparisons, include src/infra/sqlite-staging-token.ts
in the baseline directory. Its fixed lock commands use the native statement owner,
so preparing plugin source does not import the Kysely query-builder graph.
Git replies come from real host-side Git commands, and a newly created branch must appear in the worker's inventory. Plugin resolve calls must return the captured source bytes, and close must release the artifact. Discovery verifies every skill; Code Mode checks the computed result across fresh completed contexts. This is a worker-boundary workload, not a full Gateway benchmark. Main-isolate heap and total allocated bytes are not measured. GC trace markers delimit each workload; identify the worker isolate from the forced collection between the identification markers. Report worker and whole-process GC time and promoted bytes separately so deferred host work remains visible.
Sandbox measurements
Follow the current host policy. On Team, use only a task-owned
/srv/perf-sandbox/<lane>/ checkout and fixture, with HOME, OPENCLAW_HOME and TMPDIR
inside that directory. Wrap every remote command with:
systemd-run --scope --quiet -p AllowedCPUs=64-127 -p MemoryMax=64G \
nice -n 10 ionice -c3 <command>
Use loopback ports at least 40000. Never read/copy production databases or touch production services. Stop your own Gateway, then remove your sandbox after recording results. Local validation is limited to changed-file format/lint and focused tests; PR CI owns broad gates.
Operator authority allocations
operator-authority-measure.ts isolates six real synchronous authority owners:
auth-policy, retained-grant, recovery-source, model-execution,
admitted-run, and transcript-guard. Its synthetic non-owner operator has an explicitly required
access-policy plugin, registered
through createApi(...).registerGatewayAccessPolicy(...) with the normal plugin
instance invocation wrappers. Setup authorizes and resumes the grant once;
rechecks use the retained authority, as the recovery owner does. The fixture
exposes no other plugin runtime capabilities and starts no Gateway or services.
recovery-source creates one synthetic profile in a private temporary SQLite
store, decodes the persisted source shape, and calls restoreGatewayOperatorSource.
Its measured check is the restored authority's assertCurrent, including the real
profile, source-auth policy, and retained plugin-grant owners. Setup creates and
warms the profile; teardown releases it and removes only the generated state directory.
Set TMPDIR to the approved sandbox before running this scenario or all.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/operator-authority-measure.ts \
--scenario model-execution --iterations 20000 --warmup 5000 \
--revision <commit-sha> --output /path/to/authority.json
The JSON records estimated allocated bytes per check, elapsed and main-thread CPU
nanoseconds per check, and auth-policy, resume-owner, plugin-authorize,
plugin-resume, and plugin-assert counts. Setup and warmup are excluded. Allocation
uses V8 sampling at 32 KiB with both collected-object flags enabled; the report
is allocation churn, not retained-heap delta. Raw profiles accompany --output,
and the report includes the fifteen largest allocation sites with eight-frame
stacks. estimatedAuthPolicyStringifyBytesPerCheck separately totals sampled
allocations beneath captureGatewayAuthPolicy → the stable-stringify serializer,
including recursive serializer frames when V8 inlines the public wrapper and
collected objects; zero samples are an estimate, not an exact invocation count. A post-capture positive control requires grant revocation to fail the
very next retained-authority check. The recovery scenario also removes its
allowed browser origin on a fresh config and requires the next source check to
reject, outside the measured window.
Use at least five simultaneous baseline/candidate pairs on the approved
benchmark host, with one --scenario and the same future --start-at <epoch-ms>
per pair. Alternate the assigned interleaved even/odd CPU sets under the exclusive
measurement lock. Compare the mean paired differences with spread; warm both sides identically.
Builds belong outside the measurement window. The source can also be bundled
with esbuild for the host, preserving Node built-ins and installed dependencies;
use the same bundler options and dependency tree for both revisions.
These are owner-check denominators. model-execution calls the real bound model
assertion; admitted-run uses prepareSystemAgentRunAdmission, admission, and the
real admitted-run active assertion. transcript-guard captures the real owned
transcript assertion with that admitted run as its commit guard, without writing
storage. None is a model turn, tool step, transcript append, or measured production
frequency. Derive those counts from an instrumented
Gateway turn separately. The recovery scenario prepares profile-database access
outside capture and measures only the warm resident owner. This probe does not
include other access policies, contextual-ingress callbacks, or transport allocation.
Use --timing-only for an untraced CPU/time pass without allocation sampling.
Keep its CPU results separate from the default allocation pass. Timing mode
reports observed GC count, total, maximum, and p95/p99 per kind, filtered to the
synchronous workload window; every percentile includes its collection count.
It does not report promotion or event-loop delay. Allocation mode does not
report GC acceptance statistics.
For a separate recovery owner count pass, generate the same source observer used
by the Gateway driver and load it before the probe. The output reports exact
assertPolicy, readCurrentFacts, profile-owner assertCurrent (including
revision reads), and GitHub fact projections under host.counts, with instrumented
sites under host.installed.
These counts exclude warmup and do not provide timing or allocation evidence.
node --import ./scripts/tsx.mjs --input-type=module -e \
'import { writeGatewayAuthorityCountsPreload } from "./scripts/lib/gateway-bench-runtime.ts"; writeGatewayAuthorityCountsPreload("/path/to/authority-counts-preload.mjs");'
node --import /path/to/authority-counts-preload.mjs --import ./scripts/tsx.mjs \
scripts/perf/operator-authority-measure.ts --scenario recovery-source \
--counts-only --iterations 20000 --warmup 5000 --output /path/to/counts.json
To count actual restarted operator runs, use the existing crash/restart workload:
node --import ./scripts/tsx.mjs scripts/perf/post-restart-burst-measure.ts \
--state-dir /path/to/new-team-shape --entry src/entry.ts --authority-counts \
--port 41688 --mock-port 41689 --clients 1 --runs 1 --seconds 300 --poll-ms 10000
The observer runs only in the restarted Gateway, beginning at preload before
recovery, without the transcript driver's caller-stack collection. A recovery
arrival matches the exact original current-cohort user message anywhere in the
request history because recovery appends a continuation message. Activity recap
and session-observer requests are excluded even if they quote that marker.
Arrivals count unique cohort indexes, not every provider request or completed turn. The existing private IPC snapshot stops counting after its usual
window (capped at 120 seconds), extended in count mode until all expected recovery
arrivals appear, bounded by --seconds since restart. Allow enough time for cold
startup within that deadline. recoveryAuthority records actual start/finish
wall timestamps, raw counts, installed sites, and primary recovery arrivals at
that snapshot. Divide recovery policy counts by these arrivals; they are not
completed-turn counts. The driver refuses a count result without every expected
recovery arrival and a nonzero recovery-policy count. This pass includes startup
and polling work and cannot supply acceptance timing. Run the same workload
without --authority-counts using --entry dist/entry.js for built Gateway proof.
Source count runs also accept --authority-source-preload <overlay.mjs> before
the count observer. Its SHA-256 is recorded alongside the overlay's optional
globalThis[Symbol.for("openclaw.benchmark.sourceOverlay")] attestation. Use the
same loader and source module set on both sides and verify every expected module
was applied in the restarted Gateway PID.
For real turn counts, add --operator-access-policy to the existing streaming
driver's chat.send --stress-tools scenario. A small isolated fixture is enough
to exercise this path before the larger deployment-shaped comparison:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /path/to/new-authority-state --sessions 1 --runs 0 --approvals 0 \
--cron 0 --projects 1 --transcript-mib 1 --port 41481
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/new-authority-state --entry src/entry.ts \
--port 41481 --mock-port 41482 --scenario chat.send --stress-tools \
--operator-access-policy --rounds 1 --chunks 10 --chunk-interval-ms 1
This option enables a synthetic required access-policy plugin and authenticates
a non-owner operator through a loopback trusted proxy. The plugin rejects owner
identity, checks the synthetic email, and records live authorize, resume, and
assert callbacks. The normal code-mode tool runs read and exec against the
fixture. Each measured round reports plugin assertions per completed turn, per
after_tool_call completion, and per unique committed message sequence observed
through onSessionTranscriptUpdate. Raw transcript-update counts are separate:
they are not all transcript writes or synchronous precommit checks. These ratios
normalize the full turn window; they do not attribute each check to one tool or
append. Standalone host auth-policy invocations are not counted by the plugin.
The mode permits only chat.send --stress-tools, with its one client and one
active turn, and rejects background RPC polling. Counter start/stop uses private
child IPC outside allocation capture. Add --allocations for whole-workload
sampled bytes divided by policy assertions; this includes normal turn work and
counter hooks, and is not isolated bytes allocated by one assertion. Use matched
instrumentation and the same paired host procedure for comparisons. The tiny
fixture proves the path, not deployment capacity or production recheck rates.
Add --session-event-wake to route the same synthetic tool turn through the
public system-event RPC with wake: true. The driver waits for the subscribed
session's lifecycle start and passes its actual run ID to agent.wait. This
exercises the session-event handoff and delivery-generation owners; ordinary
chat.send does not. Report these workloads separately: the internal event uses
session authority while ordinary chat carries the operator run authority.
Add --cron-operator-turn --sessions 1 --reset-every-rounds 0 to measure a
scheduled continuation of the authenticated operator. Before warmup, the real
automations tool creates a disabled same-conversation job from an ordinary root
conversation. Each round force-runs that job through cron.run, joins its actual
agent-start event, and requires a successful receipt for the original session.
The loopback model invokes the existing read/exec tool workload and emits its
final response. Every measured round must restore the captured operator source;
the count pass additionally requires cron source and availability checks. This
does not exercise an external messaging provider. Session-event wake and direct
chat attachment/hold options cannot be combined with cron turns.
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /path/to/new-cron-authority-state --sessions 4 --runs 0 --approvals 0 \
--cron 0 --projects 1 --transcript-mib 0 --port 45674
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/new-cron-authority-state --entry src/entry.ts \
--port 45674 --mock-port 45675 --scenario chat.send --sessions 1 --runs 1 \
--clients 1 --rounds 5 --chunks 5 --chunk-interval-ms 0 --stress-tools \
--operator-access-policy --cron-operator-turn --authority-counts --reset-every-rounds 0
Use fresh, equivalent synthetic state for every side. For allocation and CPU/GC
measurements, omit --authority-counts and use the existing profiling flags in
separate matched passes. --start-file waits after creator setup and warmup
(PHASE warmed); write a future epoch in milliseconds only once both sides are
ready. It aligns the first measured turn, not every subsequent round. Run under
the host's exclusive quiet lock and production guard, use the current interleaved
CPU sets, and swap placement for the next pair. Count-pass timing is not latency
evidence.
Add --authority-counts --entry src/entry.ts in a separate count pass to observe
main-isolate named authority assertion closures, source-composition guards,
identity-owner statSync and realpathSync.native calls, all main-isolate statSync calls, tool-override
intersections, and stable-stringify call sites in the auth-policy and tool-override
owners. A generated benchmark-only source loader inserts counters; it does not
alter the checkout or add production hooks. Each round records host.counts and
host.installed under operatorAccessPolicy: the latter names every instrumented
site, including zero-call sites. Recovery-policy assertions and profile fact/revision
reads are included when those owners execute. Ordinary new chat turns do not
prove restart recovery: require nonzero recovery counters before reporting them
as recovery work. Divide counts by the completed turn count. These
are nested invocations, not distinct permission decisions, and the named closures
do not include every anonymous callback. The mode rejects allocation, CPU, GC,
and diagnostics profiling; its timing is not acceptance evidence.
The same pass counts profile-owner assertSettled checks and transferable
readProfileAuthority reads, with their three-frame caller stacks in host.callers.
Report both: a local settlement check can read the owner's cells directly without
constructing a cross-thread descriptor, so fewer descriptor reads do not imply
fewer live checks. Caller-stack collection belongs only to this count pass.
For cron attribution, host.callers also records up to 64 frames for source
composition (assertSources), recovery (assertRecoveryCurrent), the cron
delivery fence, and agent availability. These preserve the synchronous caller
chain so repeated compositions can be separated from the effect that requested
them. Group by source location and report counts per completed turn; nested
assertions are not independent effects. Async stacks can end at an awaited
boundary and do not establish that a later effect revalidated its authority.
Matched --allocations runs without --authority-counts additionally report
allocation.authority: sampled bytes and sample records attributed to the
outermost authority owner in each eight-frame allocation stack, matching the
production profile attribution. The four disjoint families are
session-source-authority, admitted-run-context, operator-run-authority, and
session-event-handoff. estimatedBytesPerTurn is authority-family allocation;
estimatedAllocatedBytesPerOperation remains the whole-turn denominator. Both
include objects collected by major and minor GC. Use at least five interleaved
baseline/candidate runs, identical fixtures and reset boundaries, and report
promotion separately. Sampling records are not invocation counts.
For matched source snapshots without a second checkout, --authority-source-preload /path/to/overlay.mjs imports a benchmark-owned loader in the Gateway child before
the count and profiler loaders. Use the same module list and loader on both sides,
changing only the snapshot contents. The loader can publish its revision, Gateway
PID, file hashes, and applied module counts at
globalThis[Symbol.for("openclaw.benchmark.sourceOverlay")]; the policy IPC report
preserves that attestation per round, and the final report hashes the preload.
Verify every expected module was applied in the reported Gateway PID. Overlays
return module-typescript with a string or byte source; counts use those exact
TypeScript contents. A controller-only preload is insufficient: the
isolated Gateway environment deliberately excludes inherited NODE_OPTIONS.
State and agent database path resolution
Measure the real path owners without opening a database:
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/state-path-resolution-measure.ts \
--iterations 100000 --rounds 5
The JSON-lines output separates warm roots, alternating roots, explicit agent
paths, and cold unique roots. Timing and allocation are separate passes; V8
sampling includes objects collected by major and minor GC. A third, untimed pass
counts exported path.resolve and path.join calls. These are owner-level
operation counts, not Gateway calls per turn. Run both revisions with identical
arguments, interleave samples, and compare with the chat.send allocation
workload below before drawing an end-to-end conclusion.
The default-admission and prepared-admission cases compare admission reads
with a home-selected root and the same root resolved once by its owner. They
exercise live refusal lookup without opening databases. For the built Gateway
comparison, generate a fixture at <synthetic-home>/.openclaw and pass
--default-state-dir to transcript-broadcast-measure.ts. This starts the child
with the isolated OPENCLAW_HOME and no state-directory override, so startup
must prepare the default itself. Its generated config is written beside the
fixture config, keeping the config and state roots aligned without overwriting
openclaw.json. The ordinary mode retains its explicit override.
Tool schema preparation
tool-schema-preparation-measure.ts measures the source owners used by provider
requests and generic schema validation. The default fixture has 100 nested tools.
It covers SDK OpenAI compatibility normalization, Responses, completions, the
Moonshot typed-union dialect, Anthropic, Google, and the actual Mistral SDK with an
in-process synthetic fetch. It makes no network requests.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/tool-schema-preparation-measure.ts \
--tools 100 --iterations 20 --samples 5 \
--output /path/to/report.json --golden-output /path/to/provider-json.json
Reports include sampled allocated bytes per catalog, named schema-function self and inclusive allocations, elapsed time, and median/p95/min/max. Sampling includes objects collected by minor and major GC. Warm published schemas, fresh mutable schemas, and cold publication are separate modes. Fresh fixture construction is outside sampling; cold publication includes publication and the first preparation of each new identity, but does not reset process-wide content caches. Separate work counts observe enumeration, serialization, and cloning of source schema objects after warmup, outside allocation measurement.
For synchronized A/B pairs, select the same --workload and --mode, use
--samples 1, and pass a shared future epoch millisecond --start-at. For example,
--workload sdk-responses-loose --mode warm-published. Start both processes under
the host's exclusive measurement lock on separate CPU halves, swap halves for
the next pair, and compare at least five paired differences. A missed start fails
the run. Use --golden-only for a setup check without allocation sampling.
--schemas /path/to/catalog.json accepts an array of {name, description, parameters, value?} or OpenAI function descriptors. value supplies validation
input and otherwise defaults to {}. Only use synthetic or approved catalogs.
The report records the input hash and each provider's exact serialized byte count
and hash; compare the two --golden-output files byte for byte across revisions.
A catalog captured from an outgoing request already contains that provider's
projection, so label that evidence separately from raw tool definitions.
This probe measures preparation and validation, including Mistral serialization;
it does not measure a whole chat turn. Pair it with
transcript-broadcast-measure.ts --scenario chat.send --allocations when assessing
the effect on Gateway turn allocations.
Published schemas across sessions
Measure factory-owned schema reuse through real tool finalization and settlement:
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/finalized-tool-schemas-measure.ts \
--factory message --sessions 63 --samples 5 \
--output /path/to/report.json --golden-output /path/to/provider-json.json
Use message for the default full message schema or message-configured for the
send-only schema with an empty configuration. Use assembly for the real asynchronous
coding-tool assembly with the bundled Memory and Qwen plugins, a configured video model,
and a vision-capable model. Set HOME, OPENCLAW_HOME, and TMPDIR to isolated sandbox
directories; assembly may prepare local state but does not execute tools or contact a
provider.
Each sample warms one session and creates up to 63 more, fitting the finalizer's 64-session snapshot bound. The attribution pass also rebuilds each session once to exercise same-session reuse. It reports distinct exact schema contents and returned parameter identities, and counts parameters without the owner's publication marker on the first and repeat runs. The assembly boundary cannot observe raw factory source objects; the direct factory modes report those separately. Precise V8 coverage counts actual publication and prewarming calls in a separate pass, including settlement.
The probe measures untraced CPU/time, allocation including collected objects, and
survivors in separate passes. Compare at least five alternating samples per revision
on the approved benchmark host. Use --samples 1 when a host-side driver alternates
fresh baseline/candidate processes, and compare provider JSON files byte for byte.
Forced-GC retained bytes are contextual memory evidence, not natural GC cost or
promotion measurements. The probe does not send messages or measure a whole Gateway
turn.
Allocated bytes per RPC or turn
For a bounded chat-owner replay, run:
node --import ./scripts/tsx.mjs scripts/perf/gateway-agent-event-measure.ts \
--output /path/to/owned-benchmark/replay.json --samples 5 --turns 1000
This replays a lifecycle, read tool, 100 assistant deltas, and completion through the real chat handler with a deterministic clock. It records serialized frames, lifecycle-generation lookup demand, elapsed time, and sampled allocated bytes including collected objects after 100 warmup turns. Compare the frame artifacts byte-for-byte across revisions. Persistence and network delivery are disabled; the lookup callback only counts demand, so registry allocation and whole-Gateway cost still require the workload below. Run baseline/candidate pairs under the benchmark host's measurement policy; keep import/startup outside these samples.
For provider request preparation alone, run the source owner directly:
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/provider-request-prepare-measure.ts \
--messages 500 --iterations 300 --mode prepare --output /path/to/result
Use --mode transport to isolate standalone request-body serialization. The
driver compares wire bytes with native JSON, including a custom toJSON and
ordered getters, then counts JSON serialization/parsing, UTF-8 encoding, and
hash calls and bytes for one request. wholeRequestCalls counts operations
whose byte size equals the full request; field and prefix hashes remain in the
totals. Counting runs separately from allocation/timing capture. The profile
includes objects collected by both minor and major GC. These measurements cover
the owner in the driver's isolate, excluding worker transport and the Gateway;
use the Gateway chat.send --allocations workload for main-isolate turn cost.
Compare baseline and candidate serially in interleaved ABAB order, with at least
five samples per revision.
Use the same owned Gateway driver with --allocations and a named --scenario:
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --allocations --scenario sessions.list \
--clients 1 --runs 1 --operations 100 --limit 100 --rounds 5 \
--revision <measured-commit-sha>
Use --row-mode sidebar --limit 200 for the sidebar's fact-packed wire response,
or --row-mode dashboard for gallery rows. The default compact shape does not
exercise sidebar fact packing; compare matching shapes when attributing allocation.
The report records rowMode alongside the page limit.
Supported scenarios:
| Scenario | One measured operation |
|---|---|
sessions.list |
One compact sidebar page RPC, archived=all, --limit rows |
status |
One admin status RPC with channel summary disabled; verifies session counts and recent rows |
health |
One admin health RPC with probe: true; verifies session counts and recent rows |
sessions.describe |
One detailed parent-row RPC, including the harness's synthetic children |
sessions.patch |
One title, expiring status-note, or model-selection mutation on a retained fixture session |
sessions.cleanup |
One cleanup RPC in warning mode, including current subagent preservation and commit compatibility checks |
connect |
One owned socket replacement and validated hello handshake; no sidebar restoration |
reconnect |
One owned socket replacement, subscriptions, and verified current sidebar |
subscribe |
One sessions.subscribe snapshot registration/refresh RPC on the sender, with the same page shape as list |
chat.send |
One completed turn: synthetic read-tool call, then paced streaming answer; one client, --runs concurrent turns (default one) |
transcript-broadcast |
One completed agent turn, streamed to --clients subscribers; --runs concurrent turns per round |
plugins.reload |
One live reload of a synthetic no-op plugin through the lifecycle RPC |
plugin.scope |
One synthetic plugin RPC checking invocation scope before and after an asynchronous suspension |
RPC scenarios use --operations per round. --rpc-concurrency defaults to one
and bounds concurrent requests across the connected sockets. For a reconnect
storm, use --clients 40 --operations 40 --rpc-concurrency 40 --reconnect-stagger-ms 125; starts must fit within five seconds. Reconnects
restore session/message subscriptions and compare the ordered sidebar identities
and update timestamps with the pre-storm page. The report separates handshake
latency from time until all sidebars are current. Intentional disconnection does
not imply event replay. These scenarios isolate RPC costs; they do not generate
background session activity at production rates.
For acknowledgement latency, chat.send also accepts --diagnostics without
allocation or CPU profiling. --runs 8 --rounds 125 records 1,000 measured ACKs
after one unmeasured warmup cohort:
Generate these fixtures with --settled-runs to retain the requested run inventory
without replaying synthetic interrupted runs or pending deliveries at startup.
The default recovery workload can exhaust worker capacity before the Gateway is
ready; that is a failed setup, not an attachment-latency sample.
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/team-shape --entry src/entry.ts --scenario chat.send \
--port 41617 --mock-port 41618 --runs 8 --rounds 125 \
--chunks 10 --chunk-interval-ms 10 --diagnostics --trace-gc \
--start-at <shared-future-epoch-ms> --revision <measured-commit-sha>
For workspace/sandbox setup attribution, add --attempt-setup-image <local-image-id>
and generate the fixture with at least one project, --worktree-files 5000 --worktree-file-bytes 4096, and
--worktree-install-failure --worktree-cold-commits 32. The install-failure option commits a minimal pnpm package with a
failing preinstall script, exercising source-only template recovery after an
installer failure (the Python probe image fails earlier because pnpm is absent).
The 5,000 distinct deterministic 4 KiB files add about 20 MiB of tracked source
content; this exercises object copying beyond an empty-file index inventory.
This uses the real Podman backend with a network-disabled session container,
creates managed session worktrees, and prepares one synthetic plugin tool plus
a synthetic Streamable HTTP MCP server on the loopback model-fixture listener. Every first provider request must contain
both tool definitions; the completed turn must read the committed project README
through the sandbox. The image must already be available in an isolated Podman
store selected by CONTAINERS_STORAGE_CONF; prepare it outside measurement.
The image needs the normal sandbox shell, core utilities, and Python filesystem
bridge dependencies. Keep the store, HOME, temporary files, and fixture under the
approved sandbox directory. For a task-owned Podman wrapper that supplies the
side scope as --cgroup-parent, set OPENCLAW_PATH_BOOTSTRAPPED=1 with a curated
PATH; this fixture forwards it so CLI path bootstrapping does not select the
system Podman ahead of the wrapper. Verify the real container and Gateway
cgroup effective CPUs before accepting a pair.
--cold-session-rounds 8 --rounds 58 --runs 4 measures 32 new sessions,
worktrees, and containers, followed by 200 turns on the final four sessions.
Both sides use cohorts of four concurrent turns; cold cohorts contend for the
same dependency-template owner with four distinct content revisions. Each cold
session selects a distinct pre-generated commit with identical tree contents;
this invalidates the dependency template even after Gateway warmup. Confirm the
managed-worktree log reports cold fallback for these turns before claiming a
cold-template comparison. These are
cold templates/sessions in a resident Gateway, not cold process starts: Gateway and
plugin/MCP startup are warmed before measurement. Session/worktree creation RPCs
finish before each measured send. firstModelRequest pools the actual wire-send
to first model HTTP request samples by cold-session and warm-session, including
counts, raw samples, p50/p95/p99, and max; no provider response delay is included.
Per-round streamStart.providerRequestMs retains the same underlying samples.
wholeAttemptSetupDiagnostics retains one continuous main-isolate GC and event-loop
histogram across the measured workload, including cold session creation between
send cohorts. Use its actual event-loop p99 and sample count; do not average the
per-round p99 values into a purported pooled percentile.
Use separate matching fixtures, concurrent guarded pairs, and swapped interleaved
CPU sets. p99 with 32 or 200 observations is context under the campaign's
1,000-sample rule; it cannot gate. Remove only the task-owned containers and store
after inspecting the results. This workload raises the provider-arrival deadline
to 170 seconds so a slow setup is measured rather than rejected at 45 seconds.
Each fresh send must return started with its requested run ID, then complete
successfully through agent.wait. The existing model-arrival barrier verifies
eight overlapping turns, and tool-completion and subscriber checks remain active.
samples[].rpc["chat.send"] records ACK count, p50/p95/p99/max and individual
latencies in milliseconds. chatSendAck pools those observations across measured
rounds; it measures the first wire response, separately from turn completion and
post-ACK startup diagnostics. Per-round tails with eight samples are context only;
use the pooled observations for p99. Run separate matching fixtures concurrently
under the shared guard and one quiet-slot lock, then swap interleaved CPU sets.
Apply the campaign's sample-count and paired absolute/relative-delta rules.
For --diagnostics passes without heap or CPU profiling, wholeChatDiagnostics
records one event-loop histogram across the measured cohorts; use its count and
p99 rather than averaging per-cohort percentiles. Its gc field records pause
durations. With --trace-gc, wholeChatGc also records the whole measured window's
collection counts and promotion, including the intervals between cohorts.
Profiled passes omit these acceptance metrics because stopping heap sampling
forces GC and profile serialization adds main-thread stalls.
Add --chat-attachment-kib 4 or --chat-attachment-kib 256 for separate small
and larger text-attachment runs. Each turn sends a deterministic
synthetic-attachment.txt with MIME type text/plain and origin paste through
the real attachment path. The report records chatAttachmentBytes; zero means
no attachment. Keep the attachment size identical within each comparison pair.
For config snapshot work counts, run a separate source-mode pass with
--scenario chat.send --entry src/entry.ts --chat-attachment-kib 4 --config-capture-counts --runs 1 --rounds 5. Omit allocation, CPU, GC, and
diagnostics profiling flags. The rig instruments the two config capture owners;
production exposes no counting hooks. Each sample's configCaptureCounts
reports capture calls, caller stacks, cloned payload count, serialized UTF-8
payload bytes, graph object count, and primary turns. Active-input workloads
include additional follow-up/steering work in those counts. beforeFirstWindow
includes startup and warmup; measured counts reset each round. Payload bytes are
the JSON size processed by clone/freeze, not heap allocation. Counting creates
stacks and serializes configs, so its CPU and latency are not acceptance evidence;
rerun without this flag for timing and allocation measurements.
The driver preserves explicit synthetic agent and plugin entries in the fixture's
openclaw.json, including plugin config payloads, while selecting the loopback
model and disabling memory dreaming. Use the same generated fleet on both sides
and retain its plugin paths under the owned fixture. configShape records the
effective agent count, plugin-entry count, and serialized config bytes. Never
populate this fixture from operator config or credentials.
--config-capture-source <checkout> substitutes that checkout's runtime snapshot
and ACP read-context owners in source mode and records their SHA-256 hashes.
Use it for both counting and uninstrumented paired passes against one prepared
runtime; it changes only those two main-thread source modules, not built workers.
Add --chat-active-hold-ms 1500 --runs 8 --rounds 63 to measure 1,008 ACKs for
input arriving during active turns. At the model-arrival barrier, each session
receives an explicit /followup input. After its queued notice arrives, the
driver sends two steering inputs on the same session and WebSocket connection.
The provider releases its held responses after the configured interval,
independently of ACK arrival, so a blocked baseline records the delay.
activeChatSendAck pools only the two steering ACKs per session and reports
count, p50/p95/p99/max, individual latencies, and acknowledgedBeforeRelease.
The existing chatSendAck includes initial, predecessor, and steering sends.
All extra inputs must settle successfully, leave pending custody, and appear
exactly once in user history with the matching input identity and text;
consumedExactlyOnce counts those three inputs per session. Keep the hold
interval identical across paired runs.
For entry-patch transfer measurements, prepare separate matching small fixtures sequentially, then run the existing driver against the source Gateway:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir /path/to/new-fixture --sessions 5000 --runs 6000 \
--approvals 0 --cron 0 --projects 0 --transcript-mib 16 --port 41580
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir /path/to/new-fixture --entry src/entry.ts --scenario sessions.patch \
--port 41580 --mock-port 41581 --clients 8 --runs 1 --rpc-concurrency 4 \
--warmup-operations 240 --operations 2400 --rounds 1 \
--allocations --diagnostics --trace-gc --capture-responses \
--start-at <shared-future-epoch-ms> --revision <measured-commit-sha>
No Gateway bundle is required for this source entry. The fixture retains 5,000
session entries and 6,000 run records with only 16 MiB of synthetic transcript
content; inspect the generated database sizes before choosing larger fixtures.
Each connected client has a distinct unarchived fixture target. Successive
mutations set a unique user title (label), set an expiring status note, select
a second configured synthetic model, then clear each of those fields. Every
response verifies the changed value. Warmup and measured operations continue
the same mutation sequence, avoiding repeated no-op writes. Connected clients
subscribe to session rows, so row publication is included. This scenario does
not drive viewer-declaration churn or real running/settled agent lifecycles;
use the separate transcript-broadcast or memory-soak turn scenario for that
additional coverage.
For subagent maintenance capture, use --scenario sessions.cleanup --clients 1 --runs 1 --operations 1000 --warmup-operations 2 --rounds 1 --allocations --trace-gc --capture-responses --entry src/entry.ts with a separate 5,000-session,
6,000-run fixture on each side. --transcript-mib 0 --approvals 0 --cron 0 --projects 0 keeps fixture writes small. The scenario sets and records
session.maintenance.mode=warn: real cleanup admission and preservation checks
run, while every response must report an unchanged nonempty session inventory.
This is a maintenance RPC workload, not the production frequency of cleanup or
session-list traffic. sessions.patch skips maintenance and cannot reproduce
this allocation owner. First inspect a short baseline allocation profile for
the subagent maintenance capture stack; do not use unrelated RPC allocation as
proof. Run concurrent, swapped-core pairs with the campaign guard and shared
--start-at, then apply its sample-count, GC-pause, and paired-baseline rules.
For this maintenance workload, generate each fixture with the same recent fixed
--now value. Before starting its Gateway, normalize the synthetic retained runs
with the following owner-API transform (FIXTURE is that new fixture directory).
It retains 6,000 terminal runs: 60 pending deliveries scheduled one day after the
fixture timestamp, and 5,940 delivered runs. This avoids measuring startup
recovery of the generator's interrupted runs or a due-delivery backlog. The
transform asserts the persisted counts and records them in the fixture summary.
Do not use it against live state.
node --import ./scripts/tsx.mjs --input-type=module - "$FIXTURE" <<'JS'
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = path.resolve(process.argv[2]);
const summaryPath = path.join(root, "team-shape-summary.json");
const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
assert.equal(summary.format, "openclaw-team-shape-v1");
assert.equal(summary.retainedRuns, 6000);
const { createGatewayBenchEnv } = await import("./scripts/lib/gateway-bench-runtime.ts");
process.env = {
...createGatewayBenchEnv(path.join(root, "home"), path.join(root, "openclaw.json"), {}),
OPENCLAW_STATE_DIR: root,
TMPDIR: path.join(root, "tmp"),
};
const state = await import("./src/state/openclaw-state-db.ts");
const { loadSubagentRunsByIdsInDatabase } = await import("./src/agents/subagents/registry/subagent-registry.store.sqlite.ts");
const { bindSubagentRunRecord } = await import("./src/agents/subagents/registry/subagent-registry.store.codec.ts");
const { writeSubagentRunValuesInDatabase } = await import("./src/agents/subagents/registry/subagent-registry.store.kernel.ts");
try {
const database = state.openOpenClawStateDatabase();
const ids = Array.from({ length: 6000 }, (_, index) => `team-shape-run-${index}`);
const runs = [...loadSubagentRunsByIdsInDatabase(database, ids).values()];
assert.equal(runs.length, 6000);
const pendingIds = new Set(runs.filter((run) => run.delivery.status === "pending").slice(0, 60).map((run) => run.runId));
assert.equal(pendingIds.size, 60);
const nextAttemptAt = summary.now + 86_400_000;
assert.ok(nextAttemptAt > Date.now(), "Generate the fixture with a current fixed --now");
for (const run of runs) {
const { startedAt, endedAt } = run.execution;
assert.equal(typeof endedAt, "number");
run.execution = { status: "terminal", startedAt, endedAt, outcome: run.execution.outcome ?? { status: "error", error: "Synthetic interruption retained" } };
run.expectsCompletionMessage = true;
run.completion = { ...run.completion, required: true };
if (pendingIds.has(run.runId)) {
run.delivery = { ...run.delivery, nextAttemptAt };
delete run.cleanupHandled;
delete run.cleanupCompletedAt;
} else {
run.delivery = { status: "delivered", deliveredAt: endedAt + 500 };
run.cleanupHandled = true;
run.cleanupCompletedAt = endedAt + 1000;
}
}
state.runOpenClawStateWriteTransaction((db) => writeSubagentRunValuesInDatabase(db, runs.map(bindSubagentRunRecord), []));
const verified = [...loadSubagentRunsByIdsInDatabase(database, ids).values()];
const counts = { terminal: verified.filter((run) => run.execution.status === "terminal").length, pending: verified.filter((run) => run.delivery.status === "pending").length, delivered: verified.filter((run) => run.delivery.status === "delivered").length };
assert.deepEqual(counts, { terminal: 6000, pending: 60, delivered: 5940 });
summary.maintenanceFixture = { ...counts, nextAttemptAt };
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
console.log(JSON.stringify(summary.maintenanceFixture));
} finally {
await state.closeOpenClawStateDatabaseAsync();
}
JS
The report includes allocation and promotion per completed patch, worker message
counts and known payload bytes, and RPC p50/p95/p99 with sample counts. Worker
payload bytes include transferred buffers; they are not an exact count of bytes
deserialized by node:v8. Each profile's .deserialize.json sidecar separately
records exact main-isolate v8.deserialize input bytes and call counts, grouped
by a fixed allowlist of decoded root kind labels (other for unknown labels,
or untyped when absent). The observer runs only inside profile
captures and is restored afterward. Divide a group's bytes by the completed
operation count to compare deserialize bytes per patch or turn. The generic
entry-patch transfer also carries lifecycle and transcript work, so inspect these
groups in a matched real-turn scenario before attributing its cost to the
sessions.patch RPC. Inspect unmeasuredMessages and allocation caller stacks
alongside them. --capture-responses also saves six complete mutation
responses in patch-responses.json after measurement. Compare those goldens
between revisions, normalizing only recorded timestamps and sandbox paths;
fixture session identities and requested values are deterministic.
Use the host's shared guard and exclusive measurement slot, run both sides concurrently with the same start time and interleaved CPUs, and swap sides for the second pair. Report absolute and relative paired deltas. Baselines differing by more than 5× or opposite-signed pair deltas are inconclusive; if such a metric gates, run a third pair and decide on the absolute mean. A p95 requires 200 samples per side and a p99 requires 1,000. Major GC tails are context below 50 collections per side. Report each side's maximum GC pause; per-pause tails are context when collection counts fall at least 20% without increased total GC time, unless the candidate maximum exceeds the greater of 50 ms or twice the baseline maximum. Gate on total GC time per patch, promotion, and eligible event-loop p99. Reject a mean paired regression of at least 20%, or at least 10% in both pairs, subject to those sample-count and inconclusive-result rules.
Every measured scenario records per-method request-to-response p50/p99, call rate,
and uncompressed wire response bytes. Transcript samples also report
chatDeltaDeliveryMs: the Gateway-assigned chat-delta message timestamp to each
subscriber receipt, including server delivery queues and client parsing. Both
processes run on the same host; this is not internet latency. Handler and worker queue timings remain in
Gateway diagnostics. Whole-window CPU/allocation divided by source-event or
socket-delivery counts is a workload-normalized cost, not isolated encode/send
cost. Use CPU profiles when attributing broadcast work.
Allocation profiles also report broadcastAllocation: every sampled allocation
under broadcastInternal, classified into disjoint event-projection, recipient
setup, row-presentation, encoding, and remaining-broadcast subtrees. Attribution
uses the complete stack; each family's ten largest sites retain their full stacks.
projectionSelfSampledBytes separately counts allocations directly attributed to
the session-event projection module, including its envelope objects and closures.
It overlaps the family totals and must not be added to them. The report normalizes
the broadcast subtree by source session events and completed turns as
broadcastAllocatedBytesPerSessionEvent and broadcastAllocatedBytesPerTurn.
These include other broadcast event types in the same measured window; they are
workload-normalized costs, not session-event-only allocation counters.
Use --entry src/entry.ts from each measured checkout for source-file attribution;
bundled anonymous functions cannot reliably identify these owners. A false
sourceProjectionObserved means that owner attribution is unavailable, not zero
projection allocation. Allocation sampling cannot determine exact projection,
clone, or serialization invocation counts; use the owner work-count tests for
those facts. Repeated identical audiences and distinct viewers should be separate,
equally configured comparisons.
For a separate attribution capture, add --allocations --disable-inlining.
The private benchmark preload disables TurboFan and Maglev inlining before the
Gateway entry loads, preserving source-entry overlays without an entry wrapper.
Results record inliningDisabled; these diagnostic captures must not substitute
for normal-inlining acceptance pairs. Memory-soak allocation results include
wholeSoakAllocation.deliveredSessionEvents, a continuous controller receipt count
from the allocation-start request through the stop acknowledgment. It includes
idle gaps and IPC control edges; use it instead of summed per-round receipts when
normalizing the whole-window profile by delivered session events.
For session-projection comparisons from one installed checkout, add
--entry src/entry.ts --session-projection-source <directory> to both arms.
The directory must contain all ten coherent owner files:
server-broadcast.ts, session-event-projection.ts, session-row-presentation.ts,
session-sharing-read.ts, session-utils-list.ts, session-row-placement-projection.ts,
session-ancestor-references.ts,
server-connection-state.ts, server-methods/sessions-read.ts, and
server-methods/sessions-read-by-key.ts. Put their original
revision's files in a baseline directory and use --session-projection-source src/gateway for the candidate. The preload reads all ten before Gateway startup
and fails on a missing file; it substitutes raw TypeScript while retaining canonical
module URLs, relative imports, and source line numbers. The report records the
resolved sessionProjectionSource. It also loads the canonical broadcaster as
native TypeScript so its function name remains visible in allocation profiles
without installing the timer. Use separate fixtures and ports with matching
workload flags. Collect allocation and broadcast-timing comparisons in separate
passes so the timing observer's allocations do not enter the broadcast profile.
Use a separate --session-projection-counts --entry src/entry.ts pass to count
audience cache misses, payload-variant cache misses, successful recipient projections,
and distinct final wire payload objects per session publication. The report's
sessionProjectionCounts includes both raw counts and per-publication means.
These count projector results before socket submission, not acknowledged delivery.
The observer rejects allocation, CPU, GC-trace, transport-count and timing modes
to keep its instrumentation out of those measurements. Compare identical runs
with and without --list-interval-ms 30000 --poll-seed roster-polling to isolate
staggered roster reads; negotiate --session-row-deltas --session-envelope-refs
for current Control UI clients. A growing wire-payload count with stable audience
count identifies fragmented delivery histories rather than different permissions.
The workload creates the main-session root before its synthetic parent and children;
an incomplete ancestor chain omits ancestor envelopes and cannot prove this effect.
Payload-cache misses describe the selected owner's internal construction stages and
can change meaning across refactors; compare distinct final wire payloads across
baseline and candidate implementations.
The existing allocation diagnostics include per-kind GC pause count, total,
max, p95, and p99 under allocation.diagnostics.gc, plus event-loop sample
count and p99 under allocation.diagnostics.eventLoopDelay. Promotion is read
from the matching main-isolate V8 trace after the Gateway exits. Report baseline
and candidate maximum pauses and collection counts alongside totals and tails.
chatDeltaDeliveryMs includes count, p95, p99, maximum, and individual samples;
it measures publication-to-receipt delivery. Add --broadcast-timing --diagnostics --entry src/entry.ts to time the synchronous broadcastInternal owner, including
targeted sends and retained replays. broadcastLatencyMs reports each event's
actual call count, total, maximum, p95, and p99. A benchmark-only source loader
inserts a timer and private numeric diagnostics publication, refusing changed
source anchors or an unloaded owner. This includes recipient projection, encoding,
and synchronous socket submission; it excludes completion of asynchronous socket
writes. It also counts calls that return without delivering a frame. Enable it
identically in both arms and report that timing instrumentation was present.
The timer rejects allocation sampling, including stream-start and memory-soak
retained profiles; use --retained-profile-ms 0 for a timing-only memory soak.
Use a sufficiently long single allocation round when gating natural GC and loop
tails; for example, 4,000 chunks at 10 ms with eight concurrent runs and 48 clients.
Check actual sample counts rather than inferring them from requested duration.
--start-at <epoch-ms> synchronizes measured windows after each side's warmup.
The default transcript-broadcast clients use direct loopback ingress, which does
not negotiate compression. Add --compressed to use one synthetic trusted-proxy
identity over loopback and exercise negotiated permessage-deflate. Add
--distinct-viewers instead to use a separate admitted synthetic identity per
client; this also enables compressed ingress. Both modes verify trusted-proxy
authentication, negotiated compression, and the expected user-profile count.
They configure only the generated Gateway fixture and use documentation-only
forwarded addresses; no real proxy, account, or external connection is involved.
Use the same mode on both sides of a comparison. These modes support
transcript-broadcast and memory-soak without --mixed-rpc or
--operator-access-policy.
Use --transport-counts in a separate transcript-broadcast pass to attribute
Buffer.concat calls and copied bytes by source call site, and count outbound
Socket._write/Socket._writev submissions on the isolated Gateway port. Each
sample records the counters beside its event counts; divide bytes and submissions
by the validated source-event or delivery count as appropriate. Batched writes
also record their segment count. These are native socket submissions, not kernel
syscall counts, and exclude worker isolates and other socket ports. Stack capture
adds substantial allocation and CPU overhead, so this mode rejects allocation,
CPU, GC, and diagnostics profiling flags. Its timing and heap figures must not be
used as acceptance results. Compare separate uninstrumented or equally profiled
A/B runs for those metrics. The private diagnostics IPC acknowledgment bounds the
counted workload; setup, warmup, and report serialization remain outside it.
Add --workload-cpu-profile to capture the full measured workload in the Gateway
main isolate. Each sample's workloadCpuProfile includes the full
workload-<round>.cpuprofile path, sampling duration/count, diagnostics, the 40
hottest functions, and sampled time under broadcastInternal. This differs from
--cpu-profile, which captures stream admission/release and optional recap
phases; the two flags cannot be combined.
Add --presence-deltas when measuring presence delivery preparation; without this
capability, clients receive legacy presence snapshots and bypass delta preparation.
The option uses the synthetic trusted-proxy Control UI clients so their connections
participate in presence; default backend probes are excluded from the roster.
Run full CPU attribution in a separate matched pass, replacing --allocations
with --workload-cpu-profile. The new mode rejects --allocations, --trace-gc,
--stream-start-allocations, and retained heap capture; for memory-soak, set
--retained-profile-ms 0. Serializing and writing CPU profiles allocates enough
memory to contaminate simultaneous allocation, promotion, and GC measurements.
Keep the allocation/GC pairs and full CPU pairs separate with matching workloads.
Event/RPC counting starts after the CPU-start acknowledgment and ends when the
workload, event drain, and outstanding polls finish. The CPU profile then stops.
CPU samples include the small start-acknowledgment and stop-request edges,
including stop bookkeeping before V8 stops sampling; event counts exclude those
edges. Existing whole-window CPU counters include profiler setup, stopping, and
profile serialization. No new 25 ms priming wait is added; if
--stream-start-diagnostics is also enabled, its existing 25 ms waits occur inside
the full-workload capture. Keep these attribution runs separate from unprofiled
acceptance timing, and compare equally instrumented pairs.
Add --capture-responses to retain the first successful measured response payload
per method per round at samples[].rpc[method].representativeResponse.payload.
Capture is opt-in and happens in the controller; payloads are saved only in the
result artifact, not the stdout sample summaries. Serialization into the report
happens after measurement. The sample preserves parsed payload content, not the
original wire encoding or response envelope. Use it for offline content and
serialized-payload comparison, accounting explicitly for expected dynamic fields
such as timestamps and IDs. Exact uncompressed response-frame byte totals remain
in the RPC metrics. For --scenario sessions.list, capture also writes
list-responses.json: twenty filter, sort, pin, archive, and paging requests issued
after measurement and removal of the harness-created parent/child sessions (unless
using a preseeded history). Compare these payloads across revisions with identical fixtures,
normalizing only recorded envelope timestamps, process-local rowsRevision tokens,
and sandbox paths. Capture remains
opt-in; the additional requests do not warm the measured workload.
For sessions.list response comparisons, --capture-responses can run without
allocation or CPU profiling.
For --scenario sessions.list, add --list-filter-churn to cycle ordinary, open,
archived, team-shape search, main-agent, and kind-exclusion requests. Every tenth
logical list operation first changes the harness-owned parent's label; seeded
sessions remain untouched, and capture removes the parent before writing goldens.
samples[].rpc["sessions.list"] reports list latency separately from patch RPCs;
CPU and allocation per logical list operation include churn work. The report records
listFilterChurn. Use the generated Team-shaped fixture so every filter has a
nonempty page.
For profile-reference filtering over 5,000 prepared sessions, run:
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/session-profile-reference-measure.ts \
--iterations 108 --output sample.json --golden responses.json
Each process reports one warm main-thread CPU and sampled-allocation observation
per filterSessionEntries operation, including objects collected by minor and
major GC. The 27 requests cover three viewer scopes, canonical and merged aliases,
retained profiles, prefix and missing references, archives, and empty time windows.
Every session has three participants; the 50 profile identities include ten aliases
and ten retained identities without durable rows. Preparation uses an isolated
empty catalog and asserts that no SQLite file is created. Compare responses.json
byte-for-byte between revisions; it contains complete 25-row filter results and
full-roster facets. Run at least five alternating baseline/candidate processes on
the benchmark host. This is an owner probe, not Gateway or transport evidence.
Add --ordinary --iterations 2200 to cycle through eleven ordinary, archive,
label, agent, kind-exclusion, and static-search requests over 5,000 rows, replacing
one row revision every ten operations. The static-search case matches session IDs;
it does not measure dynamic model or run-state search. The golden includes results
before and after churn. --heap-profile profile.json saves allocation attribution.
Both modes report per-operation timing sample counts and observed GC totals and
maximum pause, excluding GC triggered by stopping the allocation sampler. These
owner measurements do not include Gateway transport or event-loop delay.
For concurrent comparisons, pass the same future Unix millisecond timestamp with
--start-at to both processes. They wait after warmup and explicit GC; a missed
start deadline fails the run. The report records the actual measured start time.
For the 10,800-row selection comparison, add --team-shape --iterations 720.
This uses 4,300 subagents, 4,400 archived dashboards, 400 open dashboards, and
1,700 other sessions, including global/unknown sentinels. It adds involving-me,
owner-first, and owner-count requests to the three visibility scopes. Use
--reference-only to isolate reference resolution over prepared visible identities,
or --selection for complete page selection and facets. Compare each mode's golden
outputs between revisions. Run --mode timing separately from the default
--mode allocation so CPU comparisons exclude allocation-sampler overhead.
These owner probes exclude transport and full row presentation; use the Team-shape
stress harness for those boundaries and its populated subagent registry.
For long RPC allocation windows, use --warmup-operations to warm fewer requests
than the measured --operations count. For example, --scenario sessions.list --warmup-operations 25000 --operations 100000 --rounds 1 --allocations --trace-gc --diagnostics --heap-sampling-interval 524288 keeps one uninterrupted measured
window and bounds profile size. Record actual elapsed durations; operation counts
are not time guarantees. Warmup defaults to the measured operation count. Heap
sampling defaults remain 32 KiB for RPC/turn rounds and 512 KiB for memory soaks.
Stopping a heap sample forces GC, so use one long round for natural-GC acceptance
rather than aggregating short rounds separated by sample stops.
Turn scenarios use --runs (defaulting
to one for chat.send); --chunks controls streamed answer size. plugins.reload
defaults to one operation per round; other RPC scenarios default to 100. Count
the warmup when choosing reload batches: the admin RPC allows 30 calls per minute.
Reload rounds honor --round-interval-ms; use single-operation rounds spaced
by at least 2,400 ms for longer captures below that limit.
The reload scenario retains the synthetic fixture's plugins.allow and
plugins.entries configuration, then adds its no-op plugin. Configure bundled
plugins there to include their metadata and runtime participation in the workload;
the default team-shape fixture disables plugins. plugin-inventory.json records
the actual runtime states before capture, including enabled plugins that remain
unloaded under their lazy activation policy. The harness replaces plugins.load
with its own synthetic plugin directory.
--plugins 40 creates 40 startup-activated synthetic plugins for any scenario,
including session-idle-retention to compare resident workers and heap after
startup and ten minutes idle (--duration-ms 600000). gatewayReadyMs records
process launch through the readiness probe, before recovery waits and warmup.
For edited-source update proof, add --plugin-update to plugins.reload with
--rpc-concurrency 1. Each operation edits the selected plugin, reloads it, and
verifies the new revision through its registered RPC. pluginUpdate.latency
includes editing, reload, and verification; the ordinary plugins.reload RPC
distribution measures only the reload request. Warmup updates are excluded from
both measured distributions.
With diagnostics enabled, wholeReloadDiagnostics retains one full-run event-loop
histogram and GC totals; use its observation counts to qualify tail percentiles.
Setup, startup
recovery, subscription establishment, and one warmup round are outside capture.
Each measured window includes completion and event drain, including the one-second
quiet interval. The fixture inventory and request/client/run counts are recorded
in result.json; the driver prints each sample as JSON and a short table.
Raw .heapprofile files and numeric diagnostics remain alongside the report.
Each profile also records .worker-messages.json: main-to-worker and
worker-to-main message counts, known payload bytes, and unmeasured-message counts
by bounded message kind for that capture window. Divide those totals by the
validated operation count when comparing bytes or messages per RPC/turn. Buffer
lengths are exact and string lengths approximate UTF-16 storage; envelope and
object-graph sizes are not included. Compare the unmeasured counts too: a kind
with no known bytes is not necessarily cheap. These are logical payload bytes,
including transferred buffers, rather than bytes copied by structured clone.
For a separate worker-message attribution pass, set
OPENCLAW_BENCH_WORKER_ATTRIBUTION=1 and use --entry src/entry.ts. The private
preload instruments the existing metrics owner, SQLite admission receipt, worker
receiver, and task-pool reply boundaries in memory. Each CPU/heap capture adds .worker-attribution.json with
message counts, known payload bytes, and V8 re-serialized envelope bytes grouped
by bounded message kind, protocol operation, receipt/publication kind, and known
lifecycle script. Received dependency messages use other for unregistered
scripts; boundaries without script identity use unknown. The observer does not
infer identity from payload contents. It caps groups at 512 and reports overflow.
Direct metrics calls that do not expose their message value report
unavailableMessageValues. Outgoing dependency messages remain unsized so the
observer never runs their getters or proxy traps. Shared buffers contribute reference
encoding bytes and separate sharedBackingBytes, never copied backing bytes.
MessagePorts and unsupported native objects report failedSerializationMessages,
not empty messages. The size estimate includes transferred ArrayBuffer contents
and is neither native transport bytes nor deserialized heap bytes.
Actor receipts also report the bounded phase and separate serialized sizes of
authority snapshot entries, receipt postimage entries, and their skills snapshot,
system-prompt report, and pending-delivery fields when present. These overlapping
sections are diagnostic breakdowns; do not add them to total message bytes.
The separate taskReplies table breaks validated task replies down by the task
owner's worker URL and a fixed operation vocabulary. It refines the owner's
existing diagnostic label during input preparation and reads it at reply receipt;
there is no request/reply correlation map or retained payload. This table overlaps
the transport groups and must not be added to their totals. Replies rejected before
task/status validation are absent from this table. sourceHooks lists the source
modules actually instrumented, including the package task-pool core reached by
normal @openclaw/worker-runtime imports.
Re-serialization adds allocation and CPU work: use that pass to locate traffic, then unset the flag for allocation, retained-heap, promotion, GC, and latency acceptance runs. Profiles with collected objects estimate allocation; live-only profiles estimate survivors. Native structured-clone allocations without a JS stack cannot be assigned exactly to a message or its retaining cache by this observer. Correlate those samples with receipt counts and inspect a separate heap snapshot for retainers. The observer retains numeric aggregates only and never takes per-message sampling profiles or forces a collection.
Use a freshly generated fixture on each revision and compare multiple rounds.
The driver adds a small parent/child session set; those sessions are included in
list measurements. For production-shaped database size use --transcript-mib 1500
when generating the fixture and record its actual database bytes.
The existing private benchmark IPC preload samples the Gateway main isolate,
with both includeObjectsCollectedByMajorGC and
includeObjectsCollectedByMinorGC enabled and a 32 KiB average interval. V8's
node selfSize totals estimate allocated bytes, including objects collected
during capture. estimatedAllocatedBytesPerOperation divides that total by the
validated completed operation count. Top sites include up to eight caller frames
in entry-to-leaf order. These are statistical estimates: small batches can have
substantial sampling noise. Run an idle comparison if background allocation is
material; the driver does not subtract it or silently present heap deltas as churn.
gcMs, gcCount, and gcMsPerOperation come from the main isolate's GC performance
entries. Diagnostics also separate major, minor, incremental, and weak-callback
collections. Allocation capture enables V8's GC trace and records promoted bytes
in gcTrace; compare promotion and GC alongside sampled bytes. A forced collection
before Gateway startup identifies the main isolate, outside every measured window.
Synchronous markers bound capture; parsing rejects missing markers or ambiguous
isolate identity. Profiler setup/teardown and worker collections are excluded from
promotion. The parser supports classic name/value traces and current V8 JSON traces. Heap-used and RSS deltas are separate context fields. Worker allocations,
worker GC, external buffers, and driver/model-fixture allocation are not in
these per-operation estimates. Process CPU and RSS include workers; main-thread
CPU is reported separately. Profiling adds CPU and memory overhead, so compare
with the same sampling settings. No profiling runs when --allocations is absent.
For live operator attribution, diagnostics.heapProfile accepts
{ "durationMs": 10000, "includeCollected": true } with the existing admin
scope and 30-second churn cap; it remains a timed sample, not an RPC denominator.
Presence during chat pane replacement
presence-churn-measure.ts starts an isolated loopback Gateway with synthetic
sessions and 42 WebSocket operators (40 delta readers and two legacy readers).
It drives the same viewer-declaration owner as chat pages, replacing its owner
without changing the watched session once per second. A real session change and
final departure are positive controls after the measured churn.
node --import ./scripts/tsx.mjs scripts/perf/presence-churn-measure.ts \
--state-dir /path/to/new-synthetic-presence-state --port 41466 \
--clients 42 --rounds 20 --interval-ms 1000 --output /path/to/presence.json
The state directory must not exist. Run with an isolated HOME, OPENCLAW_HOME,
and TMPDIR. --viewer-owner accepts the baseline or candidate TypeScript owner
module; both sides otherwise use the same Gateway. This isolates client-side
publication changes without counting Gateway startup as a performance result.
Use --start-at <epoch-ms> for concurrent pairs with separate directories and
ports, swapping CPU halves between pairs under the quiet-measurement lock.
Compare viewer requests, presence frames and UTF-8 payload bytes per minute. A successful no-op fix removes unchanged-viewer requests and their fan-out while 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.
For payload attribution, use --scenario payloads --clients 40 --agents 24.
This prewarms the real public health collector, then measures one cold attach
wave and --rounds reconnect waves with reused signed devices. It records
per-field UTF-8 JSON byte totals for hello responses, health events, and presence
events; connect p50/p99; and socket-to-hello-plus-initial-presence readiness.
After the first maintenance tick, each wave records a synthetic context-engine
quarantine through its owner and explicitly probes public health to prove changed
health still broadcasts. Health RPC sizes
are separate from the per-wave hello + health + presence traffic total. Fields
contain sizes only; nested field totals overlap their parent. The default web
profile uses presence-deltas and hello-health-on-demand; --compact-payloads
adds health-summary and hello-presence-self. With --client-profile native,
only health-summary is added, preserving the profile's legacy CLI controls.
Plugins, channels, cron, and heartbeat execution are disabled; this is a small
synthetic agent roster, not a production-state or browser-rendering benchmark.
Run concurrent baseline/candidate pairs with the same --start-at and swap CPU
halves for the next pair, as above.
Measure session-row identity during sidebar churn
reconnect-read-wave-measure.ts reproduces cold Control UI RPC traffic while a
preconnected writer changes session timestamps. It uses the real compiled
Gateway and synthetic device pairing, subscriptions, board reads, session
resolution, and sidebar searches. Generate a fresh team-shape fixture per side
with 5,000 sessions, 6,000 settled runs, 200 approvals, 100 cron jobs, 12 projects,
and 64 MiB of logical transcript text. Use the same explicit seed and reference
time for both sides. The fixture is mutated during setup and cannot be reused
for another timing sample.
Before building each disposable baseline or candidate source tree, run:
python3 scripts/perf/session-row-churn-instrument.py --check
python3 scripts/perf/session-row-churn-instrument.py
OUTPUT_SOURCE_MAPS=1 node --import ./scripts/tsx.mjs scripts/build-all.mts sourcePerformance
The instrumentation imports session-row-churn-probe.ts into the scratch build;
it does not add a product configuration option or a production probe hook.
Preserve the exact instrumentation patch and compiled build identity. Validate
all source anchors again when rebasing. Never apply this instrumenter to an
operator checkout or ship the instrumented build.
Each measured wave also reports materializationWork from the production
session-row work counters: created is first construction, reused constructs
and then retains the old graph, rebuilt replaces an existing graph, and skipped
retains the old graph without constructing a replacement. Reuse among repeated
attempts is (reused + skipped) / (reused + skipped + rebuilt); exclude first
constructions and report an empty denominator as no attempts. deepComparisons
counts whole-materialization comparisons, not individual input-field checks or
diagnostic sampling. Existing diagnostic memory snapshots include these counters
and first differing source-field counts sampled on one in 64 rebuilds. The
row-or-membership bucket means source fields were equal; it does not imply the
materialized row was equal. No values or session identities are sampled.
For a separate development Gateway diagnostic through ordinary WebSocket RPCs:
node --import ./scripts/tsx.mjs scripts/perf/session-row-materialization-measure.ts \
--root /path/to/new-synthetic-state --port 62689 --sessions 32 --lists 100
This launches an isolated source Gateway, waits for admission readiness, and reports ordinary reads, cron rename/enable/disable, a changed label, and a repeated save of that label. The repeat save may still change persisted metadata. Asserted automation and label changes guard against stale reuse. Main CPU per list includes the phase's mutation and background work, but excludes the controller process; it is not exclusive RPC CPU. Apply the same host isolation, quiet slot and guard rules as the compiled diagnostic. Preserve failed setup separately from samples.
node --enable-source-maps --import ./scripts/tsx.mjs \
scripts/perf/reconnect-read-wave-measure.ts \
--root /path/to/fresh-fixture --output /path/to/new-output \
--entry /path/to/compiled-dist/entry.js --clients 65 --port 62669 \
--sync /path/to/pair/start --side base
session-row-churn-pair.py coordinates one prepared pair on the campaign host.
Create <run>/base/state and <run>/fix/state first, then run it under the
host's preparation wrapper:
python3 scripts/perf/session-row-churn-pair.py \
--checkout /path/to/source --root /srv/perf-sandbox/<task>/results/pair-1 \
--pair 1 --slot b --base-entry /path/to/base-dist/entry.js \
--fix-entry /path/to/fix-dist/entry.js
Increment --pair for each fresh pair; it swaps launch order and CPU assignment.
The coordinator reads each Gateway port from its prepared fixture configuration;
each side also reserves Gateway port + 1 for the board widget sandbox listener.
Choose distinct ports outside the host's ephemeral range, such as 62669 and 62679
on the campaign host.
Use --retention with a fresh retention-1 root for five-wave diagnostics.
The coordinator preserves guard/start/load metadata and rejects failed pairs.
python3 scripts/perf/session-row-churn-summary.py /path/to/results reports the
paired means, signed absolute/relative deltas, raw sample counts and settled
retention families. It does not decide the campaign's acceptance gates.
A sequential campaign can retain one slot lock across pairs. Its parent opens
and exclusively locks the selected slot file, adds --lock-fd <descriptor> to
each pair command, and passes that descriptor through Python's
subprocess.run(..., pass_fds=(lock.fileno(),), check=True). The pair runner
duplicates the inherited descriptor, verifies its device/inode against the
selected slot file, and still calls flock(LOCK_EX) on that shared open file
description. Each pair retains its own guard wait/watch and swapped CPU sides.
Generate only the next pair's two fixtures sequentially; after a successful
pair preserve its result/profile artifacts and delete only its fixture state
before generating the next pair. Abort the campaign and close the parent lock
on any invalid pair or guard failure, preserving the failed pair for diagnosis.
The other side uses a separate fixture, output and loopback port, the same
--sync prefix, and --side fix. Each side writes
<prefix>.wave-0.<side>.ready after its measured Gateway and writer socket are
ready. Each task-owned scope starts on its assigned 16 interleaved CPUs so the
Gateway sizes its workers for 16 CPUs from startup. The coordinator waits for
both ready files, then writes a future epoch in
milliseconds to <prefix>.wave-0.go. Missing or missed starts fail the run.
Keep the shared quiet lock and production guard around the concurrent pair;
swap both launch order and CPU placement on the next pair. Build and fixture
preparation follow the host's preparation CPU and sequential-write rules.
Each measured wave submits exactly 200 chat.inject writes at 50 ms intervals
across 200 nonarchived sessions. Three rounds of 65 sessions.list searches
start after at least 20, 80 and 140 committed writes. Every round must start
before the writer finishes. The result records writer scheduling lag, round
overlap, all RPC outcomes, and separate cold/search request windows. Search p90
uses the 195 search calls; initial static-field construction and repeated
rebuilds are reported separately. Task scopes retain inclusive CPU/time and
exclusive stall segments without double-counting nested tasks.
Run a labeled smoke before the predeclared paired comparison: the baseline must exhibit repeated field construction, the unchanged-search candidate must have zero rebuilds, and both must complete all writes and reads. Preserve setup failures separately. Timing runs collect untraced main-isolate GC time, counts, per-kind pause tails and maxima, plus event-loop sample counts. Apply the campaign's sample-count, absolute-delta, and matched A/A-control rules; a short wave may not provide enough observations to gate event-loop p99. Promotion is outside this untraced pass.
--retention runs five consecutive waves in one Gateway. Release the same
ready/go barrier for every wave. It enables detailed object-identity tracking
and a 32 KiB surviving-allocation sampler. After all reads and writes finish,
it captures with subscriptions active, then again after subscription teardown.
Settled captures wait out the response retention interval and run two major
collections separated by an event-loop turn. Memory is read before allocating
profile reports. Named presentation/selection/consume, index/trim, and
share/sidebar allocation families are inclusive and may overlap. These are
sampled surviving JavaScript bytes, not attributed native allocations. Live
presentation counts use weak references; materialized/facts replacements count
reference changes separately from value and own-property changes. Keep this
diagnostic pass separate from timing acceptance.
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:
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.
Prompt history fingerprints
worker-task-metrics-measure.ts measures the real shared compute pool and source
provider-prompt worker with one short user message, then 64 synthetic messages
containing 256 KiB of text. Run on the approved benchmark host:
node --import ./scripts/tsx.mjs scripts/perf/worker-task-metrics-measure.ts \
--start-at <future-epoch-ms> --output /path/to/result.json
Each phase has four concurrent clients and four bursts of 5,000 timed tasks,
providing 20,000 samples for p50/p95/p99; every percentile includes its sample count.
Each burst first warms 500 tasks, and then retires its workers with pool.rotate().
Task latency and phase CPU/elapsed exclude warmup and rotation; rotation times are
reported separately. The first warmup completes before the shared start deadline;
subsequent bursts and the second phase run sequentially. Missed startup deadlines,
task errors, or fingerprint mismatches fail the run. Every reply verifies count,
role, content digest, and stable packed fingerprints without rehashing the input.
GC observations include warmup and retirement and are context only; baseline
workers can lose their final GC batch at retirement. Retained pool totals are
cumulative across phases and remain in retainedAfterClose; they are absent when
running this unchanged script on a baseline without the new counters. No provider
or Gateway is contacted. Run at least two concurrent pairs under one host quiet
lock, use the current interleaved 16-CPU sides, and swap sides for the second pair.
Apply the paired-mean and sample-count decision rules; major GC needs at least
50 collections per side to gate. Set each process's HOME, OPENCLAW_HOME, and
TMPDIR to its own sandbox, and use the host's production guard and resource limits.
prompt-cache-history-measure.ts exercises the history fingerprint owner with
1,000 precreated synthetic messages. Select --mode sealed|append|mutable and
--content string|blocks; block messages contain text, thinking, and nested tool
arguments. sealed owns the whole message; append owns its content while the
envelope remains mutable. Each process warms the owner, collects garbage, creates
its fixture, and collects again before measurement.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/prompt-cache-history-measure.ts \
--mode sealed --content blocks --measure retained
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/prompt-cache-history-measure.ts \
--mode append --content blocks --measure cpu --passes 1000 --start-at <future-epoch-ms>
retained reports the incremental forced-GC heap bytes and bytes per message,
including both owner caches and returned fingerprints. cpu reports cold,
repeated-history, and history-plus-one-new-message observations. Every appended
pass uses a previously unobserved message. Fixture creation and forced collection
are outside the timed warm/append loops. Use enough --passes for the fast sealed
path; --count controls history size. The output digest covers role, identity,
layout, and packed diagnostic fields and must match between baseline and candidate.
--measure counts separately instruments SHA-256 call/input-byte counts;
--measure allocation --output <profile.json> samples allocations including
collected objects; --measure snapshot --output <prefix> writes before/after
forced-GC heap snapshots. Profiling and counting runs are separate from untraced
CPU/GC decisions. GC entries report collection kind and duration, with counts
available from the entry arrays; small collection samples are context only.
These are owner-level measurements, not whole-Gateway allocation or latency.
To compare a baseline, save its prompt-cache-history.ts beside the current
module and pass its absolute path with --owner. Both owners and the fixture
must share the same transcript normalization module. Use the same runtime and
fixture on both sides of concurrent, locked pairs, swapping the interleaved CPU
sides between pairs. Run at least five pairs using the host production guard.
The append-normalized case should eliminate repeated nested-argument hashing;
sealed history should remain at zero warm hash bytes, and mutable SDK content
must still be inspected. Diagnostic output and in-place mutation coverage live
in prompt-cache-observability.history.test.ts.
prompt-cache-observation-measure.ts measures the complete observation boundary:
append-shaped frozen messages → cloneTranscriptView → convertToLlm →
onModelRequest, including real worker preparation. It keeps 16 synthetic sessions
of 128 messages resident across 20 repeated requests per session. --hydration clone
replays the former structured-clone behavior; the default shared uses current
hydration. Run separate baseline/candidate processes with the same fixtures.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/prompt-cache-observation-measure.ts
--sessions, --messages, and --rounds control the workload. Results separate
first-request and repeated-request retained heap, cold/warm observation CPU, and
natural GC entries. Hydration itself is outside observation CPU timing; retained
bytes per call divide resident diagnostic state by the reported call count, not
allocation turnover. --measure allocation --output <profile.json> includes
collected objects; --measure snapshot --output <prefix> writes before, first,
and final snapshots. Keep profiling runs separate from CPU/GC comparisons and
use the host quiet slots, resource limits, and production guard above.
WebSocket event diagnostic accounting
ws-event-diagnostics-measure.ts exercises the real recording and diagnostic
dispatch owners with 250 batches of 1,000 synthetic accepted frames, plus wire-byte
observations. It includes plain, presence, subscribed/roster session, and unknown
event labels. --batches and --frames control the workload. Run on the approved
benchmark host:
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/ws-event-diagnostics-measure.ts
# For paired runs, schedule both passes independently:
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/ws-event-diagnostics-measure.ts \
--start-at <future-allocation-epoch-ms> --cpu-start-at <later-cpu-epoch-ms>
Add --transport --port 41587 --frames 72 to open 72 real loopback WebSockets
and send a shared 1,024-byte synthetic JSON event to every socket in each batch
through createGatewayWebSocketTransport. This mode rotates one event kind per
broadcast; the default recorder mode mixes all five kinds within every batch.
Transport mode verifies received payloads and per-socket delivery counts as well
as diagnostic bytes and every cumulative histogram bucket. Compression is disabled;
received wire payload bytes are reported separately from codec diagnostics.
Only loopback ports from 40000 through 65535 are accepted. Setup and warmup finish
before --start-at <future-epoch-ms> synchronizes the warmed measurement start.
--cpu-start-at <later-epoch-ms> independently synchronizes the unprofiled CPU
pass after allocation sampling. Each phase completes forced GC and instrument
preparation before waiting for its deadline; missed preparation deadlines fail
the run. Sampling is armed before the allocation wait, so allocation totals also
include the small idle-timer setup. cold.startedAtEpochMs, warm.startedAtEpochMs,
and cpuStartedAtEpochMs report actual workload starts for pair alignment checks.
The JSON result separates cold and warmed sampled allocation totals, bytes per
frame, and bytes per broadcast (one batch), including collected objects, for the
entire process and the recording/flush owner. Transport mode also reports bytes
per socket-second using the profiled pass's elapsed time. CPU is measured in a
separate unprofiled pass with batch latency, GC collection counts and total time,
per-kind GC p95/p99/max pauses, and event-loop p99/max delay. Every tail includes
its sample count; insufficient samples remain context. The inactive-interest
pass verifies recording is skipped. WS_DIAGNOSTICS_CPU_START and
WS_DIAGNOSTICS_CPU_END delimit the unprofiled pass for an external
--trace-gc-nvp promotion parser.
Compare concurrent locked baseline/candidate pairs with swapped interleaved CPU
placements under the campaign's guard and decision rules. Report absolute and
relative deltas, GC maxima, and tail sample counts. This isolates WebSocket
transport and diagnostics, rather than a complete agent run; use
transcript-broadcast-measure.ts --scenario transcript-broadcast --clients 30 --allocations --diagnostics --active-work-diagnostics --trace-gc and the memory-soak
scenario when whole-Gateway behavior is the question. Allocation reduction alone
is insufficient.
Mention Inbox target reads
mention-inbox-measure.ts starts a real isolated Gateway on synthetic team-shaped
state (5,000 sessions and 6,000 settled runs, without transcript bulk or projects).
It connects one sender and 24 authenticated recipient viewers through loopback
WebSockets, then seeds mentions with real chat.send turns against a synthetic
loopback model. The measured paths are mentions.list and mentioned-session
label changes that produce mentions.changed for every viewer. Inbox item counts,
model completion, and broadcast delivery are checked before and after measurement.
Add --clients 70 --sidebar-limit 200 to refresh each viewer's sidebar after every
broadcast using the real Control UI conditional-row decoder over WebSockets. Each
refresh must decode 200 rows and show the patched session title. The fixture has
70 recipient connections for one profile plus one sender; the existing list owner
can coalesce their identical sidebar requests. Its 5,000-session base fixture
remains independent of the selected page size. Omit --sidebar-limit to isolate
Inbox publication.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/mention-inbox-measure.ts \
--state-dir /path/to/new-synthetic-state --output /path/to/result.json \
--port 41605 --mock-port 41606 --mentions 100 --clients 24 \
--operations 2000 --broadcasts 100
Use one task-owned source checkout and --owner-dir /path/to/baseline-owners to
select baseline copies of mention-inbox-targets.ts, mention-inbox.ts,
mention-inbox-projection.ts, mention-inbox-recording.ts, and
session-row-presentation.ts. The module URLs
and relative imports remain unchanged. Source hooks count readTarget,
currentTarget, readView, and shareSidebarSessionRows; no production
instrumentation is required. The report also counts full, delta, and unchanged
sidebar wire responses.
--count-only runs setup plus one list and one broadcast without profiling.
--start-at schedules the warmed list allocation pass,
--broadcast-allocation-start-at the broadcast allocation pass,
--cpu-start-at the unprofiled list pass, and --broadcast-start-at the unprofiled
broadcast pass. Each rejects
a missed preparation deadline. For sequential preparation followed by concurrent
measurement, --barrier-dir <shared-pair-directory> --side baseline|candidate
writes <phase>.<side>.ready and waits for <phase>.start containing a future
epoch in milliseconds. The runner prepares each side on the preparation CPUs,
sets each owned process scope to its measurement CPU set, then releases both
sides of each phase together. Sampling starts after the allocation barrier. HOME,
OPENCLAW_HOME, and TMPDIR must remain within the isolated sandbox.
Acceptance rule: run concurrent baseline/candidate pairs with identical fixtures under one exclusive host measurement lock, using interleaved 16-CPU sides and swapping placement for the second pair. Decide on the paired mean. Stop for a 20% adverse mean, or 10% adverse in both pairs, for CPU, allocation, total GC time per operation, promotion per operation, and qualified event-loop/latency tails. A p99 requires 1,000 samples per side and a p95 requires 200. Major-GC tails are context below 50 collections per side. Opposite-signed deltas or baselines more than 5x apart are inconclusive: show absolute deltas beside percentages and run a third pair if that metric gates, deciding on the absolute mean. GC pause percentiles with paired absolute mean shifts below 1 ms are context. They are also context when collection count drops at least 20% without increased total GC time. Always report both sides' maximum pause; the candidate maximum exceeding max(50 ms, twice baseline maximum) retains its gate. Guard-interrupted pairs are discarded and rerun; use the shared production guard without a stricter threshold.
Sampled allocation includes collected objects in the main isolate, including the
in-process WebSocket and UI driver, and reports owner attribution per Inbox read
and per broadcast, plus a rate normalized to the measured minute. Worker-isolate
allocations are not sampled. Broadcast allocation also reports bytes per recipient
viewer. Owner subtrees (readTarget, readView, shareSidebarSessionRows) can
overlap, so do not sum them. The broadcast profile is saved as
<output>.broadcast.heapprofile; the list profile keeps <output>.heapprofile.
With sidebar refresh enabled, broadcast latency includes delivery and all
conditional list responses and client decoding, not only event transmission. CPU/GC/loop observations are separate from sampling; process CPU includes
workers and in-process WebSocket clients, with main-thread CPU reported separately.
Pass --trace-log /path/to/captured-stdout.log with --trace-gc-nvp to include
promotion from the existing Gateway trace parser. Synchronous identification
markers before worker startup select only the main isolate; separate capture
markers delimit list and broadcast phases. Worker collections are excluded. Every tail carries
its sample count. The Gateway and WebSocket driver share the measured main isolate, so these
allocation and CPU totals include both. Observed throughput and bytes per minute
are context; decisions use per-operation work. bytesPer450Reads provides a fixed
synthetic rate comparison without claiming production Inbox traffic. These
measurements do not cover browser rendering.
Database page-cache maintenance
database-page-cache-measure.ts invokes the Gateway's Linux maintenance owner
through its real spawn broker and read-only process pool on an existing synthetic
team-shape fixture. It reports progress-request and main-thread stat counts,
main/process/child CPU, GC duration and promotion, and event-loop p99/max with
sample counts. --profile additionally writes collected-object allocation and
CPU profiles; keep profiled runs separate from timing gates.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/database-page-cache-measure.ts \
--state-dir "$BENCH_ROOT/state" --cold --passes 3 \
--output "$BENCH_ROOT/measurement.json" \
--trace-log "$BENCH_ROOT/measurement.log" > "$BENCH_ROOT/measurement.log" 2>&1
--cold evicts only the named synthetic database files with Linux posix_fadvise;
there is no host-wide cache drop. Without it, residency is observed as found.
--prepare-only checks fixture admission and broker readiness without running
maintenance. --owner-dir selects baseline copies of sqlite-page-cache.worker.ts
and sqlite-readonly-worker-session.ts in the parent and its reader children, so
paired runs can share one source installation. The benchmark does not boot a full
Gateway or establish a deployed production effect.
For synchronized pairs, pass --barrier-dir and --side baseline|candidate.
Each side writes <pass>.<side>.ready after preparation; the controller releases
both by atomically writing a future epoch in milliseconds to <pass>.start.
Keep the exclusive measurement slot and production guard around the pair, and
swap CPU placement in the second pair. Loop measurements cover the operation,
without idle padding; tails below the campaign's sample minimum are context.
TTS preference flushes on the Gateway main thread
fsync-tts-gateway-measure.ts starts a real isolated Gateway and sends paced
tts.enable/tts.disable WebSocket requests. Each reply is checked against the
persisted preference bytes. The default workload is 120 requests in one minute.
A synthetic 50 ms delay applies to every fsyncSync in both the main isolate and
worker isolates during the measured interval. The companion
fsync-tts-worker-preload.mjs is benchmark instrumentation only. This preserves
comparable storage waits when persistence moves to a worker; total flush work is
reported separately from main-thread blocked time. An unmeasured calibration RPC
requires both durable flushes to be visible to the probe, including workers
created by a retained-task supervisor. The first, unmeasured request
reports cold-write latency separately. While waiting for the common start, both
sides repeat an unmeasured write every ten seconds so an idle writer does not
retire before the steady-state interval.
Prepare baseline siblings in the same sandbox source checkout before running:
git show <base>:src/gateway/server-methods/tts.ts > src/gateway/server-methods/tts.baseline.ts
git show <base>:src/tts/tts-settings-writes.ts > src/tts/tts-settings-writes.baseline.ts
The --baseline source hook selects both siblings and asserts both were loaded.
Relative imports retain their normal owners. Do not modify source files while
any side of a pair is running. Both sides use the same installed dependency
versions; this workload does not substitute an unpublished fs-safe dependency.
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/fsync-tts-gateway-measure.ts \
--state-dir /path/to/new-synthetic-state --port 41624 \
--operations 120 --interval-ms 500 --fsync-delay-ms 50 \
--start-at <shared-future-epoch-ms> --gc-log /path/to/captured.log \
--baseline > /path/to/captured.log 2>&1
Omit --baseline for the candidate and use a different state directory, log, and
port. --start-at must leave time for both Gateways and their first writes to
finish; a missed deadline rejects the run. FSYNC_READY records readiness.
Preparation/builds use CPUs 0–63, nice 19, CPUWeight=10, IOWeight=10, and an
explicit MemoryMax. For landing measurements hold exactly one exclusive quiet
lock around each concurrent pair, run the shared slot-b-guard.sh wait before
launch and slot-b-guard.sh watch <pair-pid> during it, and discard interrupted
pairs. Do not change the guard or apply a stricter threshold. Keep HOME,
OPENCLAW_HOME, TMPDIR, and state within the task sandbox.
Use interleaved sets: slot A (.quiet.lock) is 96,98,…,126 against
97,99,…,127; slot B (.quiet-b.lock) is 64,66,…,94 against 65,67,…,95.
Prepare the processes in their own named systemd scopes on CPUs 0–63; after both
are ready, change only those task-owned scopes' AllowedCPUs to their measurement
sets before the common start time. Swap placement in pair two. Record outside
CPU load for context and decide on paired deltas, not sequential absolute times.
Acceptance: eliminate main-thread synchronous flush calls and blocked time while preserving total flush count and persisted bytes. Process CPU includes the Gateway's workers and its in-process WebSocket driver. Main-thread and whole-process-isolate GC are both reported, including promotion and maximum pauses; native subprocess GC is outside these counters. Apply the shared v5.3 rules described above: qualify tails by sample count, treat opposite-signed deltas or greater-than-5× baseline spread as inconclusive, and run a third pair when an inconclusive metric would gate. Every latency and loop tail includes its sample count. This simulation establishes persistence-path blocking behavior; it does not claim production throughput or macOS browser cookie-import latency.
Cron delivery read comparisons
Use transcript-broadcast-measure.ts --scenario cron.list for full cron reads.
cron-delivery-gateway-entry.ts supplies only a frozen delivery owner; the
canonical harness owns workload validation, warmup, diagnostics, synchronization,
and cleanup. Both baseline and candidate use this same entry and source
installation. No production timing hooks or additional dependency installs are
needed.
Generate fresh fixtures sequentially for each side and pair:
node --import ./scripts/tsx.mjs scripts/perf/team-shape-fixture.ts \
--state-dir "$STATE" --sessions 5000 --runs 6000 --settled-runs \
--approvals 0 --projects 0 --transcript-mib 0 \
--cron 40 --cron-announce-last --seed 967 --now "$FIXTURE_EPOCH" \
--port "$PORT"
Enable cron.enabled in each generated openclaw.json before launching, keeping
all 40 jobs disabled. This exercises the resident scheduler snapshot rather than
forcing a cron-store reload on every call. Keep ordinary maintenance enabled.
Freeze each side's owner from its exact revision into its own fixture:
git show "$OWNER_REVISION:src/cron/isolated-agent/delivery-target-context.ts" \
> "$STATE/cron-delivery-owner.ts"
Use identical owner revisions for A/A controls. The entry requires the generated
fixture and owner file, replaces only the canonical delivery-owner module, and
prints CRON_OWNER_SOURCE with the SHA-256 of the source actually loaded. The
pair controller must verify this marker against the prepared file after warmup;
a Gateway that exits without loading the owner fails the run. Record the shared
source revision, owner revision, owner hash, and controller/harness hashes.
node --import ./scripts/tsx.mjs scripts/perf/transcript-broadcast-measure.ts \
--state-dir "$STATE" --entry scripts/perf/cron-delivery-gateway-entry.ts \
--scenario cron.list --clients 1 --runs 1 --rpc-concurrency 1 \
--operations 200 --warmup-operations 40 --rpc-interval-ms 1100 --rounds 1 \
--port "$PORT" --mock-port "$MOCK_PORT" --diagnostics \
--start-file "$PAIR_START_FILE" --revision "$SOURCE_REVISION"
After both sides print PHASE warmed and their owner hashes match, atomically
publish a common start epoch at least five seconds ahead. This also expires the
last warmup response. Pacing is start-to-start between requests, with no delay
before the first or after the last request. The public one-second response cache
therefore expires without changing cache policy or request identity. The harness
validates all 40 disabled jobs and their announce/last delivery previews. With
200 requests, p95 qualifies for comparison and p99 remains context. CPU, natural
GC, and event-loop observations cover the entire paced window, including idle
and maintenance work; RPC latency measures request-to-response time.
The driver prints RESULT with its result path under the fixture. Validate the
completed count and naturalGc.timingGateEligible, retain GC counts and maxima,
and compare reported runtime, process age, heap spaces, actual capture times,
and CPU affinity. Repeat promotion measurements separately by adding
--trace-gc; their timing is attribution only. Follow
Calibrating paired GC measurements for
matched A/A controls, fixed workloads, synchronized starts, and inconclusive
results. Preserve every valid observation and the campaign's declared rules.
Managed attachment cleanup
managed-attachment-cleanup-measure.mjs exercises the actual managed-media
cleanup owner with 5,000 unrelated 8 KiB transcript messages and two referenced
managed originals. Prepare each side sequentially in a fresh synthetic directory;
preparation uses the real media creator and transcript writer and is outside the
capture. Each measured process warms cleanup once, then performs 30 cleanups
paced over 30 seconds and verifies both originals remain registered.
node --import ./scripts/tsx.mjs scripts/perf/managed-attachment-cleanup-measure.mjs \
--state-dir /path/to/new-cleanup-state --output /path/to/prepare.json --prepare
node --expose-gc --trace-gc-nvp --import ./scripts/tsx.mjs \
scripts/perf/managed-attachment-cleanup-measure.mjs \
--state-dir /path/to/new-cleanup-state --output /path/to/cleanup.json \
--allocations --start-at <shared-future-epoch-ms> --trace-log /path/to/cleanup.log \
> /path/to/cleanup.log 2>&1
--messages, --message-bytes, --operations, and --seconds select the workload.
Use identical values, separate state/log/output paths, and the shared quiet-slot
rules above. A missed --start-at rejects the run. Two concurrent pairs swap the
interleaved CPU sets. The driver never starts or accesses a production Gateway.
For one installed source tree, --owner-dir /path/to/candidate-owners replaces
only these source files, preserved beneath that directory at their repository
relative paths: src/gateway/managed-image-attachments.ts,
src/gateway/session-transcript-read.types.ts,
src/gateway/session-transcript-archive-reader.ts,
src/config/sessions/session-accessor.sqlite-history-source.ts, and
src/config/sessions/session-actor-memory-history-projection.ts.
--source-root selects the installed source tree and defaults to the working
directory. The generated preload also reaches workers through NODE_OPTIONS;
overriddenOwners records the module paths and thread IDs actually replaced.
An ordinary run uses the tree's unchanged sources. Do not change those sources
or dependencies during a pair. For paired comparisons, also pass the unchanged
source root as the baseline's --owner-dir, so both sides use the same esbuild
preload and helper infrastructure.
The report contains main-isolate sampled allocation and raw V8 frame names,
process/thread CPU, before/after heap and RSS, worker-message counts and known
payload bytes, per-collection GC percentiles and maximum pauses, total GC per
cleanup, promotion from bounded main-isolate GC traces, and event-loop delay.
GC and loop capture end before stopping allocation sampling, excluding its forced
collection. Every tail carries a sample count; apply the shared v5.3 qualification
and paired-delta rules. Worker byte counts are partial and retain their
unmeasuredMessages counts; the observer never reserializes replies. Allocation
sampling excludes worker heaps and external Buffer backing stores. RSS includes
workers. This isolates the cleanup boundary; it is not a full Gateway throughput
or retained-memory soak result.
Private diagnostic dispatch
diagnostic-private-data-measure.ts exercises the real asynchronous diagnostic
emitter and drain with synthetic private model content. It registers 1, 2, or 4
private consumers (the first marked as OTel), a metadata-only consumer, and a
filtered skill consumer. The default payload contains 64 messages and 128 KiB of
text. One-event clone counting is separate from the warm, untraced CPU pass and
inspector allocation pass, which includes collected objects.
node --import ./scripts/tsx.mjs scripts/perf/diagnostic-private-data-measure.ts \
--listeners 2 --events 12000 --payload-kib 128 \
--start-at <cpu-epoch-ms> --allocation-start-at <allocation-epoch-ms>
Use identical prepared sources and fixtures, at least six paired samples per listener count, synchronized starts, and swapped interleaved CPU sets under the shared quiet-slot lock and guard. Compare clones, sampled bytes, and CPU per event; this workload does not establish whole-Gateway, GC, or promotion results. A normal OTel + Prometheus setup has one private model-content consumer: Prometheus opts out. The 2/4-consumer cells measure fanout scaling. Accept reduced duplicate clones and multi-consumer allocation only with intact isolation and no material single-consumer CPU/allocation regression. Report sample counts and absolute as well as relative deltas; short-sample percentiles are context only.
Plugin inspection retirement
plugin-inspection-retention.ts loads 44 synthetic plugins through the real
inspection loader for 20 generations. It keeps the released handles reachable
and counts surviving inspection caches using WeakRef after four forced
collections separated by event-loop turns. Separate borrower and paused-disposal
controls assert that caches remain reachable while physical custody is live.
mkdir -p /path/to/isolated/home /path/to/isolated/state /path/to/isolated/tmp
HOME=/path/to/isolated/home OPENCLAW_STATE_DIR=/path/to/isolated/state \
TMPDIR=/path/to/isolated/tmp node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/plugin-inspection-retention.ts --plugins 44 --generations 20 \
--gateway --port 41660
Run baseline and candidate on an approved benchmark host with the same fixture
size and source dependencies. Accept collection of completed caches while the
borrower and pending-disposal controls retain theirs until disposal finishes.
Report cache counts and absolute retained-heap bytes; whole-process heap includes
loader/module overhead and the deliberately retained registry handles. This is
an inspection-lifecycle probe in a real Gateway main isolate when --gateway is
set, not Gateway RPC throughput or a production old-generation plateau
measurement. Omit --gateway for the loader-only control.
Compressed WebSocket broadcast fanout
ws-compression-measure.ts sends fresh synthetic publications through the production
broadcaster and WebSocket transport to 70 negotiated permessage-deflate clients.
It prepares 32 varied payloads before warmup; one quarter stay below the compression
threshold and the remainder target 16 KiB of text (JSON escaping adds bytes).
node --import ./scripts/tsx.mjs scripts/perf/ws-compression-measure.ts \
--clients 70 --profiles 70 --batches 30000 --port 41649
Use --profiles 1 for matching recipient envelopes, and --size 512 or
--size 2048 for smaller payloads. Sequence counters start aligned; matching
profiles therefore exercise the best case for whole-frame sharing. Distinct
profiles exercise separate envelopes even with aligned counters.
--warm-start-at <epoch-ms> synchronizes the
100-broadcast warmup; --start-at <epoch-ms> synchronizes measurement after it.
A missed deadline fails the run. --ready-file <path> records completed fixture
and connection preparation. Run baseline and candidate with separate state,
ports, identical counts, counterbalanced launch order, and swapped interleaved
CPU placements under the campaign's quiet lock and production guard.
--codec <absolute-module-path> selects an alternate compression owner while
keeping the broadcaster, transport, fixtures, and dependencies identical. Preserve
the owner's relative imports when saving a baseline copy beside the source module.
Results report CPU per broadcast for the process, main thread, and synchronous
broadcast call; exact server socket wire bytes; decoded frame bytes; latency and
event-loop tails with sample counts; natural GC totals, per-kind counts/tails,
and all maximum pauses. Every client's decoded-frame SHA256 must match its expected
payloads, sequence, and recipient profile. Client inflation shares the measured
process, so use sendThreadCpuUsPerBroadcast to attribute synchronous broadcast
work and retain overall CPU/latency as context. Deferred socket writes are outside
the synchronous interval. This is transport-boundary proof with synthetic admitted
recipients, not a full Gateway authentication, agent, or maintenance workload.
No collections are forced. Use untraced runs for timing/GC gates. A separate
--trace-gc-nvp process measures promotion between WS_COMPRESSION_MEASURE_START
and WS_COMPRESSION_MEASURE_END, with its own matched baseline controls. Calibrate
at least four identical-source pairs before interpreting adverse GC/promotion/max
pause signals; noisy controls make the protocol inconclusive. Keep all valid
samples and report both absolute and relative deltas under the campaign's sample
count and maximum-pause rules.
Codex catalog file reconciliation
Measure the real reconciliation owner and rollout scanner over 5,000 prepared, unchanged synthetic rollout files. Prepare each side's directory before taking measurement locks, then run fresh processes against separate directories:
node --import ./scripts/tsx.mjs scripts/perf/codex-catalog-reconcile-measure.ts \
--state-dir /path/to/side-a --prepare
node --import ./scripts/tsx.mjs scripts/perf/codex-catalog-reconcile-measure.ts \
--state-dir /path/to/side-a --operations 5000 --warmup 100 \
--start-at <shared-future-epoch-ms> --output /path/to/result.json
For baseline comparisons, save the unchanged session-catalog-index-files.ts
module beside the candidate module and pass its path with --owner; imports then
resolve to the same source dependencies. Counterbalance launch order, swap CPU
placements, and use identical work counts and a common post-warmup start time.
The script fails if setup misses that time and records actual start/process age.
No collection is forced in untraced timing runs. Reports include verified rows,
reconciliation latency, CPU, per-kind GC counts/time/tails/maxima, and event-loop
sample counts and tails. Apply the campaign's matched-control and paired-delta
rules; this owner measurement does not include Gateway or native App Server
latency, and it leaves the scanner's normal watcher/cache behavior enabled.
Run promotion separately with --expose-gc --trace-gc-nvp --gc-log /path/to/run.log
and redirect stdout to that log; the identification collection precedes warmup.
Traced timing is attribution only. --allocations enables a separate collected
object sampling pass and writes allocation.heapprofile; sampler teardown is
outside GC capture. --count-path-work is another separate attribution mode:
it counts path.relative calls from the sessions root after scanner warmup.
These are the coverage checks for the unchanged-file workload; do not include
this instrumentation in timing comparisons.
Skill resource delivery allocation
skill-resource-delivery-measure.ts exercises production bundle preparation with
metadata-shaped synthetic skills: 32 files, 1,302,894 bytes, and eight concurrent
turns by default. File contents are generated Markdown and scripts; no operator
skill content is copied. Delivery mode retains the payloads like a worker launch
that may need to retry. Transfer mode runs the actual staging script over child
stdin and retains the returned turn handles. It does not create a Gateway
placement or run model inference.
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/skill-resource-delivery-measure.ts \
--state-dir /path/to/isolated/skill-delivery --prepare-only
node --expose-gc --import ./scripts/tsx.mjs scripts/perf/skill-resource-delivery-measure.ts \
--state-dir /path/to/isolated/skill-delivery --existing --operations 128 --concurrency 8
Use --functional for exact-byte verification without timing, --mode transfer
for staging and supporting-file readback, and --content unicode or
--content binary for alternate fixtures (prepare each in a separate directory).
--allocations includes collected objects in inspector sampling; run that pass
separately from uninstrumented CPU, GC, and event-loop comparisons. Reports also
include source reads and bytes per turn, retained payload characters, JSON bytes,
and post-GC retained heap. The transfer readback is performed by the verifier,
not a model.
Measure promotion in a separate pass with --trace-gc-nvp --gc-log /path/to/run.log
and redirect stdout to that log. The existing warmup collection identifies the
main isolate; capture markers exclude warmup and post-capture collections. The
report includes promoted bytes and collection counts. Do not combine this mode
with --allocations, and use only untraced runs for CPU, GC timing, and pause gates.
Run on the authorized benchmark host. Prepare fixtures before acquiring the
shared measurement slot, then use --existing --start-file /path/to/barrier on
both sides. Each side prints READY <pid> after warmup; write a future Unix time
in milliseconds to the barrier to synchronize the start. Compare concurrent
baseline/candidate pairs on interleaved CPUs and swap sides for the next pair,
using the host's guard and resource limits. Run identical-source controls before
attributing GC or pause differences. Never compare profiled and unprofiled runs.
Measure transcript replica size memoization
Run the sizing owner on synthetic nested message/tool payloads, without loading a Gateway or database:
node --expose-gc scripts/perf/transcript-size-memo-measure.mjs \
/path/to/session-actor-replica.ts table
node --expose-gc scripts/perf/transcript-size-memo-measure.mjs \
/path/to/session-actor-replica.ts cpu
node --expose-gc scripts/perf/transcript-size-memo-measure.mjs \
/path/to/session-actor-replica.ts allocation
The harness extracts the source's estimator and retainTranscript accounting;
touch is a no-op, so this is owner evidence, not LRU or whole-Gateway timing.
table retains 16 transcripts of 64 rows (shared by tail and context), checks each
estimate against an uncached walk, and forces GC before reading the WeakMap's
backing-table size from a heap snapshot. Its counting WeakMap is used only in that
pass. cpu prepares 100 independent append sequences (128 initial rows, then
128 appends), warms the code on disjoint rows, seeds each sequence's memo before
measurement, and measures 12,800 appends with the native WeakMap. allocation repeats that workload with sampling that includes
collected objects; its timing is not a CPU gate. Fixtures remain live during each
pass. Table bytes exclude payloads, keys, and the WeakMap object itself.
Use the same Node version and fixtures on both revisions. Run at least five alternating samples per side, counterbalance launch order and CPU placement, and keep CPU timing separate from heap inspection and allocation sampling. Compare absolute CPU per retain and table bytes; report the owner source hashes, total estimated residency, entry counts, and sample counts. Estimates must match exactly.
SessionManager initial-context residency
session-initial-context-measure.ts opens a prepared synthetic database through
SessionManager.openBoundedAsync, then consumes its real initial model context.
It never generates fixtures. Set isolated HOME, OPENCLAW_HOME,
OPENCLAW_STATE_DIR, and TMPDIR; the database must already exist at
$OPENCLAW_STATE_DIR/agents/main/agent/openclaw-agent.sqlite, with agent main
and session synthetic-session. The fixture has 400 user/tool-call/tool-result
triples, varied deterministic 8 KiB tool text, attachment references only,
compaction after turn 200 retaining from turn 180, and 20 inactive side-branch
messages. Await projection readiness during setup. The script checks 1,201
bounded active entries and 661 model-context messages with bounds of 2,000 events
and 8 MiB. It verifies context bytes and two-way mutable-container isolation
before measurement without persisting its mutations.
Prepare the fixture separately, under the benchmark host's preparation limits and
shared guard. OPENCLAW_STATE_DIR must not exist yet; its parent must exist. The
helper creates it exclusively, writes sequential batches of 150 events through
the database owner, waits for transcript projection, and closes the database.
It prints the exact fixture JSON hash, byte/event counts, and projection receipt.
The optional --fixture-json writes those same JSON bytes to a fresh file.
export HOME=/path/to/synthetic/home OPENCLAW_HOME=/path/to/synthetic/home
export TMPDIR=/path/to/synthetic/tmp OPENCLAW_STATE_DIR=/path/to/synthetic/state
mkdir -p "$HOME" "$TMPDIR"
node --import ./scripts/tsx.mjs scripts/perf/session-initial-context-fixture.ts \
--fixture-json /path/to/synthetic/fixture.json
node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/session-initial-context-measure.ts \
--mode retention --source-dir /path/to/source-overlay --output retention.json
node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/session-initial-context-measure.ts \
--mode cost --operations 2000 --source-dir /path/to/source-overlay \
--ready-file ready --start-file start --done-file done --release-file release \
--output cost.json
The same source hook runs on both arms, replacing only session-manager.ts,
session-manager-codec.ts, and transcript-snapshot.ts under their existing
src/agents/sessions/ or src/config/sessions/ paths. --source-dir contains
those repository-relative paths; omitting it uses the current checkout. Module
URLs remain canonical. The output records each source hash and load receipt and
checks the sources remain unchanged. Worker source stays in the common checkout;
the three replacements apply to the main isolate. Use fresh processes and the
same runtime, dependencies, prepared fixture, and all remaining source bytes.
Retention runs five samples alternating manager-only and manager-plus-context,
20 live roots each, with eight yielded forced collections before, while retained,
and after release. --samples 1 --context-first supports counterbalanced fresh
processes. Shape, envelope/content-array identities, frozen counts, and equal tool
text bytes are inspected after the retained-heap reading. Each sample records
all heap readings, deltas, bytes per manager, and weak-reference survivors.
Logical UTF-8 equality does not prove shared V8 string backing storage. Forced
retention is not natural GC, latency, promotion, or allocation-rate evidence.
Cost uses 32 warmup cycles, a rotating ring of 20 manager-plus-context roots,
and a one-millisecond pacing yield. Each measured operation opens a fresh bounded
manager and reads its initial context; whole-cycle and initial-read latency have
separate counts, tails, and maxima. GC/loop diagnostics cover the main isolate;
process CPU also includes workers. Forced release checks run after capture.
--warm-ready-file/--warm-start-file synchronize warmup; --ready-file/--start-file
synchronize capture using a future epoch in milliseconds. Ready/result files
must be fresh. Paired cost runs must also supply --done-file and --release-file
together: after both sides publish their own done file, the controller publishes
a shared future release epoch. Each side stops diagnostics before waiting, then
parses traces, verifies retained contexts, and forces release collections only
after that barrier. The controller owns the overall deadline if a peer fails.
Solo cost runs can omit both teardown flags. Promotion requires a separate cost pass with --trace-gc-nvp,
stdout redirected to --trace-log, and its own matched baseline. Trace markers
identify the main isolate and exclude worker collections; traced timing is context.
Run four matched A/A controls before two swapped A/B pairs in each instrumentation
mode, using the shared guard, exclusive slot, interleaved CPUs, fixed work count,
and unchanged maintenance settings. Apply the campaign's sample-count, absolute
delta, and maximum-pause rules; noisy controls are inconclusive. This boundary
workload makes no provider, whole-Gateway turn, or deployed improvement claim.
Plugin invocation scope allocation
plugin-invocation-scope-measure.ts loads 44 synthetic plugins and exercises the
production context-engine factory, retained invocation scope, assembly, and
cleanup owners with one selected engine. --gateway runs the same workload in
an isolated loopback Gateway, explicitly acquiring its full fixture registry
because startup can defer unrelated tools. It does not measure chat transport or model latency.
Use a dedicated HOME, OPENCLAW_HOME, and TMPDIR, and a fresh --state-dir.
node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/plugin-invocation-scope-measure.ts \
--gateway --port 41665 --state-dir /path/to/synthetic-state \
--mode timing --operations 4000 --plugins 44 --calls 16 --output timing.json
Run --mode count --operations 10 separately to count registry views, weak
collections, and async-store entries; its spies retain call records. Use
--mode allocation for sampled bytes including collected objects. Untraced
--mode timing reports CPU, per-kind GC totals/tails/maxima, event-loop delay,
and turn latency with sample counts. Promotion uses a separate
--mode promotion --gc-log /path/to/trace.log run with --trace-gc-nvp and stdout
redirected to that log. The workload checks exact plugin identity and registry,
factory/assembly/disposal counts, and rejection of callbacks after scope closure.
For concurrent comparisons, pass separate --ready-file paths and one shared
--start-file, then atomically publish a future epoch in milliseconds after both
sides are ready. --warm-ready-file and --warm-start-file provide the same
barrier before warmup. Use identical work counts, the shared quiet-slot lock and guard,
and swap the interleaved CPU placements in the second pair. Keep maintenance
settings identical. Apply the campaign's absolute-delta and sample-count rules;
run matched identical-source controls before interpreting adverse GC or promotion.
Session replica listener installation
session-actor-listener-measure.ts sends 32 prepared, immutable full-entry
postimages through the real row-change dispatcher and replica installation owner.
Each entry has a saved instruction snapshot and nested plugin results. It checks
content, frozen nested values, and mutation rejection, and reports full-entry
clones separately from other clones during a separate 32-install diagnostic pass.
Serialized clone bytes describe copied payload size, not V8 allocation.
node --import ./scripts/tsx.mjs scripts/perf/session-actor-listener-measure.ts \
--state-dir /path/to/private-state --iterations 100000 \
--start-at <shared-future-epoch-ms> --output listener.json
Save an unchanged owner beside the current source and select it with
--replica-owner src/config/sessions/session-actor-replica.baseline.ts for the
baseline. Run concurrent pairs under one quiet lock and the shared guard, with
separate state directories, synchronized warm starts, and swapped interleaved
CPUs in the second pair. Default runs measure untraced CPU, installation latency,
event-loop delay, and per-kind GC totals/tails/maxima with sample counts. Use
--allocations in separate paired runs to sample allocation including collected
objects; do not gate timing on those instrumented runs. Apply the campaign's
absolute-delta and sample-count rules, including matched identical-source
controls before interpreting adverse GC. Acceptance is unchanged immutable
contents and reduced copying/allocation without a material CPU or tail regression.
This measures prepared native-style notification installation, excluding producer
decoding, worker cold-freezing, Gateway traffic, and RPC latency.
Native worker CPU sampling
worker-cpu-measure.ts exercises the registered CPU source of 50 real, idle
Workers. Each batch requests every native counter through the production owner;
unavailable counters invalidate the run. Startup and 100 warmup batches precede
measurement. This isolates per-sample overhead, not whole-Gateway savings or
worker task throughput. The Gateway's one-second active and ten-second idle
sampling intervals remain unchanged.
node --import ./scripts/tsx.mjs scripts/perf/worker-cpu-measure.ts \
--workers 50 --batches 5000 --yield-ms 1 --output /path/to/timing.json
Use --allocations in a separate pass for 8 KiB collected-object heap sampling;
the raw profile is saved alongside the result. Untraced runs report main/process
CPU per batch, batch latency, natural GC by kind with counts and maximum pauses,
and event-loop delay with sample counts. For separate promotion measurements,
launch Node with --trace-gc-nvp and sum promoted bytes only between
WORKER_CPU_START and WORKER_CPU_END. Profiler shutdown and worker termination
are outside the measurement window.
Pass the same future --start-at epoch to both sides after preparing the inputs.
Hold one quiet-slot lock and use its production guard; swap interleaved CPU sets
and launch order in the second pair. Compare allocation and CPU per fixed batch,
apply the campaign's absolute-delta and sample-count rules, and use matched A/A
controls before rejecting on noisy GC, promotion, or maximum pauses. A p99 needs
1,000 samples, a p95 needs 200, and major-GC tails need 50 collections per side.
PTY session cleanup scans
On Linux, node scripts/perf/pty-session-scan-measure.mjs <kill-tree.ts> live 100
measures the real termination owner against a prepared synthetic session with a
child in a separate job-control group. Use exited for the same session after
verified teardown. An optional final epoch-millisecond argument synchronizes
measurement after warmup. Compare unchanged and candidate owner files under the
shared quiet-slot guard, with concurrent starts and swapped interleaved CPUs.
The probe counts ps calls and returned bytes, checks every signal target, and
reports main-thread CPU, synchronous call latency, natural GC events/maxima, and
event-loop tails with counts. It suppresses signals during repeated scans to
retain identical fixtures, then restores real signals and verifies both processes
exit. Child-process CPU is excluded. This is owner-boundary evidence, not a full
Gateway benchmark; 100 calls cannot qualify latency p95/p99 as gates. Apply the
campaign's absolute-delta rules and matched A/A controls before attributing GC
or pause regressions. Accept reduced process-table transfer/parse work with
unchanged descendant cleanup and no material CPU or latency regression.
Catalog worker retained revisions
catalog-worker-retention-measure.ts sends synthetic catalogs through the real
catalog worker pool, prepares 20 workspace contexts, then replaces every context
with a second remote catalog revision. Each revision contains 5,000 synthetic
model rows. Every request must return the configured fixture model. The shared
worker observer forces collection after the first workspace and each complete
revision; these are retention samples, not latency or GC-throughput measurements.
node --import ./scripts/tsx.mjs scripts/perf/catalog-worker-retention-measure.ts \
--output /path/to/new-fixture --workspaces 20 --models 5000
Use fresh output directories and five alternating baseline/candidate runs. For
source comparisons without another checkout, pass --owner-dir on both sides:
its directory mirrors src/agents/prepared-model-catalog.worker.ts from the
selected revision. Both sides use the same source substitution hook. JSON lines
report successful request count, fixture size, post-GC worker heap and external
bytes, runtime, and substituted modules. --snapshot captures the final synthetic
worker after collection; use only on a host approved for snapshots.
The fixture isolates remote catalog retention from plugin execution and network discovery. It is not a full Gateway or production heap estimate: source-runtime loads include TypeScript tooling. Workspace/source/revision correctness and retirement are covered by the catalog worker heap integration test. Analyze snapshots with the retention runbook; preserve the campaign's host, disk, and production-safety restrictions.
Worker placement preservation reads
placement-preservation-measure.ts measures the production preservation-read
owner with a synthetic retained replica inventory. By default each of 20,000
operations prepares a read and captures it twice, with 10,000 replica entries and
64 protected placements. It does not exercise SQLite, Gateway RPCs, or a model.
Pair it with the sessions.cleanup Gateway workload above, using a Team-shaped
fixture with --local-placements, to establish captures and replica sizes through
real maintenance. Keep fixture generation and build preparation outside timing.
node --expose-gc --import ./scripts/tsx.mjs \
scripts/perf/placement-preservation-measure.ts \
--mode timing --output /path/to/timing.json
Use --mode counts --operations 1 --warmup 0 for replica entries visited, and
--mode allocation for sampled allocation including objects collected by minor
and major GC. Counts instrument the replica iterator and must not supply timing
claims. Allocation and untraced timing are separate passes. --protected 0
measures the common all-local inventory. --captures selects the count established
by the Gateway probe. --owner-source /absolute/path/placement-read-authority.base.ts
selects a preserved source beside the original module so relative imports resolve.
Run concurrent baseline/candidate pairs under the campaign quiet lock and guard,
pass the same future --start-at epoch milliseconds, and swap the interleaved CPU
sets in pair two. The output reports input/output hashes, visited entries, CPU per
operation, allocation per operation, latency sample counts, per-kind GC counts,
totals and maxima, and event-loop sample counts and tails. A warmup collection
precedes measurement; no collections are forced within the measured interval.
Use --authority-state-dir /path/to/new-state to include the real database
admission and placement-observation capture before each preservation read. This
mode creates an isolated database, seeds the same 10,000 synthetic retained
replicas, and supplies 200 synthetic environment variables through native
process.env. It does not start a Gateway or model. --owner-source then selects
a sibling copy of placement-turn-authority.ts, permitting baseline and candidate
runs with identical dependencies. --mode counts reports full ambient environment
enumerations and their synthetic workload rate; that rate is not production demand.
Run counts separately from timing and allocation. Use fresh state directories,
fixed operation counts, a shared --start-at, and swapped interleaved CPU placement
under the quiet-slot lock and production guard for paired comparisons. Report GC
counts and absolute total/max deltas alongside CPU and allocation per operation.
Memory search status counts
Build qaRuntime, then measure ten keyword searches through a real isolated
Gateway. The workload uses four synthetic memory files, a real indexed agent,
and no provider calls. Corpus timestamps are fixed so returned provenance is
comparable too. Use a fresh output directory for each engine and revision:
PGCENSUS_ENGINE=sqlite PGCENSUS_OUT=/path/to/new-sqlite-output \
node --import ./scripts/tsx.mjs scripts/perf/memory-search-status-measure.ts
PGCENSUS_ENGINE=postgres PGCENSUS_CONNECTION=postgresql://localhost/throwaway \
PGCENSUS_OUT=/path/to/new-postgres-output \
node --import ./scripts/tsx.mjs scripts/perf/memory-search-status-measure.ts
The PostgreSQL database must be disposable and provide the qualified extensions.
The workload validates its fresh config, indexes the corpus, checks every top
result, and stops its Gateway. result.json retains response objects and
per-search statement-call, diagnostic-count-query, and worker-request counts.
The preload captures the database statement channel in each worker, plus native
SQLite calls that bypass that channel. Those are separate ledgers, never added.
Compare response objects within each engine and require diagnostic count queries
to disappear (PGCENSUS_EXPECT_COUNT_STATEMENTS=0 enforces this). Request windows
and worker events use the same monotonic nanosecond clock, including submillisecond
searches. Wall samples include instrumentation and incidental background work;
this small workload does not establish latency or relevance at scale.