@crvouga/dev-fleet 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +88 -7
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +83 -0
  14. package/ansible/macos/manager.yml +164 -9
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/macos/worker-host-key.yml +33 -0
  19. package/ansible/monitoring/agent-darwin.yml +3 -0
  20. package/ansible/monitoring/agent.yml +2 -0
  21. package/ansible/monitoring/hub-darwin.yml +3 -0
  22. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  23. package/ansible/net/remote-probe.sh +4 -1
  24. package/ansible/platforms.json +2 -2
  25. package/ansible/pyproject.toml +5 -0
  26. package/ansible/ubuntu/baseline.yml +39 -13
  27. package/ansible/ubuntu/disk-gc.py +6 -2
  28. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  29. package/ansible/ubuntu/policy.json +125 -6
  30. package/ansible/ubuntu/reboot.yml +89 -0
  31. package/ansible/ubuntu/recovery.yml +13 -3
  32. package/ansible/ubuntu/t3/policy.json +3 -0
  33. package/ansible/ubuntu/t3-desktop.yml +23 -2
  34. package/ansible/ubuntu/t3-headless.yml +128 -0
  35. package/ansible/ubuntu/t3-health.sh +24 -10
  36. package/ansible/ubuntu/t3-idle.py +37 -2
  37. package/ansible/ubuntu/t3-policy.yml +4 -1
  38. package/ansible/ubuntu/t3-route.yml +1 -0
  39. package/ansible/ubuntu/tailscale.yml +54 -8
  40. package/ansible/ubuntu/tasks/browser.yml +3 -0
  41. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  42. package/ansible/ubuntu/tasks/docker.yml +53 -6
  43. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  44. package/ansible/ubuntu/tasks/features.yml +22 -0
  45. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  46. package/ansible/ubuntu/tasks/install.yml +2 -1
  47. package/ansible/ubuntu/tasks/packages.yml +4 -2
  48. package/ansible/ubuntu/tasks/release.yml +6 -0
  49. package/ansible/ubuntu/tasks/resources.yml +6 -4
  50. package/ansible/ubuntu/tasks/services.yml +2 -2
  51. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  52. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  53. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  54. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  55. package/ansible/ubuntu/tasks/user.yml +1 -0
  56. package/ansible/ubuntu/worker-account.yml +117 -0
  57. package/ansible/uv.lock +265 -0
  58. package/bin/devfleet.mjs +42 -4
  59. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  60. package/bootstrap/global-config.ts +29 -3
  61. package/bootstrap/package-smoke.ts +127 -0
  62. package/bootstrap/pins.ts +189 -0
  63. package/bootstrap/release-notes.ts +88 -0
  64. package/bootstrap/release-version.ts +41 -7
  65. package/bootstrap/worker-features.ts +114 -0
  66. package/bootstrap/worker-seed.yaml +65 -0
  67. package/bootstrap/worker-tools.ts +39 -9
  68. package/cliproxy/README.md +1 -1
  69. package/cliproxy/docs/health-workflow.example.yml +20 -143
  70. package/docs/authentication.md +21 -11
  71. package/docs/consumer-setup.md +74 -58
  72. package/docs/convergence.md +67 -0
  73. package/docs/declared.md +61 -34
  74. package/docs/machine-setup.md +73 -7
  75. package/docs/manager-setup.md +34 -0
  76. package/docs/operations.md +64 -9
  77. package/docs/releasing.md +49 -1
  78. package/docs/runbooks.md +112 -7
  79. package/docs/state.md +23 -11
  80. package/docs/support-matrix.md +40 -4
  81. package/docs/topology.md +82 -12
  82. package/docs/ubuntu-sync.md +98 -42
  83. package/docs/worker-contract.md +122 -19
  84. package/docs/worker-requirements.md +107 -0
  85. package/examples/fleet.json +1 -0
  86. package/examples/workers-from-infrastructure/main.tf +132 -0
  87. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  88. package/llms.txt +3 -1
  89. package/mise.lock +29 -0
  90. package/mise.toml +5 -3
  91. package/package.json +2 -2
  92. package/schema/check-ids.json +1991 -162
  93. package/schema/check-ids.schema.json +34 -0
  94. package/schema/fleet.schema.json +349 -10
  95. package/schema/output/check.schema.json +1 -1
  96. package/schema/output/fleet-state-definition.schema.json +14 -0
  97. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  98. package/schema/output/fleet-sync.schema.json +18 -0
  99. package/schema/output/report.schema.json +3 -2
  100. package/schema/worker-record.schema.json +76 -0
  101. package/src/agents/converge.ts +140 -24
  102. package/src/agents/files.ts +2 -32
  103. package/src/agents/native-verify.ts +34 -3
  104. package/src/agents/secrets.ts +39 -4
  105. package/src/check-owners.ts +62 -2
  106. package/src/check-registry.ts +630 -0
  107. package/src/cli/agent-guide.ts +3 -3
  108. package/src/cli/cli.ts +4 -0
  109. package/src/cli/fleet-manifest.ts +108 -0
  110. package/src/cli/fleet-node-lifecycle.ts +704 -0
  111. package/src/cli/fleet-node-park.ts +238 -0
  112. package/src/cli/fleet-node-seed.ts +167 -0
  113. package/src/cli/fleet-state.ts +2 -1
  114. package/src/cli/fleet-sync-checkout.ts +7 -0
  115. package/src/cli/fleet-sync-enrollment.ts +193 -32
  116. package/src/cli/fleet-sync-forward.ts +6 -0
  117. package/src/cli/fleet-sync-manager.ts +123 -8
  118. package/src/cli/fleet-sync-output.ts +31 -11
  119. package/src/cli/fleet-sync-plan.ts +65 -9
  120. package/src/cli/fleet-sync-records.ts +113 -0
  121. package/src/cli/fleet-sync-remote.ts +172 -15
  122. package/src/cli/fleet-sync-schedule.ts +14 -4
  123. package/src/cli/fleet-sync-state.ts +31 -10
  124. package/src/cli/fleet-sync-vault.ts +152 -26
  125. package/src/cli/fleet-sync-worker.ts +214 -0
  126. package/src/cli/fleet-sync.ts +172 -21
  127. package/src/cli/fleet-validate.ts +277 -0
  128. package/src/cli/fleet-watch.ts +7 -1
  129. package/src/cli/fleet.ts +87 -5
  130. package/src/cli/managed-health.ts +25 -1
  131. package/src/cli/register-fleet.ts +251 -4
  132. package/src/cli/registry.ts +47 -0
  133. package/src/cli/tailnet.ts +43 -10
  134. package/src/cli/ui-data.ts +3 -1
  135. package/src/cli/worker-usage-report.ts +63 -0
  136. package/src/clock.ts +42 -0
  137. package/src/definition.ts +762 -232
  138. package/src/diagnostics.ts +5 -2
  139. package/src/exposure.ts +102 -0
  140. package/src/fleet-config.ts +3 -1
  141. package/src/git-identity.ts +76 -3
  142. package/src/ide/ide.ts +5 -0
  143. package/src/ide/t3.ts +12 -2
  144. package/src/input-hashes.ts +6 -0
  145. package/src/machine/machine-class.ts +59 -0
  146. package/src/machine/platform.ts +90 -11
  147. package/src/machine/state.ts +70 -16
  148. package/src/machine/support-matrix.ts +11 -9
  149. package/src/machine-health.ts +28 -5
  150. package/src/manager-health.ts +148 -2
  151. package/src/manager-sync.ts +89 -8
  152. package/src/monitoring/beszel.ts +8 -8
  153. package/src/monitoring/enroll.ts +8 -1
  154. package/src/monitoring/hub-access.ts +10 -4
  155. package/src/monitoring/systems.ts +138 -0
  156. package/src/network-definition.ts +13 -4
  157. package/src/node-converge-linux.ts +136 -27
  158. package/src/node-converge.ts +13 -3
  159. package/src/platform/ansible-receipt.ts +17 -2
  160. package/src/platform/ansible.ts +40 -4
  161. package/src/platform/manager-host.ts +25 -8
  162. package/src/platform/resource-verify.ts +18 -7
  163. package/src/platform/t3-policy.ts +24 -10
  164. package/src/platform/t3-service.ts +109 -34
  165. package/src/platform/ubuntu-release.ts +24 -0
  166. package/src/platform/ubuntu-verify.ts +31 -3
  167. package/src/platform/ubuntu-worker.ts +58 -0
  168. package/src/platform/worker-platform.ts +72 -0
  169. package/src/protocol.ts +1 -1
  170. package/src/public-reachability.ts +186 -0
  171. package/src/remote-bootstrap.ts +50 -12
  172. package/src/remote-operation.ts +18 -4
  173. package/src/repair-plan.ts +23 -12
  174. package/src/report.ts +2 -1
  175. package/src/schedule-check.ts +21 -2
  176. package/src/secrets.ts +7 -0
  177. package/src/service-auth.ts +55 -48
  178. package/src/ssh-connection.ts +141 -0
  179. package/src/ssh.ts +63 -24
  180. package/src/state/store.ts +4 -2
  181. package/src/state/sync.ts +35 -6
  182. package/src/state/workers.ts +140 -0
  183. package/src/t3/pairing.ts +59 -0
  184. package/src/t3/peer-link.ts +333 -0
  185. package/src/t3/version.ts +1 -1
  186. package/src/tailnet-cleanup.ts +185 -0
  187. package/src/tailnet-control.ts +346 -0
  188. package/src/tailscale.ts +1 -1
  189. package/src/toolchain-checks.ts +11 -1
  190. package/src/types.ts +4 -2
  191. package/src/worker-account.ts +63 -0
  192. package/src/worker-activity.ts +303 -0
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-credentials.ts +201 -0
  195. package/src/worker-exposure.ts +388 -0
  196. package/src/worker-features.ts +201 -0
  197. package/src/worker-host-checks.ts +71 -20
  198. package/src/worker-manifest.ts +224 -0
  199. package/src/worker-parking.ts +70 -0
  200. package/src/worker-policy.ts +38 -36
  201. package/src/worker-readiness.ts +15 -3
  202. package/src/worker-reboot.ts +129 -0
  203. package/src/worker-records.ts +305 -0
  204. package/src/worker-seed.ts +143 -0
  205. package/src/worker-sizing.ts +76 -0
  206. package/src/cli/schedule-logs.ts +0 -36
