Skip to content

Record pool and turn metrics through the telemetry adapter - #686

Open
samuelcolvin wants to merge 6 commits into
mainfrom
logfire-metrics
Open

Record pool and turn metrics through the telemetry adapter#686
samuelcolvin wants to merge 6 commits into
mainfrom
logfire-metrics

Conversation

@samuelcolvin

@samuelcolvin samuelcolvin commented Aug 8, 2026

Copy link
Copy Markdown
Member

Spans describe one session; they cannot answer whether the fleet is saturated, how often workers die, or how much of its duration budget a typical run uses. Add the aggregate side: five pool-health instruments (worker gauge, checkout wait, spawn cost, terminations by reason, session lifetime) and eleven per-turn ones, recorded by a TurnMetrics state machine that mirrors the protocol the way telemetry::Recorder does for spans — separate from it, because metrics must also cover untraced checkouts.

monty.run.duration minus monty.run.execution_time is time the host spent answering suspensions, which is where latency usually turns out to be. Attribute cardinality is enforced rather than hoped for: a function name is recorded only when the host resolved it, exc_type round-trips through ExcType and collapses to other otherwise, and no path reaches an attribute — sandboxed code must not be able to mint time series.

Measurements reach hosts two ways. A foreign SDK gets them through TelemetryAdapter::record_metric, which defaults to a no-op so adapters written before metrics keep working and TELEMETRY_ADAPTER_VERSION stays 1; the Python bridge probes for the method, the Node bridge emits a metric event kind. A Rust host uses Metrics::for_logfire instead and gets real instruments on its own meter, with exponential histogram buckets rather than the SDK defaults that would put every monty turn in one bucket.

The worker gauge is a gauge, not an up/down counter: one decrement missed on an error path would skew an up/down counter for the process's life. Recording never happens under the pool mutex, since it reaches into the host SDK and, for Python, its GIL.

Also run cargo test -p monty-pool with telemetry-adapter enabled in the Makefile and CI, without which none of this — nor the existing telemetry tests — is covered.

Claude-Session: https://claude.ai/code/session_0177dP9KE9Son2yHh3vgtMim


Summary by cubic

Add pool-wide and per-turn metrics to monty-pool, recorded on every checkout and delivered through the telemetry adapter or native meters. The catalog is closed to sandboxed values, adds live/idle worker gauges, and a single external-call histogram; fixes ensure accurate timing after restores and stable, correct gauges.

  • Bug Fixes

    • Re-base execution timing from a load reply so restored history is not double-counted.
    • End an open load turn when a restored feed re-raises, so its round-trip is not billed to monty.turn.duration{load}.
    • Attribute errors answering housekeeping turns to that turn, avoiding bogus monty.run.execution_time samples.
    • Publish worker gauges with a stable write to prevent races; zero a pool’s share on drop and count idle workers as terminated when a pool is dropped without close().
  • Migration

    • Feature renamed: telemetry-adapter -> telemetry (alias kept). monty-js/monty-python now depend on monty-pool with telemetry.
    • PoolConfig.metrics added; monty-js/monty-python wire it from the installed adapter. Rust hosts set PoolConfig.metrics = Metrics::for_logfire(...) (or use TelemetryAdapterHandle::metrics).
    • Adapters: TelemetryAdapter::record_metric is optional (no-op by default). Node emits { kind: 'metric' } as TelemetryMetricEvent; Python probes record_metric. TELEMETRY_ADAPTER_VERSION stays 1.

Written for commit c5f9783. Summary will update on new commits.

Review in cubic

Comment thread crates/monty-pool/src/metrics.rs Outdated
@veria-ai

veria-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

Comment thread crates/monty-pool/src/pool.rs Outdated
for _ in 0..died_idle {
self.count_termination("died_idle");
}
self.record_workers(idle, busy);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/pool.rs:238

record_workers(idle, busy) in acquire_worker_inner snapshots the state under the lock, then publishes after releasing it. A concurrent acquire/release can publish a newer snapshot in between, after which this call publishes the stale values last — leaving the worker gauge wrong until the next state transition. For example, a release snapshots (1, 0), a concurrent checkout consumes that worker and records (0, 1), then the release records (1, 0). Recording must stay outside the mutex, but needs a version/seq-lock guard or a re-snapshot-under-lock strategy so a stale snapshot cannot overwrite a newer one.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/monty-pool/src/pool.rs around line 238:

`record_workers(idle, busy)` in `acquire_worker_inner` snapshots the state under the lock, then publishes *after* releasing it. A concurrent acquire/release can publish a newer snapshot in between, after which this call publishes the stale values last — leaving the worker gauge wrong until the next state transition. For example, a release snapshots `(1, 0)`, a concurrent checkout consumes that worker and records `(0, 1)`, then the release records `(1, 0)`. Recording must stay outside the mutex, but needs a version/seq-lock guard or a re-snapshot-under-lock strategy so a stale snapshot cannot overwrite a newer one.

Comment thread crates/monty-pool/src/checkout.rs
Comment thread crates/monty-python/src/telemetry.rs
);
self.end_turn("ok");
}
Some(pb::child_event::Kind::Ok(_)) => self.end_turn("ok"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/metrics.rs:493

When a Load request adopts a dumped session whose cumulative execution clock is nonzero, the next feed's monty.run.execution_time is inflated by the pre-snapshot execution time. begin_turn resets reported_micros to 0 for Load, but the Ok event that answers a Load only calls end_turn — it never rebases reported_micros to the loaded session's total_execution_micros. So when the first feed after the load completes, end_run computes the delta as total - 0, recording the entire cumulative clock as that one feed's execution time. Rebase reported_micros from the total_execution_micros on the Load response before the next feed begins.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/monty-pool/src/metrics.rs around line 493:

When a `Load` request adopts a dumped session whose cumulative execution clock is nonzero, the next feed's `monty.run.execution_time` is inflated by the pre-snapshot execution time. `begin_turn` resets `reported_micros` to `0` for `Load`, but the `Ok` event that answers a `Load` only calls `end_turn` — it never rebases `reported_micros` to the loaded session's `total_execution_micros`. So when the first feed after the load completes, `end_run` computes the delta as `total - 0`, recording the entire cumulative clock as that one feed's execution time. Rebase `reported_micros` from the `total_execution_micros` on the `Load` response before the next feed begins.

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 8, 2026
@macroscopeapp

macroscopeapp Bot commented Aug 8, 2026

Copy link
Copy Markdown

Approvability

Verdict: Approved c5f9783

This PR adds new metrics recording capability through the telemetry adapter - an additive feature that doesn't modify unsafe code, sandbox boundaries, wire protocol parsing, snapshot formats, or break public APIs. Per repository guidelines, correctness concerns are handled separately by the Correctness check.

You can customize Macroscope's approvability policy. Learn more.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 20 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/monty-pool/src/worker.rs">

<violation number="1" location="crates/monty-pool/src/worker.rs:309">
P2: Restoring a snapshot taken during a suspension records `monty.turn.duration` for `load` only when the later resumed feed ends, so the load metric includes arbitrary host/sandbox time and gets the wrong outcome. Close the pending `load` turn when its re-announced suspension is received, before opening that suspension's host-call metric.</violation>
</file>

<file name="crates/monty-pool/src/metrics.rs">

<violation number="1" location="crates/monty-pool/src/metrics.rs:394">
P1: Restored sessions report all pre-dump execution again in the next `monty.run.execution_time` sample because this reset is never rebased from the `Load` reply. Capture `event.total_execution_micros` when handling that reply before a later feed ends.</violation>
</file>

<file name="crates/monty-pool/src/pool.rs">

<violation number="1" location="crates/monty-pool/src/pool.rs:238">
P2: Concurrent checkouts/releases can leave `monty.pool.workers` reporting stale or internally inconsistent idle/busy values because snapshots are recorded after releasing `state` without preserving mutation order. Serialize each state-change-and-gauge-publication sequence outside the pool mutex, or otherwise publish only the newest snapshot.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

self.feed = None;
self.pending = None;
self.turn = Some(("configure", now));
self.reported_micros = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Restored sessions report all pre-dump execution again in the next monty.run.execution_time sample because this reset is never rebased from the Load reply. Capture event.total_execution_micros when handling that reply before a later feed ends.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-pool/src/metrics.rs, line 394:

<comment>Restored sessions report all pre-dump execution again in the next `monty.run.execution_time` sample because this reset is never rebased from the `Load` reply. Capture `event.total_execution_micros` when handling that reply before a later feed ends.</comment>

<file context>
@@ -0,0 +1,1193 @@
+                self.feed = None;
+                self.pending = None;
+                self.turn = Some(("configure", now));
+                self.reported_micros = 0;
+            }
+            Some(pb::parent_request::Kind::Feed(_)) => {
</file context>

Comment thread crates/monty-pool/src/checkout.rs Outdated
#[cfg(feature = "telemetry-adapter")]
if let Some(metrics) = &mut self.metrics {
metrics.frame("received", len);
metrics.event(&event);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Restoring a snapshot taken during a suspension records monty.turn.duration for load only when the later resumed feed ends, so the load metric includes arbitrary host/sandbox time and gets the wrong outcome. Close the pending load turn when its re-announced suspension is received, before opening that suspension's host-call metric.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-pool/src/worker.rs, line 309:

<comment>Restoring a snapshot taken during a suspension records `monty.turn.duration` for `load` only when the later resumed feed ends, so the load metric includes arbitrary host/sandbox time and gets the wrong outcome. Close the pending `load` turn when its re-announced suspension is received, before opening that suspension's host-call metric.</comment>

<file context>
@@ -262,6 +303,13 @@ impl Worker {
+        #[cfg(feature = "telemetry-adapter")]
+        if let Some(metrics) = &mut self.metrics {
+            metrics.frame("received", len);
+            metrics.event(&event);
+        }
+        #[cfg(not(feature = "telemetry-adapter"))]
</file context>

Comment thread crates/monty-pool/src/metrics.rs Outdated
Comment thread crates/monty-pool/src/pool.rs Outdated
for _ in 0..died_idle {
self.count_termination("died_idle");
}
self.record_workers(idle, busy);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent checkouts/releases can leave monty.pool.workers reporting stale or internally inconsistent idle/busy values because snapshots are recorded after releasing state without preserving mutation order. Serialize each state-change-and-gauge-publication sequence outside the pool mutex, or otherwise publish only the newest snapshot.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-pool/src/pool.rs, line 238:

<comment>Concurrent checkouts/releases can leave `monty.pool.workers` reporting stale or internally inconsistent idle/busy values because snapshots are recorded after releasing `state` without preserving mutation order. Serialize each state-change-and-gauge-publication sequence outside the pool mutex, or otherwise publish only the newest snapshot.</comment>

<file context>
@@ -179,33 +209,49 @@ impl PoolInner {
+                for _ in 0..died_idle {
+                    self.count_termination("died_idle");
+                }
+                self.record_workers(idle, busy);
+                if let Some(worker) = reused {
+                    *outcome = if waited { "waited" } else { "idle" };
</file context>

Comment thread crates/monty-pool/src/pool.rs
Comment thread crates/monty-pool/src/worker.rs Outdated
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 9, 2026 09:27

Dismissing prior approval to re-evaluate 3b78ff8

config.request_timeout = options.request_timeout_ms.map(duration_from_ms).transpose()?;
config.duration_limit_grace = options.duration_limit_grace_ms.map(duration_from_ms).transpose()?;
config.max_checkouts_per_worker = options.max_checkouts_per_worker;
config.metrics = configured_adapter().map(TelemetryAdapterHandle::metrics);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/pool.rs:201

NativePool::new snapshots configured_adapter() into PoolConfig at construction time. If the telemetry adapter is installed after the NativePool is created, the pool permanently retains metrics = None and silently drops all pool and turn measurements — even for checkouts that happen later. Session tracing avoids this by looking up configured_adapter() at NativeSession::enter time, so the initialization order of telemetry versus pool creation determines whether metrics work. Consider deferring the metrics lookup to pool start or checkout time, matching how tracing resolves the adapter, or document that the adapter must be installed before NativePool construction.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/monty-js/src/pool.rs around line 201:

`NativePool::new` snapshots `configured_adapter()` into `PoolConfig` at construction time. If the telemetry adapter is installed after the `NativePool` is created, the pool permanently retains `metrics = None` and silently drops all pool and turn measurements — even for checkouts that happen later. Session tracing avoids this by looking up `configured_adapter()` at `NativeSession::enter` time, so the initialization order of telemetry versus pool creation determines whether metrics work. Consider deferring the `metrics` lookup to pool start or checkout time, matching how tracing resolves the adapter, or document that the adapter must be installed before `NativePool` construction.

Comment thread crates/monty-pool/src/worker.rs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/metrics.rs:478

The Error event handler at line 478 only treats dump as a housekeeping turn, so an Error replying to configure, load, reset, or install_dependencies falls into end_run instead of end_turn. This emits monty.run.execution_time and monty.run.duration_budget_used for a housekeeping failure, and may also consume an in-flight feed (via self.feed.take() in end_run), corrupting run metrics. The check should match all housekeeping turn labels, not just dump.

Suggested change
if matches!(self.turn, Some(("dump", _))) {
if matches!(self.turn, Some(("dump" | "load" | "reset" | "install_dependencies" | "configure", _))) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/monty-pool/src/metrics.rs around line 478:

The `Error` event handler at line 478 only treats `dump` as a housekeeping turn, so an `Error` replying to `configure`, `load`, `reset`, or `install_dependencies` falls into `end_run` instead of `end_turn`. This emits `monty.run.execution_time` and `monty.run.duration_budget_used` for a housekeeping failure, and may also consume an in-flight `feed` (via `self.feed.take()` in `end_run`), corrupting run metrics. The check should match all housekeeping turn labels, not just `dump`.

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 9, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 33 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing logfire-metrics (c5f9783) with main (f38c847)

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Results 📊

❌ Patch coverage is 72.89%. Project has 10006 uncovered lines.
❌ Project coverage is 83.24%. Comparing base (base) to head (head).

Files with missing lines (5)
File Patch % Lines
crates/monty-pool/src/telemetry/metrics.rs 62.94% ⚠️ 136 Missing and 9 partials
crates/monty-pool/src/pool.rs 90.80% ⚠️ 8 Missing and 6 partials
crates/monty-python/src/telemetry.rs 88.89% ⚠️ 5 Missing and 5 partials
crates/monty-pool/src/checkout.rs 90.00% ⚠️ 2 Missing and 1 partials
crates/monty-pool/src/telemetry/mod.rs 72.73% ⚠️ 3 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
- Coverage    83.38%    83.24%    -0.14%
==========================================
  Files          242       247        +5
  Lines        56961     59709     +2748
  Branches    120038    125340     +5302
==========================================
+ Hits         47496     49703     +2207
- Misses        9465     10006      +541
- Partials      3297      3355       +58

Generated by Codecov Action

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

@macroscopeapp
macroscopeapp Bot dismissed their stale review August 9, 2026 10:29

Dismissing prior approval to re-evaluate 446c570

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 9, 2026
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 9, 2026 11:14

Dismissing prior approval to re-evaluate fd2bccc

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 9, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/monty-pool/src/checkout.rs">

<violation number="1" location="crates/monty-pool/src/checkout.rs:1322">
P2: A checkout whose worker crashes mid-turn is reported two inconsistent ways: the worker termination shows reason `crash`, but the session lifetime (`monty.pool.session.duration`) is recorded as `abandoned` because `Drop::drop` fires `record_finish("abandoned")` whenever `finish` was never called. That collapses "the worker died" and "the caller chose to drop a live session" into one outcome, which weakens the session-outcome signal this PR is adding. Consider tracking a `finished`/`terminated` flag on the Checkout so the drop path records `abandoned` only when the worker was healthy, and records the session as `error`/`crash` when a termination was already counted.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/monty-pool/src/telemetry/metrics.rs
Comment thread crates/monty-pool/src/telemetry/metrics.rs
Comment thread crates/monty-pool/src/telemetry/metrics.rs
self.pool.release_capacity();
}
#[cfg(feature = "telemetry")]
self.record_finish("abandoned");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A checkout whose worker crashes mid-turn is reported two inconsistent ways: the worker termination shows reason crash, but the session lifetime (monty.pool.session.duration) is recorded as abandoned because Drop::drop fires record_finish("abandoned") whenever finish was never called. That collapses "the worker died" and "the caller chose to drop a live session" into one outcome, which weakens the session-outcome signal this PR is adding. Consider tracking a finished/terminated flag on the Checkout so the drop path records abandoned only when the worker was healthy, and records the session as error/crash when a termination was already counted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-pool/src/checkout.rs, line 1322:

<comment>A checkout whose worker crashes mid-turn is reported two inconsistent ways: the worker termination shows reason `crash`, but the session lifetime (`monty.pool.session.duration`) is recorded as `abandoned` because `Drop::drop` fires `record_finish("abandoned")` whenever `finish` was never called. That collapses "the worker died" and "the caller chose to drop a live session" into one outcome, which weakens the session-outcome signal this PR is adding. Consider tracking a `finished`/`terminated` flag on the Checkout so the drop path records `abandoned` only when the worker was healthy, and records the session as `error`/`crash` when a termination was already counted.</comment>

<file context>
@@ -1281,8 +1315,11 @@ impl Drop for Checkout {
             self.pool.release_capacity();
         }
+        #[cfg(feature = "telemetry")]
+        self.record_finish("abandoned");
     }
 }
</file context>

samuelcolvin and others added 5 commits August 9, 2026 12:49
Spans describe one session; they cannot answer whether the fleet is
saturated, how often workers die, or how much of its duration budget a
typical run uses. Add the aggregate side: five pool-health instruments
(worker gauge, checkout wait, spawn cost, terminations by reason, session
lifetime) and eleven per-turn ones, recorded by a `TurnMetrics` state
machine that mirrors the protocol the way `telemetry::Recorder` does for
spans — separate from it, because metrics must also cover untraced
checkouts.

`monty.run.duration` minus `monty.run.execution_time` is time the host
spent answering suspensions, which is where latency usually turns out to
be. Attribute cardinality is enforced rather than hoped for: a function
name is recorded only when the host resolved it, `exc_type` round-trips
through `ExcType` and collapses to `other` otherwise, and no path reaches
an attribute — sandboxed code must not be able to mint time series.

Measurements reach hosts two ways. A foreign SDK gets them through
`TelemetryAdapter::record_metric`, which defaults to a no-op so adapters
written before metrics keep working and TELEMETRY_ADAPTER_VERSION stays 1;
the Python bridge probes for the method, the Node bridge emits a `metric`
event kind. A Rust host uses `Metrics::for_logfire` instead and gets real
instruments on its own meter, with exponential histogram buckets rather
than the SDK defaults that would put every monty turn in one bucket.

The worker gauge is a gauge, not an up/down counter: one decrement missed
on an error path would skew an up/down counter for the process's life.
Recording never happens under the pool mutex, since it reaches into the
host SDK and, for Python, its GIL.

Also run `cargo test -p monty-pool` with `telemetry-adapter` enabled in
the Makefile and CI, without which none of this — nor the existing
telemetry tests — is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177dP9KE9Son2yHh3vgtMim
The name described the feature exactly while everything it gated existed
to feed a foreign SDK across a bridge. It no longer does:
`Metrics::for_logfire` and `TelemetryContext::for_logfire` are native Rust
paths with no adapter in them, so monty-server enables a feature called
`telemetry-adapter` precisely in order not to implement an adapter. The
feature gates all telemetry in the crate — spans, metrics, both delivery
paths — which is what it should be called, and matches monty-runtime's
existing `telemetry`.

`telemetry-adapter` stays as an alias: this crate is published and is
pinned by git rev elsewhere, so the old name keeps those consumers
building until they move. The `telemetry_adapter` module and its types
keep their names, being genuinely about the adapter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177dP9KE9Son2yHh3vgtMim
… names

The called function's name was recorded on every host round-trip except
`not_found`, and a script can mint one name per call: calling a method a
host object does not have raises `AttributeError` there, which comes back
as `error` rather than `not_found`, so a loop over `getattr(obj,
f'method_{i}')()` bought a time series each. Restricting it to outcomes
that "prove" resolution would not have fixed it either — a host whose
lookup is a callable resolves any name at all — so the attribute is gone,
and the rule is now absolute: no value the sandbox controls becomes an
attribute. The only name left is an os call's, which comes from the
protocol's oneof.

With that settled, the catalogue itself:

- the instrument definitions lead the file — they are what a reader is
  looking for, and every attribute's closed set is visible in one place
- `WORKERS` (a gauge split by `state`) becomes `LIVE_WORKERS`, a plain
  count, so that "idle" means one thing here
- `IDLE_WORKERS` counts workers blocked on the host rather than running
  code. It needs state shared across a pool's workers, so `Metrics` grows
  an `AtomicUsize`, and `pending` is now written and cleared in one place
  each (plus a `Drop`) so the gauge cannot drift from what it describes
- `HOST_CALL` and `OS_CALL` merge into `EXT_CALL`: one round-trip
  histogram, `kind` telling the suspensions apart
- `SPAWN_DURATION`, `RUN_BUDGET`, `OS_IO` and `ERRORS` are dropped, with
  the helpers that fed them

`SuspensionKind` also gains one variant per suspension the protocol has,
replacing a two-variant enum that kept its real discriminant in a string
beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177dP9KE9Son2yHh3vgtMim
- Gate the `Metrics` import in monty-pool's lib.rs so the crate builds
  without the `telemetry` feature, and add a featureless `cargo check` of
  monty-proto and monty-pool to the CI lint job so it stays that way.
- Total the live-worker gauge over every pool sharing one `Metrics`:
  the adapter handle now hands out one shared recorder, and each pool
  publishes deltas through a `LiveWorkersGauge` whose drop zeroes its
  share — per-pool series would clash (last-write-wins) and go stale.
- Record subprocess sent frames body-only, matching the other three
  transport/direction combinations of `monty.wire.frame.bytes`.
- Update the Python and JS telemetry tests to the reshaped metric
  catalogue (`monty.pool.workers.live`; no spawn-duration instrument).
- Tighten the two `for_logfire` docstrings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBuTusg2anZHSNSpmq5Fv8
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 9, 2026 11:54

Dismissing prior approval to re-evaluate 5c57b30

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 9, 2026
- Re-base the execution-clock ratchet from a load turn's reply: the stamp
  is the restored session's cumulative clock, spent in another process,
  so the next run records only its own delta instead of the whole
  restored history.
- End an open load turn when a restored feed re-raises its suspension,
  so the host round-trip is not billed to `monty.turn.duration{load}`.
- An error answering any open housekeeping turn is that turn's outcome,
  not a run's: no more fake `monty.run.execution_time` samples from a
  failed load/install_dependencies.
- Publish the worker gauges through `record_stable`, which re-records
  until the value it recorded is still current: racing publishes could
  otherwise land out of order and stick until the next transition, which
  may never come. Still lock-free across the host-SDK call.
- Count idle workers as terminated (`closed`) when a pool is dropped
  without `close()`, a supported shutdown that under-reported turnover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBuTusg2anZHSNSpmq5Fv8
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 9, 2026 13:24

Dismissing prior approval to re-evaluate c5f9783

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/monty-pool/src/pool.rs">

<violation number="1" location="crates/monty-pool/src/pool.rs:346">
P2: Pool-drop termination metrics call the host adapter while holding `state`; snapshot `idle.len()` before the loop so adapter/GIL work happens after unlocking.</violation>
</file>

<file name="crates/monty-pool/src/telemetry/metrics.rs">

<violation number="1" location="crates/monty-pool/src/telemetry/metrics.rs:342">
P3: `record_stable` retries in an unbounded loop, and on every pass it records into the host metrics SDK (and, for the Logfire sink, takes the instruments read lock). If the shared gauge atomic keeps changing between the `record` call and the closing load (many workers blocking/unblocking at once, or several pools sharing one `Metrics`), a thread can spin through many SDK interactions with no bound. In practice gauge transitions are infrequent so this is unlikely to bite, but adding an iteration cap (record the loaded value and bail after a couple of stale corrections) would bound the worst case without losing the self-correcting behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +346 to +348
for _ in 0..lock_ignore_poison(&self.state).idle.len() {
self.count_termination("closed");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pool-drop termination metrics call the host adapter while holding state; snapshot idle.len() before the loop so adapter/GIL work happens after unlocking.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-pool/src/pool.rs, line 346:

<comment>Pool-drop termination metrics call the host adapter while holding `state`; snapshot `idle.len()` before the loop so adapter/GIL work happens after unlocking.</comment>

<file context>
@@ -336,6 +336,19 @@ impl PoolInner {
+    /// that path does not under-report worker turnover. (`close` drains the
+    /// idle queue and counts, so nothing is counted twice.)
+    fn drop(&mut self) {
+        for _ in 0..lock_ignore_poison(&self.state).idle.len() {
+            self.count_termination("closed");
+        }
</file context>
Suggested change
for _ in 0..lock_ignore_poison(&self.state).idle.len() {
self.count_termination("closed");
}
let idle_workers = lock_ignore_poison(&self.state).idle.len();
for _ in 0..idle_workers {
self.count_termination("closed");
}

/// across the call into its SDK (the deadlock the pool avoids everywhere).
fn record_stable<T: PartialEq + Copy>(load: impl Fn() -> T, record: impl Fn(T)) {
let mut value = load();
loop {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: record_stable retries in an unbounded loop, and on every pass it records into the host metrics SDK (and, for the Logfire sink, takes the instruments read lock). If the shared gauge atomic keeps changing between the record call and the closing load (many workers blocking/unblocking at once, or several pools sharing one Metrics), a thread can spin through many SDK interactions with no bound. In practice gauge transitions are infrequent so this is unlikely to bite, but adding an iteration cap (record the loaded value and bail after a couple of stale corrections) would bound the worst case without losing the self-correcting behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-pool/src/telemetry/metrics.rs, line 342:

<comment>`record_stable` retries in an unbounded loop, and on every pass it records into the host metrics SDK (and, for the Logfire sink, takes the instruments read lock). If the shared gauge atomic keeps changing between the `record` call and the closing load (many workers blocking/unblocking at once, or several pools sharing one `Metrics`), a thread can spin through many SDK interactions with no bound. In practice gauge transitions are infrequent so this is unlikely to bite, but adding an iteration cap (record the loaded value and bail after a couple of stale corrections) would bound the worst case without losing the self-correcting behavior.</comment>

<file context>
@@ -329,6 +331,24 @@ fn to_isize(n: usize) -> isize {
+/// across the call into its SDK (the deadlock the pool avoids everywhere).
+fn record_stable<T: PartialEq + Copy>(load: impl Fn() -> T, record: impl Fn(T)) {
+    let mut value = load();
+    loop {
+        record(value);
+        let current = load();
</file context>

@samuelcolvin

Copy link
Copy Markdown
Member Author

(Written by claude)

Security review

Scope: the whole branch diff, weighted toward host/parent code, with a specific look at whether the new metrics code can cause resource exhaustion. heap.rs, path_security.rs, mount_table.rs and the proto schema are untouched; the only wire-path change is FrameRecv::recv returning the frame length it had already validated against MAX_FRAME_LEN, so decoding and its validation are unchanged.

The cardinality question is handled — this was the main risk and it's closed

Every attribute recorded is a closed set fixed in metrics.rs: outcome, reason, turn, kind, direction, stream, and function (from the protocol's own os_call oneof). Nothing the sandbox names — a called function's name, an exception class, a looked-up name, a path — reaches an attribute, under any outcome. crates/monty-pool/src/telemetry/metrics.rs:1051 tests exactly the case that would otherwise slip through (AttributeError from a host object comes back as error, not not_found). Hostile values from a compromised child are also safe: total_execution_micros: u64 goes through saturating_sub + Duration::from_micros (total), and byte lengths through saturating i64::try_from. No panic path, no unbounded map — the instrument map is keyed by 13 &'static strs.

Finding 1 — pool mutex held across host-adapter (GIL) calls

crates/monty-pool/src/pool.rs:339

for _ in 0..lock_ignore_poison(&self.state).idle.len() {
    self.count_termination("closed");
}

Rust extends the for scrutinee's temporaries over the whole loop body, so the MutexGuard is live across every count_termination — each of which reenters the host adapter and takes the Python GIL. I confirmed the scoping with a standalone program (try_lock fails on every iteration). This is the exact inversion the rest of the file goes out of its way to avoid ("never call into the host adapter under the lock", pool.rs:224; record_stable's docstring calls it "the deadlock the pool avoids everywhere").

Trigger: dropping a Pool without close() while workers sit idle — a supported shutdown. Impact: the pool mutex is held for N × GIL-handoff, blocking every concurrent checkout/release in the process; and it is a real deadlock if a host's record_metric ever reaches back into the pool. Fix is the one cubic suggested (thread still unresolved): snapshot idle.len(), then loop.

Finding 2 — record_stable's retry loop is unbounded, and degrades under exactly the conditions that trigger it

crates/monty-pool/src/telemetry/metrics.rs:340

The loop re-records until the atomic stops changing, with no iteration cap, and each pass is another call into the host SDK. The sandbox controls the input rate: every external call flips the shared idle counter twice, so for _ in range(n): host_call() across the pool keeps it moving. Measured amplification of monty.pool.workers.idle writes (Python binding, counting adapter):

workers sink latency suspensions idle-gauge records amplification
8 ~0 2,400 5,814 1.21×
16 0.5 ms 4,800 32,681 3.40×

It's a positive-feedback shape — the slower the host's sink, the more retries, which loads the sink further — and 0.5 ms is not pessimistic: I measured 0.5–2.4 ms per bridge call whenever any host thread is CPU-busy in Python. Bounding the retries (2–3) keeps the self-correcting behaviour without the tail. Also raised by cubic (P3), unresolved.

Finding 3 — metrics roughly triple sandbox-controlled host-callback traffic (documented class, new magnitude)

Every frame now records on the pool's runtime thread; a printed line costs two record_metric calls (monty.wire.frame.bytes, monty.print.bytes) on top of the existing per-print log. 20,000 × print('x') with a telemetry adapter installed produced 40,019 metric calls + 20,000 log calls. Wall time for that feed, Python binding:

no adapter main + adapter this branch + adapter
host thread idle 0.067 s 0.19 s 0.23 s
one host thread running plain Python 0.95 s 124.8 s 143.8 s

The 130× cliff is GIL handoff latency, is pre-existing, and is already written down in limitations/pool-architecture.md (which this PR correctly updates to cover measurements). What's new is that the per-frame instruments scale with sandbox output rate rather than with turns — they're the only instruments that do. If you want a lever, sampling or a per-turn aggregate for monty.wire.frame.bytes / monty.print.bytes would remove the sandbox's ability to set the host callback rate, at no cost to the other twelve instruments.

Checked and clean

  • Gauge arithmetic under concurrency: LiveWorkersGauge::record's swap-then-fetch_add telescopes correctly however the two pools interleave; to_isize saturates; Shared.idle increments and decrements are balanced through the single take_pending path (AtomicUsize can't underflow from a reachable state, and would saturate rather than wrap if it did).
  • JS bridge queue overflow → permanent telemetry disable: I expected metrics to fill napi's 1024-slot threadsafe queue and trip the one-way disabled flag, silencing telemetry process-wide. Did not reproduce: 200,000 printed lines with the JS event loop deliberately blocked for 4 s queued 400,015 metric + 200,004 span events, host RSS stayed ~100 MB, and a subsequent session still produced spans. (Both bindings pass an unparented context when captureContext returns nothing, so there is no previously-free untraced path that metrics newly make expensive.)
  • worker.rs frame-length bookkeeping: send_buf.len() - 4 cannot underflow — encode_framed_into clears and writes the 4-byte prefix before the body, and errors return before the subtraction.
  • Reference/lifecycle counting in checkout.rs: started is taken by whichever of finish/Drop runs first, so a session is recorded once; close() drains idle before Drop counts it, so terminations aren't double-counted.
  • No unsafe, no new allocation path that bypasses ResourceTracker (nothing sandbox-sized is built host-side; close_suspension's 2–3 element attribute Vec is the only per-event allocation), no new unwrap/expect reachable from sandboxed input.

Unrelated trap I hit, in case it bites anyone else: crates/monty-js/ts/binary.ts prefers the installed @pydantic/monty-<platform> npm package over the workspace build, so after make install-js the JS tests run a published monty binary against the locally built addon and fail with ChildEvent.kind: invalid wire type: Varint. MONTY_BIN=<workspace>/target/debug/monty fixes it. Not a branch issue, but it looks alarmingly like a protocol desync.

@samuelcolvin

Copy link
Copy Markdown
Member Author

@davidhewitt I need your help on this once you're back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants