@crvouga/dev-fleet 0.2.0 → 0.4.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 (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  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/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. package/src/worker-readiness.ts +10 -1
@@ -3,14 +3,15 @@
3
3
 
4
4
  Managed by personal fleet-sync (tasks/resources.yml) as
5
5
  personal-fleet-disk-guard.service. Every interval it measures free space on the
6
- watched filesystems and the bytes each leaf cgroup under user.slice wrote. When
7
- any watched filesystem is below the floor, it kills only the single cgroup that
6
+ watched filesystems (bytes and inodes) and the bytes each leaf cgroup under user.slice wrote. When
7
+ any watched filesystem is below the floor of free bytes or of free inodes, it
8
+ kills only the single cgroup that
8
9
  wrote the most during the last interval, and only if that is at least the write
9
10
  threshold. It never deletes files, never touches system services and never
10
11
  kills idle work. Free space must be recovered by a person.
11
12
 
12
- Usage: disk-guard.py --path / --path /home/me [--floor-mib N] [--write-mib N]
13
- [--interval S] [--cgroup-root DIR] [--dry-run]
13
+ Usage: disk-guard.py --path / --path /home/me [--floor-mib N]
14
+ [--inode-floor-percent N] [--write-mib N] [--interval S] [--cgroup-root DIR] [--dry-run]
14
15
  """
15
16
  import argparse
16
17
  import json
@@ -61,6 +62,21 @@ def free_bytes(path):
61
62
  return st.f_bavail * st.f_frsize
62
63
 
63
64
 
65
+ def free_inodes_percent(path):
66
+ """Free inodes as a percentage; None on a filesystem without a fixed count."""
67
+ st = os.statvfs(path)
68
+ if st.f_files <= 0:
69
+ return None
70
+ return st.f_favail * 100 / st.f_files
71
+
72
+
73
+ def low_paths(free, inodes, floor, inode_floor):
74
+ """The watched paths below either floor: bytes, or free inodes in percent."""
75
+ return [path for path in free
76
+ if free[path] < floor
77
+ or (inodes.get(path) is not None and inodes[path] < inode_floor)]
78
+
79
+
64
80
  def log(**event):
65
81
  print(json.dumps(event, sort_keys=True), flush=True)
66
82
 
@@ -69,6 +85,7 @@ def main(argv):
69
85
  parser = argparse.ArgumentParser()
70
86
  parser.add_argument('--path', action='append', required=True)
71
87
  parser.add_argument('--floor-mib', type=int, default=3072)
88
+ parser.add_argument('--inode-floor-percent', type=float, default=2)
72
89
  parser.add_argument('--write-mib', type=int, default=32)
73
90
  parser.add_argument('--interval', type=float, default=5.0)
74
91
  parser.add_argument('--cgroup-root', default='/sys/fs/cgroup/user.slice')
@@ -76,6 +93,7 @@ def main(argv):
76
93
  args = parser.parse_args(argv)
77
94
  floor, threshold = args.floor_mib * MIB, args.write_mib * MIB
78
95
  log(event='started', paths=args.path, floor_mib=args.floor_mib,
96
+ inode_floor_percent=args.inode_floor_percent,
79
97
  write_mib=args.write_mib, interval=args.interval, dry_run=args.dry_run)
80
98
  previous = leaf_writes(args.cgroup_root)
81
99
  while True:
@@ -83,7 +101,8 @@ def main(argv):
83
101
  try:
84
102
  current = leaf_writes(args.cgroup_root)
85
103
  free = {path: free_bytes(path) for path in args.path}
86
- low = [path for path, value in free.items() if value < floor]
104
+ inodes = {path: free_inodes_percent(path) for path in args.path}
105
+ low = low_paths(free, inodes, floor, args.inode_floor_percent)
87
106
  victim = choose_victim(previous, current, bool(low), threshold)
88
107
  previous = current
89
108
  if victim is None:
@@ -26,6 +26,7 @@
26
26
  "aptLockTimeoutSeconds": 120,
27
27
  "minimumFreeDiskMiB": 4096,
28
28
  "minimumFreeInodes": 10000,
29
+ "t3RestartMaxDeferralHours": 24,
29
30
  "alwaysOnServices": ["ssh", "tailscaled", "docker", "beszel-agent"],
30
31
  "resourceSafety": {
31
32
  "swapFileMiB": 8192,
@@ -47,6 +48,55 @@
47
48
  "diskGcPressureDockerAgeHours": 24,
48
49
  "diskGcPressureCaches": [".bun/install/cache"],
49
50
  "dockerLogMaxSize": "50m",
50
- "dockerLogMaxFiles": "3"
51
+ "dockerLogMaxFiles": "3",
52
+ "inodeWarnFreePercent": 10,
53
+ "inodeFailFreePercent": 2
54
+ },
55
+ "clock": {
56
+ "warnOffsetSeconds": 2,
57
+ "failOffsetSeconds": 30
58
+ },
59
+ "rebootPendingFailDays": 14,
60
+ "dockerSmokeTest": {
61
+ "image": "busybox:1.37.0@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec",
62
+ "timeoutSeconds": 60
63
+ },
64
+ "browser": {
65
+ "packages": {
66
+ "fonts-liberation": "1:2.1.5-3build1",
67
+ "libasound2t64": "1.2.15.3-1ubuntu1.1",
68
+ "libatk-bridge2.0-0t64": "2.60.4-0ubuntu0.1",
69
+ "libatk1.0-0t64": "2.60.4-0ubuntu0.1",
70
+ "libatspi2.0-0t64": "2.60.4-0ubuntu0.1",
71
+ "libcairo2": "1.18.4-3",
72
+ "libcups2t64": "2.4.16-1ubuntu1.3",
73
+ "libdbus-1-3": "1.16.2-2ubuntu4",
74
+ "libdrm2": "2.4.131-1",
75
+ "libgbm1": "26.0.8-1ubuntu0.3",
76
+ "libglib2.0-0t64": "2.88.0-1ubuntu0.1",
77
+ "libnspr4": "2:4.38.2-1ubuntu1",
78
+ "libnss3": "2:3.120-1ubuntu2.2",
79
+ "libpango-1.0-0": "1.57.0-1",
80
+ "libx11-6": "2:1.8.13-1ubuntu0.1",
81
+ "libxcb1": "1.17.0-2ubuntu1",
82
+ "libxcomposite1": "1:0.4.6-1build1",
83
+ "libxdamage1": "1:1.1.7-1",
84
+ "libxext6": "2:1.3.4-1build3",
85
+ "libxfixes3": "1:6.0.0-2build2",
86
+ "libxkbcommon0": "1.13.1-1",
87
+ "libxrandr2": "2:1.5.4-1build1"
88
+ },
89
+ "headlessShell": {
90
+ "version": "158.0.8127.18",
91
+ "amd64": {
92
+ "archive": "linux64/chrome-headless-shell-linux64",
93
+ "sha256": "18352d22cb0cdf520cbff2ad8c41391f0e02a72886cb53b0fde4bdfc4bd075d6"
94
+ },
95
+ "arm64": {
96
+ "archive": "linux-arm64/chrome-headless-shell-linux-arm64",
97
+ "sha256": "b136462898353d94c7fab50f9cdaa6d8f0d4c190153916936aa0a03353a26bf2"
98
+ }
99
+ },
100
+ "launchTimeoutSeconds": 60
51
101
  }
52
102
  }
@@ -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*$')
@@ -0,0 +1,56 @@
1
+ ---
2
+ # Headless Chromium for browser tests (Playwright, Storybook, end-to-end
3
+ # suites): the system libraries it loads, at the snapshot's exact versions,
4
+ # and one pinned Chrome Headless Shell build. Applied to workers that select
5
+ # the web tool profile; the launch below backs worker:browser.
6
+ - name: Install the exact libraries headless Chromium loads
7
+ ansible.builtin.apt:
8
+ name: '{{ item.key }}={{ item.value }}'
9
+ state: present
10
+ lock_timeout: '{{ aptLockTimeoutSeconds }}'
11
+ allow_downgrade: false
12
+ allow_unauthenticated: false
13
+ fail_on_autoremove: true
14
+ policy_rc_d: 101
15
+ environment:
16
+ APT_CONFIG: /etc/personal-fleet/apt.conf
17
+ loop: '{{ browser.packages | dict2items }}'
18
+ - name: Name the headless shell build of this architecture
19
+ ansible.builtin.set_fact:
20
+ browser_build: "{{ browser.headlessShell['arm64' if ansible_facts.architecture == 'aarch64' else 'amd64'] }}"
21
+ browser_root: '/opt/personal-fleet/browser/{{ browser.headlessShell.version }}'
22
+ - name: Create the package cache and the browser directory
23
+ ansible.builtin.file:
24
+ path: '{{ item }}'
25
+ state: directory
26
+ owner: root
27
+ group: root
28
+ mode: '0755'
29
+ loop:
30
+ - /var/cache/personal-fleet
31
+ - '{{ browser_root }}'
32
+ - name: Download the pinned headless shell
33
+ ansible.builtin.get_url:
34
+ url: 'https://cdn.playwright.dev/builds/cft/{{ browser.headlessShell.version }}/{{ browser_build.archive }}.zip'
35
+ dest: '/var/cache/personal-fleet/chrome-headless-shell-{{ browser.headlessShell.version }}-{{ browser_build.archive | basename }}.zip'
36
+ checksum: 'sha256:{{ browser_build.sha256 }}'
37
+ mode: '0644'
38
+ timeout: 300
39
+ - name: Unpack the verified headless shell
40
+ ansible.builtin.unarchive:
41
+ src: '/var/cache/personal-fleet/chrome-headless-shell-{{ browser.headlessShell.version }}-{{ browser_build.archive | basename }}.zip'
42
+ dest: '{{ browser_root }}'
43
+ remote_src: true
44
+ creates: '{{ browser_root }}/{{ browser_build.archive | basename }}/chrome-headless-shell'
45
+ - name: Launch headless as the developer, load a blank page and exit (BROWSER_LAUNCH_FAILED)
46
+ ansible.builtin.command:
47
+ argv:
48
+ - '{{ browser_root }}/{{ browser_build.archive | basename }}/chrome-headless-shell'
49
+ - --headless
50
+ - --no-sandbox
51
+ - --disable-gpu
52
+ - --dump-dom
53
+ - about:blank
54
+ become_user: '{{ development_user }}'
55
+ timeout: '{{ browser.launchTimeoutSeconds }}'
56
+ changed_when: false
@@ -29,3 +29,55 @@
29
29
  - [docker, compose, version]
30
30
  - [docker, buildx, version]
31
31
  changed_when: false
32
+ # The engine answering is not proof that an agent can use it: run a pinned
33
+ # image and build a one-line image as the developer, in a fresh session that
34
+ # reads the docker group from scratch. The image is cached here so the same
35
+ # test works offline in worker:docker-run.
36
+ - name: Look for the cached smoke-test image
37
+ ansible.builtin.command:
38
+ argv:
39
+ [
40
+ docker,
41
+ image,
42
+ inspect,
43
+ --format,
44
+ '{{ "{{.Id}}" }}',
45
+ '{{ dockerSmokeTest.image }}',
46
+ ]
47
+ become_user: '{{ development_user }}'
48
+ check_mode: false
49
+ changed_when: false
50
+ failed_when: false
51
+ register: docker_smoke_image
52
+ - name: Cache the pinned smoke-test image
53
+ ansible.builtin.command:
54
+ argv: [docker, pull, --quiet, '{{ dockerSmokeTest.image }}']
55
+ become_user: '{{ development_user }}'
56
+ when: docker_smoke_image.rc | default(0) != 0
57
+ changed_when: true
58
+ - name: Run a container as the developer, offline (DOCKER_RUN_FAILED)
59
+ ansible.builtin.command:
60
+ argv:
61
+ [
62
+ docker,
63
+ run,
64
+ --rm,
65
+ --network,
66
+ none,
67
+ --pull,
68
+ never,
69
+ '{{ dockerSmokeTest.image }}',
70
+ 'true',
71
+ ]
72
+ become_user: '{{ development_user }}'
73
+ timeout: '{{ dockerSmokeTest.timeoutSeconds }}'
74
+ changed_when: false
75
+ - name: Build a one-line image as the developer, offline (DOCKER_BUILD_FAILED)
76
+ ansible.builtin.command:
77
+ argv: [docker, build, --quiet, --pull=false, --network, none, '-']
78
+ stdin: |
79
+ FROM {{ dockerSmokeTest.image }}
80
+ RUN true
81
+ become_user: '{{ development_user }}'
82
+ timeout: '{{ dockerSmokeTest.timeoutSeconds }}'
83
+ changed_when: false
@@ -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
@@ -224,7 +224,7 @@
224
224
  StartLimitIntervalSec=0
225
225
  [Service]
226
226
  Type=simple
227
- ExecStart=/usr/bin/python3 -I -B {{ disk_guard_source.stat.path }} --path / --path {{ development_home }} --floor-mib {{ resourceSafety.diskGuardFloorMiB }} --write-mib {{ resourceSafety.diskGuardWriteMiB }} --interval {{ resourceSafety.diskGuardIntervalSeconds }}
227
+ ExecStart=/usr/bin/python3 -I -B {{ disk_guard_source.stat.path }} --path / --path {{ development_home }} --floor-mib {{ resourceSafety.diskGuardFloorMiB }} --inode-floor-percent {{ resourceSafety.inodeFailFreePercent }} --write-mib {{ resourceSafety.diskGuardWriteMiB }} --interval {{ resourceSafety.diskGuardIntervalSeconds }}
228
228
  Restart=always
229
229
  RestartSec=5
230
230
  OOMScoreAdjust=-1000
@@ -276,7 +276,7 @@
276
276
  After=docker.service
277
277
  [Service]
278
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 %}
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-inode-percent {{ resourceSafety.inodeWarnFreePercent }} --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
280
 
281
281
  TimeoutStartSec=2h
282
282
  Nice=19
@@ -29,3 +29,16 @@
29
29
  ansible.builtin.systemd_service:
30
30
  daemon_reload: true
31
31
  when: recovery_dropins.changed
32
+ # A skewed clock breaks TLS, tokens, apt signatures and Vault logins with
33
+ # errors that never mention time. Ubuntu ships one time service (chrony on
34
+ # newer releases, systemd-timesyncd before); whichever is installed stays
35
+ # enabled. A container takes its clock from its host. Measured as worker:clock.
36
+ - name: Inspect the time services this worker has
37
+ ansible.builtin.service_facts:
38
+ - name: Keep the clock synchronised with the time service Ubuntu ships
39
+ ansible.builtin.systemd_service:
40
+ name: '{{ item }}'
41
+ enabled: true
42
+ state: started
43
+ loop: "{{ (['chrony.service', 'systemd-timesyncd.service'] | select('in', ansible_facts.services) | list)[:1] }}"
44
+ when: ansible_facts.virtualization_type not in ['docker', 'container', 'podman', 'lxc', 'systemd-nspawn']
@@ -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)