@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.
Files changed (159) hide show
  1. package/README.md +265 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +10 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/README.md +86 -25
  7. package/ansible/monitoring/agent.yml +40 -2
  8. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  9. package/ansible/monitoring/lock.json +5 -0
  10. package/ansible/pyproject.toml +12 -0
  11. package/ansible/ubuntu/baseline.yml +1 -1
  12. package/ansible/ubuntu/disk-gc.py +336 -0
  13. package/ansible/ubuntu/policy.json +13 -3
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tailscale.yml +8 -5
  19. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  20. package/ansible/ubuntu/tasks/resources.yml +61 -1
  21. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  22. package/ansible/ubuntu/wip-guard.py +121 -0
  23. package/ansible/uv.lock +190 -0
  24. package/bootstrap/bootstrap.sh +25 -18
  25. package/bootstrap/doctor.ts +46 -1
  26. package/bootstrap/fleet-manager-run.sh +42 -15
  27. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  28. package/bootstrap/global-config.ts +64 -6
  29. package/bootstrap/install-launcher.sh +3 -0
  30. package/bootstrap/native-t3.ts +22 -11
  31. package/bootstrap/npm.ts +10 -2
  32. package/bootstrap/release-version.ts +157 -0
  33. package/cliproxy/README.md +3 -3
  34. package/cliproxy/scripts/accounts.py +216 -0
  35. package/cliproxy/scripts/checkpoint.py +29 -1
  36. package/cliproxy/scripts/cli.py +1 -1
  37. package/cliproxy/scripts/clientkeys.py +99 -0
  38. package/cliproxy/scripts/lease.py +80 -0
  39. package/cliproxy/scripts/ops +137 -25
  40. package/cliproxy/scripts/restore-verify +15 -0
  41. package/docs/accounts.md +154 -0
  42. package/docs/consumer-setup.md +408 -0
  43. package/docs/declared.md +91 -0
  44. package/docs/durability.md +26 -0
  45. package/docs/machine-setup.md +400 -0
  46. package/docs/operations.md +276 -0
  47. package/docs/releasing.md +103 -0
  48. package/docs/state.md +254 -0
  49. package/docs/topology.md +69 -0
  50. package/docs/ubuntu-sync.md +357 -0
  51. package/docs/worker-contract.md +67 -0
  52. package/examples/fleet.json +5 -0
  53. package/llms.txt +36 -0
  54. package/mise.lock +29 -0
  55. package/mise.toml +15 -2
  56. package/package.json +11 -2
  57. package/schema/fleet.schema.json +137 -15
  58. package/src/accounts/checks.ts +220 -0
  59. package/src/accounts/context.ts +76 -0
  60. package/src/accounts/propagate.ts +62 -0
  61. package/src/accounts/roster.ts +85 -0
  62. package/src/accounts/selection.ts +67 -0
  63. package/src/accounts/sessions.ts +120 -0
  64. package/src/agents/account-relay.ts +176 -26
  65. package/src/agents/fleet-verify.ts +9 -0
  66. package/src/agents/launch.ts +84 -5
  67. package/src/agents/native-verify.ts +293 -22
  68. package/src/agents/provider-errors.ts +9 -2
  69. package/src/agents/registry.ts +7 -1
  70. package/src/cli/agent-guide.ts +39 -5
  71. package/src/cli/app.ts +11 -4
  72. package/src/cli/cli.ts +7 -0
  73. package/src/cli/command-index.ts +7 -0
  74. package/src/cli/fleet-accounts.ts +253 -0
  75. package/src/cli/fleet-state.ts +499 -0
  76. package/src/cli/fleet-sync-accounts.ts +58 -0
  77. package/src/cli/fleet-sync-checkout.ts +56 -17
  78. package/src/cli/fleet-sync-enrollment.ts +19 -23
  79. package/src/cli/fleet-sync-forward.ts +1 -1
  80. package/src/cli/fleet-sync-manager.ts +84 -12
  81. package/src/cli/fleet-sync-monitoring.ts +5 -5
  82. package/src/cli/fleet-sync-output.ts +7 -3
  83. package/src/cli/fleet-sync-remote.ts +31 -18
  84. package/src/cli/fleet-sync-state.ts +86 -0
  85. package/src/cli/fleet-sync.ts +159 -57
  86. package/src/cli/fleet.ts +4 -2
  87. package/src/cli/managed-health.ts +4 -4
  88. package/src/cli/node-sync.ts +7 -0
  89. package/src/cli/program.ts +20 -4
  90. package/src/cli/project.ts +51 -0
  91. package/src/cli/register-fleet.ts +86 -1
  92. package/src/cli/registry.ts +41 -1
  93. package/src/cli/roster.ts +46 -30
  94. package/src/cli/settings.ts +15 -12
  95. package/src/cli/source-operations.ts +27 -3
  96. package/src/cli/tailnet.ts +2 -2
  97. package/src/definition.ts +116 -4
  98. package/src/diagnostics.ts +1 -1
  99. package/src/fleet-config.ts +105 -19
  100. package/src/ide/definition.ts +86 -0
  101. package/src/ide/ide.ts +43 -0
  102. package/src/ide/index.ts +5 -0
  103. package/src/ide/orchestrate.ts +50 -0
  104. package/src/ide/t3.ts +111 -0
  105. package/src/machine/source.ts +3 -3
  106. package/src/machine/state.ts +15 -27
  107. package/src/manager-sync.ts +9 -18
  108. package/src/monitoring/agent-evidence.ts +146 -0
  109. package/src/monitoring/apply.ts +15 -3
  110. package/src/monitoring/beszel.ts +11 -4
  111. package/src/monitoring/enroll.ts +2 -1
  112. package/src/monitoring/hub-access.ts +4 -3
  113. package/src/monitoring/hub-backup.ts +121 -0
  114. package/src/monitoring/hub-viewer.ts +8 -8
  115. package/src/monitoring/hub.ts +3 -3
  116. package/src/monitoring/manager-hub.ts +30 -14
  117. package/src/node-converge-linux.ts +30 -7
  118. package/src/package-info.ts +45 -0
  119. package/src/platform/ansible.ts +19 -15
  120. package/src/platform/darwin.ts +0 -13
  121. package/src/platform/fallback.ts +0 -8
  122. package/src/platform/linux.ts +0 -10
  123. package/src/platform/resource-verify.ts +8 -4
  124. package/src/platform/t3-policy.ts +33 -6
  125. package/src/platform/t3-service.ts +104 -0
  126. package/src/platform/types.ts +0 -4
  127. package/src/platform/ubuntu-audit.ts +5 -2
  128. package/src/platform/windows.ts +0 -14
  129. package/src/remote-bootstrap.ts +17 -19
  130. package/src/remote-entry.ts +15 -1
  131. package/src/repair-catalog.ts +1 -1
  132. package/src/runner.ts +1 -1
  133. package/src/service-auth.ts +15 -5
  134. package/src/state/bucket.ts +208 -0
  135. package/src/state/check.ts +74 -0
  136. package/src/state/connection.ts +92 -0
  137. package/src/state/enrollment.ts +45 -0
  138. package/src/state/legacy.ts +89 -0
  139. package/src/state/litestream.ts +143 -0
  140. package/src/state/operator.ts +20 -0
  141. package/src/state/paths.ts +40 -0
  142. package/src/state/roster.ts +62 -0
  143. package/src/state/store.ts +169 -0
  144. package/src/state/sync.ts +352 -0
  145. package/src/t3/accounts.ts +7 -1
  146. package/src/t3/models.ts +133 -0
  147. package/src/t3/pairing.ts +31 -0
  148. package/src/t3/profile.ts +203 -0
  149. package/src/t3/version.ts +119 -0
  150. package/src/t3-endpoint.ts +64 -8
  151. package/src/t3-linked-check.ts +24 -2
  152. package/src/toolchain-checks.ts +25 -0
  153. package/src/transport.ts +3 -2
  154. package/src/types.ts +4 -0
  155. package/src/worker-access-checks.ts +56 -3
  156. package/src/worker-host-checks.ts +41 -13
  157. package/src/workstation-checks.ts +1 -1
  158. package/src/index.ts +0 -6
  159. 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
