@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
@@ -1,19 +1,38 @@
1
+ import {
2
+ AccountSelector,
3
+ accountRefused,
4
+ retryAfter,
5
+ } from '../accounts/selection';
6
+
1
7
  /** Pins one agent process to one provider account. The proxy routes a model
2
8
  * named `<prefix>/<model>` only to the credential that owns the prefix, but
3
9
  * agents send whichever model the user picked. This loopback relay lives only
4
10
  * as long as the agent it was started for: it adds the prefix to each request
5
11
  * and forwards everything else (credentials, streaming) unchanged.
12
+ *
13
+ * With an empty prefix it pins nothing: every launch goes through a relay, so
14
+ * every launch rides out a proxy restart the same way. `selector` then picks
15
+ * the account of each request by the fleet's selection policy.
6
16
  */
7
17
  export function startAccountRelay(
8
18
  endpoint: string,
9
- prefix: string
19
+ prefix: string,
20
+ options: Pick<RelayOptions, 'selector' | 'alternatives'> = {}
10
21
  ): { url: string; stop: () => void } {
22
+ const pinned =
23
+ prefix && !options.selector
24
+ ? new AccountSelector([prefix], { strategy: 'fill-first' })
25
+ : options.selector;
11
26
  const server = Bun.serve({
12
27
  hostname: '127.0.0.1',
13
28
  port: 0,
14
29
  // Model streams are idle for as long as a model thinks.
15
30
  idleTimeout: 0,
16
- fetch: (request) => forward(request, endpoint, prefix),
31
+ fetch: (request) =>
32
+ forward(request, endpoint, prefix, {
33
+ ...options,
34
+ ...(pinned ? { selector: pinned } : {}),
35
+ }),
17
36
  });
18
37
  return {
19
38
  url: `http://127.0.0.1:${server.port}`,
@@ -24,6 +43,11 @@ export function startAccountRelay(
24
43
  type RelayOptions = {
25
44
  send?: typeof fetch;
26
45
  pause?: (seconds: number) => Promise<void>;
46
+ /** Unpinned: the accounts to try in turn. Pinned: remembers cooldowns. */
47
+ selector?: AccountSelector;
48
+ /** Pinned: the other instances of the provider, named in its error. */
49
+ alternatives?: string[];
50
+ now?: () => number;
27
51
  };
28
52
  const sleep = (seconds: number) =>
29
53
  new Promise<void>((done) => setTimeout(done, seconds * 1000));
@@ -32,33 +56,54 @@ export async function forward(
32
56
  request: Request,
33
57
  endpoint: string,
34
58
  prefix: string,
35
- { send = fetch, pause = sleep }: RelayOptions = {}
59
+ options: RelayOptions = {}
36
60
  ): Promise<Response> {
37
61
  const url = new URL(request.url);
38
62
  const headers = new Headers(request.headers);
39
63
  // fetch sets these for the upstream request and decodes its response.
40
64
  for (const name of ['host', 'content-length', 'accept-encoding'])
41
65
  headers.delete(name);
42
- let body: string | undefined;
43
- if (request.body) {
44
- // An unreadable body could not be pinned; never forward it to the pool.
45
- if (headers.has('content-encoding'))
46
- return new Response('The account relay needs an uncompressed request.', {
47
- status: 415,
48
- });
49
- body = pinModel(await request.text(), prefix);
50
- }
51
- const attempt = () =>
52
- send(endpoint + url.pathname + url.search, {
53
- method: request.method,
54
- headers,
55
- ...(body === undefined ? {} : { body }),
56
- redirect: 'error',
57
- signal: request.signal,
66
+ // An unreadable body could not be pinned; never forward it to the pool.
67
+ if (request.body && headers.has('content-encoding'))
68
+ return new Response('The account relay needs an uncompressed request.', {
69
+ status: 415,
58
70
  });
59
- const upstream = await reachProxy(attempt, request.signal, pause);
60
- if (!upstream)
61
- return new Response('The CLI proxy is unreachable.', { status: 502 });
71
+ const raw = request.body ? await request.text() : undefined;
72
+ const reach = reacher(
73
+ request,
74
+ headers,
75
+ endpoint + url.pathname + url.search,
76
+ options
77
+ );
78
+ const { selector } = options;
79
+ const reached =
80
+ !prefix && selector && raw !== undefined
81
+ ? await failOver(selector, raw, reach)
82
+ : await pinnedReach(prefix, raw, reach, options);
83
+ if (reached instanceof Response) return reached;
84
+ return answer(reached);
85
+ }
86
+
87
+ /** Sends one body to the proxy, retrying while the route is down. */
88
+ function reacher(
89
+ request: Request,
90
+ headers: Headers,
91
+ target: string,
92
+ { send = fetch, pause = sleep, now = Date.now }: RelayOptions
93
+ ): (body: string | undefined) => Promise<Reached> {
94
+ return (body) =>
95
+ reachProxy(
96
+ () => send(target, upstreamInit(request, headers, body)),
97
+ request.signal,
98
+ pause,
99
+ now
100
+ );
101
+ }
102
+
103
+ /** The proxy's answer, decoded, or the distinct "unreachable" error. */
104
+ function answer(reached: Reached): Response {
105
+ const upstream = reached.response;
106
+ if (!upstream) return unreachable(reached.since);
62
107
  const answered = new Headers(upstream.headers);
63
108
  answered.delete('content-encoding');
64
109
  answered.delete('content-length');
@@ -68,6 +113,109 @@ export async function forward(
68
113
  });
69
114
  }
70
115
 
116
+ /** A pinned account (or no pinning at all). A pinned account the provider
117
+ * limited answers at once, until its cooldown clears, with the time and the
118
+ * other instances of its provider.
119
+ */
120
+ async function pinnedReach(
121
+ prefix: string,
122
+ raw: string | undefined,
123
+ reach: (body: string | undefined) => Promise<Reached>,
124
+ { selector, alternatives = [] }: RelayOptions
125
+ ): Promise<Reached | Response> {
126
+ const cooling = prefix && selector ? selector.coolingUntil(prefix) : null;
127
+ if (cooling !== null) return coolingDown(cooling, alternatives);
128
+ const reached = await reach(
129
+ raw !== undefined && prefix ? pinModel(raw, prefix) : raw
130
+ );
131
+ if (!prefix || !selector || reached.response?.status !== 429) return reached;
132
+ selector.limited(prefix, retryAfter(reached.response.headers));
133
+ await reached.response.body?.cancel().catch(() => undefined);
134
+ return coolingDown(selector.coolingUntil(prefix)!, alternatives);
135
+ }
136
+
137
+ type Reached = { response: Response | null; since: number };
138
+
139
+ function upstreamInit(
140
+ request: Request,
141
+ headers: Headers,
142
+ body: string | undefined
143
+ ): RequestInit {
144
+ return {
145
+ method: request.method,
146
+ headers,
147
+ ...(body === undefined ? {} : { body }),
148
+ redirect: 'error',
149
+ signal: request.signal,
150
+ };
151
+ }
152
+
153
+ /** An unpinned request goes to the first usable account by the fleet's
154
+ * policy; a quota or authentication refusal sends it on to the next. With
155
+ * every account cooling down, the proxy's own pool serves it.
156
+ */
157
+ async function failOver(
158
+ selector: AccountSelector,
159
+ raw: string,
160
+ reach: (body: string | undefined) => Promise<Reached>
161
+ ): Promise<Reached> {
162
+ const candidates = selector.candidates();
163
+ if (candidates.length === 0) return reach(raw);
164
+ let reached!: Reached;
165
+ for (const [position, candidate] of candidates.entries()) {
166
+ reached = await reach(pinModel(raw, candidate));
167
+ const status = reached.response?.status;
168
+ if (status === undefined || !accountRefused(status)) return reached;
169
+ selector.limited(candidate, retryAfter(reached.response!.headers));
170
+ if (position < candidates.length - 1)
171
+ await reached.response!.body?.cancel().catch(() => undefined);
172
+ }
173
+ return reached;
174
+ }
175
+
176
+ /** One error both Anthropic and OpenAI clients show as a message. */
177
+ function agentError(
178
+ status: number,
179
+ type: string,
180
+ message: string,
181
+ retry: number
182
+ ) {
183
+ return new Response(
184
+ JSON.stringify({ type: 'error', error: { type, message } }),
185
+ {
186
+ status,
187
+ headers: {
188
+ 'content-type': 'application/json',
189
+ 'retry-after': String(Math.max(1, Math.ceil(retry))),
190
+ },
191
+ }
192
+ );
193
+ }
194
+
195
+ function coolingDown(until: number, alternatives: string[]): Response {
196
+ const others = alternatives.length
197
+ ? ` Pick another instance of this provider in T3: ${alternatives.join(', ')}.`
198
+ : '';
199
+ return agentError(
200
+ 429,
201
+ 'rate_limit_error',
202
+ `This account is cooling down until ${new Date(until).toISOString()}.${others}`,
203
+ (until - Date.now()) / 1000
204
+ );
205
+ }
206
+
207
+ /** The proxy did not answer within the retry window: a distinct, retryable
208
+ * error that says since when, never a provider's error.
209
+ */
210
+ function unreachable(since: number): Response {
211
+ return agentError(
212
+ 503,
213
+ 'proxy_unreachable',
214
+ `The CLI proxy has been unreachable since ${new Date(since).toISOString()}. This is retryable; nothing is wrong with the account.`,
215
+ 30
216
+ );
217
+ }
218
+
71
219
  /** Seconds to wait before each further attempt (about a minute in total). */
72
220
  export const RETRY_DELAYS = [1, 2, 4, 8, 15, 30];
73
221
  /** The route to the proxy answered instead of the proxy: its connector or the
@@ -83,13 +231,15 @@ const routeDown = (status: number) =>
83
231
  async function reachProxy(
84
232
  attempt: () => Promise<Response>,
85
233
  signal: AbortSignal,
86
- pause: (seconds: number) => Promise<void>
87
- ): Promise<Response | null> {
234
+ pause: (seconds: number) => Promise<void>,
235
+ now: () => number
236
+ ): Promise<Reached> {
237
+ const since = now();
88
238
  for (let tries = 0; ; tries++) {
89
239
  const delay = RETRY_DELAYS[tries];
90
240
  const response = await attempt().catch(() => null);
91
- if (response && !routeDown(response.status)) return response;
92
- if (delay === undefined || signal.aborted) return response;
241
+ if (response && !routeDown(response.status)) return { response, since };
242
+ if (delay === undefined || signal.aborted) return { response, since };
93
243
  await response?.body?.cancel().catch(() => undefined);
94
244
  await pause(delay);
95
245
  }
@@ -6,6 +6,9 @@ import { accountPrefix, accountSlug, discoverAccounts } from './accounts';
6
6
  import { startAccountRelay } from './account-relay';
7
7
  import { proxyKey } from './secrets';
8
8
  import { pinnedBinary, pinnedPath } from './toolchain';
9
+ import { AccountSelector } from '../accounts/selection';
10
+ import { fleet } from '../fleet-config';
11
+ import type { AccountSelection } from '../definition';
9
12
 
10
13
  /** `account` (its email) pins a proxy launch to one signed-in proxy account. */
11
14
  export async function launchProfile(
@@ -43,9 +46,11 @@ export async function launchProfile(
43
46
  }
44
47
  toolEnvironment(env, selection, directory);
45
48
  const pinned =
46
- account && selection.profile === 'proxy'
47
- ? await pinAccount(env, selection, { account, directory })
48
- : null;
49
+ selection.profile !== 'proxy'
50
+ ? null
51
+ : account
52
+ ? await pinAccount(env, selection, { account, directory })
53
+ : await routeThroughRelay(env, selection);
49
54
  try {
50
55
  return await spawnAgent(selection, directory, env, [
51
56
  pinned?.args ?? [],
@@ -96,9 +101,14 @@ export async function pinAccount(
96
101
  ): Promise<{ args: string[]; stop: () => void }> {
97
102
  const prefix = accountPrefix(selection.tool, account);
98
103
  // What the account serves right now, preferred default first.
99
- const served = (await discover(env['CLIPROXY_API_KEY'] ?? '')).find(
104
+ const roster = await discover(env['CLIPROXY_API_KEY'] ?? '');
105
+ const served = roster.find(
100
106
  (entry) => entry.tool === selection.tool && entry.email === account
101
107
  );
108
+ // Named in this account's "cooling down" error, so the person can switch.
109
+ const alternatives = roster
110
+ .filter((entry) => entry.tool === selection.tool && entry.email !== account)
111
+ .map((entry) => entry.email);
102
112
  if (!served)
103
113
  throw new AgentConfigurationError(
104
114
  'This account is not signed in on the CLI proxy.'
@@ -108,7 +118,9 @@ export async function pinAccount(
108
118
  env['OPENCODE_CONFIG_CONTENT'] = JSON.stringify(accountOpenCode(served));
109
119
  return { args: [], stop: () => {} };
110
120
  }
111
- const { url, stop } = relay(registry.proxy.endpoint, prefix);
121
+ const { url, stop } = relay(registry.proxy.endpoint, prefix, {
122
+ alternatives,
123
+ });
112
124
  if (selection.tool === 'claude') {
113
125
  env['ANTHROPIC_BASE_URL'] = url;
114
126
  // Aliases and the default must name models a Claude account serves.
@@ -146,6 +158,73 @@ export async function pinAccount(
146
158
  };
147
159
  }
148
160
 
161
+ /** Which tools reach the proxy through an address this wrapper sets, and so
162
+ * can be sent through a relay. OpenCode and Grok read theirs from rendered
163
+ * configuration and still go to the proxy directly.
164
+ */
165
+ const RELAYED = new Set(['claude', 'codex', 'gemini', 'antigravity']);
166
+
167
+ /** An unpinned launch goes through a relay as well: it retries a proxy that
168
+ * is restarting exactly as a pinned one does, reports "proxy unreachable"
169
+ * when it stays down, and picks each request's account by the fleet's
170
+ * selection policy when it declares one for the provider.
171
+ */
172
+ export async function routeThroughRelay(
173
+ env: NodeJS.ProcessEnv,
174
+ selection: Selection,
175
+ {
176
+ relay = startAccountRelay,
177
+ discover = discoverAccounts,
178
+ policy = selectionPolicy,
179
+ } = {}
180
+ ): Promise<{ args: string[]; stop: () => void } | null> {
181
+ if (!RELAYED.has(selection.tool)) return null;
182
+ const selector = await policySelector(env, selection, discover, policy);
183
+ const { url, stop } = relay(
184
+ registry.proxy.endpoint,
185
+ '',
186
+ selector ? { selector } : {}
187
+ );
188
+ if (selection.tool === 'claude') env['ANTHROPIC_BASE_URL'] = url;
189
+ if (selection.tool === 'gemini' || selection.tool === 'antigravity')
190
+ env['GOOGLE_GEMINI_BASE_URL'] = url;
191
+ const args =
192
+ selection.tool === 'codex'
193
+ ? [
194
+ '-c',
195
+ `model_providers.personal_proxy.base_url=${JSON.stringify(`${url}/v1`)}`,
196
+ ]
197
+ : [];
198
+ return { args, stop };
199
+ }
200
+
201
+ /** The fleet's selection policy for the tool's provider, over the accounts
202
+ * the proxy lists. The list is read only when a policy needs it; an
203
+ * unreadable list leaves the choice to the proxy's own pool.
204
+ */
205
+ async function policySelector(
206
+ env: NodeJS.ProcessEnv,
207
+ selection: Selection,
208
+ discover: typeof discoverAccounts,
209
+ policy: (provider: string) => AccountSelection | undefined
210
+ ): Promise<AccountSelector | undefined> {
211
+ const route = (
212
+ registry.proxy.accounts as Record<string, { provider: string }>
213
+ )[selection.tool];
214
+ const declared = route ? policy(route.provider) : undefined;
215
+ if (!declared) return undefined;
216
+ const accounts = await discover(env['CLIPROXY_API_KEY'] ?? '').catch(
217
+ () => []
218
+ );
219
+ const prefixes = accounts
220
+ .filter((account) => account.tool === selection.tool)
221
+ .map((account) => account.prefix);
222
+ return prefixes.length ? new AccountSelector(prefixes, declared) : undefined;
223
+ }
224
+
225
+ const selectionPolicy = (provider: string): AccountSelection | undefined =>
226
+ fleet.services.proxy.accounts?.selection?.[provider];
227
+
149
228
  /** OpenCode configuration that offers only one account's models. */
150
229
  function accountOpenCode(route: { prefix: string; models: string[] }) {
151
230
  const models = route.models.map((model) => `${route.prefix}/${model}`);
@@ -4,6 +4,13 @@ import { join } from 'node:path';
4
4
  import type { Check, Runner } from '../types';
5
5
  import { pinnedPath } from './toolchain';
6
6
  import { registry } from './registry';
7
+ import type { Account } from './accounts';
8
+ import { accountSlug } from './accounts';
9
+ import {
10
+ accountSessionChecks,
11
+ sessionResult,
12
+ type Instance,
13
+ } from '../accounts/sessions';
7
14
  import {
8
15
  cacheKey,
9
16
  cachedCheck,
@@ -98,6 +105,11 @@ export async function verifyNativeAgents(
98
105
  outOfQuota?: Set<string>;
99
106
  cache?: VerifyCache;
100
107
  t3Service?: boolean;
108
+ /** The proxy's accounts and what this node offers. Every cached result
109
+ * is keyed on the roster version and the client key's version, so any
110
+ * account change or key rotation re-verifies at once.
111
+ */
112
+ accounts?: AccountContext;
101
113
  } = {}
102
114
  ): Promise<Check[]> {
103
115
  const { outOfQuota = new Set<string>(), cache = {} } = options;
@@ -122,7 +134,9 @@ export async function verifyNativeAgents(
122
134
  agent,
123
135
  pins[agent.pin] ?? '',
124
136
  registry,
125
- await sourceKey(launcherSources(workspace, agent))
137
+ await sourceKey(launcherSources(workspace, agent)),
138
+ options.accounts?.rosterVersion ?? '',
139
+ options.accounts?.keyVersion ?? ''
126
140
  ),
127
141
  () =>
128
142
  sessionCheck(agent, { node, workspace, cwd, launch }, run),
@@ -130,12 +144,124 @@ export async function verifyNativeAgents(
130
144
  ),
131
145
  ])
132
146
  );
133
- return [...environment.checks, ...results.flat()];
147
+ const accounts =
148
+ options.accounts && launch.kind !== 'incomplete'
149
+ ? await accountSessionChecks(
150
+ options.accounts.live,
151
+ options.accounts.exposed,
152
+ (instance) =>
153
+ accountSession(instance, options.accounts!, {
154
+ node,
155
+ workspace,
156
+ cwd,
157
+ launch,
158
+ cache,
159
+ run,
160
+ })
161
+ )
162
+ : [];
163
+ return [...environment.checks, ...results.flat(), ...accounts];
134
164
  } finally {
135
165
  await rm(cwd, { recursive: true, force: true });
136
166
  }
137
167
  }
138
168
 
169
+ export interface AccountContext {
170
+ live: Account[];
171
+ exposed: Instance[];
172
+ rosterVersion: string;
173
+ keyVersion: string;
174
+ }
175
+
176
+ /** How a per-account instance is launched: its tool's launcher, pinned to
177
+ * the account as T3 pins it.
178
+ */
179
+ const ACCOUNT_LAUNCH: Record<
180
+ string,
181
+ { wrapper: string; launcher: string; args: string[] }
182
+ > = {
183
+ claude: {
184
+ wrapper: 'claude-proxy.ts',
185
+ launcher: 'claude-proxy',
186
+ args: ['-p', PROMPT],
187
+ },
188
+ codex: {
189
+ wrapper: 'codex-proxy.ts',
190
+ launcher: 'codex-proxy.ts',
191
+ args: ['exec', '--skip-git-repo-check', PROMPT],
192
+ },
193
+ opencode: {
194
+ wrapper: 'opencode-proxy.ts',
195
+ launcher: 'opencode-proxy.ts',
196
+ args: ['run', PROMPT],
197
+ },
198
+ };
199
+
200
+ async function accountSession(
201
+ instance: Instance,
202
+ accounts: AccountContext,
203
+ context: {
204
+ node: string;
205
+ workspace: string;
206
+ cwd: string;
207
+ launch: Launch;
208
+ cache: VerifyCache;
209
+ run: Runner;
210
+ }
211
+ ): Promise<'ok' | 'cooling-down' | 'failed'> {
212
+ const spec = ACCOUNT_LAUNCH[instance.tool];
213
+ if (!spec) return 'failed';
214
+ const check = await cachedCheck(
215
+ `account-${context.node}-${instance.tool}-${accountSlug(instance.account)}`,
216
+ cacheKey(
217
+ context.node,
218
+ instance,
219
+ spec,
220
+ accounts.rosterVersion,
221
+ accounts.keyVersion
222
+ ),
223
+ async () => {
224
+ const agents = join(context.workspace, 'agents');
225
+ const service = context.launch.kind === 'service';
226
+ const result = await context.run(
227
+ service
228
+ ? [join(agents, spec.launcher), ...spec.args]
229
+ : ['bun', join(agents, spec.wrapper), ...spec.args],
230
+ {
231
+ cwd: context.cwd,
232
+ env: {
233
+ ...(context.launch.kind === 'service'
234
+ ? context.launch.env
235
+ : { PATH: pinnedPath() }),
236
+ WS_AGENT_NODE: context.node,
237
+ WS_AGENT_ACCOUNT: instance.account,
238
+ },
239
+ ...(service ? { isolatedEnv: true } : {}),
240
+ input: '',
241
+ timeoutMs: 150_000,
242
+ }
243
+ );
244
+ const outcome = sessionResult(result.ok, result.stdout + result.stderr);
245
+ return {
246
+ id: 'account-session',
247
+ status:
248
+ outcome === 'ok'
249
+ ? 'pass'
250
+ : outcome === 'cooling-down'
251
+ ? 'warn'
252
+ : 'fail',
253
+ summary: outcome,
254
+ };
255
+ },
256
+ context.cache
257
+ );
258
+ return check.status === 'pass'
259
+ ? 'ok'
260
+ : check.status === 'warn'
261
+ ? 'cooling-down'
262
+ : 'failed';
263
+ }
264
+
139
265
  /** Reads `tool = "x"` and `"tool" = { version = "x", ... }` pins. */
140
266
  export function parsePins(source: string): Record<string, string> {
141
267
  const pins: Record<string, string> = {};
@@ -5,6 +5,7 @@ import { record } from './config';
5
5
  import { codingAuth, serviceAuth } from './service-auth';
6
6
  import { workstationChecks } from './workstation-checks';
7
7
  import { toolchainChecks } from './toolchain-checks';
8
+ import { workerSelection } from '../bootstrap/worker-tools';
8
9
  import type { Capability, Check, Runner, CommandResult } from './types';
9
10
 
10
11
  export async function authCheck(
@@ -24,6 +25,8 @@ export async function authCheck(
24
25
  root,
25
26
  run,
26
27
  definition.workers.some((node) => node.id === nodeId)
28
+ ? workerSelection(definition, nodeId)
29
+ : undefined
27
30
  );
28
31
  return basicAuth(capability, run, root);
29
32
  }
@@ -63,7 +63,7 @@ export function agentGuide(release: Release) {
63
63
  'devfleet --interactive (explicit opt-in; TTY required, disabled in CI)',
64
64
  groups: {
65
65
  'fleet-*':
66
- 'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
66
+ 'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-report, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
67
67
  't3-*': 'Declared T3 provider instances: t3-status, t3-apply, t3-cleanup',
68
68
  cliproxy: 'The CLI proxy service on the manager; arguments are forwarded',
69
69
  },
@@ -89,9 +89,11 @@ export function agentGuide(release: Release) {
89
89
  '130': 'Cancelled',
90
90
  },
91
91
  errors:
92
- '{version:1,ok:false,code,error}; raw subprocess output and exception details are withheld',
92
+ 'schema/output/error.schema.json: {version:1,ok:false,code,error}; raw subprocess output and exception details are withheld',
93
93
  reports:
94
- '{version:1,command,ok,generatedAt,nodes:[{node,checks:[{id,status,summary,remedy?}]}],summary,coverage}',
94
+ 'Each output has a JSON schema in this package; parse by it and by its version. Diagnostic reports (fleet-status, fleet-state, fleet-repair): schema/output/report.schema.json, version 1. fleet-sync: schema/output/fleet-sync.schema.json, version 3 (a --dry-run adds plan). fleet-report: schema/output/fleet-report.schema.json, version 1.',
95
+ checks:
96
+ 'schema/check-ids.json lists every check ID with what a passing check means; * stands for a name. Each failing check carries its own remedy.',
95
97
  statuses: ['pass', 'warn', 'fail', 'skip'],
96
98
  coverage:
97
99
  'Default fleet-status tests the manager and all configured nodes. --local tests only this manager; --tailnet and --workers cover Tailscale computers. A missing roster fails explicitly. T3/VS Code end-to-end checks remain manual.',