@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.
Files changed (72) hide show
  1. package/README.md +28 -0
  2. package/ansible/macos/manager.yml +3 -0
  3. package/ansible/macos/t3-policy.yml +49 -0
  4. package/ansible/ubuntu/policy.json +1 -0
  5. package/ansible/ubuntu/t3-desktop.yml +19 -0
  6. package/ansible/ubuntu/t3-health.sh +93 -22
  7. package/ansible/ubuntu/t3-idle.py +42 -0
  8. package/ansible/ubuntu/t3-policy.yml +4 -11
  9. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  10. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  11. package/ansible/ubuntu/wip-guard.py +121 -0
  12. package/bootstrap/native-t3.ts +22 -11
  13. package/cliproxy/README.md +3 -3
  14. package/cliproxy/scripts/accounts.py +216 -0
  15. package/cliproxy/scripts/checkpoint.py +29 -1
  16. package/cliproxy/scripts/cli.py +1 -1
  17. package/cliproxy/scripts/clientkeys.py +99 -0
  18. package/cliproxy/scripts/lease.py +80 -0
  19. package/cliproxy/scripts/ops +137 -25
  20. package/cliproxy/scripts/restore-verify +15 -0
  21. package/docs/accounts.md +154 -0
  22. package/docs/declared.md +3 -1
  23. package/docs/durability.md +26 -0
  24. package/docs/machine-setup.md +4 -3
  25. package/docs/topology.md +2 -0
  26. package/docs/ubuntu-sync.md +28 -22
  27. package/llms.txt +1 -0
  28. package/mise.toml +3 -1
  29. package/package.json +2 -2
  30. package/schema/fleet.schema.json +66 -0
  31. package/src/accounts/checks.ts +220 -0
  32. package/src/accounts/context.ts +76 -0
  33. package/src/accounts/propagate.ts +62 -0
  34. package/src/accounts/roster.ts +85 -0
  35. package/src/accounts/selection.ts +67 -0
  36. package/src/accounts/sessions.ts +120 -0
  37. package/src/agents/account-relay.ts +176 -26
  38. package/src/agents/launch.ts +84 -5
  39. package/src/agents/native-verify.ts +128 -2
  40. package/src/cli/fleet-accounts.ts +253 -0
  41. package/src/cli/fleet-sync-accounts.ts +58 -0
  42. package/src/cli/fleet-sync-manager.ts +20 -4
  43. package/src/cli/fleet-sync-output.ts +3 -2
  44. package/src/cli/fleet-sync-remote.ts +20 -13
  45. package/src/cli/fleet-sync.ts +36 -0
  46. package/src/cli/register-fleet.ts +35 -0
  47. package/src/cli/registry.ts +13 -0
  48. package/src/definition.ts +85 -3
  49. package/src/ide/definition.ts +86 -0
  50. package/src/ide/ide.ts +43 -0
  51. package/src/ide/index.ts +5 -0
  52. package/src/ide/orchestrate.ts +50 -0
  53. package/src/ide/t3.ts +111 -0
  54. package/src/manager-sync.ts +1 -15
  55. package/src/node-converge-linux.ts +28 -7
  56. package/src/platform/darwin.ts +0 -13
  57. package/src/platform/fallback.ts +0 -8
  58. package/src/platform/linux.ts +0 -10
  59. package/src/platform/t3-policy.ts +33 -6
  60. package/src/platform/t3-service.ts +104 -0
  61. package/src/platform/types.ts +0 -4
  62. package/src/platform/windows.ts +0 -14
  63. package/src/remote-entry.ts +15 -1
  64. package/src/service-auth.ts +15 -5
  65. package/src/t3/accounts.ts +7 -1
  66. package/src/t3/models.ts +133 -0
  67. package/src/t3/pairing.ts +31 -0
  68. package/src/t3/profile.ts +203 -0
  69. package/src/t3/version.ts +119 -0
  70. package/src/t3-linked-check.ts +1 -1
  71. package/src/transport.ts +3 -2
  72. package/src/worker-host-checks.ts +36 -13
