Skip to content

fix(backup): bound the command result against the server's 1 MiB cap, not the IPC frame (#3001) - #3267

Merged
ToddHebebrand merged 3 commits into
mainfrom
ToddHebebrand/fix-3001-silent-result-loss
Aug 8, 2026
Merged

fix(backup): bound the command result against the server's 1 MiB cap, not the IPC frame (#3001)#3267
ToddHebebrand merged 3 commits into
mainfrom
ToddHebebrand/fix-3001-silent-result-loss

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Fixes the residual reported in #3001 (re-confirmed on v0.104.0 / a3dc568ec during release QA). The original 64 MB-vs-16 MiB IPC oversize path was closed by #3004/#3037; this is the second, silent loss path underneath it.

Confirmed root cause

The terminal result is refused by the server's 1 MiB cap on the result fieldcommandResultSchema in apps/api/src/routes/agents/schemas.ts — not by anything on the agent.

The backup helper's stdout is the full BackupJob JSON including one snapshot.files entry per backed-up file (~522 B: source path + backup path + sha256 + modTime). The forwarder (agent/internal/heartbeat/heartbeat.go, case TypeBackupResult) parses that body and assigns it to CommandResult.Result, i.e. the wire result field — not to stdout, which has a 5 MB budget. So:

1048576 / 522  =  ~2008 files

which sits exactly inside the observed bracket: 1,200 files (~0.6 MB) lands, 4,000 files (~2.1 MB) is refused. It is the tightest limit anywhere on the path, and by a wide margin — 16 MiB agent IPC frame, 16 MiB agent WS read limit, 100 MiB ws server maxPayload.

The prime hypothesis in the issue (a WS max-payload) is ruled out. @hono/node-ws creates its WebSocketServer with no maxPayload, so ws applies its 100 MiB default; the ~2 MB frame arrives intact. No proxy layer caps it either. The frame is dropped at the application layer, one safeParse before the backup handler.

Why it was silent at every layer