@@ -1,6 +1,7 @@
1
1
  > This document was written for the fleet dev-fleet was extracted from. Paths and
2
2
  > commands were updated; some statements still describe that fleet and are being
3
- > revised. The README is authoritative.
3
+ > revised. The README is authoritative, and what a machine must offer to be a
4
+ > worker is listed only in [worker requirements](worker-requirements.md).
4
5
 
5
6
  ## Machine setup: macOS, Windows and Linux
6
7
 
@@ -184,7 +185,13 @@ railway login
184
185
  Only sign into providers the task actually needs. On fleet machines the Git
185
186
  identity and the GitHub credential helper come from the `git` block of
186
187
  `fleet.json`: `fleet-sync` writes them in `~/.config/git/config` and checks
187
- them as `worker:git-identity` and `worker:git-push-auth`. Elsewhere set `git
188
+ them as `worker:git-identity` and `worker:git-push-auth`. With
189
+ `"signing": "ssh"` in that block every node also makes an SSH key of its own
190
+ (`~/.ssh/dev-fleet-git-signing`, never copied off the node) and signs every
191
+ commit and tag with it, checked as `worker:git-signing`; GitHub shows those
192
+ commits as verified once you add each node's `.pub` to the account as a
193
+ signing key (`gh ssh-key add ~/.ssh/dev-fleet-git-signing.pub --type signing`
194
+ on the node). Elsewhere set `git
188
195
  config --global user.name` and `user.email` after confirming them; do not guess
189
196
  an email or overwrite an existing identity. Enable Git LFS hooks when needed with `git lfs install`.
190
197
 
@@ -357,9 +364,44 @@ headless service, so environment identity and pairing are kept. A conflicting
357
364
  launcher version requires reviewed maintenance; the recovery timer stops a
358
365
  still-running headless service. Routine fleet-sync never ends a running turn
359
366
  to load a new pin or policy: the app restarts into them at the first idle
360
- check (`t3:running-version`, `t3:policy-loaded`). The disposable Ubuntu VM test still exercises the former headless service
361
- and does not cover the desktop session. Private routing and paired execution
362
- require separate evidence.
367
+ check (`t3:running-version`, `t3:policy-loaded`). Private routing and paired
368
+ execution require separate evidence.
369
+
370
+ #### Headless workers
371
+
372
+ A worker whose session is `headless` (`features.session` in `fleet.json`, or
373
+ the `editing` preset) has no display manager, no graphical session and no
374
+ GPU. It runs the pinned T3 **server**, not the desktop app under a virtual
375
+ display: the pinned release ships the server as its own archive
376
+ (`t3-<version>-linux-<arch>.tar.gz`, the `t3_version` and
377
+ `t3_linux_*_sha256` pins, from the release's `SHA256SUMS`), which the
378
+ toolchain already installs on every worker for policy commands. Its
379
+ `t3 serve` runs the server the desktop app embeds, with no browser; the
380
+ subcommand and its flags were read from the pinned release itself
381
+ (`t3 serve --help` of `0.1.8-nightly.20261106.4262`, whose `--mode web`
382
+ server answered `/.well-known/t3/environment` with that `serverVersion`).
383
+ A virtual display would install and pin a display server and a desktop
384
+ toolkit for one process, which costs the memory a small worker needs.
385
+
386
+ `ansible/ubuntu/t3-headless.yml` declares `t3code-headless.service`, a user
387
+ unit started by `default.target`: the designated user lingers, so it starts at
388
+ boot with nobody logged in. It listens on `127.0.0.1:3773` like the desktop
389
+ app, with `T3CODE_HOME=%h/.t3`, so the private route, the policy, the provider
390
+ instances, `personal-worker-recovery` (which reads the release from the
391
+ unit's versioned server path) and the checks are the same for both session
392
+ types. Switching a worker between types stops and removes the other type's
393
+ unit and keeps `~/.t3`; GDM auto-login, if the machine had it, is left as it
394
+ was. A headless worker installs none of the desktop-only packages
395
+ (`features.desktop` in `ansible/ubuntu/policy.json`) and reports their checks
396
+ as not applicable. The server prints a one-time pairing token to its own
397
+ journal at start, as `t3 serve` does anywhere; dev-fleet never reads it.
398
+ Pairing is done from the manager: by a person the first time, and by
399
+ `fleet-node-replace` or the next `fleet-sync` for a machine that replaces a
400
+ worker.
401
+
402
+ The disposable Ubuntu VM test (`ansible/ubuntu/tests/vm.py`) converges each
403
+ session type, reboots with nobody logged in and kills the T3 process to
404
+ prove the recovery timer brings it back.
363
405
 
364
406
  Normal Ubuntu fleet setup is controller-led:
365
407
  `devfleet fleet-sync`. See the
@@ -396,7 +438,10 @@ Controller-driven remote bootstrap passes `--worker` to the shared setup owner.
396
438
  It installs the `vars.worker_tools` subset from `mise.toml`, with versions and
397
439
  artifacts taken from the same `mise.lock`; native T3 and Ubuntu Docker/service
398
440
  policy remain source-pinned. `bun bootstrap/doctor.ts --worker --docker --json`
399
- checks that profile. It does not require unrelated cloud CLIs on workers.
441
+ checks that profile. It does not require unrelated cloud CLIs on workers. A
442
+ worker whose `containers` feature is off (`features.containers: false`, or the
443
+ `editing` preset) leaves `docker-cli` out of its base tools, and its doctor
444
+ runs with `--no-containers` instead of `--docker`.
400
445
 
401
446
  Tool profiles add locked tool sets on top of it: `worker.toolProfiles` in
402
447
  `fleet.json` (fleet-wide) or `toolProfiles` on one worker selects `web` (pnpm,
@@ -404,7 +449,28 @@ Deno, Yarn and headless Chromium for browser tests), `go`, `rust` (with CMake
404
449
  and Ninja) or `jvm` (Temurin), each a `worker_profile_<name>` under `[vars]` in
405
450
  `mise.toml`. `worker.tools` adds single tools dev-fleet already pins. The setup
406
451
  installs them (`--node` reads the installed configuration) and the doctor
407
- checks each as `tool:<name>` (`--profiles`, `--tools`).
452
+ checks each as `tool:<name>` (`--profiles`, `--tools`). The base tools include
453
+ `tmux`, for sessions that outlive an SSH connection.
454
+
455
+ A tool dev-fleet does not ship belongs to the project that needs it: pin it in
456
+ the project's own `mise.toml` and lock it with `mise lock`. On every fleet
457
+ machine dev-fleet's global configuration sets `locked = true` for the
458
+ `project` scope, so mise installs a project's tools in a checkout only from
459
+ that project's `mise.lock` and refuses a tool the lock does not name;
460
+ `mise install --locked` in the checkout installs and verifies them.
461
+
462
+ There is no `mobile` profile. No mobile toolchain can be pinned with
463
+ checksums for both worker architectures through mise: Flutter publishes no
464
+ Linux arm64 release, the Android SDK's platforms and build tools are fetched
465
+ by `sdkmanager` at run time (outside any lock, behind a licence a person
466
+ accepts), and iOS builds need macOS and Xcode, which workers are not. A
467
+ project that builds for Android on x86_64 workers pins `flutter`, `java` and
468
+ `android-sdk` in its own `mise.toml` as above.
469
+
470
+ `worker.exposure` (fleet-wide) or `exposure` on one worker says what a worker
471
+ opens beyond loopback and the tailnet; without it, nothing. See
472
+ [Exposure](ubuntu-sync.md#exposure).
473
+
408
474
  The source-owned `worker-auth.ts` timer renews the worker's existing Vault token
409
475
  and can restore the declared GitHub account from its authorized Vault service
410
476
  credential, without copying another machine's login directory.
@@ -0,0 +1,34 @@
1
+ # What only a person does on the manager
2
+
3
+ Everything on the manager is declared and converged by `fleet-sync`: the
4
+ toolchain, the power policy (applied by `ansible/macos/manager.yml` on every
5
+ run), the CLI proxy, the monitoring hub and their jobs. What remains are the
6
+ steps that need a person: an identity, an approval or an administrator
7
+ password. Each is done once, and `fleet-sync` measures it afterwards with the
8
+ check named beside it.
9
+
10
+ The list is tested not to grow (`features/rules/manager-setup.feature`): a
11
+ step added here must be added there too, with the check that verifies it.
12
+
13
+ <!-- human-steps -->
14
+
15
+ | Step | Why a person | Verified by |
16
+ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------- |
17
+ | Install Docker Desktop at the release `mise.toml` pins and sign in to it | A licence and an account | `manager:docker` |
18
+ | Sign this Mac in to Tailscale and approve it | A tailnet account and device approval | `tailscale:discovery` |
19
+ | Pass the bucket credentials (`DEVFLEET_BUCKET_*`) or save them with `fleet-state --init` | Credentials of an account you own | `state:bucket` |
20
+ | Log the manager in to Vault | A password or an identity provider | `auth:vault` |
21
+ | Grant the power settings once: `ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml` | An administrator password | `manager:power` |
22
+
23
+ <!-- /human-steps -->
24
+
25
+ The grant lets the manager's user run exactly `pmset -c` and
26
+ `systemsetup -getusingnetworktime` without a password, so later syncs never
27
+ prompt. The same playbook, run as an administrator, also installs what only
28
+ root may write: the newsyslog drop-in that keeps the logs of the scheduled
29
+ jobs under a fixed size (`manager:job-logs`) and T3's administrator policy
30
+ (`t3:updates`). Every `fleet-sync` then applies `ansible/macos/manager.yml`
31
+ unattended: in check mode first, then for real, with a receipt like the
32
+ workers' and a full re-apply at least once a day (`manager:playbook`). Run the
33
+ grant again when `manager:clock` or `manager:job-logs` asks for it after an
34
+ upgrade, or with `-e grant=absent` to remove it.
@@ -1,6 +1,7 @@
1
1
  > This document was written for the fleet dev-fleet was extracted from. Paths and
2
2
  > commands were updated; some statements still describe that fleet and are being
3
- > revised. The README is authoritative.
3
+ > revised. The README is authoritative, and what a machine must offer to be a
4
+ > worker is listed only in [worker requirements](worker-requirements.md).
4
5
 
5
6
  # Developer fleet
6
7
 
@@ -22,7 +23,7 @@ Use `devfleet fleet-sync` to reconcile all declared workers and
22
23
  `devfleet fleet-status --workers` to inspect remaining gaps. See
23
24
  [worker-contract.md](worker-contract.md) for bootstrap, ownership and the exact
24
25
  scope of readiness. `fleet-status` without flags diagnoses the local roster.
25
- Workers are Ubuntu 26.04; the manager is macOS and never sleeps on AC power
26
+ Workers are Ubuntu 26.04 or 24.04; the manager is macOS and never sleeps on AC power
26
27
  (`ansible/macos/manager.yml`). Machine policy (never sleep, no automatic
27
28
  logout, always-on Tailscale/SSH/Docker/Beszel, memory and disk safety, per-node
28
29
  credential renewal) is
@@ -134,6 +135,26 @@ expiring: tag them, or disable key expiry for each in the Tailscale admin
134
135
  console (tagged nodes do not expire). `tailscale:key-expiry` warns two weeks
135
136
  ahead and fails three days ahead.
136
137
 
138
+ With `tailnet` in `fleet.json` and the secret `tailnet-admin`, nothing needs
139
+ renewing: every worker joins with a key that carries the fleet's tag, and a
140
+ tagged device has key expiry disabled on the hosted Tailscale. For an enrolled
141
+ worker whose key still expires (one that joined before it was tagged),
142
+ `fleet-sync` disables expiry through the control plane
143
+ (`tailscale:key-expiry-policy`); `tailscale:key-expiry` measures the result on
144
+ the next run. Headscale gives a node registered with a pre-authorised key no
145
+ expiry.
146
+
147
+ ### Maintenance reboots
148
+
149
+ Kernel and library updates apply only after a reboot (`worker:reboot-pending`).
150
+ `devfleet fleet-sync --maintenance-reboot` reboots each worker that has one
151
+ pending: one at a time, the canary first, only while it runs no agent turn
152
+ (`--force` ends them), through `ansible/ubuntu/reboot.yml`. Each worker is
153
+ converged again once it answers; a worker that does not come back ready stops
154
+ the ones after it. A new worker that was never ready is rebooted once after its
155
+ first converge in any `fleet-sync`, so it reaches ready in one run; a worker
156
+ that was ready before is rebooted only by `--maintenance-reboot`.
157
+
137
158
  To re-join a worker whose key expired, a person signs it in again, because
138
159
  only a person's identity can authorise a device:
139
160
 
@@ -205,7 +226,8 @@ serially; diagnostics run at most four remote probes concurrently.
205
226
  shell PATH and installs frozen Bun dependencies. Review OS-specific effects
206
227
  and existing config conflict guards in the shared bootstrap runbook.
207
228
  - Record input hashes only after success and stable checkout inputs. Failed
208
- setup can leave partial installs; no rollback is claimed. Trusted checkout
229
+ setup can leave partial installs, which the next sync completes. Going back
230
+ is `fleet-sync --rollback`, as the [README](../README.md#the-version) says. Trusted checkout
209
231
  package lifecycle scripts can execute during installation.
210
232
  - No other application repos, credentials, provider settings or cloud resources
211
233
  are synchronized. `devfleet fleet-sync` remains the broader convergence.
@@ -258,12 +280,45 @@ A notification carries node and check IDs only. `devfleet fleet-watch --test`
258
280
  sends one test notification to every target.
259
281
 
260
282
  Every run stores what it saw, and when, as `watch.json` beside the fleet state
261
- in the bucket. That time is the manager's heartbeat. A watcher on the manager
262
- notices nothing when the manager itself is off, so call the reusable
263
- `fleet-heartbeat.yml` workflow from the repository that holds your
264
- `fleet.json` (its header shows the one `uses:` line): it fails, and GitHub
265
- notifies you, when the heartbeat is older than `max-age-minutes`. `bun run health` remains the separate
266
- single read-only **cloud platform** report.
283
+ in the bucket. That time is the manager's heartbeat. Nobody has to run it:
284
+ `fleet-sync` installs it on the manager as the launchd job
285
+ `dev.devfleet.fleet-watch` (`ansible/macos/manager.yml`), which runs every
286
+ `manager.schedule.watchMinutes` minutes of `fleet.json` (15 by default) from
287
+ the checkout `fleet-sync` last applied, and `manager:watch` fails when it is
288
+ not loaded. It reads the bucket credentials `fleet-state --init` saved.
289
+
290
+ A watcher on the manager notices nothing when the manager itself is off, so
291
+ call the reusable `fleet-heartbeat.yml` workflow from the repository that
292
+ holds your `fleet.json` (its header shows the one `uses:` line): it fails, and
293
+ GitHub notifies you, when the heartbeat is older than `max-age-minutes`
294
+ (60 by default). With the watch job every 15 minutes and the workflow every
295
+ 30, a manager that is turned off fails the workflow within about 90 minutes
296
+ (the age limit plus one interval of the workflow's schedule; GitHub may delay
297
+ a scheduled run). Keep `max-age-minutes` well above `watchMinutes`.
298
+
299
+ The CLI proxy is checked off the manager the same way:
300
+ `.github/workflows/cliproxy-health.yml` is a reusable workflow, called with one
301
+ `uses:` line. It needs no stored secret: it reads the Vault address and the
302
+ proxy endpoint from your `fleet.json`, logs in to Vault with GitHub's OIDC
303
+ token (the read-only role `vault-role` names, `github-actions` by default) and
304
+ runs the checks of the dev-fleet release your `package.json` pins. Copy
305
+ [`cliproxy/docs/health-workflow.example.yml`](../cliproxy/docs/health-workflow.example.yml)
306
+ into `.github/workflows/` and name the release tag in its `uses:` line; it
307
+ runs the free readiness check every 15 minutes, a light inference check hourly
308
+ and the full suite nightly, and keeps one tracking issue in your repository.
309
+
310
+ ### The logs of the manager's scheduled jobs
311
+
312
+ `fleet-sync --schedule` and the watch job write to
313
+ `~/.local/share/personal/fleet/manager/logs/<label>.{out,err}.log`. newsyslog
314
+ rotates each once it passes `jobLogs.maxKiB` of `ansible/macos/policy.json`
315
+ (1024 KiB) and keeps `jobLogs.keep` compressed archives, from the drop-in
316
+ `/etc/newsyslog.d/dev-fleet-USER.conf` that the manager's one administrator
317
+ grant (`ansible/macos/manager-privilege.yml`) installs. `manager:job-logs`
318
+ warns when the drop-in is missing, or a log has grown past twice that size.
319
+
320
+ `bun run health` remains the separate single read-only **cloud platform**
321
+ report.
267
322
 
268
323
  ## Development
269
324
 
package/docs/releasing.md CHANGED
@@ -51,7 +51,33 @@ cannot release because publishing was never set up (no tag and nothing on
51
51
  npm) fails, and the run summary says why; a merge with nothing new to release
52
52
  passes and says so. Re-running a
53
53
  release whose version is already on npm skips the publish and finishes the tag
54
- and GitHub Release.
54
+ and GitHub Release, whether all its jobs or only the failed ones are re-run: a
55
+ `HEAD` that carries its tag but has no GitHub Release is released again.
56
+
57
+ Nothing is published until the release has been tried. The workflow packs
58
+ the package once, then, for that commit and that tarball:
59
+
60
+ - installs the tarball in an empty directory, the way a fleet project does,
61
+ and runs `devfleet --version` and `devfleet fleet-state --example` on Node
62
+ 22 and 24 ([`bootstrap/package-smoke.ts`](../bootstrap/package-smoke.ts)):
63
+ a broken bin path or a file missing from `files` fails here;
64
+ - runs the [Ubuntu VM test](../.github/workflows/vm.yml) (a real Ubuntu
65
+ machine: baseline, a second run, drift, reboot, and the production SSH
66
+ bootstrap of the commit);
67
+ - runs the [tailnet test](../.github/workflows/tailnet.yml) (a worker joins
68
+ a self-hosted tailnet unattended and leaves no device behind).
69
+
70
+ Only when all three pass is that same tarball published, and the GitHub Release
71
+ gets notes written from the Conventional Commits since the last release
72
+ ([`bootstrap/release-notes.ts`](../bootstrap/release-notes.ts)). A commit
73
+ whose change restarts T3 on the workers, and so ends the agent turns running
74
+ there, says so in a `Worker-Restart: <reason>` footer, and the notes list it
75
+ first.
76
+
77
+ There is no `next` channel yet: publishing uses npm Trusted Publishing, which
78
+ can publish but cannot move a dist-tag, and moving one would need an npm
79
+ token stored in CI. The VM test before publishing stands in for the soak a
80
+ `next` channel would give.
55
81
 
56
82
  A hand-pushed tag still works and publishes exactly that version:
57
83
 
@@ -62,6 +88,28 @@ git tag vX.Y.Z && git push origin vX.Y.Z
62
88
  CI never receives an npm token: it publishes through npm Trusted Publishing
63
89
  (OIDC).
64
90
 
91
+ ## When a release fails
92
+
93
+ A release that fails publishes nothing, and neither does any merge after it
94
+ until one passes: npm stays at the last version. The
95
+ [Failure issues workflow](../.github/workflows/failures.yml) keeps one open
96
+ issue, `Release is failing on main` (label `ci-failure`), with the run, the
97
+ commit and the jobs and steps that failed; a failed VM test keeps its
98
+ redacted results and the guest's logs as an artifact of the run
99
+ (`gh run download <run>`). The next passing release closes the issue and
100
+ publishes everything since the last tag. The same holds for CI on `main`,
101
+ which the release waits for.
102
+
103
+ A pull request is merged by a workflow, and a merge made with a workflow's
104
+ own token starts no CI on `main`. [`auto-merge.yml`](../.github/workflows/auto-merge.yml)
105
+ starts it when the newest commit on `main` has no run: when a CI run ends,
106
+ and every hour. A run started that way starts nothing when it ends either,
107
+ so the same hourly run starts the release once CI has passed on the newest
108
+ commit of `main` and no release has run for it, and the Failure issues
109
+ workflow reads the newest finished run of every workflow it follows each
110
+ hour as well. A merge is therefore released within about an hour of its CI
111
+ passing.
112
+
65
113
  ## One-time setup
66
114
 
67
115
  npm can publish new versions through OIDC but cannot create a package that does
package/docs/runbooks.md CHANGED
@@ -8,7 +8,7 @@ person (a login, an approval, a choice) is marked **needs a person**.
8
8
  `features/rules/runbooks.feature` fails when a code in the source has no
9
9
  entry here, or a `devfleet` command quoted in the documents does not exist.
10
10
 
11
- There are 244 codes; 31 need a person.
11
+ There are 301 codes; 46 need a person.
12
12
 
13
13
  ## Bootstrap of a new or returning worker
14
14
 
@@ -34,7 +34,7 @@ There are 244 codes; 31 need a person.
34
34
  - **Meaning:** A package transaction could not run or the package database is inconsistent.
35
35
  - **Confirm:** `ssh WORKER sudo dpkg --audit` and `sudo apt-get -o Debug::NoLocking=1 check`.
36
36
  - **Fix:** Let any running package process finish (it is never killed), free disk under `/var`, then run `devfleet fleet-sync`.
37
- - **Escalate:** APT_STATE_BROKEN and DPKG_INTERRUPTED need `sudo dpkg --configure -a` by a person once no package process runs.
37
+ - **Escalate:** An interrupted package run is finished by the next `devfleet fleet-sync`: the bootstrap and the baseline each run `dpkg --configure -a` once, bounded. APT_STATE_BROKEN and DPKG_INTERRUPTED mean that repair did not clear `dpkg --audit`: a person repairs the packages it names.
38
38
 
39
39
  - `APT_DISK_FULL`: apt disk full.
40
40
  - `APT_DNS_UNAVAILABLE`: apt dns unavailable.
@@ -104,6 +104,18 @@ There are 244 codes; 31 need a person.
104
104
  - `WORKER_RECOVERY_SETUP_FAILED`: worker recovery setup failed.
105
105
  - `WORKER_RECOVERY_TIMER_INACTIVE`: worker recovery timer inactive.
106
106
 
107
+ ## Worker size and features
108
+
109
+ - **Meaning:** The machine is smaller than the minimum worker (`sizing.minimumWorker` in `ansible/ubuntu/policy.json`), more agent sessions run than its memory policy supports, its declared `features` in `fleet.json` are not ones dev-fleet knows, or something dev-fleet installed for a feature that is now off is still there.
110
+ - **Confirm:** `devfleet fleet-sync --dry-run` shows the `platform:worker-size` check of each worker and, under the plan, what turning a feature off removes; `devfleet fleet-status --workers` shows `worker:features` and `worker:agent-sessions`.
111
+ - **Fix:** Resize a machine below the minimum, then run `devfleet fleet-sync`; nothing was installed on it. For a feature removal, run `devfleet fleet-sync` again. For too many sessions, finish some or move work to a larger worker.
112
+ - **Escalate:** A package another package depends on stays installed until a person removes that one.
113
+
114
+ - `AGENT_SESSION_CAPACITY_EXHAUSTED`: agent session capacity exhausted: more agent sessions run than the memory policy supports (a warning; the size of a session is an estimate).
115
+ - `WORKER_FEATURES_INVALID`: worker features invalid: the session is neither desktop nor headless.
116
+ - `WORKER_FEATURE_REMOVAL_INCOMPLETE`: worker feature removal incomplete: a Docker package or the docker group membership is still there with containers off.
117
+ - `WORKER_SIZE_BELOW_MINIMUM`: worker size below minimum: less memory, disk or CPUs than the minimum worker. **Needs a person.**
118
+
107
119
  ## Containers on a worker
108
120
 
109
121
  - **Meaning:** `worker:docker-run` could not start the pinned container or build a one-line image as the worker user.
@@ -119,6 +131,23 @@ There are 244 codes; 31 need a person.
119
131
  - `DOCKER_SMOKE_IMAGE_MISSING`: docker smoke image missing.
120
132
  - `DOCKER_SOCKET_ACCESS_DENIED`: docker socket access denied.
121
133
 
134
+ ## Exposure of a worker
135
+
136
+ - **Meaning:** Something on the worker is reachable from outside the tailnet, or could be: the fleet firewall (`ansible/ubuntu/tasks/firewall.yml`) is not loaded or kept SSH open, a service listens beyond loopback and the tailnet (`worker:exposure`), or the manager reached a fleet port on the worker's public address (`worker:public-reachability`). The exposure policy is `exposure` in `fleet.json` (fleet-wide under `worker`, per worker beside `network`).
137
+ - **Confirm:** On the worker: `sudo nft list table inet personal_fleet`, `sudo ss -H -l -n -t -u -p` and `sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin'`. From the manager: `devfleet fleet-status --workers`.
138
+ - **Fix:** Run `devfleet fleet-sync`. It closes SSH beyond the tailnet only when the manager's session arrives over the tailnet: point the worker's SSH alias at its tailnet name first. Bind a service to `127.0.0.1` or the tailnet address, or declare its port in `publicPorts`.
139
+ - **Escalate:** A firewall the tailnet path could not verify (the previous rules are back) needs `tailscale status` on the worker; a public SSH port is opened on purpose only with `publicSsh`, by a person.
140
+
141
+ - `FIREWALL_RULES_REJECTED`: firewall rules rejected by `nft --check`; the previous rules stay loaded.
142
+ - `FIREWALL_TAILNET_UNVERIFIED`: the tailnet path did not answer a new probe after the rules loaded; the previous rules were restored.
143
+ - `FIREWALL_UNMANAGED_CONFLICT`: `/etc/personal-fleet/firewall.nft` exists without the fleet's marker. **Needs a person.**
144
+ - `SSH_HARDENING_OVERRIDDEN`: `sshd -T` still allows passwords or root login: an earlier fragment or a Match block wins. **Needs a person.**
145
+ - `WORKER_EXPOSURE_UNOBSERVED`: the addresses, sockets or firewall could not be read with `sudo -n`.
146
+ - `WORKER_FIREWALL_INACTIVE`: the fleet firewall is not loaded.
147
+ - `WORKER_LISTENER_UNRESTRICTED`: a socket listens beyond loopback and the tailnet outside the allow-list; the summary names the port and the owning unit. A fleet service fails; another warns or fails by `otherListeners`.
148
+ - `WORKER_PUBLIC_PORT_UNRESTRICTED`: the manager connected to SSH, T3 or the monitoring port on the worker's public address.
149
+ - `WORKER_PUBLIC_SSH_UNDECLARED`: SSH is still open beyond the tailnet without `publicSsh`, because no session over the tailnet proved the manager's path. **Needs a person.**
150
+
122
151
  ## Headless browser on a worker
123
152
 
124
153
  - **Meaning:** `worker:browser` could not launch the pinned headless Chromium of the web tool profile.
@@ -133,10 +162,10 @@ There are 244 codes; 31 need a person.
133
162
 
134
163
  ## Git identity and pushing
135
164
 
136
- - **Meaning:** Commits on the node would carry the wrong author, or git cannot authenticate to GitHub on its own.
165
+ - **Meaning:** Commits on the node would carry the wrong author, would not be signed although `git.signing` is declared, or git cannot authenticate to GitHub on its own.
137
166
  - **Confirm:** On the node: `git config --global --get user.name`, and `printf "protocol=https\nhost=github.com\n\n" | GIT_TERMINAL_PROMPT=0 git credential fill >/dev/null`.
138
167
  - **Fix:** Declare the `git` block in `fleet.json` and run `devfleet fleet-sync` (`ansible/github/git.yml`). Remove lines in `~/.gitconfig` that override it.
139
- - **Escalate:** GIT_REMOTE_REFUSED means the fleet account lost access to the declared repository: a person grants it.
168
+ - **Escalate:** GIT_REMOTE_REFUSED means the fleet account lost access to the declared repository: a person grants it. GitHub shows signed commits as verified only once a person adds each node's `~/.ssh/dev-fleet-git-signing.pub` to the account as a signing key.
140
169
 
141
170
  - `GIT_CONFIGURATION_INCOMPLETE`: git configuration incomplete.
142
171
  - `GIT_CREDENTIAL_HELPER_MISSING`: git credential helper missing.
@@ -146,6 +175,9 @@ There are 244 codes; 31 need a person.
146
175
  - `GIT_IDENTITY_UNDECLARED`: git identity undeclared.
147
176
  - `GIT_REMOTE_REFUSED`: git remote refused. **Needs a person.**
148
177
  - `GIT_REMOTE_UNREACHABLE`: git remote unreachable.
178
+ - `GIT_SIGNING_FAILED`: git signing failed: the node's own key is missing or cannot sign without a prompt.
179
+ - `GIT_SIGNING_MISSING`: git signing missing.
180
+ - `GIT_SIGNING_OVERRIDDEN`: git signing overridden.
149
181
 
150
182
  ## GitHub sign-in
151
183
 
@@ -180,12 +212,34 @@ There are 244 codes; 31 need a person.
180
212
  - `TAILSCALE_SOURCE_OWNER_CONFLICT`: tailscale source owner conflict. **Needs a person.**
181
213
  - `TAILSCALE_UPGRADE_PENDING`: tailscale upgrade pending. **Needs a person.**
182
214
 
215
+ ## A new worker with no hands on it
216
+
217
+ - **Meaning:** A machine created without a person (from a pipeline or infrastructure-as-code) could not be enrolled, joined to the tailnet, given its account or brought to ready in one run: its first boot is still running or failed, the tailnet control plane refused a key, the image lets only root in and the account could not be created, or its first-converge or maintenance reboot did not happen.
218
+ - **Confirm:** The `bootstrap:first-boot`, `tailscale:enrolment-key`, `worker:account` and `worker:reboot` checks of the report `devfleet fleet-sync --json` prints, or the `tailscale:device` check of `fleet-node-replace` and `fleet-node-retire` and the `tailscale:leftover` check of `devfleet fleet-status`, which ask the same control plane; on the worker, `cloud-init status --long`.
219
+ - **Fix:** FIRST_BOOT_PENDING and WORKER_REBOOT_BUSY clear on their own: run `devfleet fleet-sync` again later. A TAILNET code names what to fix in `tailnet` of `fleet.json` or in the secret `tailnet-admin`; then run `devfleet fleet-sync`.
220
+ - **Escalate:** A first boot that failed means the image or its user data is wrong: recreate the machine. A root login that cannot create the declared account needs a person on the machine (`useradd --create-home --shell /bin/bash --groups sudo USER`).
221
+
222
+ - `FIRST_BOOT_FAILED`: first boot failed: cloud-init ended in an error. **Needs a person.**
223
+ - `FIRST_BOOT_PENDING`: first boot pending: cloud-init still runs; retryable.
224
+ - `TAILNET_ADMIN_REQUIRED`: tailnet admin required: fleet.json declares tailnet without the secret. **Needs a person.**
225
+ - `TAILNET_CONTROL_DENIED`: tailnet control denied the tailnet-admin credential. **Needs a person.**
226
+ - `TAILNET_CONTROL_REJECTED`: tailnet control rejected the request (tag or user). **Needs a person.**
227
+ - `TAILNET_CONTROL_RESPONSE_INVALID`: tailnet control response invalid.
228
+ - `TAILNET_CONTROL_UNAVAILABLE`: tailnet control unavailable.
229
+ - `TAILNET_KEY_DELIVERY_FAILED`: tailnet key delivery failed.
230
+ - `TAILNET_KEY_INVALID`: tailnet key invalid.
231
+ - `WORKER_ACCOUNT_CREATION_FAILED`: worker account creation failed. **Needs a person.**
232
+ - `WORKER_ACCOUNT_ROOT_REQUIRED`: worker account root required. **Needs a person.**
233
+ - `WORKER_ACCOUNT_UNDECLARED`: worker account undeclared in workers[].network.user. **Needs a person.**
234
+ - `WORKER_REBOOT_BUSY`: worker reboot busy: an agent turn runs.
235
+ - `WORKER_REBOOT_FAILED`: worker reboot failed.
236
+
183
237
  ## T3 Code
184
238
 
185
239
  - **Meaning:** The native T3 server or desktop app, its policy, its private route or its providers did not converge or verify.
186
240
  - **Confirm:** `devfleet t3-status` and the `t3:*` checks of `devfleet fleet-status`.
187
241
  - **Fix:** Run `devfleet fleet-sync`, then `devfleet t3-apply` for providers. A MIGRATION or MAINTENANCE code names a one-time step in its remedy.
188
- - **Escalate:** Pairing and relinking a peer needs a person in T3 Settings.
242
+ - **Escalate:** A worker's first pairing, and relinking a peer that was not replaced, need a person in T3 Settings (Connections). A replaced worker is paired by `devfleet fleet-node-replace` or the next `devfleet fleet-sync` (`t3:link`).
189
243
 
190
244
  - `MANAGER_T3_LINK_UNVERIFIED`: manager t3 link unverified.
191
245
  - `T3_BASELINE_PREREQUISITES_REQUIRED`: t3 baseline prerequisites required.
@@ -224,6 +278,8 @@ There are 244 codes; 31 need a person.
224
278
  - `T3_PROVIDER_NAMES_PENDING`: t3 provider names pending.
225
279
  - `T3_PROVIDER_NAMES_UNVERIFIED`: t3 provider names unverified.
226
280
  - `T3_RESTART_PENDING`: t3 restart pending.
281
+ - `T3_SERVER_INSTALL_FAILED`: t3 server install failed: the pinned server of a headless worker is missing from the toolchain, or `t3-headless.yml` did not apply.
282
+ - `T3_SERVER_STOPPED`: t3 server stopped: the lingering `t3code-headless.service` of a headless worker is not running.
227
283
  - `T3_SETTINGS_BUSY`: t3 settings busy.
228
284
 
229
285
  ## Monitoring (Beszel)
@@ -295,10 +351,12 @@ There are 244 codes; 31 need a person.
295
351
  - **Meaning:** An agent CLI, its profile or a provider behind the CLI proxy did not answer as expected.
296
352
  - **Confirm:** The `agent:*` and `provider:*` checks of `devfleet fleet-status`, and `devfleet cliproxy` on the manager.
297
353
  - **Fix:** Run `devfleet fleet-sync`; a rate limit or upstream outage clears on its own.
298
- - **Escalate:** PROVIDER_AUTH_REJECTED needs a person to sign the account in again on the proxy.
354
+ - **Escalate:** PROVIDER_AUTH_REJECTED needs a person to sign the account in again on the proxy. AGENT_PROFILE_RENDER_MISMATCH is a defect of the release (a template of `ansible/agents/agents.yml` no longer renders what `src/agents/render.ts` specifies): report it to dev-fleet. No receipt vouches for what that play wrote, so later applies refuse the file until a person reviews it.
299
355
 