- "serviceStartLimitBurst": 5,
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 and responsive. Run every minute by
3
- # personal-worker-recovery.timer (managed by fleet-sync, tasks/recovery.yml).
4
- # Stopped or failed: start it. Running but its local API has not answered for
5
- # three checks in a row: restart it, since a hung server serves no turns.
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
- state="${XDG_RUNTIME_DIR:?}/personal-t3-health-failures"
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
- # The former headless server still runs until its approved migration; never
12
- # start a second T3 next to it.
13
- if systemctl --user is-active --quiet t3code.service; then
14
- echo "Headless T3 still runs; desktop migration pending."
15
- exit 0
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
- if ! systemctl --user is-active --quiet t3code-desktop.service; then
18
- rm -f "$state"
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
- exec systemctl --user start t3code-desktop.service
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 --output /dev/null "$url"; then
23
- rm -f "$state"
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
- failures=$(($(cat "$state" 2>/dev/null || echo 0) + 1))
27
- echo "$failures" > "$state"
28
- echo "T3 local API did not answer ($failures/$limit)."
29
- if [ "$failures" -ge "$limit" ]; then
30
- rm -f "$state"
31
- echo "T3 is unresponsive; restarting it."
32
- exec systemctl --user restart t3code-desktop.service
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 (this ends its running turns) so every
45
- # node offers the same providers after every sync.
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
- - name: Restart T3 so it loads the staged policy
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 == '26.04'
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 == '1.104.5'"
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:3e03dacf222698c60b8e2f990b809ca1b3e104de127767864284e6c228f1fb39
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=1.104.5
54
+ name: 'tailscale={{ tailscale.version }}'
52
55
  state: present
53
56
  update_cache: true
54
57
  cache_valid_time: 0
55
- lock_timeout: 120
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 {{ development_source }}/ansible/ubuntu/t3-health.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()