package/src/definition.ts CHANGED
@@ -5,6 +5,12 @@ import { parseConfig, record } from './config';
5
5
  import type { FleetConfig } from './types';
6
6
  import { parseSecretsConfig, type SecretsConfig } from './secrets';
7
7
  import { parseLocation, type BucketLocation } from './state/bucket';
8
+ import {
9
+ parseIdes,
10
+ parseT3,
11
+ type IdeId,
12
+ type T3Definition,
13
+ } from './ide/definition';
8
14
 
9
15
  export interface MachineDefinition {
10
16
  id: string;
@@ -40,7 +46,7 @@ export interface FleetDefinition {
40
46
  workers: MachineDefinition[];
41
47
  services: {
42
48
  /** Public HTTPS origin of the CLI proxy every node uses. */
43
- proxy: { endpoint: string };
49
+ proxy: { endpoint: string; accounts?: AccountPolicy };
44
50
  monitoring: {
45
51
  hubPort: 8443;
46
52
  /** Public dashboard origin. */
@@ -52,8 +58,28 @@ export interface FleetDefinition {
52
58
  };
53
59
  };
54
60
  worker?: WorkerPolicy;
61
+ /** The IDEs every machine runs; `["t3"]` when not named (idesOf). */
62
+ ides?: IdeId[];
63
+ /** What every machine's T3 holds beyond its provider instances. */
64
+ t3?: T3Definition;
55
65
  ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
56
66
  }
67
+ /** How provider accounts are watched and chosen (src/accounts/). */
68
+ export interface AccountPolicy {
69
+ /** Fewer usable accounts of a provider than this is a warning; none fails. */
70
+ minimumUsable?: number;
71
+ /** Per provider (`claude`, `codex`, `antigravity`): which account an
72
+ * unpinned request goes to first. Accounts cooling down are skipped.
73
+ */
74
+ selection?: Record<string, AccountSelection>;
75
+ }
76
+ export interface AccountSelection {
77
+ strategy: 'fill-first' | 'round-robin' | 'least-recently-limited';
78
+ /** Account labels (sign-in emails) first, in this order; the others
79
+ * follow in the order of their routing prefix.
80
+ */
81
+ order?: string[];
82
+ }
57
83
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
58
84
  const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
59
85
  function fail(): never {
@@ -148,6 +174,8 @@ export function parseDefinition(value: unknown): FleetDefinition {
148
174
  'services',
149
175
  'ubuntu',
150
176
  'worker',
177
+ 'ides',
178
+ 't3',
151
179
  ])
152
180
  )
153
181
  return fail();
@@ -169,6 +197,9 @@ export function parseDefinition(value: unknown): FleetDefinition {
169
197
  ...(value['worker'] === undefined
170
198
  ? {}
171
199
  : { worker: parseWorkerPolicy(value['worker']) }),
200
+ // Kept only when named: an earlier revision must still read the copy.
201
+ ...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
202
+ ...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
172
203
  };
173
204
  }
174
205
  /** Singleton services name no host: they always run on the manager. */
@@ -177,15 +208,66 @@ function services(value: unknown): FleetDefinition['services'] {
177
208
  const { proxy, monitoring } = value;
178
209
  if (
179
210
  !record(proxy) ||
180
- !keys(proxy, ['endpoint']) ||
211
+ !keys(proxy, ['endpoint', 'accounts']) ||
181
212
  !origin(proxy['endpoint'])
182
213
  )
183
214
  return fail();
184
215
  return {
185
- proxy: { endpoint: proxy['endpoint'] },
216
+ proxy: {
217
+ endpoint: proxy['endpoint'],
218
+ ...(proxy['accounts'] === undefined
219
+ ? {}
220
+ : { accounts: accountPolicy(proxy['accounts']) }),
221
+ },
186
222
  monitoring: monitoringService(monitoring),
187
223
  };
188
224
  }