300
356
  - `AGENT_MEMORY_LIMIT_MISSING`: agent memory limit missing.
301
357
  - `AGENT_MODEL_UNSERVED`: agent model unserved.
358
+ - `AGENT_PROFILE_RENDER_MISMATCH`: agent profile render mismatch.
359
+ - `AGENT_PROFILE_REQUEST_INVALID`: agent profile request invalid.
302
360
  - `AGENT_PROFILE_STALE`: agent profile stale.
303
361
  - `AGENT_SERVICE_ENVIRONMENT_INCOMPLETE`: agent service environment incomplete.
304
362
  - `AGENT_SERVICE_ENVIRONMENT_UNVERIFIED`: agent service environment unverified.
@@ -338,7 +396,7 @@ There are 244 codes; 31 need a person.
338
396
  ## Vault and credentials
339
397
 
340
398
  - **Meaning:** A machine cannot read the secrets it needs with its own Vault session.
341
- - **Confirm:** `vault token lookup` on the machine and the `worker:vault-renewal` check.
399
+ - **Confirm:** `vault token lookup` on the machine and the `worker:vault-renewal` or `manager:vault-renewal` check.
342
400
  - **Fix:** Log the machine in once (`devfleet fleet-sync` asks on the manager); renewal is automatic afterwards.
343
401
  - **Escalate:** A refused policy needs the Vault administrator.
344
402
 
@@ -346,6 +404,39 @@ There are 244 codes; 31 need a person.
346
404
  - `VAULT_LOGIN_REQUIRED`: vault login required. **Needs a person.**
347
405
  - `VAULT_POLICY_PERMISSION_REQUIRED`: vault policy permission required. **Needs a person.**
348
406
  - `VAULT_RENEWAL_FAILING`: vault renewal failing.
407
+ - `VAULT_RENEWAL_MISSING`: vault renewal missing: the manager's renewal job is not loaded. Run `devfleet fleet-sync` on the manager.
408
+ - `VAULT_RENEWAL_NOT_MANAGER`: vault renewal not manager.
409
+ - `VAULT_WORKER_POLICY_FAILED`: vault worker policy failed.
410
+
411
+ ## The fleet definition and a worker's lifecycle
412
+
413
+ - **Meaning:** `fleet.json` breaks a rule of the fleet definition, the pinned Ansible rejects the inventory it describes, or a seed, replacement or retirement could not change the host keys the manager trusts.
414
+ - **Confirm:** `devfleet fleet-validate` names the field that breaks a rule as `{code, path, expected, actual}`; `devfleet fleet-validate --ansible` also renders the inventory and syntax-checks every playbook.
415
+ - **Fix:** Correct the field the check names in `fleet.json`, then run the command again. For SSH_HOST_KEY_NOT_CHANGED run `devfleet fleet-validate --ansible`, then repeat `devfleet fleet-node-seed`, `devfleet fleet-node-replace` or `devfleet fleet-node-retire`.
416
+ - **Escalate:** An inventory the pinned Ansible rejects for a valid definition is an issue.
417
+
418
+ - `DEFINITION_INVALID`: definition invalid.
419
+ - `DEFINITION_INVENTORY_REJECTED`: definition inventory rejected.
420
+ - `DEFINITION_MISSING`: definition missing.
421
+ - `DEFINITION_UNREADABLE`: definition unreadable.
422
+ - `SSH_HOST_KEY_NOT_CHANGED`: ssh host key not changed.
423
+
424
+ ## Parked, idle, replaced and retired workers
425
+
426
+ - **Meaning:** a worker could not be parked or resumed (agent turns under way, a declared `parking.stop` or `parking.start` command failed, the sync after a resume did not end ready), a worker whose record disappeared could not be retired, or the manager's T3 link to a replaced or retired worker could not be removed, or the manager's T3 could not pair with a replaced worker's new environment.
427
+ - **Confirm:** `devfleet fleet-status --workers` lists each worker as active or parked, with its uptime, agent turns and last agent activity; the report of `devfleet fleet-node-park`, `devfleet fleet-node-resume` or `devfleet fleet-sync` names the step that failed.
428
+ - **Fix:** For WORKER_BUSY wait for the turns to end, or repeat `devfleet fleet-node-park` with `--force`. For a failed hook, run the command `fleet.json` names under `parking` by hand with the worker ID, then repeat. For WORKER_RESUME_FAILED follow the sync report and run `devfleet fleet-sync --node ID`. For T3_LINK_UNLINK_FAILED remove the link in the manager's T3 (Settings, Connections). For T3_LINK_PAIRING_FAILED follow the remedy, which names why the link was refused (Linked environments off in the T3 policy, the manager's T3 not running, a worker T3 that did not mint a code), then run `devfleet fleet-sync --node ID`: it pairs a replaced worker until it is paired.
429
+ - **Escalate:** A record retirement that keeps failing at the same step: follow that step's own runbook.
430
+
431
+ - `T3_LINK_PAIRING_FAILED`: t3 link pairing failed: the manager's T3 could not pair with a replaced worker's new T3 environment. The pairing code never appears in a report.
432
+ - `T3_LINK_UNLINK_FAILED`: t3 link unlink failed.
433
+ - `WORKER_ACTIVITY_UNREADABLE`: worker activity unreadable. **Needs a person.**
434
+ - `WORKER_AUTO_PARK_REFUSED`: worker auto park refused.
435
+ - `WORKER_BUSY`: worker busy. **Needs a person.**
436
+ - `WORKER_RECORD_RETIREMENT_FAILED`: worker record retirement failed.
437
+ - `WORKER_RESUME_FAILED`: worker resume failed.
438
+ - `WORKER_START_HOOK_FAILED`: worker start hook failed.
439
+ - `WORKER_STOP_HOOK_FAILED`: worker stop hook failed.
349
440
 
