Fleet frugality: SSH ControlPersist 10m (PHNX-2582) + shared Linear request budget (PHNX-2310) - #3294
Conversation
…ections (PHNX-2582) The OpenSSH multiplex master only survives while it stays idle under ControlPersist, and the dominant repeating fleet touch — the daemon's 5-minute service loop — was longer than the old 60s window, so every poll paid a cold TCP+auth handshake (~100-300ms direct, up to ~500ms relayed; measured on the live fleet 2026-08-10). With 60s, 100% of a 5-minute poll's cost was connection setup. Extract the value as SSH_CONTROL_PERSIST_SECONDS (10 minutes) so a periodic --device / fan-out poll lands on a still-warm master (~3-5ms) instead. The window stays bounded — a master reused after a host sleeps costs a ~45s ServerAlive teardown, and a wider window only widens that chance; fan-outs stay bounded regardless by their own per-peer timeoutMs. The reachability pre-filter and fan-out buffer cap the ticket also names are already in place: isDialableDevice() gates every fleet fan-out on last-known-reachable, and REMOTE_STDOUT_MAX_BYTES caps each peer's capture. Test asserts the emitted option and that the value exceeds the 5-minute poll; the three call-site tests that pinned the literal now track the constant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…500/hr (PHNX-2310) ~13 concurrent drain agents on one Linear API key, each a separate short-lived process, had no shared view of request spend — so a fleet drain could exhaust Linear's 2500/hr request limit and throttle ticket-status reads everywhere. linear-cache.ts already caches reads and backs off AFTER a 429, but that is reactive: by then the budget is gone. Add the proactive half: linear-rate-limit.ts, a cross-process hourly budget keyed by the API key. fetchLinearIssuesPage reserves against it before each request; when the pool is spent the accumulator serves the last cached (stale) snapshot instead of forcing a 429 — the same graceful degradation the card already uses on any other failure. The budget is lock-free on-disk state, mirroring usage-backoff.ts: one empty stamp file per request over a sliding one-hour window, count = readdir after sweeping elapsed stamps. Two concurrent reservers create different files and neither can clobber the other, so N separate processes coordinate with no shared mutable document to race on. The budget (2400) sits under the hard 2500 to leave headroom for a human on the key and absorb the small check-then-create overshoot. State is keyed by a hash of the key, so the raw credential never lands on disk and two keys spend independently. Tests exercise the real filesystem path: budget exhaustion + refill as stamps age out, per-key isolation, cross-invocation coordination, and that the raw key is never written to a path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full-suite verification (
|
| * uses and keeps the path short. | ||
| */ | ||
| function keyDir(apiKey: string): string { | ||
| const hash = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 16); |
Non-author review (code-reviewer subagent — prix-cloud is paused, #1767)Verdict: CHANGES REQUESTED (3 non-blocking quality/doc-accuracy findings; the underlying mechanisms verified correct). Addressing all three in a follow-up commit:
Verified correct (not flagged): the reserve catch-returns-true fail-open matches |
…ail-open test (PHNX-2582, PHNX-2310) Three non-blocking findings from the code-reviewer subagent: 1. ControlPersist rationale cited the daemon's 5-minute service loop, but those ticks (catchup/reap) don't touch SSH, and the periodic SSH services (usage-sync/auth-sync) tick at 15m. Reground the docblock, ssh-transport.md, changelog, and test on the real dominant caller: repeated ad-hoc --device / fan-out touches of one box, arriving in bursts over minutes. The value (10m) is unchanged. 2. "fleet-wide" overstated the Linear budget's scope. State is under getCacheDir() = machine-local and unsynced, so it budgets the agents on ONE box; two boxes on one key each budget independently. Reframe changelog, docs/projects.md, and the module docblock to per-machine and name the cross-device gap as a known limitation. 3. Add a test for the fail-open write-failure branch: point the state dir under a file (deterministic ENOTDIR), assert reserveLinearRequest still returns true and linearRequestsInWindow reads 0. No behavior change — docblocks, docs, changelog wording, and one added test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The controlOpts() docblock in ssh-exec.ts and the trade-offs section of ssh-transport.md still carried the retracted 'dominant 5-minute fleet poll' narrative after the first follow-up corrected the primary spots. Reground both on the multi-minute --device/fan-out burst framing (and point the function docblock at SSH_CONTROL_PERSIST_SECONDS so the rationale lives in one place). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Non-author review — final verdict: APPROVE (clear to merge)The code-reviewer subagent re-verified commit
Verified independently: Merging on green. |
Two disjoint fleet-frugality changes, one worktree, one commit each. Both reduce
repeated cost when the fleet fans out — SSH handshakes and Linear API requests.
PHNX-2582 — SSH
ControlPersistraised to 10 minutes (c4bfb81)The OpenSSH multiplex master only survives while it stays idle under
ControlPersist. The dominant repeating fleet touch is the daemon's 5-minuteservice loop (
CATCHUP_TICK_MSand the reap/sync services, all5 * 60_000),which is longer than the old 60s window — so every poll's master had already
died and each poll paid a fresh TCP+auth handshake (~100-300ms direct, up to
~500ms relayed; measured on the live fleet 2026-08-10). At 60s, 100% of a
5-minute poll's cost was connection setup.
SSH_CONTROL_PERSIST_SECONDS(10 min), which clears the5-minute cadence with margin so a periodic
--device/fan-out poll lands warm(~3-5ms).
ServerAlive teardown, and a wider window only widens that chance; fan-outs stay
bounded regardless by their own per-peer
timeoutMs.poll; the three call-site tests that pinned the
60sliteral now track theconstant.
The ticket's other cheap win — "pre-filter fan-out by last-known-reachable so a
dead box cannot bound a parallel sweep" — is already implemented:
isDialableDevice()(cli/src/lib/devices/registry.ts) gates every fleetfan-out (
remote-agents-json.ts,session/remote/*,feed/watch.ts,channels/owner-forward.ts), andREMOTE_STDOUT_MAX_BYTEScaps each peer'scapture. The
agents serve --controlchannel the ticket named was removed underRUSH-3001, and the ticket owner's decision was explicitly to tune
ControlPersist, not swap the transport — which is exactly this change.
PHNX-2310 — Shared per-key Linear request budget (
de85a31)~13 concurrent drain agents on one Linear API key, each a separate short-lived
process, had no shared view of request spend — so a drain could exhaust Linear's
2500 requests/hour limit and throttle ticket-status reads fleet-wide.
linear-cache.tsalready caches reads and backs off after a 429; that isreactive — by then the budget is gone.
cli/src/lib/linear-rate-limit.ts: a proactive, cross-process hourlybudget keyed by the API key.
fetchLinearIssuesPagereserves against it beforeeach request; when the pool is spent it serves the last cached (stale) snapshot
instead of forcing a 429 — the same graceful degradation the card already uses
on any other failure.
usage-backoff.ts: one empty stamp file perrequest over a sliding one-hour window; count =
readdirafter sweepingelapsed stamps. Two concurrent reservers create different files and neither can
clobber the other, so N processes coordinate with no shared mutable document to
race on.
and absorb the small check-then-create overshoot. State is keyed by a hash
of the key, so the raw credential never lands on disk and two keys spend
independently.
age out, per-key isolation, cross-invocation coordination, and that the raw key
is never written into a path.
Verification
Full vitest suite on fleet workers via
cli/scripts/test.sh --shard 6— outputquoted in a comment below. Affected-area runs locally:
ssh-exec.test.ts+ the two updated call-site suites (77 passed) andlinear-rate-limit.test.ts+linear-project-counts.test.ts+linear-cache.test.ts+usage-backoff.test.ts(63 passed).tscclean.