Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,93 @@ a CI job fails when they disagree.

## [Unreleased]

## [0.9.2] — 2026-10-23

A device can be reserved for one kind of work, so a signed-in phone is never
lent to a job that only matched its OS. The reference lab's brain moves to
ultra, and the explore night survives a model unloaded under it.

### Added

- **Reserved devices.** `fleet device reserve <id> <label>` (and
`unreserve`, or `POST /api/v1/devices/{id}/reserve` · `/unreserve`, admin)
keeps a device for one kind of work: it is offered only to a job whose new
`reservation` field is that label **and** that pins the device by id
(`targets.device_id`, or a `targets.match` such as
`device_id == '<id>'`). Fan-out, pools, unpinned matches, the composer's
preview, device-executor claims and host-executor routing all pass it by,
and the host executor's own device selection applies the same rule from the
`reserved_devices` its claim now carries — so an `os ~ 'android'` job or the
explore night cannot open other apps on the growth pilot's signed-in
Instagram phone on ultra. A job pinning a reserved device without the label
is refused with the new error code `device_reserved`; validate warns when a
reserved device was left out. Reservations live in the brain's database, so a
phone can be reserved before it is cabled up. `fleet devices` gains a
`RESERVED` column, `fleet_devices` and `/api/v1/devices` a `reserved_for`
field, and `fleet run` a `--reservation` flag.
([#106](https://github.com/addisdev/fleet-runner/pull/106))

### Changed

- **The reference lab's brain moved to ultra.** It answers on ultra's loopback
(`127.0.0.1:8789`) and, for the shelf, through a new standard-library
forwarder, `collector/deploy/lan-forward.py` under `/usr/bin/python3`
(`com.addisdev.fleet-lan-forward.plist`): ultra's stealth-mode firewall drops
LAN connections to a Homebrew `node`. The night manifests talk to the local
brain and open no tunnel; `com.addisdev.fleet-tunnel-ultra.plist` is gone, and
the workstation's `com.addisdev.fleet-tunnel` goes to ultra, LAN first and
the tailnet when away. `backup-brain.sh` gains `FLEET_BACKUP_OUTBOX` (hand
each backup to an outbox sealer for an off-site pull) and
`FLEET_BACKUP_NO_DB=1` (a host with suites and no brain backs those up
instead of failing). [The lab](docs/lab.md) has the new layout.
([#105](https://github.com/addisdev/fleet-runner/pull/105))

### Fixed

- **A host job pinned by `targets.match "device_id == '<serial>'"` matched
nothing once claimed.** The brain evaluated `device_id` when routing it, but
the executor described an Android target without one, so the job reached the
right Mac and then skipped with no targets. The executor's descriptor now
carries `device_id`. ([#106](https://github.com/addisdev/fleet-runner/pull/106))
- **A model unloaded mid-night is loaded again.** On ultra the model shelf
unloads an on-demand model it sees idle under memory pressure, and between
two missions Holo4 is idle: on the first three 0.9.0 nights it was dropped
mid-run four times, and every later mission ended at step 0 on `Invalid
model identifier`. The workload now runs the night's warm-up again
(`FLEET_EXPLORE_WARM`, set by `explore-night.ts --warm`) and retakes the
step, twice a mission at most. The night command also sets `SHELF_HOLD_PID`
for its warm-up, which ultra's shelf now records as a hold so it leaves the
model loaded for the night. ([#101](https://github.com/addisdev/fleet-runner/pull/101))
- **No goal findings from missions that never drove the app.** A mission cut
off by the model server or the device, or abandoned by the model (four
replies without a tool call), is no longer judged: the judge had filed the
untouched or abandoned screens as goal findings (#70, #71 and the bug
garden's 10-17 goal findings). A cut-off mission is `explore_cut_off`, not
`explore_missions`, and a bench card it had not finished is not scored.
([#101](https://github.com/addisdev/fleet-runner/pull/101))
- **A bench card's end state the app takes away by itself counts.** When
nothing touched the device after the model's last look, that look is checked
too: Dozehound hides its channel bar six seconds after a press, so
`bench-focus-all-channels` failed on a screen the model had already left
right. ([#101](https://github.com/addisdev/fleet-runner/pull/101))
- **An Xcode that needs its first-launch install fails the night up front,
with the fix.** `explore-night.ts --sim` asks `xcodebuild
-checkFirstLaunchStatus` before warming the model, and FleetDriver stops at
the first `CoreSimulator is out of date` instead of spending a minute on each
card. ultra's Xcode updated itself to 27.1 on 10-16 and the TV and Apple
nights each spent about ten minutes a job finding that out.
([#101](https://github.com/addisdev/fleet-runner/pull/101))
- **A failed explore job says why on the job.** Its error is the failing
device's reason, not "one or more devices completed no mission; see their
rows", so the alert and the digest carry it.
([#101](https://github.com/addisdev/fleet-runner/pull/101))
- **The night command's log lines are in local time**, like the times inside
them. ([#101](https://github.com/addisdev/fleet-runner/pull/101))
- **The changelog coverage check exempts the release's own pull requests**:
`release/*` into `develop` and `develop` into `main`. 0.9.0's `release/0.9.0`
pull request (#98) failed its own `develop` → `main` check and had to be
labelled `no-changelog` by hand. ([#101](https://github.com/addisdev/fleet-runner/pull/101))

## [0.9.1] — 2026-10-18

A phone on the shelf is woken before it is driven, and one with a PIN says so
Expand Down Expand Up @@ -1494,7 +1581,8 @@ The first public release, when the project was still four repositories.
that starts a throwaway collector on a spare port so it never touches a live
fleet's history.

[Unreleased]: https://github.com/addisdev/fleet-runner/compare/v0.9.1...HEAD
[Unreleased]: https://github.com/addisdev/fleet-runner/compare/v0.9.2...HEAD
[0.9.2]: https://github.com/addisdev/fleet-runner/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/addisdev/fleet-runner/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/addisdev/fleet-runner/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/addisdev/fleet-runner/compare/v0.7.2...v0.8.0
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ One version covers every component, so a release is one number everywhere.
into `develop` since the last `v*` tag and fails, naming each, if one is
not mentioned as `#N` or a `/pull/N` link between `## [Unreleased]` and the
last release's heading. Label a pull request `no-changelog` to exempt it;
Dependabot's are exempt already. The check only runs when the branch is
Dependabot's are exempt already, and so are the release's own pull
requests (`release/*` into `develop`, `develop` into `main`). The check only runs when the branch is
`release/*`, or `develop` into `main` — so name the branch that way, or it
will not look. Run it yourself first with
`node scripts/changelog-coverage.mjs --head origin/develop` (it needs `gh`
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.1
0.9.2
2 changes: 1 addition & 1 deletion collector/dash/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleet-dash",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"type": "module",
"description": "Fleet Runner dashboard SPA. Built to dash/dist and served by the collector at /dash.",
Expand Down
37 changes: 34 additions & 3 deletions collector/deploy/backup-brain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
# Environment:
# FLEET_BACKUP_KEEP how many to keep (default 14)
# FLEET_HOME where config.json lives (default ~/.fleet)
# FLEET_BACKUP_NO_DB 1 on a host that runs no brain but keeps the suites
# (fleet-host since 2026-10-21: its executor still runs
# the flows and specs). Without it, a brain that does
# not answer is a failure, never a quiet suites-only run
# FLEET_BACKUP_OUTBOX path to an outbox sealer (`outbox.sh <app> <stamp>
# <path>`, as on ultra) to hand each backup to the
# off-site pull. Unset: no off-site copy from here
# FLEET_BACKUP_OUTBOX_APP the bundle name in the outbox (default fleet-brain)
set -eu

DEST="${1:-$HOME/fleet-backups}"
Expand All @@ -42,7 +50,9 @@ SYSTEM=$(curl -fsS -m 10 "http://127.0.0.1:$PORT/api/system" 2>/dev/null || true

field() { printf '%s' "$SYSTEM" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1; }
DATA_DIR=$(field data_dir)
if [ -z "$DATA_DIR" ]; then
if [ -z "$DATA_DIR" ] && [ "${FLEET_BACKUP_NO_DB:-0}" = "1" ] && [ -z "${FLEET_DATA_DIR:-}" ]; then
echo "backup-brain: no brain on this host (FLEET_BACKUP_NO_DB=1); suites and config only"
elif [ -z "$DATA_DIR" ]; then
echo "backup-brain: the collector on port $PORT did not answer /api/system." >&2
echo " A brain that is down is not a reason to skip the backup, but its data" >&2
echo " directory has to come from somewhere. Set FLEET_DATA_DIR and re-run." >&2
Expand All @@ -61,7 +71,9 @@ mkdir -p "$OUT"
echo "backup-brain -> $OUT"

# 1. the database
if [ -f "$DATA_DIR/fleet.db" ]; then
if [ -z "$DATA_DIR" ]; then
echo " fleet.db none: this host runs no brain"
elif [ -f "$DATA_DIR/fleet.db" ]; then
sqlite3 "$DATA_DIR/fleet.db" ".backup '$OUT/fleet.db'"
# A backup nobody checked is a file, not a backup.
if [ "$(sqlite3 "$OUT/fleet.db" 'pragma integrity_check;')" != "ok" ]; then
Expand All @@ -83,12 +95,30 @@ fi
{
echo "taken $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "host $(hostname)"
echo "data_dir $DATA_DIR"
echo "data_dir ${DATA_DIR:-<none: no brain on this host>}"
echo "flows ${FLOWS:-<unset>}"
echo "specs ${SPECS:-<unset>}"
echo "fleet $("$FLEET_HOME/bin/fleet" version 2>/dev/null | head -1 || echo unknown)"
} > "$OUT/MANIFEST.txt"

# Off-site. The sealer encrypts and verifies; this only hands it one gzip of
# the directory (a sqlite file shrinks a lot, and the outbox pays per byte on
# both machines). A failed hand-off fails the run: the local copy is fine, but
# nobody would otherwise notice the off-site one had stopped.
if [ -n "${FLEET_BACKUP_OUTBOX:-}" ]; then
APP="${FLEET_BACKUP_OUTBOX_APP:-fleet-brain}"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/fleet-outbox.XXXXXX")
tar -czf "$TMP/brain-$STAMP.tar.gz" -C "$DEST" "brain-$STAMP"
if "$FLEET_BACKUP_OUTBOX" "$APP" "$(date -u +%Y-%m-%dT%H%M%SZ)" "$TMP/brain-$STAMP.tar.gz"; then
echo " outbox sealed as $APP ($(wc -c < "$TMP/brain-$STAMP.tar.gz" | tr -d ' ') bytes before encryption)"
rm -rf "$TMP"
else
rm -rf "$TMP"
echo "backup-brain: the outbox refused the bundle; the local copy is kept" >&2
OUTBOX_FAILED=1
fi
fi

# Rotation. Sorted by name, which is sorted by time because the stamp leads.
COUNT=$(find "$DEST" -maxdepth 1 -type d -name 'brain-*' | wc -l | tr -d ' ')
if [ "$COUNT" -gt "$KEEP" ]; then
Expand All @@ -99,3 +129,4 @@ if [ "$COUNT" -gt "$KEEP" ]; then
fi

echo " kept $(find "$DEST" -maxdepth 1 -type d -name 'brain-*' | wc -l | tr -d ' ') of $KEEP"
[ -z "${OUTBOX_FAILED:-}" ] || exit 1
2 changes: 1 addition & 1 deletion collector/deploy/com.addisdev.fleet-executor-ios.plist
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<!-- Loopback, not the LAN address. macOS gates local-network access per
app and a launchd-started process has no grant and cannot prompt for
one, so the LAN address returns EHOSTUNREACH here while working fine
from Terminal. com.addisdev.fleet-tunnel carries 18788 to fleet-host. -->
from Terminal. com.addisdev.fleet-tunnel carries 18788 to the brain on ultra. -->
<key>FLEET_URL</key>
<string>http://127.0.0.1:18788</string>
<!-- The name jobs pin with: targets.executor = "mac-xcode". -->
Expand Down
10 changes: 5 additions & 5 deletions collector/deploy/com.addisdev.fleet-executor-ultra.plist
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
(targets.executor = "ultra"): the model it talks to is on this machine,
behind the gateway, and so are the emulator and simulator it drives.

Talks to the brain on fleet-host through com.addisdev.fleet-tunnel-ultra,
for two reasons that both point at loopback: ultra sits on the Mini's
192.168.1.x side and cannot route to fleet-host's 192.168.50.x at all,
and a launchd process has no local-network grant anyway. -->
The brain runs on ultra too (since 2026-10-21), on 127.0.0.1:8789, so
this talks to it over loopback; a launchd process has no local-network
grant anyway. Not installed today: the night queue runs the executor
in-process through scripts/explore-night.ts instead. -->
<key>ProgramArguments</key>
<array>
<string>__NODE__</string>
Expand All @@ -33,7 +33,7 @@
<key>PATH</key>
<string>__NODE_BIN_DIR__:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:__ANDROID_PLATFORM_TOOLS__</string>
<key>FLEET_URL</key>
<string>http://127.0.0.1:18788</string>
<string>http://127.0.0.1:8789</string>
<key>FLEET_EXECUTOR_NAME</key>
<string>ultra</string>
<key>FLEET_IOS_PROJECT</key>
Expand Down
40 changes: 40 additions & 0 deletions collector/deploy/com.addisdev.fleet-lan-forward.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.addisdev.fleet-lan-forward</string>
<!-- The brain's LAN address on ultra. The brain (`fleet up`, role brain)
binds 127.0.0.1:8789 only; this carries 192.168.1.111:8789 to it.
ultra's application firewall is in stealth mode and drops LAN
connections to a Homebrew node even though `socketfilterfw` reports
that path as permitted (measured 2026-10-21; the entry probably holds
an older build's ad-hoc signature), while /usr/bin/python3 is let
through. See lan-forward.py.
The executors and runner apps on the 192.168.50.x side reach this
address; the laptop and ultra's own night jobs use loopback.
Install: copy lan-forward.py to ~/.fleet/bin/, fill __HOME__, and
`launchctl bootstrap gui/$(id -u)` this file. -->
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>__HOME__/.fleet/bin/lan-forward.py</string>
<string>192.168.1.111:8789</string>
<string>127.0.0.1:8789</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<!-- Standard, not Background: a background job is throttled hard on a busy
Mac, and the fleet's devices long-poll through this. -->
<key>ProcessType</key>
<string>Standard</string>
<key>StandardOutPath</key>
<string>__HOME__/.fleet/logs/lan-forward.log</string>
<key>StandardErrorPath</key>
<string>__HOME__/.fleet/logs/lan-forward.log</string>
</dict>
</plist>
35 changes: 0 additions & 35 deletions collector/deploy/com.addisdev.fleet-tunnel-ultra.plist

This file was deleted.

47 changes: 18 additions & 29 deletions collector/deploy/com.addisdev.fleet-tunnel.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,35 @@
<dict>
<key>Label</key>
<string>com.addisdev.fleet-tunnel</string>

<!-- Loopback to the collector, for the iOS executor on this workstation.
<!-- Loopback to the brain, for the agent, executor, CLI and MCP server on
this workstation. The brain is on ultra (since 2026-10-21) at
127.0.0.1:8789 there; this Mac reaches it at 127.0.0.1:18788.
macOS 26+ gates local-network access per app, and a process started by
launchd has no grant and no way to prompt for one: the same node binary
that reaches the collector's LAN address from Terminal gets EHOSTUNREACH
under launchd.
Loopback is not gated, so the executor talks to 127.0.0.1:18788 and ssh
carries it to fleet-host.

The alternative is granting Local Network to node in System Settings,
which is cleaner and needs a human at the keyboard. If that is done, this
agent can be removed and FLEET_URL pointed back at the LAN address.

18788 because 8788 on this machine belongs to another project. -->
that reaches the brain's LAN address from Terminal gets EHOSTUNREACH
under launchd. Loopback is not gated, so everything here talks to
127.0.0.1:18788 and ssh carries it.
`ultra` is the LAN alias and `ultra-ts` the tailnet one, so the same
agent works at home and away: the LAN is tried first, with a short
connect timeout, and when that session ends launchd starts the pair
again. 18788 because 8788 on this machine belongs to another project. -->
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>-o</string><string>BatchMode=yes</string>
<string>-o</string><string>ExitOnForwardFailure=yes</string>
<!-- Notice a dead link in ~90s rather than hanging a long-poll forever. -->
<string>-o</string><string>ServerAliveInterval=30</string>
<string>-o</string><string>ServerAliveCountMax=3</string>
<string>-L</string><string>18788:127.0.0.1:8788</string>
<!-- Reverse: fleet-host's own loopback 8790 lands on this Mac's alert
receiver. The collector is a launchd agent and cannot reach a LAN
address, and fleet-host is headless, so this is how a red nightly
becomes a notification somebody sees. Binds fleet-host's loopback
only, which is why no GatewayPorts change is needed there. -->
<string>-R</string><string>8790:127.0.0.1:8790</string>
<string>fleet-host</string>
<string>/bin/sh</string>
<string>-c</string>
<!-- Notice a dead link in ~90s rather than hanging a long-poll forever.
Reverse: ultra's own loopback 8790 lands on this Mac's alert
receiver. The brain is a launchd agent, ultra is headless, so this is
how a red nightly becomes a notification somebody sees. It binds
ultra's loopback only, so no GatewayPorts change is needed there. -->
<string>for h in ultra ultra-ts; do /usr/bin/ssh -N -o BatchMode=yes -o ConnectTimeout=8 -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 18788:127.0.0.1:8789 -R 8790:127.0.0.1:8790 "$h"; done</string>
</array>

<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>

<key>StandardOutPath</key>
<string>__HOME__/Library/Logs/fleet-tunnel.log</string>
<key>StandardErrorPath</key>
Expand Down
Loading
Loading