225
+ const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
226
+ function accountPolicy(value: unknown): AccountPolicy {
227
+ if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
228
+ return fail();
229
+ const { minimumUsable, selection } = value;
230
+ if (
231
+ minimumUsable !== undefined &&
232
+ !(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
233
+ )
234
+ return fail();
235
+ return {
236
+ ...(minimumUsable === undefined
237
+ ? {}
238
+ : { minimumUsable: minimumUsable as number }),
239
+ ...(selection === undefined
240
+ ? {}
241
+ : { selection: selectionPolicies(selection) }),
242
+ };
243
+ }
244
+ function selectionPolicies(value: unknown): Record<string, AccountSelection> {
245
+ if (!record(value)) return fail();
246
+ const providers: Record<string, AccountSelection> = {};
247
+ for (const [provider, entry] of Object.entries(value)) {
248
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider)) return fail();
249
+ providers[provider] = accountSelection(entry);
250
+ }
251
+ return providers;
252
+ }
253
+ function accountSelection(entry: unknown): AccountSelection {
254
+ if (
255
+ !record(entry) ||
256
+ !keys(entry, ['strategy', 'order']) ||
257
+ !STRATEGIES.includes(String(entry['strategy']))
258
+ )
259
+ return fail();
260
+ const { order } = entry;
261
+ if (
262
+ order !== undefined &&
263
+ !(Array.isArray(order) && order.every((label) => account(label)))
264
+ )
265
+ return fail();
266
+ return {
267
+ strategy: entry['strategy'] as AccountSelection['strategy'],
268
+ ...(order ? { order: order as string[] } : {}),
269
+ };
270
+ }
189
271
  function monitoringService(
190
272
  value: unknown
191
273
  ): FleetDefinition['services']['monitoring'] {
@@ -0,0 +1,86 @@
1
+ import { FleetUsageError } from '../errors';
2
+ import { record } from '../config';
3
+
4
+ /** The IDEs dev-fleet supports. The support matrix in the README lists the
5
+ * same; enabling one on the fleet is `ides` in fleet.json, not new code.
6
+ */
7
+ export const SUPPORTED_IDES = ['t3'] as const;
8
+ export type IdeId = (typeof SUPPORTED_IDES)[number];
9
+
10
+ /** What `t3` in fleet.json declares for every machine's T3. */
11
+ export interface T3Definition {
12
+ /** Top-level T3 settings every machine holds the same: tool servers,
13
+ * projects, selected preferences. A key not named here stays local.
14
+ */
15
+ profile?: Record<string, unknown>;
16
+ /** Models T3 must offer for every account of a tool, by tool. */
17
+ requiredModels?: Record<string, string[]>;
18
+ }
19
+
20
+ const KEY = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
21
+ /** Settings another part of dev-fleet owns. */
22
+ const RESERVED = ['providerInstances'];
23
+
24
+ function invalid(detail: string): never {
25
+ throw new FleetUsageError(`Invalid fleet.json: ${detail}`);
26
+ }
27
+
28
+ /** The IDEs a fleet runs: those it names, or T3 alone. */
29
+ export const idesOf = (definition: { ides?: readonly IdeId[] }): IdeId[] => [
30
+ ...(definition.ides ?? ['t3']),
31
+ ];
32
+
33
+ export function parseIdes(value: unknown): IdeId[] {
34
+ if (
35
+ !Array.isArray(value) ||
36
+ value.length === 0 ||
37
+ new Set(value).size !== value.length ||
38
+ value.some((id) => !SUPPORTED_IDES.includes(id as IdeId))
39
+ )
40
+ invalid(`ides lists each of ${SUPPORTED_IDES.join(', ')} at most once.`);
41
+ return value as IdeId[];
42
+ }
43
+
44
+ export function parseT3(value: unknown): T3Definition {
45
+ if (
46
+ !record(value) ||
47
+ Object.keys(value).some(
48
+ (key) => !['profile', 'requiredModels'].includes(key)
49
+ )
50
+ )
51
+ invalid('t3 holds only profile and requiredModels.');
52
+ const result: T3Definition = {};
53
+ if (value['profile'] !== undefined) {
54
+ const profile = value['profile'];
55
+ if (
56
+ !record(profile) ||
57
+ Object.keys(profile).some(
58
+ (key) => !KEY.test(key) || RESERVED.includes(key)
59
+ ) ||
60
+ JSON.stringify(profile).length > 65_536
61
+ )
62
+ invalid(
63
+ 't3.profile names top-level T3 settings (not providerInstances) and stays under 64 KiB.'
64
+ );
65
+ result.profile = profile;
66
+ }
67
+ if (value['requiredModels'] !== undefined) {
68
+ const required = value['requiredModels'];
69
+ if (
70
+ !record(required) ||
71
+ Object.entries(required).some(
72
+ ([tool, models]) =>
73
+ !KEY.test(tool) ||
74
+ !Array.isArray(models) ||
75
+ models.some(
76
+ (model) =>
77
+ typeof model !== 'string' ||
78
+ !/^[A-Za-z0-9._:-]{1,128}$/.test(model)
79
+ )
80
+ )
81
+ )
82
+ invalid('t3.requiredModels lists model names by tool.');
83
+ result.requiredModels = required as Record<string, string[]>;
84
+ }
85
+ return result;
86
+ }
package/src/ide/ide.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Check, FleetNode, Runner } from '../types';
2
+
3
+ /** What every supported IDE is asked for. An implementation owns its own
4
+ * install, service and policy (an Ansible role per IDE, with the common
5
+ * variable contract: version, checksum, policy, instances, profile) and is
6
+ * reached only through these calls, so the orchestration never names one.
7
+ */
8
+ export interface Ide {
9
+ /** The name the fleet configuration enables it by, under `ides`. */
10
+ readonly id: string;
11
+ /** The IDE's settings on this machine: provider instances from the account
12
+ * roster, the declared profile and the models it offers. Run on the manager
13
+ * and on every worker by the machine itself.
14
+ */
15
+ settings(context: MachineContext): Promise<Check[]>;
16
+ /** The manager's own IDE: its settings, its pinned version and its policy. */
17
+ manager(context: MachineContext): Promise<Check[]>;
18
+ /** What the manager measures of a worker's IDE: remote access, pairing and
19
+ * the version the worker runs.
20
+ */
21
+ worker(node: FleetNode, context: WorkerContext): Promise<Check[]>;
22
+ }
23
+
24
+ export interface MachineContext {
25
+ /** The node ID this machine is in the fleet. */
26
+ id: string;
27
+ /** The checkout this run applies. */
28
+ workspace: string;
29
+ apply: boolean;
30
+ run: Runner;
31
+ }
32
+
33
+ export interface WorkerContext {
34
+ run: Runner;
35
+ /** The manager's checkout of the revision this sync applies. */
36
+ workspace: string;
37
+ /** The checks this sync already reported for the worker. */
38
+ earlier: Check[];
39
+ /** Records what identifies the worker's IDE in the fleet state, the first
40
+ * time it is seen through a verified link.
41
+ */
42
+ remember?: (node: string, identity: string) => Promise<void>;
43
+ }
@@ -0,0 +1,5 @@
1
+ import type { Ide } from './ide';
2
+ import { t3Ide } from './t3';
3
+
4
+ /** Every IDE dev-fleet implements, by the ID fleet.json enables it with. */
5
+ export const IDES: Readonly<Record<string, Ide>> = { t3: t3Ide() };
@@ -0,0 +1,50 @@
1
+ import type { Check, FleetNode } from '../types';
2
+ import type { Ide, MachineContext, WorkerContext } from './ide';
3
+
4
+ /** The implementations the fleet enables, in the order it names them. An ID
5
+ * nobody implements is refused before anything runs.
6
+ */
7
+ export function enabled(
8
+ ids: readonly string[],
9
+ registry: Readonly<Record<string, Ide>>
10
+ ): Ide[] {
11
+ return ids.map((id) => {
12
+ const ide = registry[id];
13
+ if (!ide) throw new Error(`Unsupported IDE: ${id}`);
14
+ return ide;
15
+ });
16
+ }
17
+
18
+ export async function settingsChecks(
19
+ ides: Ide[],
20
+ context: MachineContext
21
+ ): Promise<Check[]> {
22
+ const checks: Check[] = [];
23
+ for (const ide of ides) checks.push(...(await ide.settings(context)));
24
+ return checks;
25
+ }
26
+
27
+ export async function managerChecks(
28
+ ides: Ide[],
29
+ context: MachineContext
30
+ ): Promise<Check[]> {
31
+ const checks: Check[] = [];
32
+ for (const ide of ides) checks.push(...(await ide.manager(context)));
33
+ return checks;
34
+ }
35
+
36
+ export async function workerChecks(
37
+ ides: Ide[],
38
+ node: FleetNode,
39
+ context: WorkerContext
40
+ ): Promise<Check[]> {
41
+ const checks: Check[] = [];
42
+ for (const ide of ides)
43
+ checks.push(
44
+ ...(await ide.worker(node, {
45
+ ...context,
46
+ earlier: [...context.earlier, ...checks],
47
+ }))
48
+ );
49
+ return checks;
50
+ }
package/src/ide/t3.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import type { Check, FleetNode } from '../types';
4
+ import type { Ide, MachineContext, WorkerContext } from './ide';
5
+ import { fleet } from '../fleet-config';
6
+ import { machineProviders } from '../t3/providers';
7
+ import { providerNames } from '../t3/names-check';
8
+ import { profileCheck } from '../t3/profile';
9
+ import { modelsCheck } from '../t3/models';
10
+ import { proxyAccounts, type Roster } from '../t3/accounts';
11
+ import {
12
+ MACOS_POLICY,
13
+ managerVersion,
14
+ updatesCheck,
15
+ workerVersion,
16
+ } from '../t3/version';
17
+ import { verifyT3Endpoint } from '../t3-endpoint';
18
+ import { observedIdentity, verifyT3Linked } from '../t3-linked-check';
19
+ import { T3_RESTARTED_SUMMARY } from '../platform/t3-policy';
20
+ import { ansibleCommand } from '../platform/ansible';
21
+
22
+ /** Where T3 keeps its data on this machine. */
23
+ const t3Home = (): string =>
24
+ resolve(process.env['T3CODE_HOME'] ?? join(homedir(), '.t3'));
25
+
26
+ /** What the T3 checks read besides the machine: injected by scenarios. */
27
+ export interface T3Inputs {
28
+ t3Home?: string;
29
+ roster?: Roster;
30
+ profile?: Record<string, unknown>;
31
+ requiredModels?: Record<string, string[]>;
32
+ policyPath?: string;
33
+ }
34
+
35
+ /** T3 Code, the first supported IDE. Its install, service and policy on
36
+ * workers are the `ansible/ubuntu/t3*` playbooks and on the manager
37
+ * `ansible/macos/t3-policy.yml`; everything else is reached from here.
38
+ */
39
+ export function t3Ide(inputs: T3Inputs = {}): Ide {
40
+ const home = () => inputs.t3Home ?? t3Home();
41
+ const settings = async (context: MachineContext): Promise<Check[]> => {
42
+ const required = inputs.requiredModels ?? fleet.t3?.requiredModels;
43
+ const options = {
44
+ t3Home: home(),
45
+ ...(inputs.roster ? { roster: inputs.roster } : {}),
46
+ };
47
+ return [
48
+ await machineProviders(
49
+ context.id,
50
+ context.workspace,
51
+ context.apply,
52
+ options
53
+ ),
54
+ await providerNames(
55
+ context.id,
56
+ context.workspace,
57
+ context.apply,
58
+ options
59
+ ),
60
+ await profileCheck(
61
+ join(home(), 'userdata/settings.json'),
62
+ inputs.profile ?? fleet.t3?.profile ?? {},
63
+ { apply: context.apply }
64
+ ),
65
+ await modelsCheck({
66
+ t3Home: home(),
67
+ roster: inputs.roster ?? proxyAccounts,
68
+ ...(required ? { required } : {}),
69
+ }),
70
+ ];
71
+ };
72
+ return {
73
+ id: 't3',
74
+ settings,
75
+ async manager(context) {
76
+ return [
77
+ ...(await settings(context)),
78
+ await managerVersion(home(), context.workspace),
79
+ await updatesCheck(
80
+ inputs.policyPath ?? MACOS_POLICY,
81
+ `cd ${context.workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`
82
+ ),
83
+ ];
84
+ },
85
+ async worker(node: FleetNode, context: WorkerContext) {
86
+ const checks = [...(await verifyT3Endpoint(node, context.run))];
87
+ // A worker T3 restarted by this sync (idle policy heal) needs a minute
88
+ // or two before the manager's link reconnects.
89
+ const restarted = context.earlier.some(
90
+ (check) => check.summary === T3_RESTARTED_SUMMARY
91
+ );
92
+ const linked = await verifyT3Linked(node, context.run, {
93
+ reconnectMs: restarted ? 180_000 : 0,
94
+ });
95
+ checks.push(linked);
96
+ if (
97
+ linked.status !== 'fail' &&
98
+ !node.t3EnvironmentId &&
99
+ context.remember
100
+ ) {
101
+ const identity = await observedIdentity(node, context.run).catch(
102
+ () => undefined
103
+ );
104
+ if (identity)
105
+ await context.remember(node.id, identity).catch(() => undefined);
106
+ }
107
+ checks.push(await workerVersion(node, context.run, context.workspace));
108
+ return checks;
109
+ },
110
+ };
111
+ }
@@ -385,21 +385,7 @@ function mayDeploy(result: ManagerNode, nodes: FleetNode[]): boolean {
385
385
  */
386
386
  export function blocksPromotion(check: Check): boolean {
387
387
  if (!blocking(check)) return false;
388
- if (check.id === 't3:service' && T3_MAINTENANCE.has(check.summary))
389
- return false;
390
- return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|ubuntu:boot-policy$)/.test(
388
+ return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|t3:running-version$|ubuntu:boot-policy$)/.test(
391
389
  check.id
392
390
  );
393
391
  }
394
-
395
- /** Native T3 outcomes that need an operator maintenance window (an active
396
- * listener or running release must never be killed by routine sync). They are
397
- * reported as failures but do not block independent OS policy on other nodes.
398
- */
399
- const T3_MAINTENANCE = new Set([
400
- 'T3_LISTENER_CONFLICT',
401
- 'T3_ACTIVE_LISTENER_MIGRATION_REQUIRED',
402
- 'T3_MAINTENANCE_REQUIRED',
403
- 'T3_POLICY_RESTART_PENDING',
404
- 'T3_DESKTOP_MIGRATION_REQUIRED',
405
- ]);
@@ -6,12 +6,19 @@ import { verifyUbuntuBoot } from './platform/ubuntu-verify';
6
6
  import { verifyResourceSafety } from './platform/resource-verify';
7
7
  import { convergeT3Route, T3_ROUTE } from './platform/t3-route';
8
8
  import { convergeProxy } from './proxy-converge';
9
- import { providerNames } from './t3/names-check';
10
- import { machineProviders } from './t3/providers';
9
+ import { IDES } from './ide';
10
+ import { enabled, settingsChecks } from './ide/orchestrate';
11
+ import { idesOf } from './ide/definition';
12
+ import { fleet } from './fleet-config';
11
13
  import { quotaLimited, verifyFleetProviders } from './agents/fleet-verify';
