@crvouga/dev-fleet 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/ubuntu/policy.json +1 -0
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/native-t3.ts +22 -11
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/declared.md +3 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +4 -3
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +28 -22
- package/llms.txt +1 -0
- package/mise.toml +3 -1
- package/package.json +2 -2
- package/schema/fleet.schema.json +66 -0
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +20 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-remote.ts +20 -13
- package/src/cli/fleet-sync.ts +36 -0
- package/src/cli/register-fleet.ts +35 -0
- package/src/cli/registry.ts +13 -0
- package/src/definition.ts +85 -3
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/manager-sync.ts +1 -15
- package/src/node-converge-linux.ts +28 -7
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-entry.ts +15 -1
- package/src/service-auth.ts +15 -5
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/transport.ts +3 -2
- package/src/worker-host-checks.ts +36 -13
package/README.md
CHANGED
|
@@ -189,6 +189,14 @@ warns that the manager holds the only one. [docs/state.md](docs/state.md)
|
|
|
189
189
|
explains how replication stays correct, how to move an existing fleet to a
|
|
190
190
|
project, and which state is not in the bucket yet.
|
|
191
191
|
|
|
192
|
+
## Provider accounts
|
|
193
|
+
|
|
194
|
+
Provider accounts live only on the CLI proxy. `npx devfleet fleet-accounts`
|
|
195
|
+
lists them with their state; `--add`, `--relogin` and `--remove` change one
|
|
196
|
+
and propagate the result to every node, and `fleet-sync --only accounts`
|
|
197
|
+
propagates them without a full sync.
|
|
198
|
+
[docs/accounts.md](docs/accounts.md) explains how.
|
|
199
|
+
|
|
192
200
|
## Add or remove a machine
|
|
193
201
|
|
|
194
202
|
1. Set up SSH from the manager to the new Ubuntu box (a non-root user with
|
|
@@ -233,6 +241,26 @@ package carries: `fleet-state --example` prints a complete example that points
|
|
|
233
241
|
| `workers` | The Ubuntu workers |
|
|
234
242
|
| `services` | The proxy's public origin and the monitoring dashboard and accounts |
|
|
235
243
|
| `worker` | Policy every worker must meet |
|
|
244
|
+
| `ides` | The IDEs every machine runs; `["t3"]` when absent |
|
|
245
|
+
| `t3` | T3 settings every machine holds (`profile`) and `requiredModels` |
|
|
246
|
+
|
|
247
|
+
### Supported IDEs
|
|
248
|
+
|
|
249
|
+
Every supported IDE is reached through one interface (`src/ide/ide.ts`): its
|
|
250
|
+
settings on each machine, the manager's own copy (pinned version and policy)
|
|
251
|
+
and what the manager measures of each worker. Enabling one is `ides` in
|
|
252
|
+
`fleet.json`.
|
|
253
|
+
|
|
254
|
+
| IDE | `ides` | Manager | Workers |
|
|
255
|
+
| ------- | ------ | ----------------------------------------------------- | ------------------------------------------ |
|
|
256
|
+
| T3 Code | `t3` | Pinned CLI, policy with updates off, version measured | Pinned desktop app, locked policy, pairing |
|
|
257
|
+
|
|
258
|
+
Under `t3.profile`, `fleet.json` names top-level T3 settings every machine
|
|
259
|
+
holds the same, such as tool servers. A setting not named there stays the
|
|
260
|
+
machine's own, and one edited on a machine since `fleet-sync` last wrote it
|
|
261
|
+
is reported as `T3_PROFILE_CONFLICT` by the check `t3:profile`, never
|
|
262
|
+
overwritten. The check `t3:models` reports a model T3 offers that the proxy
|
|
263
|
+
does not serve, and a model in `t3.requiredModels` that is missing.
|
|
236
264
|
|
|
237
265
|
Secrets are asked for by purpose (`proxy-client-key`,
|
|
238
266
|
`monitoring-account-password`, `monitoring-tunnel-token`,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
# T3 Code on the manager reads the same administrator policy as on every
|
|
3
|
+
# worker (ansible/ubuntu/t3/policy.json): updates off, so the manager's T3
|
|
4
|
+
# changes only when the fleet's pin does. Provider instances are reconciled
|
|
5
|
+
# by fleet-sync on every run and stay unlocked here: locking them needs an
|
|
6
|
+
# administrator for each change of the account roster. fleet-sync measures
|
|
7
|
+
# the result as t3:updates. Needs an administrator: ansible-playbook -K manager.yml
|
|
8
|
+
- name: Stage the fleet's T3 policy on the manager
|
|
9
|
+
hosts: localhost
|
|
10
|
+
connection: local
|
|
11
|
+
become: true
|
|
12
|
+
gather_facts: false
|
|
13
|
+
vars:
|
|
14
|
+
t3_policy_directory: /Library/Application Support/T3Code
|
|
15
|
+
tasks:
|
|
16
|
+
- name: Inspect existing administrator policy
|
|
17
|
+
ansible.builtin.stat:
|
|
18
|
+
path: '{{ t3_policy_directory }}/policy.json'
|
|
19
|
+
register: policy_file
|
|
20
|
+
- name: Inspect Fleet ownership marker
|
|
21
|
+
ansible.builtin.stat:
|
|
22
|
+
path: '{{ t3_policy_directory }}/.personal-fleet-policy'
|
|
23
|
+
register: policy_owner
|
|
24
|
+
- name: Refuse to overwrite an unrelated administrator policy
|
|
25
|
+
ansible.builtin.assert:
|
|
26
|
+
that: not policy_file.stat.exists or policy_owner.stat.exists
|
|
27
|
+
fail_msg: T3_POLICY_OWNER_CONFLICT; review and merge the existing administrator policy in Git before adoption.
|
|
28
|
+
- name: Create administrator-owned policy directory
|
|
29
|
+
ansible.builtin.file:
|
|
30
|
+
path: '{{ t3_policy_directory }}'
|
|
31
|
+
state: directory
|
|
32
|
+
owner: root
|
|
33
|
+
group: wheel
|
|
34
|
+
mode: '0755'
|
|
35
|
+
- name: Record policy ownership before installation
|
|
36
|
+
ansible.builtin.copy:
|
|
37
|
+
dest: '{{ t3_policy_directory }}/.personal-fleet-policy'
|
|
38
|
+
content: "ansible/ubuntu/t3/policy.json\n"
|
|
39
|
+
owner: root
|
|
40
|
+
group: wheel
|
|
41
|
+
mode: '0644'
|
|
42
|
+
- name: Install the fleet's policy with backup
|
|
43
|
+
ansible.builtin.copy:
|
|
44
|
+
content: "{{ lookup('ansible.builtin.file', '../ubuntu/t3/policy.json') | from_json | to_nice_json }}"
|
|
45
|
+
dest: '{{ t3_policy_directory }}/policy.json'
|
|
46
|
+
owner: root
|
|
47
|
+
group: wheel
|
|
48
|
+
mode: '0644'
|
|
49
|
+
backup: true
|
|
@@ -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*$')
|
|
@@ -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
|
|
@@ -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()
|
package/bootstrap/native-t3.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface Configuration {
|
|
|
19
19
|
t3_version: string;
|
|
20
20
|
t3_linux_x64_sha256: string;
|
|
21
21
|
t3_linux_arm64_sha256: string;
|
|
22
|
+
t3_darwin_arm64_sha256?: string;
|
|
22
23
|
t3_desktop_amd64_sha256: string;
|
|
23
24
|
t3_desktop_arm64_sha256: string;
|
|
24
25
|
};
|
|
@@ -52,20 +53,27 @@ export function managedT3Desktop() {
|
|
|
52
53
|
) as Configuration;
|
|
53
54
|
return desktopT3Artifact(config, process.arch);
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** The pinned server archive: Ubuntu workers on either architecture, and
|
|
57
|
+
* the manager (macOS on Apple silicon, the only macOS build upstream ships).
|
|
58
|
+
*/
|
|
59
|
+
export function nativeT3Artifact(
|
|
60
|
+
config: Configuration,
|
|
61
|
+
architecture: string,
|
|
62
|
+
platform: string = 'linux'
|
|
63
|
+
) {
|
|
64
|
+
const sha256 = (<Record<string, string | undefined>>{
|
|
65
|
+
'linux-x64': config.vars.t3_linux_x64_sha256,
|
|
66
|
+
'linux-arm64': config.vars.t3_linux_arm64_sha256,
|
|
67
|
+
'darwin-arm64': config.vars.t3_darwin_arm64_sha256,
|
|
68
|
+
})[`${platform}-${architecture}`];
|
|
69
|
+
if (sha256 === undefined) throw new Error('Unsupported T3 architecture.');
|
|
58
70
|
const version = config.vars.t3_version;
|
|
59
|
-
const sha256 =
|
|
60
|
-
architecture === 'x64'
|
|
61
|
-
? config.vars.t3_linux_x64_sha256
|
|
62
|
-
: config.vars.t3_linux_arm64_sha256;
|
|
63
71
|
if (
|
|
64
72
|
!/^\d+\.\d+\.\d+(?:-nightly\.\d{8}\.\d+)?$/.test(version) ||
|
|
65
73
|
!/^[a-f0-9]{64}$/.test(sha256)
|
|
66
74
|
)
|
|
67
75
|
throw new Error('Invalid T3 pin.');
|
|
68
|
-
const name = `t3-${version}
|
|
76
|
+
const name = `t3-${version}-${platform}-${architecture}`;
|
|
69
77
|
return {
|
|
70
78
|
version,
|
|
71
79
|
sha256,
|
|
@@ -77,18 +85,21 @@ export function managedT3(): { path: string; version: string } {
|
|
|
77
85
|
const config = Bun.TOML.parse(
|
|
78
86
|
readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
|
|
79
87
|
) as Configuration;
|
|
80
|
-
const artifact = nativeT3Artifact(config, process.arch);
|
|
88
|
+
const artifact = nativeT3Artifact(config, process.arch, process.platform);
|
|
81
89
|
return {
|
|
82
90
|
path: join(homedir(), '.local/share/personal/tools', artifact.name, 't3'),
|
|
83
91
|
version: artifact.version,
|
|
84
92
|
};
|
|
85
93
|
}
|
|
94
|
+
/** Installs the pinned, checksum-verified server archive on a worker and on
|
|
95
|
+
* the manager, so both run the T3 version the fleet pins.
|
|
96
|
+
*/
|
|
86
97
|
export async function installNativeT3(): Promise<void> {
|
|
87
|
-
if (process.platform
|
|
98
|
+
if (!['linux', 'darwin'].includes(process.platform)) return;
|
|
88
99
|
const config = Bun.TOML.parse(
|
|
89
100
|
readFileSync(join(workspaceRoot, 'mise.toml'), 'utf8')
|
|
90
101
|
) as Configuration;
|
|
91
|
-
const artifact = nativeT3Artifact(config, process.arch);
|
|
102
|
+
const artifact = nativeT3Artifact(config, process.arch, process.platform);
|
|
92
103
|
const root = join(homedir(), '.local/share/personal/tools');
|
|
93
104
|
const archive = join(root, `${artifact.name}.tar.gz`);
|
|
94
105
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|