Three independent causes, all fixed here:

  • Agent, producer. The tiered degradation from fix(agent): bound the backup result payload to the IPC frame (#3001) #3004 bounded against ipc.MaxMessageSize - 64 KiB ≈ 15.9 MiB — the next hop, not the destination. A 2 MB result cleared it untouched, so no tier ran and no degradation line was logged.
  • Server. agentWs.ts logged the rejection as a generic Invalid message from agent <id>: carrying only the raw Zod issues — no commandId, no size, no message type. Both backup-specific lines (Processing backup result / Dropping backup result) live inside processCommandResult, downstream of the failed parse, so neither could ever print. Grepping for them reads as "the frame vanished".
  • Agent, consumer. The server's {type:'error', code:'INVALID_MESSAGE'} reply carries no id, so readPump discarded it under the "not a command" skip. The write had genuinely succeeded, so every send path reported success.

The fix

1. Make the terminal result survive. New leaf package agent/internal/wire holds the server's cap. result_bounds.go now bounds Stdout against wire.CommandResultBudget as well as the IPC frame, so tier 2 empties the per-file index at the limit that actually binds. A 100k-file backup degrades to scalars-plus-snapshot-identity and reports completion; a 1,200-file backup is still sent byte-for-byte intact with its full file index.

The rule the change encodes: bound against the tightest limit anywhere on the path, never the one nearest to hand.

2. Kill the silence.

  • Server: a rejected command_result now logs at error with commandId, frame bytes, measured result bytes and the limit, and says plainly that the job will be reaped. Everything else stays a warning. The error reply echoes commandId and messageType.
  • Agent: readPump handles inbound error frames and logs them at error with the server's code and details.
  • Agent: a generic backstop in SendResult — if the result body still exceeds the cap, it is replaced with a _breezeResultOmitted marker and logged at error, so the terminal status lands regardless. This is deliberately not backup-specific: software inventory, patch scans and filesystem analysis are all result bodies that scale with the endpoint and share the same exposure.

3. The misleading log line. sendBackupResult reported limitBytes=16777216 unconditionally, so a 10 KB payload truncated by the 8 KiB maxResultTextBytes stderr cap was described as overflowing a 16 MiB frame. Attribution is now tracked as the tiers run and reported as limitName + limitBytes.

4. Regression tests at the layer the cause lives in — see below.

fitBackupResultToIPC is renamed fitBackupResultForDelivery: the old name asserted the exact wrong thing about which limit matters, and that assumption is what shipped this bug.

Not raising the cap — needs your sign-off

The result cap stays at 1 MiB. A larger cap only moves the cliff (a 100k-file index is ~52 MB and fits no sane limit), and the agent-side bound is the actual fix. There is a defensible argument for raising it to 5 MB to match stdout/stderr — it would preserve restore browsing for ~5x more endpoints, and the inconsistency is arguably what caused this — but that is a security-surface change, so it is left as a one-line, mirrored decision rather than made here.

Related: the new loud logging will likely reveal other command types already hitting this cap silently. Worth watching the first week of REJECTED command_result lines.

Verification

Command Result
go test -race ./cmd/breeze-backup/... ./internal/websocket/... ./internal/wire/... ./internal/heartbeat/... ./internal/ipc/... pass (5 packages)
go vet + gofmt -l on changed packages clean
GOOS=windows go build ./..., GOOS=linux go build ./... clean
vitest run schemas.commandResult.test.ts schemas.test.ts schemas.heartbeatTolerance.test.ts commands.test.ts 98 passed
vitest run agentWs.test.ts agentWs.enqueueContract.test.ts agentWs.terminalResultSchema.test.ts 105 passed
tsc --noEmit (apps/api) clean

New tests:

  • TestFourThousandFileRunIsDegradedForTheServerCap — the QA reproduction. Asserts the fixture is a size the old IPC-only bounding accepted, then that it is now degraded, attributed to the server cap, with the file index as the thing dropped.
  • TestTwelveHundredFileRunIsSentIntact — the other half: the run that worked must keep its full index. Guards against a fix that degrades everything.
  • TestHundredThousandFileRunStillReportsCompletion — requirement 1 as a test, including snapshot identity survival.
  • TestStderrOnlyDegradationNamesTheTextCap — requirement 3.
  • TestMaxCommandResultBytesMatchesServerSchema (Go) parses the TypeScript declaration; schemas.commandResult.test.ts parses the Go one. The cap is pinned from both directions, so raising one alone reddens CI rather than quietly re-opening this issue.
  • boundResultFieldForServer coverage: oversize dropped with status preserved, in-budget untouched, unmarshallable handled, and SendResult proven to bound before enqueue.

🤖 Generated with Claude Code

… not the IPC frame (#3001)

The #3001 residual, reproduced on v0.104.0: a 4,000-file backup completes on
the endpoint, its terminal result never reaches the API, and the stale-backup
reaper fails a job that succeeded. A 1,200-file run lands normally. Nothing is
logged anywhere on either side.

Root cause. The result is refused by `commandResultSchema`'s 1 MiB cap on the
`result` field (apps/api/src/routes/agents/schemas.ts). The backup helper's
stdout is the full BackupJob JSON including one `snapshot.files` entry per
backed-up file (~522 B each), and the forwarder assigns that body to `result`
rather than `stdout` — which has a 5 MB budget. 1048576/522 puts the cliff at
~2,008 files, exactly inside the observed 1,200-passes / 4,000-fails bracket.

The silence had three independent causes, all fixed here:
  - the helper's tiered degradation bounded against the 16 MiB IPC frame — the
    next hop, not the binding limit — so it never fired and never logged;
  - the server logged the rejection as a generic invalid-message with no
    commandId, no size and no type, while both backup-specific log lines live
    downstream of the failed parse and never ran;
  - the server's error frame carries no `id`, so the agent's readPump discarded
    it under the "not a command" skip.

Changes:
  - agent/internal/wire: new leaf package mirroring the server's cap, pinned to
    the TypeScript declaration by a test on each side.
  - helper: bound Stdout against the server budget as well as the IPC frame, so
    tier 2 drops the per-file index at the limit that actually binds. A 100k-file
    backup now reports completion with its snapshot identity intact.
  - WS client: generic backstop replacing an over-cap `result` body with a marker
    so ANY command type keeps its terminal status; and readPump now logs server
    rejections instead of dropping them.
  - server: a rejected command_result logs at error with commandId, frame size,
    measured result size and the limit, and echoes commandId in the reply.
  - the degradation log line names the limit that actually fired instead of
    always claiming the IPC frame (it reported limitBytes=16777216 for a 10 KB
    payload truncated by the 8 KiB stderr cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: 16c9132
Status: ✅  Deploy successful!
Preview URL: https://270e16c2.breeze-9te.pages.dev
Branch Preview URL: https://toddhebebrand-fix-3001-silen.breeze-9te.pages.dev

View logs

…not the bare cap

Review finding on #3267. boundResultFieldForServer and the SendResult
short-circuit compared against wire.MaxCommandResultBytes, so the one guard
that protects every NON-backup command type ran with no margin for the
server's JSON.stringify re-measurement. A body landing in the 64 KiB band
below the cap could pass here and still be refused on arrival — the exact
silent loss this PR closes, for the commands with no producer-side bounding.

Both comparisons now use wire.CommandResultBudget; the bare cap is kept only
for reporting the server's contract in logs and the omission marker.
TestBoundResultFieldUsesTheBudgetNotTheBareCap pins the choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: /pr-review-toolkit:review-pr — code-reviewer + silent-failure-hunter (both ran the touched Go and Vitest suites themselves, not just read the diff).

Findings: 1 raised → addressed in 88a32ea49; 0 outstanding.

  • code-reviewer (1 finding, Important). The generic backstop boundResultFieldForServer and the SendResult short-circuit compared against the bare wire.MaxCommandResultBytes rather than the headroom-adjusted wire.CommandResultBudget. The backup helper correctly targeted the budget, but the backstop — which is the only guard for every non-backup command type (software inventory, patch scans, filesystem analysis) — ran with no margin for the server's JSON.stringify(JSON.parse(...)) re-measurement. A body landing in the 64 KiB band below the cap could pass agent-side and still be refused on arrival: this issue's exact failure, for the commands with no producer-side bounding. Both comparisons now use the budget; the bare cap is kept only for reporting the server's contract in logs and the omission marker. TestBoundResultFieldUsesTheBudgetNotTheBareCap pins the choice so it cannot drift back.

    Also verified clean by that reviewer: tier-4 convergence under the tighter budget, the limit-attribution capture point, the readPump type == "error" branch placement (no command type is literally "error", and these frames were already being discarded by the msg.ID == "" skip, so it is pure added logging), the len(data) short-circuit's soundness, and both cross-language pin tests actually resolving and reading the other language's file rather than skipping.

  • silent-failure-hunter (0 findings). Confirmed end-to-end rather than from comments: every degradation path emits both a note and a log line; agent-side rejections log at slog.LevelError, which is above the warn shipping threshold in config.go:329, so they actually reach the server rather than only a local file on the endpoint; agentMessageSchema is a discriminated union that genuinely routes an oversize result through the new command_result-specific error branch; and backupResultPersistence.ts commits the backup_jobs status update unconditionally ahead of the providerSnapshotId gate, so a degraded body still flips the job out of running — the property that actually matters for the reaper. Empty snapshot.files is truthy, so the stale-row cleanup still runs.

    One low-severity edge it flagged, not fixed here: if tier 4 ever fires for a snapshot that a prior delivery already indexed, lastResortStdout drops the nested snapshot object, so the file-rows cleanup (gated on result.snapshot?.files) is skipped and stale backup_snapshot_files rows survive while hasIndexedFiles reports false. Data-consistency nit, not a stuck job or a lost log — and tier 4 now requires the backup-specific bounding and the generic backstop to both miss. Recording it as known rather than expanding this PR.

Tests: go test -race ./cmd/breeze-backup/... ./internal/websocket/... ./internal/wire/... ./internal/heartbeat/... ./internal/ipc/... pass; go vet + gofmt -l clean; GOOS=windows/GOOS=linux go build ./... clean; vitest run 98 passed (schemas + commands) and 105 passed (agentWs); tsc --noEmit clean. Full CI green on 88a32ea49, including Test Agent, Test Agent (race), Test API, Type Check and all four Integration Tests shards.

Open decision for the maintainer: the result cap stays at 1 MiB. Raising it to 5 MB to match stdout/stderr would preserve restore browsing for ~5x more endpoints, but it is a security-surface change and the agent-side bound is the real fix, so it is left as a deliberate, mirrored one-liner rather than made here. Separately, the new loud rejection logging will likely surface other command types that have been hitting this cap silently — worth watching REJECTED command_result for the first week after deploy.

Status: review-clean, CI green, awaiting maintainer merge. Issue #3001 left open and assigned.

…the unencodable-body path

Review round on #3267.

1. The five PERSISTED warnings still hardcoded the IPC limit — the same bug
   this PR fixes in the log line, one layer further out. These strings land in
   result.warning -> backup_jobs.errorLog -> the UI, so after the binding limit
   moved to the 1 MiB server cap the headline 4,000-file repro would have told
   a customer that a 2 MB result exceeded a 16 MiB limit; oversizeFailureResult
   printed the actual size next to the limit it supposedly exceeded, making the
   contradiction visible in one line.

   The limit is now a value (deliveryLimit) threaded into every tier, so the
   warning, the failure text and the log line cannot tell different stories.
   It carries the enforced BUDGET separately from the enforcing party's CAP:
   degradation trips at 983,040 while the server allows 1,048,576, so reporting
   the cap as "exceeded" described a 1,000,000-byte payload as overflowing a
   limit it was under. Warnings name the budget; the log carries both.

   Five existing tests asserted the literal "IPC limit" as a proxy for "the
   tier explained itself", which had welded the suite to one specific limit
   being the trigger forever. They now assert the shared limitExceededPhrase.

2. The unencodable-body recovery in the WS backstop was dead code. SendResult
   marshalled the whole result first and returned on error, so
   boundResultFieldForServer's marshal-error branch was unreachable from the
   wired path and its unit test asserted a repair that never ran. A NaN in any
   nested map still lost the terminal status. SendResult now substitutes the
   marker and retries, and still errors when there is no body to drop.

3. commandResultResultByteLength re-serialised a just-parsed object with no
   size guard, on the one branch guaranteed to see unbounded input (no
   maxPayload is set; pre-fix agents send 64 MB results). Above 8 MB the frame
   size is reported instead — this repo has event-loop-stall form (#3236).

4. frameBytes used String.length (UTF-16 code units) — the exact confusion this
   PR's own test pins — and the echoed messageType/commandId came off an
   unvalidated message unbounded. Now Buffer.byteLength and clamped.

Tests: the rejection frame is pinned from BOTH sides (Vitest asserts the shape
the Go parser reads; a Go test asserts the field names against agentWs.ts and
that the readPump error branch still precedes the id-less skip, the ordering
the fix depends on). Plus IPC-frame attribution, budget-vs-cap reporting, the
persisted-warning wording, and the wired NaN path. Both cross-language pin
regexes are anchored on the declaration keyword so a doc comment cannot
retarget them onto prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review round 2: code-reviewer + silent-failure-hunter + pr-test-analyzer. Nothing blocking; delivery correctness verified sound end-to-end. Four confirmed findings, all fixed in 16c9132b5.

1 (HIGH, found independently by two reviewers) — the operator-facing warnings still named the wrong limit. Five persisted strings in result_bounds.go hardcoded ipc.MaxMessageSize. These are not logs: they land in result.warningbackup_jobs.errorLog → the UI. Since this PR moves the dominant trigger to the 1 MiB server cap, the headline 4,000-file repro would have told a customer that a 2 MB result exceeded a 16 MiB limit — and oversizeFailureResult prints the actual size right next to the limit it supposedly exceeded, so the contradiction was visible in a single line. This is the same defect the PR fixes in the log line, one layer further out.

The limit is now a deliveryLimit value threaded into every tier, so the warning, the failure text and the structured log cannot tell different stories. It carries the enforced budget separately from the enforcing party's cap — the second half of that finding: degradation trips at 983,040 while the server allows 1,048,576, so reporting the cap as "exceeded" described a 1,000,000-byte payload as overflowing a limit it was under. Warnings name the budget; the log carries both.

Correction to the finding: it is not a free change. Five existing tests asserted the literal "IPC limit" as a proxy for "the tier explained itself" — which had quietly welded the suite to one specific limit being the trigger forever, so it passed on wording that had become false and would have failed on wording that became true. They now assert a shared limitExceededPhrase, with a comment at the constant saying why a test must never pin a bare limit name here.

2 (MEDIUM) — the unencodable-body recovery was dead code. SendResult marshalled the whole result first and returned on error, so boundResultFieldForServer's marshal-error branch was unreachable from the wired path and its unit test asserted a repair that never ran. A NaN in any nested map — metrics-shaped results carry floats routinely — still cost the terminal status. SendResult now substitutes the marker and retries; TestSendResultRecoversFromAnUnencodableBody drives it through the wired path, and a companion test pins that it still errors when there is no body to drop, so the recovery cannot broaden into swallowing unrelated marshal failures.

3 — event-loop guard. commandResultResultByteLength re-serialised a just-parsed object with no size guard, on the one branch guaranteed to see unbounded input. Above 8 MB the frame size is reported instead.

4 — the two nits. frameBytes used String.length (UTF-16 code units), the exact confusion this PR's own test pins; now Buffer.byteLength(data, 'utf8'). The echoed messageType/commandId came off an unvalidated message unbounded; now clamped, along with the issues array.

The rejection branch is extracted into an exported pure buildAgentMessageRejection so the frame shape is testable without a socket.

Tests (the pr-test-analyzer fast-follows). The error-frame contract is now pinned from both sides — the rated-most-valuable gap, since a rename of messageType/commandId on either side silently regresses to this issue's no-trace state. Vitest asserts the emitted shape and checks each key against a json:"…" tag in the Go parser; a Go test asserts the field names against agentWs.ts and that the readPump error branch still precedes the id-less skip — the ordering the whole fix depends on, and the one a refactor would quietly undo. Also added: IPC-frame attribution (the branch where naming the IPC limit is correct, so the assertions can't pass with the server cap hardcoded in the other direction), budget-vs-cap reporting, persisted-warning wording, and the wired NaN path. Both cross-language pin regexes are anchored on the declaration keyword so a doc comment containing NAME = <number> cannot retarget them onto prose.

Verification: go test -race ./... (entire agent module) clean; go vet ./... clean; gofmt clean on all touched packages; GOOS=windows/linux/darwin + native builds clean; 172 Vitest tests across the 7 affected API suites; tsc --noEmit clean. Full CI green on 16c9132b5 — Test Agent, Test Agent (race), Test API, Test Web, Type Check, Lint Agent (Go), and all four Integration Tests shards.

Status: review-clean, CI green, awaiting maintainer merge. The 1 MiB-vs-5 MB cap decision noted in the previous comment is still yours; issue #3001 stays open and assigned.

@ToddHebebrand
ToddHebebrand merged commit 7cff512 into main Aug 8, 2026
55 checks passed
@ToddHebebrand
ToddHebebrand deleted the ToddHebebrand/fix-3001-silent-result-loss branch August 8, 2026 17:33
ToddHebebrand added a commit that referenced this pull request Aug 8, 2026
… stdout/stderr (#3283)

Follow-up to #3267, approved after that fix landed. Refs #3001 — no
closing keyword; that issue's residual is already fixed and this only
widens the good path.

## Why

`result` was capped at 1 MiB while `stdout` and `stderr` in the **same
schema** allowed 5,000,000. That split was not an incidental
inconsistency — it is what caused #3001. The backup forwarder assigns
its run body to `result` rather than `stdout`
(`agent/internal/heartbeat/heartbeat.go`, `case TypeBackupResult`), so
the payload silently inherited a limit five times tighter than the one
anyone had reasoned about, and every backup over ~2,000 files lost its
terminal result with no log on either side. Making the three equal
removes the trap rather than documenting it.

**Effect:** a snapshot keeps a browsable restore file index to **~9,500
files instead of ~2,000** (at ~522 B/entry). Concretely, the v0.104.0 QA
reproduction — the 4,000-file run — now delivers its index intact, where
it was previously rejected outright and then (post-#3267) degraded to a
bare terminal status.

The agent's tiered degradation is **unchanged** and still guarantees the
terminal status past that point: a 100k-file index is ~52 MB and fits no
sane cap. This widens the good path; it does not replace the machinery.

## Value: 5_000_000, not 5 * 1024 * 1024

Deliberate. The point is parity with the sibling caps, and rounding to
`5 * 1024 * 1024` would leave `result` 242,880 bytes **looser** than
`stdout` — a smaller version of the exact mismatch being fixed. Both
sides carry a comment saying so, and a new test asserts the three caps
are equal.

## Changes

Both halves of the mirrored contract move together, plus both literal
pins:

- `apps/api/src/routes/agents/schemas.ts` — `MAX_COMMAND_RESULT_BYTES`
1_048_576 → 5_000_000
- `agent/internal/wire/limits.go` — `MaxCommandResultBytes` `1024 *
1024` → `5_000_000`
- both literal pin assertions, and **both cross-language parsers**,
which now accept digit separators — the Go literal gained underscores,
and the Vitest parser silently read `5_000_000` as `5` until its
character class was widened. Caught by the pin itself.
- `CommandResultHeadroom` **stays at 64 KiB**: it is an absolute
allowance for JSON re-encoding differences (HTML escaping, number
formatting, string quoting), not a percentage of the payload. Scaling it
with the cap would have widened it to 320 KiB for no reason. Documented
at the constant.
- comments/docs citing 1 MiB, 1,048,576, 983,040 or the ~2,008-file
threshold updated in `result_bounds.go`, `wire/limits.go`, `schemas.ts`
and the test narratives. Historical references are kept where they
describe what actually happened, and marked as history.

## Fixtures: each still exercises the band it exists for

The self-checking preconditions from #3267 did their job — every stale
fixture failed loudly with an explicit message rather than going
vacuously green. Two rounds of that caught both the resize list and a
wrong per-entry estimate of mine (`buildLargeRunJob` encodes ~375
B/entry, not the field report's ~522).

- **`TestFourThousandFileRunSendsItsIndexIntact`** — the QA
reproduction, inverted. It now asserts the result arrives *whole*, and
guards that the fixture is still over the *original* 1 MiB cap so it
keeps representing the payload that reproduced #3001. A regression to
either earlier behaviour (rejected, or degraded) is invisible without
this.
- **`TestOversizeIndexIsDegradedForTheServerCap`** — new 16,000-file
(~6.0 MB) fixture keeping the degradation path pinned, in the band only
the server cap owns (over the ~4.93 MB budget, under the ~15.9 MiB IPC
budget). Without it the raise would have silently deleted coverage of
the entire reason the tiers exist.
- the non-object array fixture (150,000 elements) and the
websocket-backstop fixture (20,000 entries) grew past the new budget;
**"just under the budget" is now sized off the budget itself** rather
than an entry count, so it stays meaningful at any cap.
- new `equals the stdout/stderr caps in the same schema` test, plus one
asserting the caps remain **independent** checks — a message may
legitimately carry a full-size `stdout` *and* a full-size `result`, and
a refactor folding them into one shared budget would break that.

## Confirmations requested

- **WS frame path is fine:** no `maxPayload` is configured on the agent
WebSocket server, so `ws` applies its 100 MiB default — 20x the new cap.
- **Event-loop measurement guard still clears it:**
`MAX_PRECISE_RESULT_MEASURE_BYTES` is 8,000,000, above the new 5,000,000
cap, so a rejected result is still measured precisely rather than
falling into the unmeasured branch.

Swept for other 1 MiB assumptions: the only nearby hits are unrelated —
`CRITICAL_RESULT_STDOUT_MAX_BYTES` (a separate `stdout` limit on the
critical-result validation path) and `TERMINAL_BYTES_LIMIT`. Neither
touches `result`.

## Verification

| Command | Result |
|---|---|
| `go test -race ./internal/wire/... ./internal/websocket/...
./cmd/breeze-backup/...` | pass |
| `go test -race ./...` (whole agent module) | pass |
| `go vet ./...` | clean |
| `gofmt -l` on touched packages | clean |
| `GOOS=windows` / `GOOS=linux` / native builds | clean |
| `vitest run` — schemas, schemas.commandResult, agentWs (×3), commands,
backupResultPersistence | 228 passed (8 files) |
| `tsc --noEmit` (apps/api) | clean |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Todd Hebebrand <todd@lanternops.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant