Skip to content

feat(agent): stream per-job runner output to the gateway - #503

Draft
PeronGH wants to merge 2 commits into
masterfrom
feat/runner-log-streaming
Draft

feat(agent): stream per-job runner output to the gateway#503
PeronGH wants to merge 2 commits into
masterfrom
feat/runner-log-streaming

Conversation

@PeronGH

@PeronGH PeronGH commented Jul 27, 2026

Copy link
Copy Markdown
Member

Capture each runner's output on the machine and report it on the existing attach
stream, so a job's log can be tailed live while it runs. Nothing is stored on
the machine.

Shape

  • RunnerLogChunk added to the AttachRequest oneof, carrying job_id, a
    per-job seq, raw bytes, and a dropped_bytes count.
  • Wired into all four backends: SSH Data/ExtendedData for the VM runner,
    docker logs --follow for the container runner, and piped stdout/stderr for
    the host and Windows-interop runners.
  • This is the one lossy path in the agent, deliberately. The attach stream
    is a control plane: a heartbeat later than 60s marks the machine offline and
    stops placement. Output can arrive orders of magnitude faster than the egress
    channel drains, so every write passes a per-job token budget and a
    non-blocking try_send, and anything that does not fit is dropped and
    counted. A chatty job loses log lines, never throughput.
  • Output travels on its own queue with its own delivery contract: try_send,
    never awaited, and never parked in the pending slot that carries an
    undelivered verdict across a reconnect. spawn_supervisor returns a named
    struct rather than a tuple of two same-typed receivers, because swapping them
    would make output durable and verdicts lossy and still compile.
  • Readers are always drained regardless of the budget — with Stdio::piped()
    that is a liveness requirement, not just capture.

Behaviour change

Host-runner output is now piped rather than inherited, so it no longer appears
in the service manager's stdout/stderr capture, where it used to land
interleaved and unattributed. Use the live tail instead.

Tests

  • Chunks reach the gateway on a real stream and leave the verdict pending
    slot untouched — the copy-paste bug a neighbouring select! arm invites, and
    the one thing nothing else would catch.
  • Oversized writes split and number in order; a full channel sheds, burns a
    seq, and reports the loss on the next chunk; sustained output past the burst
    allowance is shed rather than queued.
  • End-to-end through handle_provision → backend routing → sink, so a backend
    that captures nothing fails.

PeronGH added 2 commits July 27, 2026 18:05
Vendored ahead of the BSR publish from the platform repo; the next
`make fleet-proto-sync` reconciles this copy with the pushed module.
Every backend discarded run.sh output: the VM path dropped ssh Data frames,
Docker never attached to the container, interop drained stdout into a debug
log, and the host path inherited stdio into the service manager's tee. Capture
it per job and ship it as RunnerLogChunk.

Output rides its own channel and its own contract. The attach stream is a
control plane whose heartbeat writes to a 64-slot channel with a blocking
send, and the gateway flips a machine Offline after 60s without one, so the
log path never awaits and never touches the pending slot that carries a
verdict across a reconnect. A chunk that does not fit is dropped, counted, and
reported as a seq gap.

Host-runner output no longer reaches the service manager's stdout/stderr
capture, where it used to land interleaved and unattributed.
Copilot AI review requested due to automatic review settings July 27, 2026 12:23

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog 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.

ℹ️ No critical issues — one concurrency rough edge on a best-effort counter, inline.

Reviewed changes — the fleet agent now captures each runner's stdout/stderr and streams it to the gateway on the existing attach stream for live tailing, as a deliberately lossy path that never delays the control-plane heartbeat.

  • Add RunnerLogChunk to the attach oneof — new field runner_log = 6 on AttachRequest, carrying job_id, per-job seq, raw data, and a best-effort dropped_bytes; documented as the one lossy, never-acked message on the stream.
  • New joblog.rs sinkJobLogSink splits output into 32KiB chunks, gates each through a TokenBucket (256KiB/s sustained, 1MiB burst), then try_sends onto a dedicated log channel; shed bytes are counted and reported on the next successful chunk.
  • Split egress from output in attach.rsspawn_supervisor returns SupervisorHandles (a named struct, not a tuple of two same-typed receivers) and connect_and_serve gains a log_rx arm that uses try_send, is never awaited, and never parks in the verdict-durability pending slot.
  • Wire all four backends — SSH Data/ExtendedData (vm.rs), docker logs --follow (docker.rs), and piped stdout/stderr for host (runner.rs) and Windows-interop (interop.rs) runners; host output is now piped rather than inherited (behavior change, documented).
  • Tests — chunks reach a real gateway stream while leaving pending untouched; oversized writes split and number in order; a full channel sheds, burns a seq, and reports the loss; sustained output past the burst allowance is shed; end-to-end through handle_provision → backend routing → sink.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// Subtract rather than store 0: a concurrent writer may have added
// to the count between the load above and here.
Ok(()) => {
self.inner.dropped.fetch_sub(reported, Ordering::Relaxed);

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.

⚠️ This dropped-bytes accounting races under concurrent writers. Host and interop runners share one sink across two pipe/reader tasks, so two write_chunk calls can both read the same reported at line 111, both try_send OK, and both fetch_sub(reported) here. The first zeroes the counter; the second underflows the AtomicU64 to ~u64::MAX, so the next successful chunk claims exabytes dropped — and both chunks already over-reported the same reported value to the consumer.

This only corrupts the best-effort dropped_bytes field (the seq gap remains the authoritative loss signal), so it is non-blocking, but it defeats the care the surrounding accounting takes.

Technical details
# Concurrent write_chunk double-subtracts the shared dropped counter

## Affected sites
- `fleet/arcbox-fleet-agent/src/joblog.rs:111``reported = dropped.load(Relaxed)`
- `fleet/arcbox-fleet-agent/src/joblog.rs:124-125` — success path `dropped.fetch_sub(reported, Relaxed)`

## Why it triggers
- The `budget` mutex is released after `take()`, so steps load→build→send→clear run unserialized.
- Host runner (`run_host_job`) pipes stdout and stderr as two tasks sharing one `JobLogSink` clone; interop pipes stderr while a line-reader task writes stdout to the same sink. Both are real concurrent `write_chunk` callers.
- Interleave: dropped=D>0; A reads reported=D; B reads reported=D; A send OK, fetch_sub(D)→0; B send OK, fetch_sub(D)→wraps to u64::MAX-D+1.

## Required outcome
- A successful send must clear only the amount that chunk actually reported, and the counter must never underflow, even when two sends observe the same `reported`.

## Suggested approach (optional)
- Move the load + build-`dropped_bytes` + send + clear under the existing `budget` mutex (it is already the single serialization point and holds no await), so `reported` and its subtraction are atomic w.r.t. other writers.
- Or replace the `load`/`fetch_sub` pair with a CAS loop that subtracts `min(reported, current)`, so the clear is claim-once and saturating.

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

Adds lossy live streaming of per-job runner output over the attach stream.

  • Extends the fleet protocol with sequenced RunnerLogChunk messages and dropped-byte metadata.
  • Captures output from VM, container, host, and Windows-interop runners.
  • Adds separate supervisor log queues, rate limiting, bounded non-blocking forwarding, and coverage for chunking and backend routing.

Confidence Score: 2/5

The PR should not merge until dropped-byte accounting is made concurrency-safe and log traffic is prevented from occupying heartbeat and verdict capacity.

Concurrent pipe readers can underflow the shared loss counter, and a slow attach stream allows log chunks to fill the same downstream queue used by control-plane messages.

Files Needing Attention: fleet/arcbox-fleet-agent/src/joblog.rs, fleet/arcbox-fleet-agent/src/attach.rs

T-Rex T-Rex Logs

What T-Rex did

  • Executed a focused Rust test reproducing the dropped-byte counter underflow in JobLogSink using two synchronized concurrent writers after seeding a four-byte dropped balance.
  • Observed the test results showing two chunks reported dropped_bytes=4 at sequences 3 and 4 and a subsequent wrapped value of u64::MAX-3 at sequence 5; the test passed after asserting the wrapped value and the production source file was restored.
  • Ran a focused Tokio test harness patch to exercise the connect_and_serve path against an in-process gateway paused during downstream draining; the verdict remained blocked for about 750 ms and arrived behind 96 runner-log messages after draining.
  • Encountered a missing protoc during prost-build, causing cargo to exit with code 101.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
fleet/arcbox-fleet-agent/src/joblog.rs Introduces bounded chunking and loss accounting, but concurrent stdout/stderr writers can corrupt dropped-byte metadata.
fleet/arcbox-fleet-agent/src/attach.rs Multiplexes logs onto the attach stream, but the final request queue remains shared with heartbeat and verdict traffic.
fleet/arcbox-fleet-agent/src/runner.rs Wires per-job sinks into all runner lifecycle paths and drains pipe-backed stdout and stderr.
fleet/arcbox-fleet-agent/src/docker.rs Adds detached Docker log following for combined container stdout and stderr.
fleet/arcbox-fleet-agent/src/interop.rs Captures Windows wrapper output after the PID handshake while continuously draining both pipes.
fleet/arcbox-fleet-agent/src/vm.rs Forwards SSH data and extended-data frames through the per-job sink.
fleet/arcbox-fleet-proto/proto/arcbox/fleet/v1/fleet.proto Adds the RunnerLogChunk attach variant with job, sequence, raw data, and loss fields.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    R[Runner stdout/stderr] --> S[Per-job JobLogSink]
    S -->|token budget + try_send| L[Log queue]
    L --> A[Attach connection loop]
    A --> Q[Shared request queue]
    H[Heartbeat task] --> Q
    V[Lifecycle events] --> A
    Q --> G[Gateway attach stream]
Loading

Reviews (1): Last reviewed commit: "feat(agent): stream per-job runner outpu..." | Re-trigger Greptile

Comment on lines +111 to +125
let reported = self.inner.dropped.load(Ordering::Relaxed);
let chunk = RunnerLogChunk {
job_id: self.inner.job_id.clone(),
seq: self.inner.seq.fetch_add(1, Ordering::Relaxed),
data: slice.to_vec(),
dropped_bytes: reported,
};
let message = AttachRequest {
msg: Some(attach_request::Msg::RunnerLog(chunk)),
};
match self.inner.tx.try_send(message) {
// Subtract rather than store 0: a concurrent writer may have added
// to the count between the load above and here.
Ok(()) => {
self.inner.dropped.fetch_sub(reported, Ordering::Relaxed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Dropped-byte counter underflows

When host or Windows-interop jobs write stdout and stderr concurrently after a drop, both writers can load and report the same nonzero counter before each subtracts it. This underflows the AtomicU64, causing a later chunk to report a dropped_bytes value near u64::MAX.

Artifacts

Repro: focused test injector that executes concurrent writes through the repository JobLogSink

  • Evidence file captured while the check ran.

Repro: verbose Cargo test output showing duplicate dropped-byte reports and the subsequent wrapped value

  • The full command output behind this check.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +541 to +543
Some(msg) => {
if req_tx.try_send(msg).is_err() {
tracing::trace!("runner log chunk dropped; request channel full");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Logs occupy control-plane capacity

When a chatty job fills req_tx while the gRPC stream drains slowly, heartbeats and verdicts block behind queued log chunks because they share this downstream channel. Heartbeat delay can mark the machine offline, and a blocked verdict send stalls cancellation, drain, and acknowledgement handling in the connection loop.

Artifacts

Repro: focused Tokio and tonic test harness patch

  • Evidence file captured while the check ran.

Repro: focused test output showing the verdict blocked for 750 ms and delivered behind 96 log chunks

  • The full command output behind this check.

View artifacts

T-Rex Ran code and verified through T-Rex

@PeronGH
PeronGH marked this pull request as draft July 28, 2026 05:23
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