The status endpoint now reports, per account, how many sessions are
bound with traffic inside the routing load window, plus the account's
cooldown deadline. The coderouter CLI uses both to make sticky routing
and rate-limit cooldowns visible. The session-count read is display
only and fails to zero so it can never take the status endpoint down.
Pricing change: a team may connect and route up to 3 provider
accounts (subscriptions) for free. More than 3 requires an active
cmux Pro or Team subscription.
- New services/coderouter/entitlement.ts: free tier checked first
(one indexed count read); the Stripe read runs only for teams over
the limit. Both checks fail closed.
- Session issuance: replaces the flat Pro gate. Over-limit teams
without a subscription get 402 with the count in the message.
- Account add: connecting an account beyond the limit without a
subscription returns 402 before anything is stored. Re-importing
an account the team already has is always allowed, so a broken
account stays repairable on the free tier. The accounts POST
handler is now factory-built for dependency-injected tests.
- Billing-lapse token revocation stays unchanged: after a lapse the
CLI renews and the free tier re-qualifies teams with <= 3 accounts.
- New analytics: entitlement_basis on route_session_issued and a
coderouter_account_limit_reached event, both schema-whitelisted.
- CODEROUTER_HOSTED_PRO_REQUIRED=0 still disables all gating.
Boundary: exactly 3 accounts is free; the 4th needs Pro/Team.
A bound account in state 'refreshing' is healthy; its refresh lease
resolves in seconds. Treat it as usable for sticky reuse, and give a
sticky session a short bounded wait (4 x 500ms) when the refresh lease
is busy, instead of instantly moving the session and discarding its
prompt cache. Non-sticky requests keep the fail-fast move.
If the code deploys before the additive migration is applied, the
session-stickiness statements hit undefined_table (42P01). Treat that
as: no binding found, claim without the session-load ordering term, and
skip the pin. Routing behaves exactly like the pre-change code until
the migration lands, instead of erroring every Codex request. Covered
by a DB behavior test that renames the table away and back.
Port of subrouter PR #228 to the coderouter TypeScript data plane.
Problems in the current routing:
- selectAccountForRequest picks an account per request with three
independent statements (sweep, select, update). Nothing spans the
read-pick-write sequence, so parallel session starts read the same
snapshot and herd onto one account. That account burns quota first,
then every session reroutes to the next account at once.
- No session stickiness exists. Each request rotates to the
least-recently-used account, so the provider prompt cache is
discarded on nearly every turn and the whole prefix is re-billed
as uncached input across all accounts.
Fix:
- New coderouter_session_accounts table pins one agent session (the
Codex CLI session_id header) to one account.
- selectAccountForSession honors a usable binding first (sticky), and
moves a session only when its account is broken, cooling down,
removed, or already attempted in this request.
- New placements claim an account atomically in one statement with
FOR UPDATE SKIP LOCKED, ordered by fewest recently-active bound
sessions, then least-recently-used. Concurrent claims take
different accounts instead of the same snapshot argmax. When every
candidate row is locked, a blocking fallback claim accepts a
collision instead of failing the request.
- selectAccountForRequest (models, opencode) now uses the same atomic
claim, which closes its read-pick-write race too.
Tests: unit tests for the selector and the responses proxy, and
CMUX_DB_TEST-gated behavior tests that prove sequential spread,
concurrent non-herding, stickiness, move-on-cooldown, exclusion,
and last-write-wins binding against real Postgres.
The lane is fleet infrastructure we operate, not contributor guidance for this
codebase, so the prose now lives in cmuxterm-hq at
skills/infra/blacksmith-testbox/ beside macfleet and cloud-vm-ops. See
manaflow-ai/cmuxterm-hq#306.
Everything the box actually executes stays here: both workflows, the five
scripts/blacksmith-*.sh helpers, and the two guards that validate them. Only
tests/test_testbox_doc_blocks.sh follows the skill, because it validates that
prose, and hq gained a workflow so it keeps gating rather than quietly stopping.
CLAUDE.md keeps a pointer rather than dropping the subject, because an agent
starting cmux-tui Rust work reads this file and must still learn not to compile
on the Mac. Worth stating plainly: all nine trial agents found this skill from a
local file in this repo, and a pointer to another checkout is weaker
discoverability, particularly from a plain cmux clone.
Revalidating the demo after the set-difference rewrite passed the build (2m13s
then 0.14s, box stopped, exit 0), but the org check afterwards showed its warmup
run still in_progress, holding a 32 vCPU runner. The skill calls cancelling a
required step and the script did not do it, so the one entry point most people
will run was the one leaking.
It records the run it approves and cancels it from the same EXIT trap that stops
the box, so a Ctrl-C cleans up both.
I added the cargo test line to the skill without running it. A concurrency
trial ran it and it fails on a fresh box: 104 failures, exit 101, all one root
cause. `blacksmith testbox run` gives a shell at umask 0002, so test directories
are created group-writable, and cmux-remote's secure-directory check correctly
rejects an ancestor writable by other users without the sticky bit.
Hosted CI runs at umask 022, so the suite passes there and fails here. The suite
is not umask-independent, which means this lane does not reproduce CI unless the
command says so. With `umask 022` prepended, 3504 tests pass in about 88s.
Builds and clippy are unaffected.
Two agents dispatching nine seconds apart deadlocked. The guard correctly
refused to guess between two identical waiting gates, then told the operator to
"approve yours in the UI". An agent has no UI, and nothing in the REST API binds
a run to a Testbox ID, so the recovery was unusable by the caller the lane is
built for. One trial burned 11 minutes and an extra box getting out of it.
Snapshot the waiting gates before dispatching and take the set difference after.
That is exact where a 120 second window is not, since another operator lands
inside any window you pick. On the rare true tie the instruction is now
deterministic and needs no browser: stop your box, re-snapshot, dispatch again.
Also record the trap the same trial nearly fell into. Correlating a box to a run
by timestamp looks right and is wrong: Blacksmith rewrites a box's CREATED value
as it hydrates, measured moving 05:54:43 to 05:55:03 to 05:58:48 on one box. The
authoritative binding is the RUN URL column, which appears only once the box is
ready.
SKILL.md, benchmark.md, and the demo script now share the one algorithm.
Every trial exercised the evidence path, so the skill only ever showed
cargo build. An agent's real task is usually cargo test or clippy, which use the
same warm target/ and need no stage helper; show them.
Move the trust-boundary reasoning to references/trust-boundary.md and keep the
operative summary inline. An agent about to build needs the rule and the
enforcement, not the full argument. SKILL.md is 11.7 KB, still above the 10 KB
guideline; the remaining bulk is the approval block, and the eighth trial proved
that moving an executable step away from the file that needs it strands a live
box, so it stays.
Ninth trial ran all eight documented blocks literally, in order, each exiting 0,
repairing nothing. Two cosmetic inaccuracies left, both mine.
The EXIT trap printed "Testbox <id> is still running; no stop was authorized"
at the end of a run whose ceremony had already stopped the box, because it
inferred liveness from CONFIRM_TESTBOX_STOP_SHA being unset rather than from
the inventory it had just captured. On a clean run that reads as a failure. It
checks list-at-exit.log now.
And I cited the changed-file overhead gap as "several times larger, 8.1s against
1.2s". It measured 1.48s this run with no change to the lane. The guidance not
to read that gap as sync overhead stands; the magnitude does not, so the text no
longer promises one.
Eighth trial ran the documented blocks literally, as instructed, and the
warmup block stranded a live 32 vCPU box. `grep -c .` exits 1 when the count is
zero, and the plan sets `set -euo pipefail`, so the assignment
`waiting_count="$(... | grep -c .)"` ended the script on the first poll. Zero
waiting runs is the normal state right after warmup returns, so the 150s poll
loop was unreachable dead code, and the exact failure commit 8d3edf5b28 set out
to fix happened again, one round later, in the fix itself. There is no error
text; the script simply stops, leaving a warmed box, an un-approved run, and a
receipt the trap correctly refuses to act on.
Guarded every count with `|| true` in both files.
tests/test_testbox_doc_blocks.sh now treats the plan as executable
documentation: it parses all 21 fenced bash blocks under the shell the plan
mandates, then executes each counting construct against an empty result to prove
it survives its normal first state. Verified red against the unguarded line and
green after. Syntax checking could never have caught this, which is why two
rounds of review missed it.
Two more from the same trial. benchmark.md never cancelled the warmup run, so
following it alone leaves the keepalive holding a runner until the 120 minute
timeout; it now cancels and polls to terminal state. And the setup artifact is
required evidence whose command sat in prose rather than a fenced block, so a
literal run never captured it.
Seventh trial ran smooth end to end, zero unexpected failures, and still found
that benchmark.md's warmup block goes straight from the receipt to
`status --wait`. The approval existed only in the surrounding prose, so anyone
copying the block parks at the environment gate, burns the full 15 minute
timeout, and strands a warmed 32 vCPU box. Prose next to a code block does not
protect someone running the code block. The approval, with the polling guard and
its own attempt transcript, is now in the code, along with the DISPATCH_EPOCH
capture it binds to. Removed the unreachable second warmup_status check while
there.
Also qualified the overhead column. The changed-file gap measured 8.1s against
1.2s for the other stages while all three synced strategy=skip, because that
stage's backup, edit, restore, and re-verify work runs inside the CLI call but
outside wall_seconds. It is real, it is not sync and transport, and
operations.md now says to compare overhead only across first-clean and
incremental-noop.
Every bash block in the plan is now syntax-checked as a unit.
Sixth trial reported the run as not smooth, and it was right. The guard I wrote
in SKILL.md Step 3 queries for waiting runs the instant warmup returns. GitHub
had not surfaced the run yet, so zero matched, and the guard printed "more than
one run waiting; approve yours in the UI" and exited. Zero runs is not
ambiguity, it is "not yet". Under benchmark.md's set -e that abort left a live,
un-approved box on the org inventory with its receipt already written, and the
plan's own trap is designed to refuse to stop it.
The guard now polls for 150s, distinguishes zero from two-or-more, and says
which happened. It also tells you that an abort means you already own a running
box, so stop it before retrying or the next dispatch leaves two boxes and one
receipt.
The trap also lied about why it gave up: it printed "warmup returned no owned
Testbox receipt" when the receipt existed and the real cause was
CONFIRM_TESTBOX_STOP_SHA being unset, which is the normal state. It now reports
the condition that actually held, and names the box you must stop yourself. Its
inventory file was called list-after-warmup-failure.log even on the success
path; it is list-at-exit.log now.
Two smaller ones. Record every approval attempt separately, because a retry that
overwrites the first hides a live unapproved box from the evidence pack. And
warm-before-reading has an exception for evidence runs, whose preflight and
receipt must exist before warmup; three agents in a row hit that ordering.
Finally, changed-file rebuilds cmux-remote as well as cmux-tui, so its ~8s is a
small-edit figure, not a single-crate floor.
Fifth trial ran the lane smoothly end to end: every documented command exited
as documented, no retries, no procedural guessing, and the only non-zero exits
were the two the docs predict. 132.25s clean, 0.17s no-op, 8.03s changed-file,
box and runner both proven gone. The artifact check added last round worked: it
recorded a 746 MB binary, 48 bytes larger in the changed-file stage.
Two defects were mine from the previous commit. The artifact prose said
cmux-tui-testbox-<run-id> while the command beside it correctly said
cmux-tui-testbox-setup-<run-id>, so following the prose downloads nothing. And
required field 3 asked for an "identity run ID" for a run the same document
forbids issuing; it now says there is no such run and names the real transcripts.
The third is older and worse. operations.md promised two clocks and described
the second as the local CLI transcript covering sync, transport, and queueing.
No pack ever contained it: stage `wall_seconds` is measured on the box around
cargo, which is why it sits milliseconds above `/usr/bin/time -p` real rather
than well above it, and three separate agents nearly cited it as CLI wall time.
The plan now records the real local clock per stage in <stage>.cli-wall.txt, and
operations.md says plainly that a pack without it cannot measure Testbox
overhead at all.
Fourth trial agent ran the plan with zero non-zero exits and no retries
(127.03s clean, 0.17s no-op, 8.02s changed-file, box and runner proven gone),
then found the ordering defect that matters most: benchmark.md printed the
three-stage loop BEFORE the pin instruction, so following that file top to
bottom benchmarks main rather than your branch. SKILL.md had the right order,
so the two documents disagreed. The pin now has its own section ahead of the
loop, and says plainly that running the loop first measures main.
The stage helper now verifies the build produced cmux-tui/target/debug/cmux-tui
and records its size in each stage JSON. A zero exit proves cargo was happy, not
that anything was produced, and the box is destroyed before anyone can check.
Also: name the setup artifact and give the gh run download command, instead of
"keep the artifact URL" with no artifact name; document that gh run cancel takes
about five minutes to land, so in_progress right after cancelling is not a
failure; and explain the two surfaces that look contradictory after a stop, that
status still prints a completed row while list shows nothing, and that
setup-identity.json says X64 where stage records say x86_64 for the same host.
Third agent ran the whole plan with no failure, no retry, and every exit code
the docs predict, including the intended 75 from PREVIEW: 129.97s clean, 0.18s
no-op, 9.28s changed-file, box and runner both proven gone. What it found was
that the evidence pack the plan produces cannot be fully trusted.
`blacksmith auth whoami` writes to stderr, so the plan's bare invocation put no
org identity in the pack while the field list implied one; it captures stderr
now. `timings.json` hardcoded schema 2 while stage records self-report schema 3,
and operations.md still described stage records as schema 2, so the wrapper now
carries stage_record_schema and the prose matches the code.
The guard naming a historical evidence directory pointed at a path that does not
exist and could not: `blacksmith-testbox-e40704611ac35f4ffa153` is the first 13
characters of that SHA glued to its last 8, and it lacks the mandatory suffix. A
guard you cannot resolve protects nothing, so both files now state the rule
itself: never write into an evidence directory you did not create this run. The
superseded 161.47s/8.28s/9.13s numbers go with it; three trials have since
measured this lane directly.
The collision-avoidance branch was dead code. It tested for
blacksmith-testbox-<sha> while every directory the plan creates is
blacksmith-testbox-<sha>-<suffix>, so the timestamp path never fired and two
runs of one SHA from one PID collided. It globs the suffix now.
Finally, Blacksmith's CREATED column is a last-transition time, not a creation
time; the same box reported three different values across hydrating, ready, and
stopped. Say so, and point at the status --wait transcript for elapsed time.
Second independent agent, given only "produce defensible benchmark evidence",
found the skill from CLAUDE.md and ran the full plan first try: 129.37s clean,
0.17s no-op, 9.69s changed-file, box and runner both confirmed gone. The
receipt-bound cleanup fixed in the previous commit worked for it. Five things
still failed it.
Two were code. The post-stop poll wrote to status-before-stop.log, so the
evidence pack's own pre-stop record was overwritten with post-stop state and an
auditor would misread when the box existed; it writes status-after-stop.log now.
And cleanup-preview.json paired warmup_ref with the benchmarked source_sha,
so the destruction record named a SHA that is not on the ref beside it; it
records both refs now, schema 2, and the regression test asserts it.
Three were documentation. benchmark.md bills itself as the full plan but never
mentioned the deployment gate, so its warmup block deadlocks for 15 minutes on
`status --wait`; it now points at the guarded approval and warns against
approving workflow_runs[0]. The PREVIEW cleanup exits 75 on success, which the
same plan's mandated `set -e` turns into an abort right before cleanup; both
call sites say so and show the `set +e` wrapper. And SKILL.md claimed stopping
the box ends the warmup run, which is false: the measured run sat in_progress
for four minutes afterwards holding a 32 vCPU runner, so cancelling is now a
required step rather than a hedge.
A subagent given only "compile cmux-tui on Linux" found the skill from
CLAUDE.md and followed it to real numbers, then reported what broke.
The receipt-bound cleanup could never succeed. #10303 pinned warmup to
--ref main, so the inventory REF column is always main, while the receipt
recorded source_ref, the branch being benchmarked. cleanup.sh compared the two
and exited 66 with "inventory ownership context differs from the warmup
receipt", for every legitimate user. That pushed operators toward a bare stop
and away from the ownership check the docs spend a page justifying. The receipt
now carries warmup_ref and source_ref separately and cleanup compares the
inventory against warmup_ref. tests/test_testbox_cleanup_receipt_ref.sh drives
the real script against a stubbed CLI; it reproduces the old exit 66 and passes
on the fix.
The approval snippet took workflow_runs[0]. Every run in this lane shares a
title and a main head branch, and the agent found a stranger's run waiting two
minutes ahead of its own, so the snippet would have approved someone else's
deployment gate. It now binds to a run created after your own dispatch and
refuses when more than one is waiting, matching the demo script's guard.
Also: `list --all` is not proof nothing is burning, because the keepalive holds
a 32 vCPU runner after the box is gone, so step 7 checks the run too and shows
`gh run cancel`; say which of the two cleanup paths is authoritative and when;
state that warmup does not block; put the --idle-timeout unit on the command;
and note that the aggregator is all-three-stages or nothing.
Running a script named "testbox demo" is the consent. The spend is bounded by
--idle-timeout and by the EXIT trap that stops the box, so a prompt whose answer
is always yes was noise. It also aborted confusingly with no TTY, because `read`
returns non-zero under `set -e`. Print what is about to happen instead.
Reading a runbook does not tell you what a persistent build box feels like.
scripts/blacksmith-testbox-demo.sh warms one, pins it to your pushed HEAD,
builds cmux-tui twice, and prints both times so the second build makes the
point the prose cannot.
It prints every remote command before running it, so the tour doubles as the
documentation. It stops the box it created from an EXIT trap, including on
Ctrl-C, and it only ever stops that box. Deployment approval matches runs
dispatched within two minutes of its own warmup and refuses to guess when more
than one is waiting, so it cannot approve someone else's run. --stages swaps
the two plain builds for the three measured stages.
The skill opened with its threat model and buried the commands, so an agent
about to compile cmux-tui learned the lane existed only after it had already
waited. Lead with the operating rule instead: warm your own box in the first
minute, before reading code, because the four minutes of hydration are four
minutes you can spend reading.
Then a seven-step CLI walkthrough covering warmup, deployment approval, pinning
the box to your commit, building or benchmarking, download, and stop. One box
per worktree and per agent, since `testbox run` synchronizes with
`rsync --delete` and two agents on one ID overwrite each other.
SKILL.md drops from 19.7 KB to 8 KB. Stage orchestration, the cleanup ceremony,
and how to read the two clocks move to references/operations.md; the full
evidence plan stays in benchmark.md. Index the skill in CLAUDE.md, which #10303
missed, so agents can find it at all.
Testing the broker's cross-commit path on a live box disproved a claim I wrote
into the skill. `blacksmith testbox run` synchronizes file contents, not
history: it makes one opportunistic `git fetch` of the benchmarked commit, falls
back to copying changed files, and skips even that once fingerprints match. A
local-only commit fails on the box with `upload-pack: not our ref`, leaving the
box on the `main` checkout the warmup job made, with candidate file contents
written over it. The stage helper then died on a raw `git rev-parse
<sha>^{tree}` error.
Require the pushed commit, and pin the box to it once per box with fetch plus
`git reset --hard` before the first stage. That makes the box an exact checkout
of the benchmarked revision, so a timing always names something anyone can
fetch, and an uncommitted edit can never be what got built. The helper now
verifies the commit is present and checked out, and prints the exact push and
pin commands instead of a git internals error.
Ubuntu 24.04 ships shellcheck 0.9, which reports trap-only functions as
SC2317; 0.10 renamed that to SC2329, which is what the file disabled. The new
lint job runs on the runner's shellcheck, so it flagged all 15 lines of the two
trap handlers. Name both codes.
The main CI suite is dispatch-only while CI is paused, so tests/test_ci_testbox_broker_guard.py
would not have run on a pull request that weakened the lane it guards. Give it
a small always-on workflow that also shellchecks the four helper scripts and
actionlints the warmup workflow with a checksum-pinned actionlint.
No path filter, deliberately. A change that moves or renames the guard is
exactly the change a path filter would let through.
The property that keeps candidate code out of the token-bearing job is a
workflow shape, and no runtime check can observe it without spending a 32 vCPU
box. Assert it statically: manual dispatch only, no input that selects a
source revision, a refs/heads/main guard ahead of begin-testbox, no repository
script or local composite action before the token, every checkout pinned to
github.sha, SHA-pinned actions, and a keepalive that runs from the checked-in
main script.
Verified red against the previous candidate-controlled workflow (two failures:
the candidate-selecting inputs and the missing main guard) and green here.
blacksmith testbox warmup resolves the workflow definition and the hydrated
source from one --ref, so a lane that warms a candidate branch runs that
branch's copy of the workflow before begin-testbox writes the Testbox auth
token into the job. A candidate could therefore delete its own guards.
Hydrate main only. The first step refuses any ref except refs/heads/main, and
no repository code runs before the token. A candidate revision reaches the box
afterwards through blacksmith testbox run, which syncs a maintainer's worktree
onto the warm VM and needs Blacksmith org credentials that already grant box
access, so it moves no trust boundary.
The hydrated commit and the benchmarked commit are now deliberately different.
The stage helper checks the setup marker for VM identity, runner class, and
toolchain completeness instead of source equality, records the hydrated ref and
SHA under a new "hydration" block, and still fails closed when the active Rust,
Cargo, or Zig differs from what warmed the caches.
The lane no longer needs BLACKSMITH_TESTBOX_REVIEWED_REF or
BLACKSMITH_TESTBOX_REVIEWED_SHA; the environment needs a deployment branch rule
of exactly main.
Tab identity was re-derived on every read from either the live surface or
the slot indexes, so each reader invented its own missing-data policy: the
projection failed hard, `rebuild_resource_indexes` silently dropped the tab,
and the browser branch required a live surface. A silent drop is the worst
of the three, because the next projection tombstones durable rows that are
still live.
`State::register_tab_identity` is now the writer every placement path uses,
including `insert_surface_checked` and the browser attach path, which
previously left identity to be harvested from the surface at the next index
rebuild. The reserved-placement install in `resource_project_terminal_selected`
still writes its own placement order, which `register_tab_identity` must not
reorder; it writes the same identity fields.
Every reader now takes identity from the topology: the index rebuild no
longer consults surfaces, the projection resolver reads the owner, and the
layout-undo token and active-tab lookup drop their surface fallbacks.
`State::ensure_tab_identity_coverage` runs at the projection boundary, so a
missing identity fails that mutation by name instead of erasing durable rows.
Browser tabs now project from their durable row when their runtime is gone,
matching terminals. That closes the same failure for a browser view whose
surface never materialized.
`ordered_terminal_tab_ids` demanded a live surface for every pane tab, while
the projection loop right below it already accepted a restored tab that had
no surface yet. Startup restores tabs before adoption, so an unadoptable host
leaves a tab with no surface, and the ordering pre-pass aborted the whole
projection with "pane references missing surface <slot>".
The abort happened inside the exit-detach commit that was supposed to prune
that terminal, so nothing was written and every later start repeated it. One
orphan host recovered, because removing it made the tree consistent; two or
more behind the same pane wedged the session forever.
Both call sites now resolve tab identity through one helper that prefers the
live surface and falls back to the durable indexes.
Also commit the durable exit before deleting the host record in the two
startup paths that proved a host dead. The record is the only evidence that
the host existed, so a failed commit must not erase it first.
A daemon restart that finds several unadoptable terminal hosts in the same
pane must still start. Today the first exit-detach projects the whole tree
while the other restored tab still has no surface, so startup aborts with
"pane references missing surface <slot>" and writes nothing, which makes the
session permanently unopenable.
* Prevent Mac sleep while agents run or iPhone is connected
Keeps the Mac reachable for the iOS app and long-running agent tasks by
holding a single PreventUserIdleSystemSleep power assertion (display
still sleeps) while either gate is active:
- power.preventSleepWhileAgentsRunning.enabled (default off): any
workspace has a registered agent PID (SleepyAgentCensus).
- power.preventSleepWhileMobileConnected.enabled (default on): at least
one iOS client connection (MobileHostConnectionRegistry).
Mechanism: new PowerAssertionHolder extracted from SleepyModeController
(which now uses two holders, behavior unchanged); new PreventSleepManager
recomputes a pure decision (PreventSleepDecision) on agent-model changes,
per-TabManager tabsPublisher emissions, mobile host status changes, and
settings flips, and acquires/releases the assertion idempotently.
Released on app termination.
Settings: two toggles in the Mobile section (Keep Awake group), catalog
keys in cmux.json, search aliases, EN+JA localization.
Validated empirically on a Mac mini (macOS 26.5): with 1-minute idle
sleep armed and protections removed the machine slept and dropped off
Tailscale (brief DarkWake windows only); with a headless process holding
this exact assertion, 157/157 reachability probes over 8 minutes passed
with zero sleep entries.
* Address review: fix stale-tab reconcile, gate agent census behind setting
- tabsPublisher emits during willSet, before tabManager.tabs commits.
Reconcile now counts agents from the emitting manager's new tab list
(override) instead of the stale census, so closing the last agent
workspace releases the sleep assertion immediately.
- Agent observation (tab subscriptions, per-workspace change streams,
census scan) now attaches only while the default-off agents setting
is enabled; disabled users pay no observer fanout.
- PreventSleepManager owns its own UserDefaults observer; AppDelegate
keeps only start()/stop() lifecycle calls (budget +2 accepted).
- Register the two Keep Awake rows in the settings search anchor
contract list (fixes SettingsRowAnchorResolutionTests).
* Make power assertion logger nonisolated, align subsystem with app convention
* Gate mobile keep-awake on authenticated connections; bound agent-event fanout
- MobileHostServiceStatus gains authenticatedConnectionCount (connections
with at least one authorized request, from clientIDsByConnectionID);
activeConnectionCount is unchanged for existing consumers. The service
posts mobileHostStatusDidChange on the unauthenticated->authenticated
transition and after authenticated-connection teardown so keep-awake
reconciles at both edges.
- PreventSleepManager's mobile gate now uses the authenticated count via
a lean accessor (no statusSnapshot route resolution per reconcile), so
unauthenticated LAN peers holding TCP sessions cannot pin the power
assertion.
- Per-agent runtime events no longer trigger app-wide sweeps: the manager
maintains per-workspace running-agent counts, updates only the emitting
model's count, and reconciles only when that count changes. Full rebuilds
happen only on topology/settings/mobile syncs. SleepyAgentCensus reverts
to main (helper no longer needed).
- Unit test: authenticatedConnectionCount tracks authorized connections
only (no double-count per connection, drops on close).
* Only record client IDs for verbs that required authorization; lean mobile reconcile
- onAuthorizedRequest fires for auth-exempt verbs too (authorizeRequest and
authorizationError both return nil for mobile.host.status), so a spoofed
client_id on the unauthenticated status verb was recorded and counted as
an authenticated connection, letting an unauthenticated LAN peer hold the
keep-awake assertion. Both listener sites now skip recording unless
requiresAuthorization(method:) is true.
- PreventSleepManager's mobile status observer now runs the constant-work
reconcile instead of a full topology sync; mobile events cannot change
workspace topology or agent observation.
* Count authenticated connections at the authorization gate, not the client-id hook
mobile.events.subscribe is authorized but intercepted before
onAuthorizedRequest, and subscribe requests need not carry client_id, so a
phone connected only for live events counted 0 and keep-awake released the
assertion mid-stream. Connections are now marked authenticated in the
authorizeRequest gate itself (any auth-required verb that passes), tracked
in a dedicated authorizedConnectionIDs set that backs
authenticatedConnectionCount; client-id bookkeeping stays as-is for
viewport cleanup. Set cleared on listener adoption/stop/debug-reset and on
connection close (with a status post so keep-awake drops promptly). Test
updated: client-id recording alone no longer counts, gate marking does.
* Refuse stale authenticated-connection inserts after mid-authorization disconnect
authorizationError suspends on network Stack verification; a client that
disconnects during that await has already been cleaned up by onClose, so an
unconditional insert in recordAuthorizedConnection would never be removed
and would pin the keep-awake assertion until the listener restarts. The
record now requires the connection to still be tracked (activeConnections
or the connection registry); record and removal both run on the main actor,
so either interleaving converges. Registry gains contains(id:). Test covers
the refused stale insert via a real liveness gate (never-started dummy
session registered through a DEBUG seam).
* Make PreventSleepDecision a value type instead of a top-level function
Mirrors MobileHostSyncDecision/MobileHostPortApplyOutcome: the pure policy
lives on a value type with stored inputs and an isDesired property, giving
the power feature a scoped API instead of a module-wide free function.
* Replace new debug seams with internal members reached via @testable import
Aziz test/debug-seam policy (cmux PR #6452 precedent): the two seams added
for authorized-connection tests are removed; recordAuthorizedConnection and
activeConnections are internal instead, and the test registers its
never-started dummy session directly.
* Add Keep Mac Awake control on Mac and iOS
* Tolerate headless app activation in caffeine UI test
* Fix iOS caffeine settings compilation
* Use mobile localization support in caffeine settings
* Remove reintroduced disabled toast setting
* Initialize task composer setting on iOS
* Test iOS Keep Mac Awake control
* Capture Keep Mac Awake menu evidence
* Fix iOS test Foundation import
* Revert "Fix iOS test Foundation import"
This reverts commit 37897fcf59.
* Harden mobile caffeine status handling
* Annotate caffeine RPC handlers on main actor
* Harden ambiguous mobile caffeine mutations
* Report failed caffeine mutations accurately
* Keep caffeine retry available after unknown failure
* Isolate caffeine controller injection on main actor
* Fence caffeine snapshots by state revision
---------
Co-authored-by: Abdulaziz Albahar <[email protected]>