350
441
  ## Fleet operations and the fleet state
351
442
 
@@ -366,6 +457,7 @@ There are 244 codes; 31 need a person.
366
457
  - `FLEET_REMOTE_LOCKED`: fleet remote locked.
367
458
  - `FLEET_REMOTE_OPERATION_FAILED`: fleet remote operation failed.
368
459
  - `FLEET_REVISION_REQUIRED`: fleet revision required.
460
+ - `FLEET_ROLLBACK_UNAVAILABLE`: fleet rollback unavailable.
369
461
  - `FLEET_SCHEDULE_PREVIEW`: fleet schedule preview.
370
462
  - `FLEET_SOURCE_UNAVAILABLE`: fleet source unavailable.
371
463
  - `FLEET_STATE_DIVERGED`: fleet state diverged. **Needs a person.**
@@ -380,6 +472,19 @@ There are 244 codes; 31 need a person.
380
472
  - `REMOTE_CONFIGURATION_INCOMPLETE`: remote configuration incomplete.
381
473
  - `REMOTE_PHASE_FAILED`: remote phase failed.
382
474
 
475
+ ## The manager's own policy and scheduled jobs
476
+
477
+ - **Meaning:** `ansible/macos/manager.yml` did not pass its check-mode run or its apply, or something it declares (network time, the fleet-watch job, the rotation of the scheduled jobs' logs) is not in place.
478
+ - **Confirm:** The `manager:playbook`, `manager:clock`, `manager:watch` and `manager:job-logs` checks of `devfleet fleet-sync --dry-run`; on the manager, the check-mode run the remedy quotes.
479
+ - **Fix:** Run `devfleet fleet-sync`, which applies the playbook in check mode first, then for real. What needs root (power settings, reading network time, newsyslog) comes from the one grant: `ansible-playbook -e user=$(id -un) -K ansible/macos/manager-privilege.yml`, run again after an upgrade that changed it.
480
+ - **Escalate:** The grant needs an administrator's password, once.
481
+
482
+ - `MANAGER_CLOCK_UNOBSERVED`: manager clock unobserved: the grant that lets fleet-sync read network time is missing. **Needs a person.**
483
+ - `MANAGER_JOB_LOG_ROTATION_MISSING`: manager job log rotation missing: the newsyslog drop-in the grant installs is missing or outdated. **Needs a person.**
484
+ - `MANAGER_PLAYBOOK_CHECK_FAILED`: manager playbook check failed: nothing was applied.
485
+ - `MANAGER_PLAYBOOK_FAILED`: manager playbook failed.
486
+ - `MANAGER_WATCH_INTERVAL_INVALID`: manager watch interval invalid: `manager.schedule.watchMinutes` is outside 5 to 1440.
487
+
383
488
  ## Notifications (fleet-watch)
384
489
 
385
490
  - **Meaning:** fleet-watch could not reach a notification target `notify` in `fleet.json` names, or its address is not stored as a secret.
package/docs/state.md CHANGED
@@ -4,12 +4,13 @@ A fleet is described in two places you own. Your project in Git holds
4
4
  `fleet.json` and pins the dev-fleet version. Everything else the manager
5
5
  cannot rebuild from Git is the **fleet state**:
6
6
 
7
- | Document | What it is |
8
- | ------------ | --------------------------------------------------------------------- |
9
- | `roster` | The enrolled connection to each worker (SSH alias, Tailscale device) |
10
- | `enrollment` | The revision `fleet-sync` approved and the one it last applied |
11
- | `definition` | The fleet configuration `fleet-sync` last applied: your `fleet.json` |
12
- | `history` | The last 90 days of `fleet-sync` runs (at most 500), and the schedule |
7
+ | Document | What it is |
8
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `roster` | The enrolled connection to each worker (SSH alias, Tailscale device) |
10
+ | `enrollment` | The revision `fleet-sync` approved and the one it last applied |
11
+ | `definition` | The fleet configuration `fleet-sync` last applied: your `fleet.json` |
12
+ | `history` | The last 90 days of `fleet-sync` runs (at most 500), and the schedule |
13
+ | `workers` | Per worker: its pinned SSH host key, whether it is parked and since when, whether it came from a worker record, and whether the manager is to pair its T3 after a replacement |
13
14
 
14
15
  `devfleet fleet-watch` also keeps `watch.json` in the bucket, beside the
15
16
  database: the health of each worker when it last looked, and when that was
@@ -195,6 +196,12 @@ state that is replicated differently or not at all:
195
196
  | Monitoring hub (Beszel) database | `~/Library/Application Support/personal-beszel` | `fleet-sync` recreates accounts and systems; metric history, alerts and settings made by hand are lost |
196
197
  | T3 ownership record | `~/.t3/userdata/personal-managed.json` | `t3-apply --import ID` adopts instances again |
197
198
 
199
+ The manager reaches a worker that has no SSH alias against
200
+ `~/.local/share/dev-fleet/ssh/known_hosts` (beside the state directory): a
201
+ cache rendered from the host keys the `workers` document pins and the worker
202
+ records declare, rewritten before every session. Delete it and the next
203
+ command writes it again; nothing is ever learned into it.
204
+
198
205
  Everything else under `~/.local/share/personal/fleet` and
199
206
  `~/.local/state/personal/fleet` (the copy of the source repository, run
200
207
  checkouts, reports, receipts, logs) is a cache that the next `fleet-sync`
@@ -257,9 +264,14 @@ mise exec litestream -- litestream restore -no-expand-env \
257
264
  ```
258
265
 
259
266
  `devfleet fleet-state --restore --timestamp 2026-11-01T00:00:00Z --yes` does
260
- the same and then uploads that version as the bucket's current one. It is the
261
- remedy for a `corrupt` replica: the bucket answers, but its latest version
262
- cannot be read.
267
+ the same and then uploads that version as the bucket's current one, which
268
+ takes the manager lease. It is the remedy for a `corrupt` replica: the bucket
269
+ answers, but its latest version cannot be read. `--yes` consents to replacing
270
+ the bucket, not to replacing another manager: while one holds the lease in the
271
+ bucket's current version, the restore stops with `FLEET_MANAGER_LEASE_HELD`
272
+ before it changes anything, and `--take-over` overrides it. When the current
273
+ version cannot be read, its lease cannot either, and the lease in this
274
+ machine's copy (the last one it saw) decides instead.
263
275
 
264
276
  Litestream is pinned in `mise.toml` and installed the first time it is used.
265
277
  The manager is the only machine that runs it.
@@ -274,8 +286,8 @@ writes anything and stops with `FLEET_MANAGER_LEASE_HELD`. `fleet-state
274
286
  --init` still restores the state on such a machine; it only writes nothing.
275
287
 
276
288
  The lease runs for seven days after the last change. After that, any machine
277
- may take it. Before that, `--take-over` (on `fleet-sync`, `fleet-state --init`
278
- or `fleet-state --import`) makes this machine the manager; use it only once
289
+ may take it. Before that, `--take-over` (on `fleet-sync`, `fleet-state --init`,
290
+ `fleet-state --import` or `fleet-state --restore --timestamp`) makes this machine the manager; use it only once
279
291
  the other one is gone ([replacing the manager](manager-recovery.md)).
280
292
 
281
293
  A machine is named in the lease by a digest of its machine ID