@crvouga/dev-fleet 0.1.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 +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +13 -3
- 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/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- 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/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- 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/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +86 -1
- package/src/cli/registry.ts +41 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- 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/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- 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/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- 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-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -16,13 +16,17 @@
|
|
|
16
16
|
"git": "1:2.53.0-1ubuntu1.1",
|
|
17
17
|
"python3": "3.14.3-0ubuntu2",
|
|
18
18
|
"unzip": "6.0-29ubuntu1",
|
|
19
|
-
"xz-utils": "5.8.3-1ubuntu0.1"
|
|
19
|
+
"xz-utils": "5.8.3-1ubuntu0.1",
|
|
20
|
+
"ansible-core": "2.20.1-1"
|
|
21
|
+
},
|
|
22
|
+
"tailscale": {
|
|
23
|
+
"version": "1.104.5",
|
|
24
|
+
"keySha256": "3e03dacf222698c60b8e2f990b809ca1b3e104de127767864284e6c228f1fb39"
|
|
20
25
|
},
|
|
21
26
|
"aptLockTimeoutSeconds": 120,
|
|
22
27
|
"minimumFreeDiskMiB": 4096,
|
|
23
28
|
"minimumFreeInodes": 10000,
|
|
24
|
-
"
|
|
25
|
-
"serviceStartLimitIntervalSeconds": 300,
|
|
29
|
+
"t3RestartMaxDeferralHours": 24,
|
|
26
30
|
"alwaysOnServices": ["ssh", "tailscaled", "docker", "beszel-agent"],
|
|
27
31
|
"resourceSafety": {
|
|
28
32
|
"swapFileMiB": 8192,
|
|
@@ -37,6 +41,12 @@
|
|
|
37
41
|
"diskGuardFloorMiB": 3072,
|
|
38
42
|
"diskGuardWriteMiB": 32,
|
|
39
43
|
"diskGuardIntervalSeconds": 5,
|
|
44
|
+
"diskGcIdleDays": 7,
|
|
45
|
+
"diskGcPressureMiB": 15360,
|
|
46
|
+
"diskGcPressureIdleHours": 12,
|
|
47
|
+
"diskGcDockerAgeHours": 168,
|
|
48
|
+
"diskGcPressureDockerAgeHours": 24,
|
|
49
|
+
"diskGcPressureCaches": [".bun/install/cache"],
|
|
40
50
|
"dockerLogMaxSize": "50m",
|
|
41
51
|
"dockerLogMaxFiles": "3"
|
|
42
52
|
}
|
|
@@ -53,6 +53,16 @@
|
|
|
53
53
|
register: t3_desktop_installed
|
|
54
54
|
changed_when: false
|
|
55
55
|
failed_when: false
|
|
56
|
+
- name: Check whether the desktop app is running before an update
|
|
57
|
+
become: true
|
|
58
|
+
become_user: '{{ development_user }}'
|
|
59
|
+
environment:
|
|
60
|
+
XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
|
|
61
|
+
ansible.builtin.command:
|
|
62
|
+
argv: [systemctl, --user, is-active, --quiet, t3code-desktop.service]
|
|
63
|
+
register: t3_desktop_was_active
|
|
64
|
+
changed_when: false
|
|
65
|
+
failed_when: false
|
|
56
66
|
- name: Unpack the pinned app when its version differs
|
|
57
67
|
when: t3_desktop_installed.stdout | default('') != t3_desktop_name
|
|
58
68
|
block:
|
|
@@ -94,6 +104,7 @@
|
|
|
94
104
|
- -T
|
|
95
105
|
- '/opt/.t3code-staging{{ t3_desktop_dir }}'
|
|
96
106
|
- '{{ t3_desktop_dir }}'
|
|
107
|
+
register: t3_desktop_updated
|
|
97
108
|
changed_when: true
|
|
98
109
|
- name: Add the launcher entry and icon
|
|
99
110
|
ansible.builtin.copy:
|
|
@@ -218,3 +229,11 @@
|
|
|
218
229
|
name: t3code-desktop.service
|
|
219
230
|
state: started
|
|
220
231
|
failed_when: false
|
|
232
|
+
# A running app keeps the files of its release: restart it into the one
|
|
233
|
+
# just installed, only while it runs no agent turn. Otherwise
|
|
234
|
+
# personal-worker-recovery restarts it at the first idle check, and
|
|
235
|
+
# t3:running-version reports T3_RESTART_PENDING until then.
|
|
236
|
+
- ansible.builtin.import_tasks: tasks/t3-restart.yml
|
|
237
|
+
when:
|
|
238
|
+
- t3_desktop_was_active.rc == 0
|
|
239
|
+
- t3_desktop_updated is changed
|
|
@@ -1,33 +1,104 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Keep the worker T3 desktop app (UI and server) up
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
2
|
+
# Keep the worker T3 desktop app (UI and server) up, responsive and on the
|
|
3
|
+
# installed release. Run every minute by personal-worker-recovery.timer
|
|
4
|
+
# (managed by fleet-sync, tasks/recovery.yml), from the stable copy the role
|
|
5
|
+
# installs beside t3-idle.py.
|
|
6
|
+
# - The former headless server is stopped and disabled: one T3 per worker.
|
|
7
|
+
# - No graphical session: T3_NO_GRAPHICAL_SESSION; after three checks in a row
|
|
8
|
+
# the display manager is restarted so auto-login starts one, three times at
|
|
9
|
+
# most until a session is seen again.
|
|
10
|
+
# - Stopped or failed: reset its start limit and start it.
|
|
11
|
+
# - Running but its local API has not answered for three checks in a row:
|
|
12
|
+
# restart it, since a hung server serves no turns.
|
|
13
|
+
# - Running another release than the installed one, or a policy restart that
|
|
14
|
+
# was deferred: restart it once it runs no agent turn (T3_RESTART_PENDING).
|
|
6
15
|
set -u
|
|
7
16
|
url=http://127.0.0.1:3773/.well-known/t3/environment
|
|
8
|
-
|
|
17
|
+
unit=t3code-desktop.service
|
|
18
|
+
runtime="${XDG_RUNTIME_DIR:?}"
|
|
19
|
+
failures="$runtime/personal-t3-health-failures"
|
|
20
|
+
no_session="$runtime/personal-t3-no-session"
|
|
21
|
+
display_restarts="$runtime/personal-t3-display-restarts"
|
|
22
|
+
pending="$runtime/personal-t3-restart-pending"
|
|
23
|
+
restarted="$runtime/personal-t3-restarted-for"
|
|
24
|
+
installed="${T3_DESKTOP_DIR:-/opt/T3 Code (Nightly)}/.personal-fleet-version"
|
|
25
|
+
here=$(dirname "$0")
|
|
9
26
|
limit=3
|
|
10
27
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
echo "
|
|
15
|
-
|
|
28
|
+
count() {
|
|
29
|
+
n=$(($(cat "$1" 2>/dev/null || echo 0) + 1))
|
|
30
|
+
echo "$n" > "$1"
|
|
31
|
+
echo "$n"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if systemctl --user is-active --quiet t3code.service ||
|
|
35
|
+
systemctl --user is-enabled --quiet t3code.service; then
|
|
36
|
+
echo "Retiring the former headless T3 server."
|
|
37
|
+
if ! systemctl --user disable --now t3code.service; then
|
|
38
|
+
echo "T3_LEGACY_SERVER_RUNNING"
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if ! systemctl --user is-active --quiet graphical-session.target; then
|
|
44
|
+
rm -f "$failures"
|
|
45
|
+
seen=$(count "$no_session")
|
|
46
|
+
echo "T3_NO_GRAPHICAL_SESSION ($seen/$limit)."
|
|
47
|
+
if [ "$seen" -ge "$limit" ]; then
|
|
48
|
+
rm -f "$no_session"
|
|
49
|
+
restarts=$(count "$display_restarts")
|
|
50
|
+
if [ "$restarts" -le "$limit" ]; then
|
|
51
|
+
echo "Restarting the display manager so auto-login starts a session ($restarts/$limit)."
|
|
52
|
+
sudo -n systemctl restart display-manager.service
|
|
53
|
+
else
|
|
54
|
+
echo "The display manager was restarted $limit times; a person must log in."
|
|
55
|
+
fi
|
|
56
|
+
fi
|
|
57
|
+
exit 1
|
|
16
58
|
fi
|
|
17
|
-
|
|
18
|
-
|
|
59
|
+
rm -f "$no_session" "$display_restarts"
|
|
60
|
+
|
|
61
|
+
if ! systemctl --user is-active --quiet "$unit"; then
|
|
62
|
+
rm -f "$failures"
|
|
19
63
|
echo "T3 is not running; starting it."
|
|
20
|
-
|
|
64
|
+
systemctl --user reset-failed "$unit" 2>/dev/null
|
|
65
|
+
exec systemctl --user start "$unit"
|
|
21
66
|
fi
|
|
22
|
-
if curl --fail --silent --max-time 10
|
|
23
|
-
|
|
67
|
+
if ! environment=$(curl --fail --silent --max-time 10 "$url"); then
|
|
68
|
+
failed=$(count "$failures")
|
|
69
|
+
echo "T3 local API did not answer ($failed/$limit)."
|
|
70
|
+
if [ "$failed" -ge "$limit" ]; then
|
|
71
|
+
rm -f "$failures"
|
|
72
|
+
echo "T3 is unresponsive; restarting it."
|
|
73
|
+
systemctl --user reset-failed "$unit" 2>/dev/null
|
|
74
|
+
exec systemctl --user restart "$unit"
|
|
75
|
+
fi
|
|
24
76
|
exit 0
|
|
25
77
|
fi
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
78
|
+
rm -f "$failures"
|
|
79
|
+
|
|
80
|
+
running=$(printf '%s' "$environment" | sed -n 's/.*"serverVersion" *: *"\([^"]*\)".*/\1/p')
|
|
81
|
+
release=$(sed -n 's/^T3-Code-\(.*\)-[a-z0-9]*\.deb$/\1/p' "$installed" 2>/dev/null)
|
|
82
|
+
reason=
|
|
83
|
+
[ -f "$pending" ] && reason="the staged policy"
|
|
84
|
+
if [ -n "$release" ] && [ "$running" != "$release" ]; then
|
|
85
|
+
# One restart per release: a server that still reports another version
|
|
86
|
+
# after it is never restarted in a loop.
|
|
87
|
+
if [ "$(cat "$restarted" 2>/dev/null)" = "$release" ]; then
|
|
88
|
+
echo "T3_RUNNING_VERSION_MISMATCH: restarted for $release, still reports ${running:-nothing}."
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
reason="release $release"
|
|
92
|
+
else
|
|
93
|
+
rm -f "$restarted"
|
|
94
|
+
fi
|
|
95
|
+
[ -z "$reason" ] && exit 0
|
|
96
|
+
if python3 -I -B "$here/t3-idle.py" --state "${T3CODE_HOME:-$HOME/.t3}/userdata/statev2.sqlite"; then
|
|
97
|
+
rm -f "$pending"
|
|
98
|
+
[ "$reason" = "release $release" ] && echo "$release" > "$restarted"
|
|
99
|
+
echo "T3 runs no agent turn; restarting it to load $reason."
|
|
100
|
+
systemctl --user reset-failed "$unit" 2>/dev/null
|
|
101
|
+
exec systemctl --user restart "$unit"
|
|
33
102
|
fi
|
|
103
|
+
echo "T3_RESTART_PENDING: agent turns are running; $reason loads at the first idle check."
|
|
104
|
+
exit 0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Say whether the worker's T3 runs an agent turn: exit 0 when it runs none,
|
|
2
|
+
1 when it runs one and 2 when it cannot tell.
|
|
3
|
+
|
|
4
|
+
T3 offers no command that lists the running turns of every project, so this
|
|
5
|
+
reads the runs T3 records in its own state database, read-only. Anything it
|
|
6
|
+
cannot read counts as busy: a restart never ends a turn on a guess.
|
|
7
|
+
|
|
8
|
+
Usage: t3-idle.py --state ~/.t3/userdata/statev2.sqlite
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
IDLE, BUSY, UNKNOWN = 0, 1, 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def running_turns(state):
|
|
18
|
+
connection = sqlite3.connect(f'file:{state}?mode=ro', uri=True, timeout=5)
|
|
19
|
+
try:
|
|
20
|
+
(count,) = connection.execute(
|
|
21
|
+
"SELECT count(*) FROM orchestration_v2_projection_runs WHERE status = 'running'"
|
|
22
|
+
).fetchone()
|
|
23
|
+
return count
|
|
24
|
+
finally:
|
|
25
|
+
connection.close()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
parser = argparse.ArgumentParser()
|
|
30
|
+
parser.add_argument('--state', required=True)
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
try:
|
|
33
|
+
count = running_turns(args.state)
|
|
34
|
+
except sqlite3.Error:
|
|
35
|
+
print('T3_IDLE_UNKNOWN')
|
|
36
|
+
return UNKNOWN
|
|
37
|
+
print(f'{count} running turn(s)')
|
|
38
|
+
return BUSY if count else IDLE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == '__main__':
|
|
42
|
+
sys.exit(main())
|
|
@@ -41,8 +41,9 @@
|
|
|
41
41
|
mode: '0644'
|
|
42
42
|
backup: true
|
|
43
43
|
# T3 reads its policy once, at startup. A running app that has not loaded
|
|
44
|
-
# the staged policy is restarted
|
|
45
|
-
#
|
|
44
|
+
# the staged policy is restarted so every node offers the same providers,
|
|
45
|
+
# but only while it runs no agent turn (tasks/t3-restart.yml): a busy app
|
|
46
|
+
# keeps its turns and loads the policy at the first idle check.
|
|
46
47
|
- name: Check whether the desktop app is running
|
|
47
48
|
become: true
|
|
48
49
|
become_user: '{{ development_user }}'
|
|
@@ -62,15 +63,7 @@
|
|
|
62
63
|
changed_when: false
|
|
63
64
|
failed_when: false
|
|
64
65
|
when: t3_desktop_active.rc == 0
|
|
65
|
-
-
|
|
66
|
-
become: true
|
|
67
|
-
become_user: '{{ development_user }}'
|
|
68
|
-
environment:
|
|
69
|
-
XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
|
|
70
|
-
ansible.builtin.systemd_service:
|
|
71
|
-
scope: user
|
|
72
|
-
name: t3code-desktop.service
|
|
73
|
-
state: restarted
|
|
66
|
+
- ansible.builtin.import_tasks: tasks/t3-restart.yml
|
|
74
67
|
when:
|
|
75
68
|
- t3_desktop_active.rc == 0
|
|
76
69
|
- t3_policy_loaded.stdout is not search('loaded this policy\.\s*$')
|
|
@@ -4,18 +4,21 @@
|
|
|
4
4
|
connection: local
|
|
5
5
|
become: true
|
|
6
6
|
gather_facts: true
|
|
7
|
+
# The approved version and the signing key's digest: `tailscale` in policy.json.
|
|
8
|
+
vars_files:
|
|
9
|
+
- policy.json
|
|
7
10
|
tasks:
|
|
8
11
|
- name: Require supported Ubuntu
|
|
9
12
|
ansible.builtin.assert:
|
|
10
13
|
that:
|
|
11
14
|
- ansible_facts.distribution == 'Ubuntu'
|
|
12
|
-
- ansible_facts.distribution_version ==
|
|
15
|
+
- ansible_facts.distribution_version == release
|
|
13
16
|
- name: Inspect installed packages before possible network interruption
|
|
14
17
|
ansible.builtin.package_facts:
|
|
15
18
|
manager: apt
|
|
16
19
|
- name: Require maintenance for an installed version change
|
|
17
20
|
ansible.builtin.assert:
|
|
18
|
-
that: "'tailscale' not in ansible_facts.packages or ansible_facts.packages.tailscale[0].version ==
|
|
21
|
+
that: "'tailscale' not in ansible_facts.packages or ansible_facts.packages.tailscale[0].version == tailscale.version"
|
|
19
22
|
fail_msg: TAILSCALE_UPGRADE_PENDING; upgrading an active VPN can disconnect SSH. Review an explicit maintenance operation.
|
|
20
23
|
- name: Inspect existing package-source ownership before writing
|
|
21
24
|
ansible.builtin.find:
|
|
@@ -34,7 +37,7 @@
|
|
|
34
37
|
ansible.builtin.get_url:
|
|
35
38
|
url: https://pkgs.tailscale.com/stable/ubuntu/resolute.noarmor.gpg
|
|
36
39
|
dest: /usr/share/keyrings/tailscale-archive-keyring.gpg
|
|
37
|
-
checksum: sha256:
|
|
40
|
+
checksum: 'sha256:{{ tailscale.keySha256 }}'
|
|
38
41
|
owner: root
|
|
39
42
|
group: root
|
|
40
43
|
mode: '0644'
|
|
@@ -48,11 +51,11 @@
|
|
|
48
51
|
register: tailscale_source
|
|
49
52
|
- name: Install the approved version only when absent
|
|
50
53
|
ansible.builtin.apt:
|
|
51
|
-
name: tailscale=
|
|
54
|
+
name: 'tailscale={{ tailscale.version }}'
|
|
52
55
|
state: present
|
|
53
56
|
update_cache: true
|
|
54
57
|
cache_valid_time: 0
|
|
55
|
-
lock_timeout:
|
|
58
|
+
lock_timeout: '{{ aptLockTimeoutSeconds }}'
|
|
56
59
|
allow_downgrade: false
|
|
57
60
|
allow_unauthenticated: false
|
|
58
61
|
when: "'tailscale' not in ansible_facts.packages"
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
---
|
|
2
|
+
# The unit runs a copy installed outside every revision: a synced revision is
|
|
3
|
+
# pruned two syncs later, and the unit must not name a file that is gone.
|
|
4
|
+
- name: Create the directory of the installed recovery scripts
|
|
5
|
+
become: true
|
|
6
|
+
ansible.builtin.file:
|
|
7
|
+
path: /usr/local/lib/personal-fleet
|
|
8
|
+
state: directory
|
|
9
|
+
owner: root
|
|
10
|
+
group: root
|
|
11
|
+
mode: '0755'
|
|
12
|
+
- name: Install the T3 recovery scripts outside the revision
|
|
13
|
+
become: true
|
|
14
|
+
ansible.builtin.copy:
|
|
15
|
+
src: '{{ playbook_dir }}/{{ item }}'
|
|
16
|
+
dest: '/usr/local/lib/personal-fleet/{{ item }}'
|
|
17
|
+
owner: root
|
|
18
|
+
group: root
|
|
19
|
+
mode: '0755'
|
|
20
|
+
loop:
|
|
21
|
+
- t3-health.sh
|
|
22
|
+
- t3-idle.py
|
|
2
23
|
- name: Declare bounded recovery of the worker T3 desktop app
|
|
3
24
|
ansible.builtin.copy:
|
|
4
25
|
dest: '{{ development_home }}/.config/systemd/user/personal-worker-recovery.service'
|
|
@@ -10,7 +31,7 @@
|
|
|
10
31
|
Description=Keep the worker T3 desktop app running and responsive
|
|
11
32
|
[Service]
|
|
12
33
|
Type=oneshot
|
|
13
|
-
ExecStart=/bin/sh
|
|
34
|
+
ExecStart=/bin/sh /usr/local/lib/personal-fleet/t3-health.sh
|
|
14
35
|
TimeoutStartSec=90
|
|
15
36
|
- name: Declare persistent service recovery checks
|
|
16
37
|
ansible.builtin.copy:
|
|
@@ -37,3 +58,41 @@
|
|
|
37
58
|
daemon_reload: true
|
|
38
59
|
enabled: true
|
|
39
60
|
state: started
|
|
61
|
+
- name: Declare the work-in-progress guard of agent worktrees
|
|
62
|
+
ansible.builtin.copy:
|
|
63
|
+
dest: '{{ development_home }}/.config/systemd/user/personal-worker-wip-guard.service'
|
|
64
|
+
owner: '{{ development_user }}'
|
|
65
|
+
mode: '0600'
|
|
66
|
+
content: |
|
|
67
|
+
# Managed by personal fleet-sync: ansible/ubuntu/wip-guard.py
|
|
68
|
+
[Unit]
|
|
69
|
+
Description=Push work in progress of agent worktrees to recovery refs
|
|
70
|
+
[Service]
|
|
71
|
+
Type=oneshot
|
|
72
|
+
ExecStart=/usr/bin/python3 {{ development_source }}/ansible/ubuntu/wip-guard.py --root {{ development_home }}/.t3/worktrees --node %l
|
|
73
|
+
TimeoutStartSec=600
|
|
74
|
+
- name: Declare how often work in progress is saved
|
|
75
|
+
ansible.builtin.copy:
|
|
76
|
+
dest: '{{ development_home }}/.config/systemd/user/personal-worker-wip-guard.timer'
|
|
77
|
+
owner: '{{ development_user }}'
|
|
78
|
+
mode: '0600'
|
|
79
|
+
content: |
|
|
80
|
+
[Unit]
|
|
81
|
+
Description=Save work in progress of agent worktrees every 15 minutes
|
|
82
|
+
[Timer]
|
|
83
|
+
OnBootSec=5min
|
|
84
|
+
OnUnitActiveSec=15min
|
|
85
|
+
Persistent=true
|
|
86
|
+
[Install]
|
|
87
|
+
WantedBy=timers.target
|
|
88
|
+
- name: Enable the work-in-progress guard
|
|
89
|
+
become: true
|
|
90
|
+
become_user: '{{ development_user }}'
|
|
91
|
+
environment:
|
|
92
|
+
XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
|
|
93
|
+
ansible.builtin.systemd_service:
|
|
94
|
+
scope: user
|
|
95
|
+
name: personal-worker-wip-guard.timer
|
|
96
|
+
daemon_reload: true
|
|
97
|
+
enabled: true
|
|
98
|
+
state: started
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
# Memory and disk safety: a runaway workload is stopped on its own before the
|
|
3
3
|
# machine, SSH, Tailscale or the T3 server run out of memory or disk. These
|
|
4
4
|
# tasks run before the capacity preflight because they matter most when a disk
|
|
5
|
-
# is nearly full; they only write small files and never delete user data.
|
|
5
|
+
# is nearly full; they only write small files and never delete user data. The
|
|
6
|
+
# disk GC timer they install deletes only build output and caches a build or
|
|
7
|
+
# install recreates (ansible/ubuntu/disk-gc.py).
|
|
6
8
|
- name: Inspect active swap
|
|
7
9
|
ansible.builtin.command:
|
|
8
10
|
argv: [swapon, --show=NAME, --noheadings]
|
|
@@ -250,3 +252,61 @@
|
|
|
250
252
|
enabled: true
|
|
251
253
|
state: '{{ "restarted" if disk_guard_unit.changed else "started" }}'
|
|
252
254
|
when: development_source is defined and disk_guard_source.stat.exists
|
|
255
|
+
|
|
256
|
+
- name: Read the tracked disk GC
|
|
257
|
+
ansible.builtin.stat:
|
|
258
|
+
path: '{{ development_source }}/ansible/ubuntu/disk-gc.py'
|
|
259
|
+
checksum_algorithm: sha256
|
|
260
|
+
register: disk_gc_source
|
|
261
|
+
when: development_source is defined
|
|
262
|
+
- name: Declare the disk GC service and its hourly timer
|
|
263
|
+
ansible.builtin.copy:
|
|
264
|
+
dest: '{{ item.dest }}'
|
|
265
|
+
owner: root
|
|
266
|
+
group: root
|
|
267
|
+
mode: '0644'
|
|
268
|
+
content: '{{ item.content }}'
|
|
269
|
+
loop:
|
|
270
|
+
- dest: /etc/systemd/system/personal-fleet-disk-gc.service
|
|
271
|
+
content: |
|
|
272
|
+
# Managed by personal fleet-sync: ansible/ubuntu/disk-gc.py
|
|
273
|
+
# Source sha256 {{ disk_gc_source.stat.checksum }}
|
|
274
|
+
[Unit]
|
|
275
|
+
Description=Personal Fleet disk GC (deletes build output and caches a build recreates)
|
|
276
|
+
After=docker.service
|
|
277
|
+
[Service]
|
|
278
|
+
Type=oneshot
|
|
279
|
+
ExecStart=/usr/bin/python3 -I -B {{ disk_gc_source.stat.path }} --home {{ development_home }} --path / --path {{ development_home }} --idle-days {{ resourceSafety.diskGcIdleDays }} --pressure-mib {{ resourceSafety.diskGcPressureMiB }} --pressure-idle-hours {{ resourceSafety.diskGcPressureIdleHours }} --docker-age-hours {{ resourceSafety.diskGcDockerAgeHours }} --pressure-docker-age-hours {{ resourceSafety.diskGcPressureDockerAgeHours }}{% for cache in resourceSafety.diskGcPressureCaches %} --pressure-cache {{ cache }}{% endfor %}
|
|
280
|
+
|
|
281
|
+
TimeoutStartSec=2h
|
|
282
|
+
Nice=19
|
|
283
|
+
IOSchedulingClass=idle
|
|
284
|
+
CPUQuota=50%
|
|
285
|
+
MemoryMax=512M
|
|
286
|
+
NoNewPrivileges=true
|
|
287
|
+
PrivateTmp=true
|
|
288
|
+
LogRateLimitIntervalSec=60
|
|
289
|
+
LogRateLimitBurst=200
|
|
290
|
+
- dest: /etc/systemd/system/personal-fleet-disk-gc.timer
|
|
291
|
+
content: |
|
|
292
|
+
# Managed by personal fleet-sync: ansible/ubuntu/disk-gc.py
|
|
293
|
+
[Unit]
|
|
294
|
+
Description=Run the Personal Fleet disk GC hourly
|
|
295
|
+
[Timer]
|
|
296
|
+
OnBootSec=10min
|
|
297
|
+
OnCalendar=hourly
|
|
298
|
+
RandomizedDelaySec=5min
|
|
299
|
+
Persistent=true
|
|
300
|
+
[Install]
|
|
301
|
+
WantedBy=timers.target
|
|
302
|
+
loop_control:
|
|
303
|
+
label: '{{ item.dest }}'
|
|
304
|
+
when: development_source is defined and disk_gc_source.stat.exists
|
|
305
|
+
register: disk_gc_units
|
|
306
|
+
- name: Schedule the disk GC
|
|
307
|
+
ansible.builtin.systemd_service:
|
|
308
|
+
name: personal-fleet-disk-gc.timer
|
|
309
|
+
daemon_reload: '{{ disk_gc_units.changed }}'
|
|
310
|
+
enabled: true
|
|
311
|
+
state: started
|
|
312
|
+
when: development_source is defined and disk_gc_source.stat.exists
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Restart a running T3 only while it runs no agent turn: a restart ends every
|
|
3
|
+
# running turn. A busy T3 keeps its turns; the restart is recorded as pending
|
|
4
|
+
# (with the time it was first deferred) and personal-worker-recovery applies it
|
|
5
|
+
# at the first idle check (t3-health.sh). t3_restart_busy restarts it anyway.
|
|
6
|
+
- name: Ask T3 whether an agent turn is running
|
|
7
|
+
become: true
|
|
8
|
+
become_user: '{{ development_user }}'
|
|
9
|
+
ansible.builtin.command:
|
|
10
|
+
argv:
|
|
11
|
+
- python3
|
|
12
|
+
- -I
|
|
13
|
+
- -B
|
|
14
|
+
- '{{ playbook_dir }}/t3-idle.py'
|
|
15
|
+
- --state
|
|
16
|
+
- '{{ development_home }}/.t3/userdata/statev2.sqlite'
|
|
17
|
+
register: t3_idle
|
|
18
|
+
changed_when: false
|
|
19
|
+
failed_when: false
|
|
20
|
+
- name: Restart T3 while it runs no agent turn
|
|
21
|
+
become: true
|
|
22
|
+
become_user: '{{ development_user }}'
|
|
23
|
+
environment:
|
|
24
|
+
XDG_RUNTIME_DIR: '/run/user/{{ development_uid }}'
|
|
25
|
+
ansible.builtin.systemd_service:
|
|
26
|
+
scope: user
|
|
27
|
+
name: t3code-desktop.service
|
|
28
|
+
state: restarted
|
|
29
|
+
when: t3_idle is not skipped and (t3_idle.rc == 0 or t3_restart_busy | default(false) | bool)
|
|
30
|
+
- name: Record the restart as pending while agent turns run
|
|
31
|
+
ansible.builtin.copy:
|
|
32
|
+
dest: '/run/user/{{ development_uid }}/personal-t3-restart-pending'
|
|
33
|
+
content: "{{ now(utc=true).timestamp() | int }}\n"
|
|
34
|
+
force: false
|
|
35
|
+
owner: '{{ development_user }}'
|
|
36
|
+
mode: '0600'
|
|
37
|
+
when: t3_idle is not skipped and t3_idle.rc != 0 and not (t3_restart_busy | default(false) | bool)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""Push work in progress of every agent worktree to a recovery ref.
|
|
3
|
+
|
|
4
|
+
Managed by personal fleet-sync (tasks/recovery.yml) as
|
|
5
|
+
personal-worker-wip-guard.service, started by personal-worker-wip-guard.timer.
|
|
6
|
+
|
|
7
|
+
Workers are disposable: a worker lost mid-task must lose at most one interval
|
|
8
|
+
of uncommitted work. For each Git worktree under --root, the files as they are
|
|
9
|
+
now (tracked changes and untracked files that are not ignored) are written as
|
|
10
|
+
one commit on top of HEAD, built in a private index so the worktree, its index
|
|
11
|
+
and its branches are never touched, and pushed to
|
|
12
|
+
|
|
13
|
+
refs/recovery/<node>/<branch>
|
|
14
|
+
|
|
15
|
+
of the worktree's own remote. A clean worktree whose HEAD is already on a
|
|
16
|
+
remote branch has nothing to recover and pushes nothing. Recover with
|
|
17
|
+
`git fetch origin 'refs/recovery/*:refs/recovery/*'`.
|
|
18
|
+
|
|
19
|
+
Every decision is one JSON line on stdout.
|
|
20
|
+
|
|
21
|
+
Usage: wip-guard.py --root DIR [--root DIR ...] [--node ID] [--remote NAME]
|
|
22
|
+
[--max-depth N] [--dry-run]
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import socket
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
GIT = ['git', '-c', 'safe.directory=*']
|
|
34
|
+
AUTHOR = {
|
|
35
|
+
'GIT_AUTHOR_NAME': 'dev-fleet work-in-progress guard',
|
|
36
|
+
'GIT_AUTHOR_EMAIL': 'wip-guard@example.invalid',
|
|
37
|
+
'GIT_COMMITTER_NAME': 'dev-fleet work-in-progress guard',
|
|
38
|
+
'GIT_COMMITTER_EMAIL': 'wip-guard@example.invalid',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def log(**event):
|
|
43
|
+
print(json.dumps(event, sort_keys=True), flush=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def git(path, *args, env=None):
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
[*GIT, '-C', str(path), *args], capture_output=True, text=True,
|
|
49
|
+
env={**os.environ, **(env or {})}, timeout=120)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def worktrees(root, depth):
|
|
53
|
+
if depth < 0 or not root.is_dir() or root.is_symlink():
|
|
54
|
+
return
|
|
55
|
+
if (root / '.git').exists():
|
|
56
|
+
yield root
|
|
57
|
+
return
|
|
58
|
+
for child in sorted(root.iterdir()):
|
|
59
|
+
if child.is_dir() and not child.is_symlink():
|
|
60
|
+
yield from worktrees(child, depth - 1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ref_part(value):
|
|
64
|
+
part = re.sub(r'[^A-Za-z0-9._/-]+', '-', value).strip('./-')
|
|
65
|
+
part = re.sub(r'/+', '/', part).replace('..', '.')
|
|
66
|
+
return part or 'unnamed'
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def snapshot(path):
|
|
70
|
+
"""The worktree's files as one commit on HEAD, or HEAD when clean."""
|
|
71
|
+
head = git(path, 'rev-parse', '--verify', 'HEAD').stdout.strip()
|
|
72
|
+
if not head:
|
|
73
|
+
return None, None
|
|
74
|
+
with tempfile.TemporaryDirectory() as private:
|
|
75
|
+
index = {'GIT_INDEX_FILE': os.path.join(private, 'index')}
|
|
76
|
+
if git(path, 'read-tree', 'HEAD', env=index).returncode != 0:
|
|
77
|
+
return head, None
|
|
78
|
+
if git(path, 'add', '--all', '--', '.', env=index).returncode != 0:
|
|
79
|
+
return head, None
|
|
80
|
+
tree = git(path, 'write-tree', env=index).stdout.strip()
|
|
81
|
+
if tree == git(path, 'rev-parse', 'HEAD^{tree}').stdout.strip():
|
|
82
|
+
return head, head
|
|
83
|
+
commit = git(path, 'commit-tree', tree, '-p', head, '-m',
|
|
84
|
+
'Work in progress saved by the dev-fleet guard', env=AUTHOR)
|
|
85
|
+
return head, commit.stdout.strip() or None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def guard(path, node, remote, dry_run):
|
|
89
|
+
if git(path, 'remote', 'get-url', remote).returncode != 0:
|
|
90
|
+
return log(worktree=str(path), action='skip', reason='no-remote')
|
|
91
|
+
head, commit = snapshot(path)
|
|
92
|
+
if not commit:
|
|
93
|
+
return log(worktree=str(path), action='skip', reason='no-commit')
|
|
94
|
+
if commit == head and git(path, 'branch', '--remotes', '--contains', head).stdout.strip():
|
|
95
|
+
return log(worktree=str(path), action='skip', reason='clean')
|
|
96
|
+
branch = git(path, 'symbolic-ref', '--quiet', '--short', 'HEAD').stdout.strip()
|
|
97
|
+
ref = 'refs/recovery/%s/%s' % (ref_part(node), ref_part(branch or 'detached-' + head[:12]))
|
|
98
|
+
if dry_run:
|
|
99
|
+
return log(worktree=str(path), action='would-push', ref=ref)
|
|
100
|
+
pushed = git(path, 'push', '--force', '--no-verify', '--quiet', remote, '%s:%s' % (commit, ref))
|
|
101
|
+
log(worktree=str(path), action='pushed' if pushed.returncode == 0 else 'failed', ref=ref)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
parser = argparse.ArgumentParser()
|
|
106
|
+
parser.add_argument('--root', action='append', required=True)
|
|
107
|
+
parser.add_argument('--node', default=socket.gethostname().split('.')[0])
|
|
108
|
+
parser.add_argument('--remote', default='origin')
|
|
109
|
+
parser.add_argument('--max-depth', type=int, default=3)
|
|
110
|
+
parser.add_argument('--dry-run', action='store_true')
|
|
111
|
+
options = parser.parse_args()
|
|
112
|
+
for root in options.root:
|
|
113
|
+
for path in worktrees(Path(root).expanduser(), options.max_depth):
|
|
114
|
+
try:
|
|
115
|
+
guard(path, options.node, options.remote, options.dry_run)
|
|
116
|
+
except (OSError, subprocess.SubprocessError):
|
|
117
|
+
log(worktree=str(path), action='failed', reason='git')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == '__main__':
|
|
121
|
+
main()
|