12
14
  import { verifyNativeAgents } from './agents/native-verify';
15
+ import { accountContext, nodeAccountChecks } from './accounts/context';
13
16
  import { managedT3, managedT3Desktop } from '../bootstrap/native-t3';
14
- import { convergeT3Service, t3StartedAt } from './platform/t3-service';
17
+ import {
18
+ convergeT3Service,
19
+ runningVersionCheck,
20
+ t3StartedAt,
21
+ } from './platform/t3-service';
15
22
  import { userInfo } from 'node:os';
16
23
  import { resolve } from 'node:path';
17
24
  import { auditUbuntu } from './platform/ubuntu-audit';
@@ -74,12 +81,19 @@ export async function convergeLinux(
74
81
  verifyFleetProviders(node)
75
82
  );
76
83
  checks.push(...providers);
84
+ const accounts = await accountContext();
85
+ checks.push(
86
+ ...(await attempt('accounts:verification', () =>
87
+ nodeAccountChecks(accounts)
88
+ ))
89
+ );
77
90
  checks.push(
78
91
  ...(await attempt('agent:verification', () =>
79
92
  verifyNativeAgents(node, root, run, {
80
93
  outOfQuota: quotaLimited(providers),
81
94
  // Where T3 starts them: the environment of its user service.
82
95
  t3Service: true,
96
+ ...(accounts ? { accounts } : {}),
83
97
  })
84
98
  ))
85
99
  );
@@ -236,13 +250,20 @@ async function convergeT3(
236
250
  restarted: startedAt !== (await t3StartedAt(run)),
237
251
  })
238
252
  );
239
- // Settings are watched by T3: providers arrive and stale ones leave at once.
240
- checks.push(await machineProviders(node, root, true));
241
- // Names live in the same settings, so they conform without a restart.
242
- checks.push(await providerNames(node, root, true));
253
+ // Settings are watched by T3: providers, names and the profile apply
254
+ // without a restart.
255
+ checks.push(
256
+ ...(await settingsChecks(enabled(idesOf(fleet), IDES), {
257
+ id: node,
258
+ workspace: root,
259
+ apply: true,
260
+ run,
261
+ }))
262
+ );
243
263
  const serviceReady = checks.some(
244
264
  (check) => check.id === 't3:service' && check.status === 'pass'
245
265
  );
266
+ if (serviceReady) checks.push(await runningVersionCheck(run, userInfo().uid));
246
267
  if (serviceReady)
247
268
  checks.push(
248
269
  ...(await convergeT3Route(run, () => publishT3Route(root, run)))
@@ -176,19 +176,6 @@ export class DarwinPlatform implements Platform {
176
176
  );
177
177
  }
178
178
 
179
- vscodeSettingsPath(): string | null {
180
- return join(
181
- homedir(),
182
- 'Library/Application Support/Code/User/settings.json'
183
- );
184
- }
185
-
186
- vscodeCliBinaries(): readonly string[] {
187
- return [
188
- '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
189
- ].filter((p) => existsSync(p));
190
- }
191
-
192
179
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
193
180
  return installLaunchdJob(job);
194
181
  }
@@ -78,14 +78,6 @@ export class FallbackPlatform implements Platform {
78
78
  return [];
79
79
  }
80
80
 
81
- vscodeSettingsPath(): string | null {
82
- return null;
83
- }
84
-
85
- vscodeCliBinaries(): readonly string[] {
86
- return [];
87
- }
88
-
89
81
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
90
82
  return {
91
83
  ok: false,
@@ -115,16 +115,6 @@ export class LinuxPlatform implements Platform {
115
115
  );
116
116
  }
117
117
 
118
- vscodeSettingsPath(): string | null {
119
- const base =
120
- process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
121
- return join(base, 'Code/User/settings.json');
122
- }
123
-
124
- vscodeCliBinaries(): readonly string[] {
125
- return ['/usr/share/code/bin/code'].filter((p) => existsSync(p));
126
- }
127
-
128
118
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
129
119
  return installSystemdJob(job);
130
120
  }
@@ -1,6 +1,8 @@
1
1
  import { runAnsible } from './ansible';
2
2
  import { desired } from '../t3/desired';
3
3
  import policy from '../../ansible/ubuntu/t3/policy.json';
4
+ import ubuntu from '../../ansible/ubuntu/policy.json';
5
+ import { restartDeferral } from './t3-service';
4
6
  import { basename, dirname, join, resolve } from 'node:path';
5
7
  import { rename, rm, symlink } from 'node:fs/promises';
6
8
  import { userInfo } from 'node:os';
@@ -34,6 +36,7 @@ export async function stageT3Policy(
34
36
  t3_policy_document: document,
35
37
  development_user: user.username,
36
38
  development_uid: user.uid,
39
+ development_home: user.homedir,
37
40
  }),
38
41
  timeoutMs: 120_000,
39
42
  });
@@ -84,14 +87,21 @@ export const T3_RESTARTED_SUMMARY =
84
87
  'T3 was restarted to load the new managed policy; the running server confirms it.';
85
88
 
86
89
  /** `t3-policy.yml` restarts a running T3 that has not loaded the staged policy
87
- * (ending its running turns), so every node offers the same providers after
88
- * every sync. This measures the outcome; `restarted` says whether the app
89
- * restarted during this sync and so needs time to reconnect.
90
+ * so every node offers the same providers, but only while it runs no agent
91
+ * turn: a busy T3 keeps its turns and the play records the restart as
92
+ * pending, for personal-worker-recovery to apply at the first idle check.
93
+ * This measures the outcome; `restarted` says whether the app restarted
94
+ * during this sync and so needs time to reconnect.
90
95
  */
91
96
  export async function loadedT3Policy(
92
97
  executable: string,
93
98
  run: Runner,
94
- options: { restarted: boolean; pause?: () => Promise<void> }
99
+ options: {
100
+ restarted: boolean;
101
+ pause?: () => Promise<void>;
102
+ uid?: number;
103
+ now?: number;
104
+ }
95
105
  ): Promise<Check> {
96
106
  // A restarted desktop app takes a few seconds to come back with its server.
97
107
  const check = await verifyT3Policy(
@@ -103,8 +113,25 @@ export async function loadedT3Policy(
103
113
  setTimeout(done, options.restarted ? 5000 : 1000)
104
114
  ))
105
115
  );
106
- if (check.status !== 'pass')
107
- return { ...check, summary: 'T3_POLICY_RESTART_FAILED' };
116
+ if (check.status !== 'pass') {
117
+ const deferral = await restartDeferral(
118
+ run,
119
+ options.uid ?? userInfo().uid,
120
+ options.now
121
+ );
122
+ if (!deferral.pending)
123
+ return { ...check, summary: 'T3_POLICY_RESTART_FAILED' };
124
+ return {
125
+ ...check,
126
+ status: deferral.overdue ? 'fail' : 'warn',
127
+ summary: 'T3_POLICY_RESTART_PENDING',
128
+ remedy: {
129
+ action: deferral.overdue
130
+ ? `Agent turns kept T3 from loading the staged policy for ${ubuntu.t3RestartMaxDeferralHours} hours. Finish or stop them, or restart t3code-desktop.service on the worker (this ends its running turns).`
131
+ : 'Agent turns are running, so T3 keeps them and loads the staged policy at the first check of personal-worker-recovery with no turn running.',
132
+ },
133
+ };
134
+ }
108
135
  return options.restarted
109
136
  ? { ...check, summary: T3_RESTARTED_SUMMARY }
110
137
  : check;