@crvouga/dev-fleet 0.2.0 → 0.3.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.
- package/README.md +28 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/ubuntu/policy.json +1 -0
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/native-t3.ts +22 -11
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/declared.md +3 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +4 -3
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +28 -22
- package/llms.txt +1 -0
- package/mise.toml +3 -1
- package/package.json +2 -2
- package/schema/fleet.schema.json +66 -0
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +20 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-remote.ts +20 -13
- package/src/cli/fleet-sync.ts +36 -0
- package/src/cli/register-fleet.ts +35 -0
- package/src/cli/registry.ts +13 -0
- package/src/definition.ts +85 -3
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/manager-sync.ts +1 -15
- package/src/node-converge-linux.ts +28 -7
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-entry.ts +15 -1
- package/src/service-auth.ts +15 -5
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/transport.ts +3 -2
- package/src/worker-host-checks.ts +36 -13
package/cliproxy/scripts/ops
CHANGED
|
@@ -8,6 +8,7 @@ import pathlib as _pathlib
|
|
|
8
8
|
sys.path.insert(0, str(_pathlib.Path(__file__).resolve().parent))
|
|
9
9
|
import accounts
|
|
10
10
|
import cliproxy_host
|
|
11
|
+
import clientkeys
|
|
11
12
|
|
|
12
13
|
import fcntl, json, os, pathlib, shutil, subprocess, sys, tempfile, time, urllib.request, urllib.error
|
|
13
14
|
|
|
@@ -205,6 +206,92 @@ def apply_prefixes(prefixes):
|
|
|
205
206
|
sys.exit("The proxy did not record the declared account prefixes.")
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
LOGIN_FLAGS = {
|
|
210
|
+
"codex": "-codex-device-login",
|
|
211
|
+
"claude": "-claude-login",
|
|
212
|
+
"antigravity": "-antigravity-login",
|
|
213
|
+
"kimi": "-kimi-login",
|
|
214
|
+
"xai": "-xai-login",
|
|
215
|
+
"meta": "-meta-login",
|
|
216
|
+
"devin": "-devin-login",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def login(provider, replace=None):
|
|
221
|
+
"""Runs the proxy's own login into a staging directory, then moves the
|
|
222
|
+
complete credential in with one rename: an interrupted login leaves no
|
|
223
|
+
half-written auth file. `replace` is the credential a re-login renews."""
|
|
224
|
+
import yaml
|
|
225
|
+
|
|
226
|
+
if provider not in LOGIN_FLAGS:
|
|
227
|
+
sys.exit(
|
|
228
|
+
"This release has no login flag for that provider. Use the official dashboard OAuth/API key configuration."
|
|
229
|
+
)
|
|
230
|
+
auths = root / "state/runtime/auths"
|
|
231
|
+
stage = accounts.begin(auths)
|
|
232
|
+
config = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
|
|
233
|
+
config.setdefault("oauth", {})["auth-dir"] = "/state/" + stage.name
|
|
234
|
+
login_config = root / "state/runtime/login-config.yaml"
|
|
235
|
+
login_config.write_text(yaml.safe_dump(config))
|
|
236
|
+
try:
|
|
237
|
+
compose(
|
|
238
|
+
"exec",
|
|
239
|
+
"proxy",
|
|
240
|
+
"/CLIProxyAPI/CLIProxyAPI",
|
|
241
|
+
"-config",
|
|
242
|
+
"/state/" + login_config.name,
|
|
243
|
+
LOGIN_FLAGS[provider],
|
|
244
|
+
"-no-browser",
|
|
245
|
+
)
|
|
246
|
+
if not accounts.adopt(stage, auths, replace=replace):
|
|
247
|
+
sys.exit("The login wrote no credential; nothing was changed.")
|
|
248
|
+
finally:
|
|
249
|
+
login_config.unlink(missing_ok=True)
|
|
250
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def account_roster():
|
|
254
|
+
"""The roster with each account's state, from the credential files and
|
|
255
|
+
the management API (which knows cooldowns and refresh failures)."""
|
|
256
|
+
entries = []
|
|
257
|
+
try:
|
|
258
|
+
admin = (root / "state/secrets/management-key").read_text().strip()
|
|
259
|
+
code, body = request("/v0/management/auth-files", admin)
|
|
260
|
+
if code == 200:
|
|
261
|
+
entries = json.loads(body).get("files") or []
|
|
262
|
+
except Exception:
|
|
263
|
+
entries = []
|
|
264
|
+
roster = accounts.roster(root / "state/runtime/auths", entries)
|
|
265
|
+
return {
|
|
266
|
+
"version": 1,
|
|
267
|
+
"rosterVersion": accounts.version(roster),
|
|
268
|
+
"managementApi": bool(entries),
|
|
269
|
+
"accounts": accounts.public(roster),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def key_ring():
|
|
274
|
+
return clientkeys.load(root / "state/secrets/client-keys.json")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def apply_keys(ring):
|
|
278
|
+
"""The proxy accepts exactly the shared key and the ring's keys."""
|
|
279
|
+
import yaml
|
|
280
|
+
|
|
281
|
+
live = yaml.safe_load((root / "state/runtime/config.yaml").read_text())
|
|
282
|
+
shared = (root / "state/secrets/client-key").read_text().strip()
|
|
283
|
+
live.setdefault("access", {})["api-keys"] = clientkeys.api_keys(ring, [shared])
|
|
284
|
+
with tempfile.NamedTemporaryFile(
|
|
285
|
+
"w", dir=root / "state/runtime", suffix=".yaml", delete=False
|
|
286
|
+
) as file:
|
|
287
|
+
file.write(yaml.safe_dump(live, sort_keys=False))
|
|
288
|
+
try:
|
|
289
|
+
os.chmod(file.name, 0o600)
|
|
290
|
+
apply_config(pathlib.Path(file.name), restart=False)
|
|
291
|
+
finally:
|
|
292
|
+
pathlib.Path(file.name).unlink(missing_ok=True)
|
|
293
|
+
|
|
294
|
+
|
|
208
295
|
def lock():
|
|
209
296
|
f = open(root / "state/status/operation.lock", "w")
|
|
210
297
|
if os.geteuid() == 0:
|
|
@@ -414,33 +501,58 @@ try:
|
|
|
414
501
|
sys.exit(
|
|
415
502
|
"Install a reviewed Git revision with bun run cliproxy install --revision REV; use --rollback for the recorded previous revision."
|
|
416
503
|
)
|
|
417
|
-
elif command
|
|
504
|
+
elif command in ["add-account", "relogin"]:
|
|
505
|
+
# `add-account PROVIDER` signs in a new account; `relogin PROVIDER
|
|
506
|
+
# INDEX` signs that one account in again. Prefixes follow at once.
|
|
418
507
|
operation_lock = lock()
|
|
419
508
|
provider = sys.argv[2]
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"antigravity": "-antigravity-login",
|
|
425
|
-
"kimi": "-kimi-login",
|
|
426
|
-
"xai": "-xai-login",
|
|
427
|
-
"meta": "-meta-login",
|
|
428
|
-
"devin": "-devin-login",
|
|
429
|
-
}
|
|
430
|
-
if provider == "gemini":
|
|
431
|
-
sys.exit(
|
|
432
|
-
"This release has no Gemini CLI login flag. Use official dashboard OAuth/API key configuration."
|
|
509
|
+
replace = None
|
|
510
|
+
if command == "relogin":
|
|
511
|
+
account = accounts.find(
|
|
512
|
+
accounts.roster(root / "state/runtime/auths"), provider, int(sys.argv[3])
|
|
433
513
|
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
"
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
514
|
+
if account is None:
|
|
515
|
+
sys.exit("No such account. List them with: devfleet fleet-accounts")
|
|
516
|
+
replace = account["file"]
|
|
517
|
+
login(provider, replace)
|
|
518
|
+
prefixes = pending_prefixes()
|
|
519
|
+
if prefixes:
|
|
520
|
+
apply_prefixes(prefixes)
|
|
521
|
+
elif command == "remove-account":
|
|
522
|
+
# Deletes the credential; the proxy stops serving it, and the next
|
|
523
|
+
# account propagation removes its instances from every node.
|
|
524
|
+
operation_lock = lock()
|
|
525
|
+
accounts.remove(root / "state/runtime/auths", sys.argv[2], int(sys.argv[3]))
|
|
526
|
+
elif command == "accounts":
|
|
527
|
+
report = account_roster()
|
|
528
|
+
if "--json" in sys.argv[2:]:
|
|
529
|
+
print(json.dumps(report))
|
|
530
|
+
else:
|
|
531
|
+
print("Roster version:", report["version"])
|
|
532
|
+
for account in report["accounts"]:
|
|
533
|
+
print(
|
|
534
|
+
account["provider"],
|
|
535
|
+
account["index"],
|
|
536
|
+
account["state"],
|
|
537
|
+
account["until"] or "",
|
|
538
|
+
)
|
|
539
|
+
elif command == "client-keys":
|
|
540
|
+
print(json.dumps({"version": 1, "nodes": clientkeys.report(key_ring())}))
|
|
541
|
+
elif command in ["issue-key", "rotate-key", "retire-key", "revoke-key"]:
|
|
542
|
+
# Per-node client keys. A rotation keeps the old key accepted until
|
|
543
|
+
# retire-key, so no running agent fails while nodes move over.
|
|
544
|
+
operation_lock = lock()
|
|
545
|
+
ring = key_ring()
|
|
546
|
+
node = sys.argv[2]
|
|
547
|
+
{
|
|
548
|
+
"issue-key": clientkeys.issue,
|
|
549
|
+
"rotate-key": clientkeys.rotate,
|
|
550
|
+
"retire-key": clientkeys.retire,
|
|
551
|
+
"revoke-key": clientkeys.revoke,
|
|
552
|
+
}[command](ring, node)
|
|
553
|
+
clientkeys.save(root / "state/secrets/client-keys.json", ring)
|
|
554
|
+
apply_keys(ring)
|
|
555
|
+
print(json.dumps({"version": 1, "nodes": clientkeys.report(ring)}))
|
|
444
556
|
elif command == "inference":
|
|
445
557
|
protocol, model = sys.argv[2:4]
|
|
446
558
|
key = (root / "state/secrets/client-key").read_text().strip()
|
|
@@ -519,7 +631,7 @@ try:
|
|
|
519
631
|
)
|
|
520
632
|
else:
|
|
521
633
|
sys.exit(
|
|
522
|
-
"Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER inference PROTOCOL MODEL portable-login activate drain"
|
|
634
|
+
"Commands: start stop restart status health models logs validate apply FILE declared [--apply] add-account PROVIDER relogin PROVIDER INDEX remove-account PROVIDER INDEX accounts [--json] client-keys issue-key|rotate-key|retire-key|revoke-key NODE inference PROTOCOL MODEL portable-login activate drain"
|
|
523
635
|
)
|
|
524
636
|
except Exception:
|
|
525
637
|
sys.exit(
|
|
@@ -58,3 +58,18 @@ print(
|
|
|
58
58
|
"credential files validated offline.",
|
|
59
59
|
)
|
|
60
60
|
print("No restored credentials or code were activated.")
|
|
61
|
+
# `--newest DIR`: the newest checkpoint. Refresh tokens rotate, so an older
|
|
62
|
+
# checkpoint can hold tokens the provider has already replaced; name exactly
|
|
63
|
+
# the accounts that need a new login (provider and index, never an account).
|
|
64
|
+
if "--newest" in sys.argv[2:]:
|
|
65
|
+
import checkpoint
|
|
66
|
+
|
|
67
|
+
newest = pathlib.Path(sys.argv[sys.argv.index("--newest") + 1]).resolve()
|
|
68
|
+
if not (newest / "CHECKPOINT.json").is_file():
|
|
69
|
+
newest = checkpoint.locate(newest)
|
|
70
|
+
stale = checkpoint.stale_accounts(root, newest)
|
|
71
|
+
for provider, index in stale:
|
|
72
|
+
print("needs-login:", provider, index)
|
|
73
|
+
print("Accounts restored with a current refresh token:", "all" if not stale else "all others")
|
|
74
|
+
if stale:
|
|
75
|
+
sys.exit(4)
|
package/docs/accounts.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Provider accounts
|
|
2
|
+
|
|
3
|
+
**The rule.** Provider accounts exist only as the CLI proxy's credential files
|
|
4
|
+
on the manager. Every node and IDE gets a view derived from them, and nothing
|
|
5
|
+
on a node holds a provider login of its own. T3 offers one instance per
|
|
6
|
+
account, named `<Provider> <account email>`, and every launcher reaches the
|
|
7
|
+
provider through the proxy.
|
|
8
|
+
|
|
9
|
+
## The roster
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx devfleet fleet-accounts # provider, index, label, state, when a cooldown clears
|
|
13
|
+
npx devfleet fleet-accounts --diff # and what this machine offers against it
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Each account has one state, derived from its credential file and the proxy's
|
|
17
|
+
management API:
|
|
18
|
+
|
|
19
|
+
| State | Meaning |
|
|
20
|
+
| ----------------- | -------------------------------------------------------------------------- |
|
|
21
|
+
| `usable` | Serves requests |
|
|
22
|
+
| `cooling-down` | The provider limited it; `until` says when it clears, when the proxy knows |
|
|
23
|
+
| `refresh-failing` | Its token refresh fails for now, and its access token is still valid |
|
|
24
|
+
| `needs-login` | The provider refused its refresh token: only signing in again helps |
|
|
25
|
+
| `removed` | Disabled on the proxy |
|
|
26
|
+
|
|
27
|
+
The index of an account is its position among its provider's accounts, sorted
|
|
28
|
+
by sign-in email. Checks name accounts by provider and index only.
|
|
29
|
+
|
|
30
|
+
The **roster version** is a digest of the routing prefixes of the accounts.
|
|
31
|
+
The manager computes it from the credential files and every node from the
|
|
32
|
+
proxy's model list, so the two agree exactly when a node offers what the
|
|
33
|
+
proxy holds.
|
|
34
|
+
|
|
35
|
+
## Checks
|
|
36
|
+
|
|
37
|
+
| Check | Fails when |
|
|
38
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
39
|
+
| `proxy:account:<provider>` | The provider has no usable account; it warns below `services.proxy.accounts.minimumUsable` (default 2) |
|
|
40
|
+
| `accounts:roster-current` | The node offers another roster version than the proxy's |
|
|
41
|
+
| `accounts:native-login` | (warns) An agent CLI on the node keeps a provider login of its own |
|
|
42
|
+
| `proxy:reachable-from` | The node gets no answer from the proxy; it warns above two seconds |
|
|
43
|
+
| `agent:<tool>:account-<n>:session` | That account's instance does not run a session; it warns while the account is cooling down |
|
|
44
|
+
| `agent:<tool>:removed-<n>:session` | The node still offers an instance for an account the proxy no longer holds |
|
|
45
|
+
|
|
46
|
+
Session results are cached for six hours under a key that holds the roster
|
|
47
|
+
version and the version of the client key, so any account change or key
|
|
48
|
+
rotation re-verifies at once.
|
|
49
|
+
|
|
50
|
+
## Adding, signing in again, removing
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx devfleet fleet-accounts --add claude
|
|
54
|
+
npx devfleet fleet-accounts --relogin codex 2
|
|
55
|
+
npx devfleet fleet-accounts --remove codex 2
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A sign-in writes into a staging directory beside the credentials; only a
|
|
59
|
+
complete credential is moved in, with one rename, so an interrupted sign-in
|
|
60
|
+
leaves nothing behind. Routing prefixes are applied in the same command. Every
|
|
61
|
+
change is then propagated to every node.
|
|
62
|
+
|
|
63
|
+
## Propagation without a full sync
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npx devfleet fleet-sync --only accounts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
On the manager and every worker at once, each node re-reads the proxy's
|
|
70
|
+
roster and rewrites only its per-account T3 instances. T3 watches its
|
|
71
|
+
settings, so nothing restarts and no running turn ends. Instance ids derive
|
|
72
|
+
from the account's routing prefix and stay the same. A node that cannot be
|
|
73
|
+
reached catches up on the next propagation or sync; until then
|
|
74
|
+
`accounts:roster-current` reports the version it has.
|
|
75
|
+
|
|
76
|
+
## Choosing an account
|
|
77
|
+
|
|
78
|
+
Unpinned requests go through a loopback relay on every node. When the fleet
|
|
79
|
+
declares a policy for a provider, the relay picks the account of each request:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
"services": {
|
|
83
|
+
"proxy": {
|
|
84
|
+
"endpoint": "https://cli-proxy.example.com",
|
|
85
|
+
"accounts": {
|
|
86
|
+
"minimumUsable": 2,
|
|
87
|
+
"selection": {
|
|
88
|
+
"codex": { "strategy": "fill-first", "order": ["a@example.com"] }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Strategy | Order |
|
|
96
|
+
| ------------------------ | ------------------------------------------------ |
|
|
97
|
+
| `fill-first` | The same order for every request |
|
|
98
|
+
| `round-robin` | Each request starts one account further along |
|
|
99
|
+
| `least-recently-limited` | The account limited longest ago (or never) first |
|
|
100
|
+
|
|
101
|
+
The order is the accounts `order` names, in that order, then the others in
|
|
102
|
+
the order of their routing prefix. A quota or authentication refusal sends
|
|
103
|
+
the request on to the next account, and the refused one cools down for its
|
|
104
|
+
`Retry-After` (a minute without one). Without a policy the proxy's own
|
|
105
|
+
fill-first pool chooses. An instance pinned to one account answers "This
|
|
106
|
+
account is cooling down until …" and names the other instances of its
|
|
107
|
+
provider.
|
|
108
|
+
|
|
109
|
+
## Proxy outages
|
|
110
|
+
|
|
111
|
+
Every relayed launch (Claude, Codex, Gemini and Antigravity, pinned or not)
|
|
112
|
+
retries a proxy that does not answer for about a minute. If it stays down,
|
|
113
|
+
the agent gets one distinct, retryable error: "The CLI proxy has been
|
|
114
|
+
unreachable since …".
|
|
115
|
+
|
|
116
|
+
## Checkpoints and the proxy state
|
|
117
|
+
|
|
118
|
+
Each checkpoint records a short digest of every credential's refresh token,
|
|
119
|
+
never the token. After restoring an older checkpoint, `restore-verify
|
|
120
|
+
RESTORED --newest NEWEST` names exactly the accounts whose refresh token the
|
|
121
|
+
provider has since replaced (`needs-login: <provider> <index>`).
|
|
122
|
+
|
|
123
|
+
`cliproxy/scripts/lease.py` is a fencing lease on the proxy state: a second
|
|
124
|
+
proxy cannot take it while the first holds it, and taking over an expired
|
|
125
|
+
lease fences the old holder.
|
|
126
|
+
|
|
127
|
+
## Client keys
|
|
128
|
+
|
|
129
|
+
`devfleet cliproxy issue-key|rotate-key|retire-key|revoke-key NODE` keep one
|
|
130
|
+
proxy client key per node in the manager's key ring. A rotation keeps the old
|
|
131
|
+
key accepted until `retire-key`, so no running agent fails while nodes move
|
|
132
|
+
over. Revoking one node's key leaves the others working.
|
|
133
|
+
`devfleet cliproxy client-keys` reports each node's key by fingerprint only.
|
|
134
|
+
|
|
135
|
+
## Not yet done
|
|
136
|
+
|
|
137
|
+
- The proxy does not notify anyone when an account turns `needs-login`, and
|
|
138
|
+
no watcher on the manager triggers propagation or a checkpoint when a
|
|
139
|
+
credential file changes. Run `fleet-sync --only accounts` or
|
|
140
|
+
`fleet-accounts` instead.
|
|
141
|
+
- Nothing forces a refresh of every account after a restore. The digest
|
|
142
|
+
comparison above is offline.
|
|
143
|
+
- The lease is not yet taken by the proxy service at start, and its
|
|
144
|
+
production store (the checkpoint bucket) is not wired.
|
|
145
|
+
- Per-node client keys are not yet distributed to nodes through the secrets
|
|
146
|
+
provider, so nodes still present the shared key. No
|
|
147
|
+
`proxy:client-key-current` check exists yet.
|
|
148
|
+
- OpenCode and Grok read the proxy's address from rendered configuration and
|
|
149
|
+
are not relayed yet. There is no warm standby proxy.
|
|
150
|
+
- The selection policy is applied by the client relay; the proxy itself stays
|
|
151
|
+
fill-first.
|
|
152
|
+
- A sign-in uses the provider's device code where the proxy offers one.
|
|
153
|
+
Other providers print a callback URL, and no loopback forward is managed
|
|
154
|
+
for them.
|
package/docs/declared.md
CHANGED
|
@@ -18,7 +18,9 @@ may only shrink.
|
|
|
18
18
|
| Tools: languages, CLIs, coding agents | `mise.toml` | `mise.lock`: version and checksum for each platform | `mise install --locked` | Tool and `agent:verification` checks |
|
|
19
19
|
| mise itself | `min_version` and `mise_*_sha256` in `mise.toml` | The digest of its release archive, held here | `bootstrap/bootstrap.sh` | Its version, at every toolchain setup |
|
|
20
20
|
| JavaScript dependencies | `package.json` | `bun.lock` | `bun install --frozen-lockfile` | The install fails on a difference |
|
|
21
|
-
| T3 Code, server and desktop app | `t3_*` under `[vars]` in `mise.toml` | SHA-256 of each artifact | `bootstrap/native-t3.ts`; `ansible/ubuntu/t3-desktop.yml` | T3 checks
|
|
21
|
+
| T3 Code, server and desktop app | `t3_*` under `[vars]` in `mise.toml` | SHA-256 of each artifact | `bootstrap/native-t3.ts`; `ansible/ubuntu/t3-desktop.yml` | T3 checks; `t3:version` on every machine |
|
|
22
|
+
| T3 policy on the manager (updates off) | `ansible/ubuntu/t3/policy.json`, shared with the workers | The commit | `ansible/macos/t3-policy.yml`, once, as an administrator | `t3:updates` |
|
|
23
|
+
| Worker work-in-progress guard | `ansible/ubuntu/wip-guard.py`, `ansible/ubuntu/tasks/recovery.yml` | The commit | `fleet-sync` | `features/ide/work-in-progress.feature` |
|
|
22
24
|
| Ubuntu packages | `packages` and `snapshot` in `ansible/ubuntu/policy.json` | Exact versions from a dated archive snapshot | `ansible/ubuntu/tasks/packages.yml` | `package:*` |
|
|
23
25
|
| Tailscale on workers | `tailscale` in `ansible/ubuntu/policy.json` | Exact version; digest of the signing key | `ansible/ubuntu/tailscale.yml` | `package:tailscale` |
|
|
24
26
|
| Worker policy: power, SSH, sudo, memory, disk | `ansible/ubuntu/*.yml`, values shared with checks in `policy.json` | The commit | `fleet-sync` | Host and resource checks |
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# What survives the loss of a machine
|
|
2
|
+
|
|
3
|
+
This is the one place that says what dev-fleet keeps when a machine is lost.
|
|
4
|
+
|
|
5
|
+
| Lost | Kept | Lost with it |
|
|
6
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| A worker | Everything `fleet-sync` rebuilds from Git and secrets. Work in agent worktrees (`~/.t3/worktrees`) up to the last run of the work-in-progress guard, every 15 minutes, in `refs/recovery/<node>/<branch>` of each worktree's remote. The T3 environment ID in the roster. | Up to 15 minutes of uncommitted work; T3 threads and their transcripts on that worker. |
|
|
8
|
+
| The manager | The fleet state, from the bucket ([state](state.md)). The T3 profile, provider instances and policy, from `fleet.json` and Git. | T3's own data on the manager: its linked environments and its threads. They are not in a backup set yet. |
|
|
9
|
+
|
|
10
|
+
## Recovering work from a lost worker
|
|
11
|
+
|
|
12
|
+
The guard pushes the files of each worktree as they were (changed and new
|
|
13
|
+
files, not ignored ones) as one commit on top of its `HEAD`, without touching
|
|
14
|
+
the worktree, its index or its branches:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
git fetch origin 'refs/recovery/*:refs/recovery/*'
|
|
18
|
+
git log refs/recovery/<node>/<branch>
|
|
19
|
+
git switch -c recovered refs/recovery/<node>/<branch>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A worktree whose work is already on a branch of its remote pushes nothing.
|
|
23
|
+
The guard is `ansible/ubuntu/wip-guard.py`, run by the user timer
|
|
24
|
+
`personal-worker-wip-guard.timer` that `ansible/ubuntu/tasks/recovery.yml`
|
|
25
|
+
installs. It pushes with the worker's own Git credentials, so a worktree
|
|
26
|
+
whose remote the worker cannot write to is reported and skipped.
|
package/docs/machine-setup.md
CHANGED
|
@@ -352,9 +352,10 @@ only running T3 is the T3 Code desktop app, installed from the same release's
|
|
|
352
352
|
upstream `SHA256SUMS` omits packages) and started by the `t3code-desktop.service`
|
|
353
353
|
user unit in the GDM auto-login session. It shares `~/.t3` with the former
|
|
354
354
|
headless service, so environment identity and pairing are kept. A conflicting
|
|
355
|
-
launcher version
|
|
356
|
-
|
|
357
|
-
pin
|
|
355
|
+
launcher version requires reviewed maintenance; the recovery timer stops a
|
|
356
|
+
still-running headless service. Routine fleet-sync never ends a running turn
|
|
357
|
+
to load a new pin or policy: the app restarts into them at the first idle
|
|
358
|
+
check (`t3:running-version`, `t3:policy-loaded`). The disposable Ubuntu VM test still exercises the former headless service
|
|
358
359
|
and does not cover the desktop session. Private routing and paired execution
|
|
359
360
|
require separate evidence.
|
|
360
361
|
|
package/docs/topology.md
CHANGED
|
@@ -53,6 +53,8 @@ your project's `fleet.json` (version 3, checked by [the schema](../schema/fleet.
|
|
|
53
53
|
the manager.
|
|
54
54
|
- Anything on a worker is disposable: T3 sessions and worktrees, caches,
|
|
55
55
|
checkouts. Durable work lives in Git remotes; credentials come from Vault.
|
|
56
|
+
Work in progress in agent worktrees is pushed to a recovery ref every 15
|
|
57
|
+
minutes; [durability](durability.md) says what a lost machine takes with it.
|
|
56
58
|
- The first declared worker is the rollout canary for a revision new to the
|
|
57
59
|
fleet: a failed canary stops promotion to the other workers. Repeating a
|
|
58
60
|
revision that already converged everywhere runs all workers at once.
|
package/docs/ubuntu-sync.md
CHANGED
|
@@ -179,21 +179,21 @@ independently. Bespoke TypeScript remains only where a native CLI or API has no
|
|
|
179
179
|
declarative owner: T3 service lifecycle, Beszel enrollment tokens, provider
|
|
180
180
|
verification and the CLIProxy singleton.
|
|
181
181
|
|
|
182
|
-
| Policy | Workers (Ubuntu 26.04, stateless)
|
|
183
|
-
| --------------------------- |
|
|
184
|
-
| Never sleep | systemd sleep targets masked; logind lid/idle policy; GNOME power plugin set to `nothing`
|
|
185
|
-
| No automatic logout or lock | GNOME `lock-enabled false`, `idle-delay 0`; designated-user lingering
|
|
186
|
-
| Always-on services | drop-ins `Restart=always`, `StartLimitIntervalSec=0` for ssh, tailscaled, docker, beszel-agent
|
|
187
|
-
| T3 health and connection | `fleet-sync` and `fleet-status --workers` require `worker:t3-health` (local API answers), `t3:manager-endpoint` (manager HTTPS over Tailscale) and `t3:paired-access` (manager T3 link reachable)
|
|
188
|
-
| Memory safety | `systemd-oomd` (swap 90%, pressure 60%); `user.slice` `MemoryMax=90%`; each `t3code-*.scope` agent `MemoryMax=30%`, `OOMPolicy=continue`; swap file when none
|
|
189
|
-
| Disk safety | `personal-fleet-disk-guard` kills only the top user-cgroup writer below 3 GiB free, never deletes files; hourly `personal-fleet-disk-gc.timer` deletes git-ignored build output (`node_modules`, `.cache`, `dist`, ...) in checkouts idle 7d (12h under 15 GiB free) with no process inside, plus unused Docker images and build cache, never source, worktrees or volumes; journal 1G; `/tmp` 25% of RAM, aged 2d hourly, a measured write probe (`TMP_QUOTA_EXHAUSTED`), and T3 agents use disk-backed `/var/tmp` (`TMPDIR`, aged 14d); Docker log rotation
|
|
190
|
-
| Tailscale | pinned APT package, `tailscaled` enabled, native login required
|
|
191
|
-
| Dashboard agent | pinned native binary, hardened `beszel-agent` systemd unit as user `beszel`
|
|
192
|
-
| Credential renewal | user timer `personal-worker-auth` renews the node's own Vault token and GitHub login every 10min
|
|
193
|
-
| Noninteractive sudo | validated `/etc/sudoers.d/personal-fleet-USER`
|
|
194
|
-
| T3 worker app | pinned desktop `.deb` is the only T3 process (UI plus embedded server); GDM auto-login, user unit `t3code-desktop.service` bound to the graphical session with `Restart=always`; `personal-worker-recovery`
|
|
195
|
-
| T3 providers | the same managed instances (`fleet/agents/t3.json`) through locked T3 administrator policy
|
|
196
|
-
| Singleton services | never; clients of the manager's services only
|
|
182
|
+
| Policy | Workers (Ubuntu 26.04, stateless) | Manager (macOS, singleton) |
|
|
183
|
+
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
184
|
+
| Never sleep | systemd sleep targets masked; logind lid/idle policy; GNOME power plugin set to `nothing` | never sleeps on AC power (`pmset -c sleep 0`, `macos/manager.yml`, verified as `manager:power`); battery sleep is left to the human |
|
|
185
|
+
| No automatic logout or lock | GNOME `lock-enabled false`, `idle-delay 0`; designated-user lingering | not managed (the human uses it) |
|
|
186
|
+
| Always-on services | drop-ins `Restart=always`, `StartLimitIntervalSec=0` for ssh, tailscaled, docker, beszel-agent | singleton services under launchd and Docker Desktop (see the last row) |
|
|
187
|
+
| T3 health and connection | `fleet-sync` and `fleet-status --workers` require `worker:t3-health` (local API answers), `t3:manager-endpoint` (manager HTTPS over Tailscale) and `t3:paired-access` (manager T3 link reachable) | the human's T3 desktop holds the links to every worker (`t3:paired-access`) |
|
|
188
|
+
| Memory safety | `systemd-oomd` (swap 90%, pressure 60%); `user.slice` `MemoryMax=90%`; each `t3code-*.scope` agent `MemoryMax=30%`, `OOMPolicy=continue`; swap file when none | not managed |
|
|
189
|
+
| Disk safety | `personal-fleet-disk-guard` kills only the top user-cgroup writer below 3 GiB free, never deletes files; hourly `personal-fleet-disk-gc.timer` deletes git-ignored build output (`node_modules`, `.cache`, `dist`, ...) in checkouts idle 7d (12h under 15 GiB free) with no process inside, plus unused Docker images and build cache, never source, worktrees or volumes; journal 1G; `/tmp` 25% of RAM, aged 2d hourly, a measured write probe (`TMP_QUOTA_EXHAUSTED`), and T3 agents use disk-backed `/var/tmp` (`TMPDIR`, aged 14d); Docker log rotation | not managed |
|
|
190
|
+
| Tailscale | pinned APT package, `tailscaled` enabled, native login required | Tailscale app, human login |
|
|
191
|
+
| Dashboard agent | pinned native binary, hardened `beszel-agent` systemd unit as user `beszel` | not installed |
|
|
192
|
+
| Credential renewal | user timer `personal-worker-auth` renews the node's own Vault token and GitHub login every 10min | the human's own Vault and GitHub sessions |
|
|
193
|
+
| Noninteractive sudo | validated `/etc/sudoers.d/personal-fleet-USER` | never; the human runs `manager.yml` once with `-K` |
|
|
194
|
+
| T3 worker app | pinned desktop `.deb` is the only T3 process (UI plus embedded server); GDM auto-login, user unit `t3code-desktop.service` bound to the graphical session with `Restart=always`; `personal-worker-recovery` runs a copy of `t3-health.sh` installed in `/usr/local/lib/personal-fleet` every minute: it stops and disables a still-running headless `t3code.service`, reports `T3_NO_GRAPHICAL_SESSION` and restarts the display manager (three times at most) when there is no session, starts a stopped app after `reset-failed`, restarts one whose local API missed three checks, and restarts one on another release than the installed one or with a deferred policy restart once it runs no agent turn; administrator policy | not a worker |
|
|
195
|
+
| T3 providers | the same managed instances (`fleet/agents/t3.json`) through locked T3 administrator policy | reconciled into the human's T3 settings by `fleet-sync` (`t3:providers`) with the ownership-safe `t3-apply` engine, so every T3 offers the identical set; unrelated and unmanaged instances are preserved. `fleet-sync` also applies the same proxy agent profiles (`configuration:apply`) and verifies the shared Vault gateway credential end to end with `provider:*:inference`, exactly as on workers |
|
|
196
|
+
| Singleton services | never; clients of the manager's services only | every singleton runs here: CLI proxy (`cliproxy`), Beszel hub (`workstation/monitoring`) |
|
|
197
197
|
|
|
198
198
|
Credential sync is deliberately indirect: no secret is copied between machines.
|
|
199
199
|
OpenTofu issues each enrolled worker its own renewable periodic token
|
|
@@ -257,8 +257,11 @@ the existing LaunchAgent and requires a user session. Unattended manager boot
|
|
|
257
257
|
acceptance and log-retention configuration remain gates. No node pull scheduler
|
|
258
258
|
is installed or necessary.
|
|
259
259
|
|
|
260
|
-
Sync never reboots a box
|
|
261
|
-
|
|
260
|
+
Sync never reboots a box and never ends a running agent turn. A worker's T3
|
|
261
|
+
that must restart for a new provider policy or release is restarted only while
|
|
262
|
+
it runs no agent turn; otherwise the restart is pending
|
|
263
|
+
(`T3_POLICY_RESTART_PENDING`, `T3_RESTART_PENDING`) and the recovery timer
|
|
264
|
+
applies it at the first idle check. Explicit
|
|
262
265
|
maintenance reboot/reconnect orchestration remains an acceptance gate. Kernel,
|
|
263
266
|
driver, firmware, hardware and power failures are outside any “never freezes”
|
|
264
267
|
promise. Inspect disk/inodes, swap/memory pressure, `systemctl --failed`, and prior
|
|
@@ -303,17 +306,20 @@ launch; nothing is copied between nodes.
|
|
|
303
306
|
|
|
304
307
|
**Ansible applies, TypeScript measures.** On a worker the T3 lifecycle is
|
|
305
308
|
declared in playbooks: `t3-policy.yml` stages the provider policy and restarts
|
|
306
|
-
a running T3 that has not loaded it, `t3-desktop.yml` installs the app, retires
|
|
309
|
+
a running T3 that has not loaded it once it runs no agent turn, `t3-desktop.yml` installs the app, retires
|
|
307
310
|
the former headless server and starts it, and `t3-route.yml` publishes the
|
|
308
311
|
private tailnet route when port 443 has no owner. The TypeScript around them
|
|
309
312
|
only refuses conflicts and reports what it observes afterwards. Each sync also
|
|
310
313
|
prunes old revision checkouts on the worker, keeping the new one, the one T3
|
|
311
314
|
launches from and the two most recent others.
|
|
312
315
|
|
|
313
|
-
**Every sync applies everything
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
**Every sync applies everything, except a T3 restart that would end a turn.**
|
|
317
|
+
A worker whose T3 has not loaded the staged provider policy, or runs an older
|
|
318
|
+
release than the one installed, is restarted only while it runs no agent turn;
|
|
319
|
+
otherwise sync reports the pending restart and the recovery timer applies it
|
|
320
|
+
at the first idle check, failing the check after
|
|
321
|
+
`t3RestartMaxDeferralHours` (`ansible/ubuntu/policy.json`). A pending proxy
|
|
322
|
+
update is installed even when it recreates the gateway. The manager finishes before the workers
|
|
317
323
|
start, so they are verified against the updated proxy. `--dry-run` previews.
|
|
318
324
|
|
|
319
325
|
**Accounts are never listed in this repository.** The proxy's credential files
|
package/llms.txt
CHANGED
|
@@ -22,6 +22,7 @@ Rules for an agent working in a project that uses dev-fleet:
|
|
|
22
22
|
- [Fleet state](https://github.com/crvouga/dev-fleet/blob/main/docs/state.md): the bucket, replication, replacing the manager, what the bucket does not hold yet
|
|
23
23
|
- [Topology](https://github.com/crvouga/dev-fleet/blob/main/docs/topology.md): the manager and worker roles and the canary
|
|
24
24
|
- [What is declared, and where](https://github.com/crvouga/dev-fleet/blob/main/docs/declared.md): what is pinned on a machine and what is not
|
|
25
|
+
- [Provider accounts](https://github.com/crvouga/dev-fleet/blob/main/docs/accounts.md): the account roster, its states and checks, adding or removing an account, propagation to every node, account selection
|
|
25
26
|
- [CLI proxy](https://github.com/crvouga/dev-fleet/blob/main/cliproxy/README.md): the CLIProxyAPI service on the manager that every coding agent uses
|
|
26
27
|
- [Monitoring](https://github.com/crvouga/dev-fleet/blob/main/ansible/monitoring/README.md): the Beszel hub on the manager and the agent on every machine
|
|
27
28
|
|
package/mise.toml
CHANGED
|
@@ -88,10 +88,12 @@ mise_linux_x64_sha256 = "9a6c9c55f5c6372d0af70a9979fe6d742aea3793267a929e1738022
|
|
|
88
88
|
mise_linux_arm64_sha256 = "8c8f5d7f748bf3aef431dc50f0ad776270f36025c788aa21c74e2fabdbbd6d3e"
|
|
89
89
|
mise_macos_x64_sha256 = "83a36355bf6ee7c1660300e12dd94464de5c672dab1b8cc33ec4d19356e7ddac"
|
|
90
90
|
mise_macos_arm64_sha256 = "aa2fc59242db108f97ea9fb2d2e2cb42ff1467fcafe3c91e48d51c841ba67a65"
|
|
91
|
-
# Official native
|
|
91
|
+
# Official native server distribution (Ubuntu workers, and the manager's
|
|
92
|
+
# pinned CLI); bootstrap/native-t3.ts owns installation. From the release's SHA256SUMS.
|
|
92
93
|
t3_version = "0.1.8-nightly.20261106.4262"
|
|
93
94
|
t3_linux_x64_sha256 = "5df94ca15c4f68f749fa5540fbb9d5ca879ca8750ac43dd2704f6a18899ed6d5"
|
|
94
95
|
t3_linux_arm64_sha256 = "0649615c4a294eee40bc753c8668e4a83c5c3620794f2163758f8dc48a39b880"
|
|
96
|
+
t3_darwin_arm64_sha256 = "40c81f1d696e7056c0c43e29bdfd2a154ea6f6bd9129826034256751418de633"
|
|
95
97
|
# Official T3 Code desktop .deb (same release). Workers run this GUI app as their
|
|
96
98
|
# only T3 process; it embeds the server. Upstream SHA256SUMS omits .deb files,
|
|
97
99
|
# so these digests were computed from the release assets.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crvouga/dev-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Keeps a set of development machines in sync: one manager and many workers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:crvouga/dev-fleet",
|
|
@@ -72,5 +72,5 @@
|
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=22"
|
|
74
74
|
},
|
|
75
|
-
"gitHead": "
|
|
75
|
+
"gitHead": "0c583e40a0c32c0db355e9c0bca6903894945310"
|
|
76
76
|
}
|