@phnx-labs/agents-cli 1.22.98 → 1.22.100
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/CHANGELOG.md +14 -0
- package/README.md +16 -7
- package/dist/commands/setup-secrets.d.ts +18 -11
- package/dist/commands/setup-secrets.js +50 -18
- package/dist/lib/account-catalog.js +20 -4
- package/dist/lib/accounts/slots.d.ts +19 -0
- package/dist/lib/accounts/slots.js +61 -1
- package/dist/lib/auth-health.d.ts +79 -5
- package/dist/lib/auth-health.js +133 -33
- package/dist/lib/browser/arc-discovery.d.ts +1 -1
- package/dist/lib/browser/arc-discovery.js +14 -4
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -4
- package/dist/lib/daemon/auth-sync-service.js +49 -21
- package/dist/lib/daemon/usage-sync-service.d.ts +26 -7
- package/dist/lib/daemon/usage-sync-service.js +35 -8
- package/dist/lib/daemon-ticks.d.ts +22 -5
- package/dist/lib/daemon-ticks.js +38 -13
- package/dist/lib/fleet-shared-repo-sync.d.ts +7 -0
- package/dist/lib/fleet-shared-repo-sync.js +66 -3
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/reconcile-and-repair.d.ts +8 -0
- package/dist/lib/reconcile-and-repair.js +32 -1
- package/package.json +1 -1
package/dist/lib/auth-health.js
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as path from 'path';
|
|
21
21
|
import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
|
|
22
|
-
import {
|
|
22
|
+
import { listNativeAccounts } from './account-registry.js';
|
|
23
|
+
import { readSlots } from './accounts/slots.js';
|
|
24
|
+
import { getCacheDir, readMeta } from './state.js';
|
|
23
25
|
import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
|
|
24
26
|
import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
|
|
25
27
|
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
@@ -218,6 +220,71 @@ export function authAccountLabel(info) {
|
|
|
218
220
|
export function authCacheKey(host, agent, version) {
|
|
219
221
|
return `${host}:${agent}:${version}`;
|
|
220
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Host-independent identity of one probe target — the (agent, version) pair
|
|
225
|
+
* {@link authCacheKey} is keyed by. The separator cannot appear in either half,
|
|
226
|
+
* so an agent id can never run into a version the way a `:` join allows.
|
|
227
|
+
*/
|
|
228
|
+
export function authTargetKey(agent, version) {
|
|
229
|
+
return `${agent}@${version}`;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The `version` slot an account SLOT occupies in the auth cache and the probe
|
|
233
|
+
* rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
|
|
234
|
+
* version, so it needs its own key or its verdict would collide with (or be
|
|
235
|
+
* silently dropped in favor of) whichever version home the account's label
|
|
236
|
+
* happened to match.
|
|
237
|
+
*/
|
|
238
|
+
export function slotAuthVersionKey(accountId) {
|
|
239
|
+
return `slot:${accountId}`;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Every registered account's slot on this device (PHNX-3940 T1), as probe
|
|
243
|
+
* targets. Before this the probe walked `listInstalledVersions` only, so a
|
|
244
|
+
* slot's verdict was never re-derived after `accounts add/login` wrote it —
|
|
245
|
+
* a slot re-materialized as `unconfigured` while the device doc was unreadable
|
|
246
|
+
* stayed MISSING forever even though its login was live. A slot whose dir is
|
|
247
|
+
* gone is skipped: there is nothing to probe and the row would only say so.
|
|
248
|
+
*/
|
|
249
|
+
export function enumerateSlotInstalls(meta, agentIds) {
|
|
250
|
+
const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const [accountId, slot] of Object.entries(readSlots(meta))) {
|
|
253
|
+
const account = byId.get(accountId);
|
|
254
|
+
if (!account || !agentIds.includes(account.agent))
|
|
255
|
+
continue;
|
|
256
|
+
if (!fs.existsSync(slot.slotDir))
|
|
257
|
+
continue;
|
|
258
|
+
out.push({ agent: account.agent, version: slotAuthVersionKey(accountId), home: slot.slotDir, account: undefined, accountId });
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Every (agent, version) home the local auth probe covers on this device —
|
|
264
|
+
* installed version homes plus account slots. {@link probeLocalFleetAuth} probes
|
|
265
|
+
* exactly this set, so it is also the answer to "which cached rows are still
|
|
266
|
+
* backed by something on disk" (PHNX-4051).
|
|
267
|
+
*/
|
|
268
|
+
export function enumerateLocalAuthInstalls(meta, agentIds) {
|
|
269
|
+
const installs = [];
|
|
270
|
+
for (const agent of agentIds) {
|
|
271
|
+
for (const version of listInstalledVersions(agent)) {
|
|
272
|
+
installs.push({ agent, version, home: getVersionHomePath(agent, version), account: undefined });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const slot of enumerateSlotInstalls(meta, agentIds))
|
|
276
|
+
installs.push(slot);
|
|
277
|
+
return installs;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* {@link authTargetKey} for every local probe target — what a cached row must
|
|
281
|
+
* match to still be about this device. A row outside it is an ORPHAN: its home
|
|
282
|
+
* was uninstalled, so nothing will ever re-probe it and its `checkedAt` is
|
|
283
|
+
* frozen at whatever the last probe left (PHNX-4051).
|
|
284
|
+
*/
|
|
285
|
+
export function localAuthTargetKeys(agentIds = ALL_AGENT_IDS) {
|
|
286
|
+
return new Set(enumerateLocalAuthInstalls(readMeta(), agentIds).map((i) => authTargetKey(i.agent, i.version)));
|
|
287
|
+
}
|
|
221
288
|
function cacheFilePath() {
|
|
222
289
|
return path.join(getCacheDir(), '.auth-health.json');
|
|
223
290
|
}
|
|
@@ -237,23 +304,34 @@ export function readAuthHealthCache() {
|
|
|
237
304
|
export function readAuthHealth(host, agent, version) {
|
|
238
305
|
return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
|
|
239
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Split a cache key back into the install it names, for one host. Returns null
|
|
309
|
+
* for a key that belongs to another host or does not name a known agent — the
|
|
310
|
+
* one place the `host:agent:version` join is undone.
|
|
311
|
+
*/
|
|
312
|
+
export function parseAuthCacheKey(key, host) {
|
|
313
|
+
const prefix = `${host}:`;
|
|
314
|
+
if (!key.startsWith(prefix))
|
|
315
|
+
return null;
|
|
316
|
+
const identity = key.slice(prefix.length);
|
|
317
|
+
const separator = identity.indexOf(':');
|
|
318
|
+
if (separator <= 0)
|
|
319
|
+
return null;
|
|
320
|
+
const agent = identity.slice(0, separator);
|
|
321
|
+
if (!ALL_AGENT_IDS.includes(agent))
|
|
322
|
+
return null;
|
|
323
|
+
return { agent: agent, version: identity.slice(separator + 1) };
|
|
324
|
+
}
|
|
240
325
|
/** Reconstruct one host's published probe rows for a lease waiter/CLI reader. */
|
|
241
326
|
export function readFleetAuthRows(host) {
|
|
242
|
-
const prefix = `${host}:`;
|
|
243
327
|
const rows = [];
|
|
244
328
|
for (const [key, health] of Object.entries(readAuthHealthCache())) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const identity = key.slice(prefix.length);
|
|
248
|
-
const separator = identity.indexOf(':');
|
|
249
|
-
if (separator <= 0)
|
|
250
|
-
continue;
|
|
251
|
-
const agent = identity.slice(0, separator);
|
|
252
|
-
if (!ALL_AGENT_IDS.includes(agent))
|
|
329
|
+
const install = parseAuthCacheKey(key, host);
|
|
330
|
+
if (!install)
|
|
253
331
|
continue;
|
|
254
332
|
rows.push({
|
|
255
|
-
agent: agent,
|
|
256
|
-
version:
|
|
333
|
+
agent: install.agent,
|
|
334
|
+
version: install.version,
|
|
257
335
|
account: health.account,
|
|
258
336
|
accountId: health.accountId,
|
|
259
337
|
health,
|
|
@@ -277,15 +355,26 @@ export function mergeAuthHealthEntries(current, incoming) {
|
|
|
277
355
|
}
|
|
278
356
|
return merged;
|
|
279
357
|
}
|
|
280
|
-
/**
|
|
281
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Merge one or more entries into the cache (best-effort write).
|
|
360
|
+
*
|
|
361
|
+
* `drop` removes entries the writer knows are gone — it runs on the PRE-merge
|
|
362
|
+
* cache, so an incoming row always wins over a drop of the same key and the two
|
|
363
|
+
* can never fight. Only a writer that knows the full truth for the keys it drops
|
|
364
|
+
* may pass one (see {@link writeFleetAuthRows}).
|
|
365
|
+
*/
|
|
366
|
+
export function writeAuthHealthEntries(entries, drop) {
|
|
282
367
|
try {
|
|
283
368
|
const target = cacheFilePath();
|
|
284
369
|
ensureLockTarget(target, JSON.stringify({ version: 1, entries: {} }));
|
|
285
370
|
withFileLock(target, () => {
|
|
371
|
+
let current = readAuthHealthCache();
|
|
372
|
+
if (drop) {
|
|
373
|
+
current = Object.fromEntries(Object.entries(current).filter(([key]) => !drop(key)));
|
|
374
|
+
}
|
|
286
375
|
const merged = {
|
|
287
376
|
version: 1,
|
|
288
|
-
entries: mergeAuthHealthEntries(
|
|
377
|
+
entries: mergeAuthHealthEntries(current, entries),
|
|
289
378
|
};
|
|
290
379
|
atomicWriteFileSync(target, JSON.stringify(merged, null, 2));
|
|
291
380
|
});
|
|
@@ -410,26 +499,20 @@ export function groupFleetAuthInstalls(installs, isMergeable = () => true) {
|
|
|
410
499
|
*/
|
|
411
500
|
export async function probeLocalFleetAuth(opts) {
|
|
412
501
|
const agentIds = opts?.agents ?? ALL_AGENT_IDS;
|
|
413
|
-
// Enumerate every install
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
}
|
|
502
|
+
// Enumerate every install AND every account slot on this device, then resolve
|
|
503
|
+
// each one's account label. getAccountInfo is a local credential-file read
|
|
504
|
+
// (no network), so this fan-out is cheap and cannot contribute to the rate
|
|
505
|
+
// limit the probe grouping below exists to avoid.
|
|
506
|
+
const { findNativeAccountByIdentity } = await import('./account-registry.js');
|
|
507
|
+
const meta = readMeta();
|
|
508
|
+
const installs = enumerateLocalAuthInstalls(meta, agentIds).map((inst) => ({ ...inst, info: null }));
|
|
422
509
|
await Promise.all(installs.map(async (inst) => {
|
|
423
510
|
inst.info = await getAccountInfo(inst.agent, inst.home).catch(() => null);
|
|
424
511
|
inst.account = authAccountLabel(inst.info);
|
|
425
512
|
}));
|
|
426
|
-
const [{ readMeta }, { findNativeAccountByIdentity }] = await Promise.all([
|
|
427
|
-
import('./state.js'),
|
|
428
|
-
import('./account-registry.js'),
|
|
429
|
-
]);
|
|
430
|
-
const meta = readMeta();
|
|
431
513
|
for (const inst of installs) {
|
|
432
|
-
|
|
514
|
+
// A slot already knows its account; a version home is joined by identity.
|
|
515
|
+
inst.accountId ??= findNativeAccountByIdentity(meta, inst.agent, inst.info)?.id;
|
|
433
516
|
}
|
|
434
517
|
// Probe once per (agent, account) — but only for the network-probing agents
|
|
435
518
|
// that can actually 429; best-effort agents stay per-install (see
|
|
@@ -453,11 +536,28 @@ export async function probeLocalFleetAuth(opts) {
|
|
|
453
536
|
}));
|
|
454
537
|
return perGroup.flat();
|
|
455
538
|
}
|
|
456
|
-
/**
|
|
457
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Persist a host's probed rows into the cache (keyed by host+agent+version).
|
|
541
|
+
*
|
|
542
|
+
* `installed` — the {@link authTargetKey} set of homes that still exist on
|
|
543
|
+
* `host` ({@link localAuthTargetKeys}) — prunes this host's ORPHAN entries: rows
|
|
544
|
+
* for a version that has since been uninstalled. Nothing re-probes those, so
|
|
545
|
+
* they never age out on their own; they froze the reuse window permanently false
|
|
546
|
+
* and kept surfacing in `agents view` / fleet status (PHNX-4051). Only a caller
|
|
547
|
+
* that enumerated `host`'s real installs may pass it — the `fleet ping` fan-out
|
|
548
|
+
* writes a PEER's rows and cannot enumerate that box's homes, so it omits it and
|
|
549
|
+
* merges as before.
|
|
550
|
+
*/
|
|
551
|
+
export function writeFleetAuthRows(host, rows, installed) {
|
|
458
552
|
const entries = {};
|
|
459
553
|
for (const row of rows) {
|
|
460
554
|
entries[authCacheKey(host, row.agent, row.version)] = row.health;
|
|
461
555
|
}
|
|
462
|
-
|
|
556
|
+
const drop = installed
|
|
557
|
+
? (key) => {
|
|
558
|
+
const install = parseAuthCacheKey(key, host);
|
|
559
|
+
return install != null && !installed.has(authTargetKey(install.agent, install.version));
|
|
560
|
+
}
|
|
561
|
+
: undefined;
|
|
562
|
+
writeAuthHealthEntries(entries, drop);
|
|
463
563
|
}
|
|
@@ -65,6 +65,13 @@ function parseProfileId(value, spaceId) {
|
|
|
65
65
|
}
|
|
66
66
|
return profileId;
|
|
67
67
|
}
|
|
68
|
+
function parseTitle(value, spaceId) {
|
|
69
|
+
if (value === undefined || value === null)
|
|
70
|
+
return '';
|
|
71
|
+
if (typeof value === 'string')
|
|
72
|
+
return value;
|
|
73
|
+
return { error: `Arc Space ${JSON.stringify(spaceId)} has a non-text title` };
|
|
74
|
+
}
|
|
68
75
|
export function parseSidebarSpaces(sidebarPath) {
|
|
69
76
|
let parsed;
|
|
70
77
|
try {
|
|
@@ -101,9 +108,12 @@ export function parseSidebarSpaces(sidebarPath) {
|
|
|
101
108
|
if (typeof value.id !== 'string' || value.id !== encodedId) {
|
|
102
109
|
return { error: `Arc Space key ${JSON.stringify(encodedId)} does not match its stable id` };
|
|
103
110
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
// Identity is the stable id and the profile binding. The title is
|
|
112
|
+
// display-only: Arc omits it for an untitled Space, which must still be
|
|
113
|
+
// listed (as `arc-space`) instead of taking every other Space down.
|
|
114
|
+
const title = parseTitle(value.title, encodedId);
|
|
115
|
+
if (typeof title !== 'string')
|
|
116
|
+
return title;
|
|
107
117
|
if (ids.has(encodedId)) {
|
|
108
118
|
return { error: `Arc Space ${JSON.stringify(encodedId)} appears more than once` };
|
|
109
119
|
}
|
|
@@ -111,7 +121,7 @@ export function parseSidebarSpaces(sidebarPath) {
|
|
|
111
121
|
if (typeof profileId !== 'string')
|
|
112
122
|
return profileId;
|
|
113
123
|
ids.add(encodedId);
|
|
114
|
-
spaces.push({ id: encodedId, title
|
|
124
|
+
spaces.push({ id: encodedId, title, profileId });
|
|
115
125
|
}
|
|
116
126
|
}
|
|
117
127
|
return spaces;
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This tick owns only the NON-git duties of auth sync: it materializes worker
|
|
5
|
+
* slots from durable keys already on the box, then (on the elected headed
|
|
6
|
+
* publisher) provisions peers whose LAST-DELIVERED verdict says `missing` by
|
|
7
|
+
* pushing the credential over SSH. The secret never enters Git.
|
|
8
|
+
*
|
|
9
|
+
* It no longer runs its own `syncFleetSharedStateRepo` (PHNX-4051). The verdict
|
|
10
|
+
* this tick's decisions read is published and delivered by the single git
|
|
11
|
+
* committer, the usage-sync tick — folding both publishes into one caller is
|
|
12
|
+
* what stops the two ticks (30 s apart) from contending for the one shared-repo
|
|
13
|
+
* lock and starving the usage snapshot workers depend on. This tick keeps its
|
|
14
|
+
* own deadline and circuit breaker, so a hung peer SSH push parks only auth-sync
|
|
15
|
+
* and never the usage delivery. The pushes read the peer verdicts the last
|
|
16
|
+
* usage-sync exchange wrote into the local checkout; they are idempotent
|
|
17
|
+
* (push only when a peer is missing a key), so acting on at-most-one-tick-old
|
|
18
|
+
* data converges exactly as the in-tick exchange did. To keep "at-most-one-tick-
|
|
19
|
+
* old" true, the pushes are gated on the exchange's freshness marker
|
|
20
|
+
* (`readLastSuccessfulExchangeMs`): when the last usage-sync exchange is missing
|
|
21
|
+
* or older than one usage-sync tick interval (`USAGE_SYNC_TICK_MS`, the
|
|
22
|
+
* producer's cadence — not this service's own `AUTH_SYNC_TICK_MS`), this tick
|
|
23
|
+
* skips the pushes and WARNs instead of acting on peer state that may no longer
|
|
24
|
+
* hold.
|
|
8
25
|
*/
|
|
9
26
|
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
10
27
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This tick owns only the NON-git duties of auth sync: it materializes worker
|
|
5
|
+
* slots from durable keys already on the box, then (on the elected headed
|
|
6
|
+
* publisher) provisions peers whose LAST-DELIVERED verdict says `missing` by
|
|
7
|
+
* pushing the credential over SSH. The secret never enters Git.
|
|
8
|
+
*
|
|
9
|
+
* It no longer runs its own `syncFleetSharedStateRepo` (PHNX-4051). The verdict
|
|
10
|
+
* this tick's decisions read is published and delivered by the single git
|
|
11
|
+
* committer, the usage-sync tick — folding both publishes into one caller is
|
|
12
|
+
* what stops the two ticks (30 s apart) from contending for the one shared-repo
|
|
13
|
+
* lock and starving the usage snapshot workers depend on. This tick keeps its
|
|
14
|
+
* own deadline and circuit breaker, so a hung peer SSH push parks only auth-sync
|
|
15
|
+
* and never the usage delivery. The pushes read the peer verdicts the last
|
|
16
|
+
* usage-sync exchange wrote into the local checkout; they are idempotent
|
|
17
|
+
* (push only when a peer is missing a key), so acting on at-most-one-tick-old
|
|
18
|
+
* data converges exactly as the in-tick exchange did. To keep "at-most-one-tick-
|
|
19
|
+
* old" true, the pushes are gated on the exchange's freshness marker
|
|
20
|
+
* (`readLastSuccessfulExchangeMs`): when the last usage-sync exchange is missing
|
|
21
|
+
* or older than one usage-sync tick interval (`USAGE_SYNC_TICK_MS`, the
|
|
22
|
+
* producer's cadence — not this service's own `AUTH_SYNC_TICK_MS`), this tick
|
|
23
|
+
* skips the pushes and WARNs instead of acting on peer state that may no longer
|
|
24
|
+
* hold.
|
|
8
25
|
*/
|
|
9
26
|
import { BasePeriodicService } from './service.js';
|
|
27
|
+
import { USAGE_SYNC_TICK_MS } from './usage-sync-service.js';
|
|
10
28
|
const AUTH_SYNC_TICK_MS = 15 * 60_000;
|
|
11
29
|
const AUTH_SYNC_DEADLINE_MS = 2 * 60_000;
|
|
12
30
|
const AUTH_SYNC_KICKOFF_MS = 60_000;
|
|
@@ -22,13 +40,14 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
22
40
|
// Nothing to release — the supervisor's timer teardown is the only cleanup.
|
|
23
41
|
}
|
|
24
42
|
async onTick(ctx) {
|
|
25
|
-
const {
|
|
43
|
+
const { reconcileLocalWorkerSlots, syncReservedAuthBundle, syncReservedStores, } = await import('../secrets-policy.js');
|
|
26
44
|
// Worker-side slot materialization FIRST (PHNX-3940 T6): for each registered
|
|
27
45
|
// account whose durable key is already on this box, create the HOME-shaped
|
|
28
46
|
// slot the picker and spawn read. It touches only local state — the registry
|
|
29
|
-
// copy and the file-backed store — so it never waits on
|
|
30
|
-
//
|
|
31
|
-
// `git rebase timed out` postponed every slot on the box by another
|
|
47
|
+
// copy and the file-backed store — so it never waits on any git exchange. It
|
|
48
|
+
// used to sit after this tick's own `if (!transport.success) return`, so a
|
|
49
|
+
// single `git rebase timed out` postponed every slot on the box by another
|
|
50
|
+
// tick; that exchange has since moved to the usage-sync tick (PHNX-4051).
|
|
32
51
|
// Self-gated on device role: a headed box returns immediately.
|
|
33
52
|
try {
|
|
34
53
|
const slots = reconcileLocalWorkerSlots();
|
|
@@ -48,20 +67,29 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
48
67
|
catch (err) {
|
|
49
68
|
ctx.log('WARN', `auth-sync: worker slot reconcile: ${err.message}`);
|
|
50
69
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
// The verdict this tick's pushes read is published by the usage-sync tick,
|
|
71
|
+
// the single git committer (PHNX-4051), and delivered into the local checkout
|
|
72
|
+
// by its exchange. The pushes below act on that last-delivered peer state, so
|
|
73
|
+
// they are only sound while that state is fresh. Gate them on the exchange's
|
|
74
|
+
// freshness: if the last successful usage-sync exchange is missing or older
|
|
75
|
+
// than one tick interval, the delivered peer verdicts may be stale — a peer
|
|
76
|
+
// that already received the key still reads `missing`, or a cleared `missing`
|
|
77
|
+
// still reads stale — so skip the pushes and WARN rather than pushing off it.
|
|
78
|
+
// The worker-slot reconcile above is NOT gated: it reads only local durable
|
|
79
|
+
// keys, never delivered peer verdicts.
|
|
80
|
+
const { readLastSuccessfulExchangeMs } = await import('../fleet-shared-repo-sync.js');
|
|
81
|
+
const lastExchangeMs = readLastSuccessfulExchangeMs();
|
|
82
|
+
const ageMs = lastExchangeMs === null ? null : Date.now() - lastExchangeMs;
|
|
83
|
+
// The threshold is the PRODUCER's cadence (USAGE_SYNC_TICK_MS), not this
|
|
84
|
+
// service's own AUTH_SYNC_TICK_MS: the delivered peer verdicts are refreshed
|
|
85
|
+
// once per usage-sync exchange, so "stale" is measured against that tick, not
|
|
86
|
+
// this one. The two constants are equal today, but sourcing it here keeps the
|
|
87
|
+
// gate tracking the usage-sync cadence if either is ever retuned alone.
|
|
88
|
+
if (ageMs === null || ageMs > USAGE_SYNC_TICK_MS) {
|
|
89
|
+
const age = ageMs === null ? 'never completed' : `last completed ${Math.round(ageMs / 1000)}s ago`;
|
|
90
|
+
ctx.log('WARN', `auth-sync: skipping credential push — usage-sync exchange ${age} (need one within ${Math.round(USAGE_SYNC_TICK_MS / 1000)}s); peer verdicts may be stale`);
|
|
64
91
|
return;
|
|
92
|
+
}
|
|
65
93
|
const result = await syncReservedAuthBundle();
|
|
66
94
|
if (result.pushed.length > 0) {
|
|
67
95
|
ctx.log('INFO', `auth-sync: pushed auth to ${result.pushed.join(', ')}`);
|
|
@@ -2,19 +2,38 @@
|
|
|
2
2
|
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
3
|
* PHNX-3792 session mirror).
|
|
4
4
|
*
|
|
5
|
-
* This is the
|
|
5
|
+
* This is the ONE tick that owns the bounded Git exchange over the fleet-synced
|
|
6
6
|
* user repo, so every non-secret daemon-state field rides it rather than opening
|
|
7
7
|
* a second committer. Each tick: (1) publishes this box's own fields into its
|
|
8
8
|
* conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
|
|
9
|
-
* usage snapshot,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* usage snapshot, EVERY box's lightweight session digests (PHNX-3792), and the
|
|
10
|
+
* reserved-auth readiness verdict (PHNX-4051, folded in from the auth-sync tick
|
|
11
|
+
* so a single caller holds the shared-repo lock per tick); (2) runs one
|
|
12
|
+
* serialized, timeout-bounded commit/rebase/push; (3) consumes the peer fields
|
|
13
|
+
* the exchange delivered — a worker merges usage newest-wins, and every
|
|
14
|
+
* non-worker box folds peers' session digests into its local index so the picker
|
|
15
|
+
* renders remote-host previews inline. No tick opens a device-to-device SSH mesh.
|
|
16
|
+
*
|
|
17
|
+
* Why the auth verdict publishes here (PHNX-4051): auth-sync used to run its OWN
|
|
18
|
+
* `syncFleetSharedStateRepo`, so on every box two ticks 30 s apart contended for
|
|
19
|
+
* the one `proper-lockfile` lock (20×100 ms ≈ 2 s of retries) while a real
|
|
20
|
+
* fetch/rebase/push on a drifted repo runs far longer — the usage tick then
|
|
21
|
+
* failed with "Lock file is already being held" (zion logged it 95× in 24 h) and
|
|
22
|
+
* workers never received a fresh usage snapshot, which the 40-min placement gate
|
|
23
|
+
* turned into "no ready device". Folding the auth verdict into this single
|
|
24
|
+
* committer removes the second caller entirely. Auth-sync keeps its non-git
|
|
25
|
+
* duties (worker-slot reconcile + the credential SSH pushes) under its own
|
|
26
|
+
* deadline and circuit breaker.
|
|
15
27
|
*/
|
|
16
28
|
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
17
29
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
30
|
+
/**
|
|
31
|
+
* The usage-sync tick cadence. Exported because auth-sync's credential-push
|
|
32
|
+
* freshness gate is the CONSUMER of this producer's cadence: it skips the
|
|
33
|
+
* pushes when the last exchange is older than one usage-sync interval, so it
|
|
34
|
+
* must track this constant rather than its own equal-by-coincidence literal.
|
|
35
|
+
*/
|
|
36
|
+
export declare const USAGE_SYNC_TICK_MS: number;
|
|
18
37
|
export declare class UsageSyncService extends BasePeriodicService {
|
|
19
38
|
readonly id: DaemonServiceId;
|
|
20
39
|
readonly intervalMs: number;
|
|
@@ -2,19 +2,37 @@
|
|
|
2
2
|
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
3
|
* PHNX-3792 session mirror).
|
|
4
4
|
*
|
|
5
|
-
* This is the
|
|
5
|
+
* This is the ONE tick that owns the bounded Git exchange over the fleet-synced
|
|
6
6
|
* user repo, so every non-secret daemon-state field rides it rather than opening
|
|
7
7
|
* a second committer. Each tick: (1) publishes this box's own fields into its
|
|
8
8
|
* conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
|
|
9
|
-
* usage snapshot,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* usage snapshot, EVERY box's lightweight session digests (PHNX-3792), and the
|
|
10
|
+
* reserved-auth readiness verdict (PHNX-4051, folded in from the auth-sync tick
|
|
11
|
+
* so a single caller holds the shared-repo lock per tick); (2) runs one
|
|
12
|
+
* serialized, timeout-bounded commit/rebase/push; (3) consumes the peer fields
|
|
13
|
+
* the exchange delivered — a worker merges usage newest-wins, and every
|
|
14
|
+
* non-worker box folds peers' session digests into its local index so the picker
|
|
15
|
+
* renders remote-host previews inline. No tick opens a device-to-device SSH mesh.
|
|
16
|
+
*
|
|
17
|
+
* Why the auth verdict publishes here (PHNX-4051): auth-sync used to run its OWN
|
|
18
|
+
* `syncFleetSharedStateRepo`, so on every box two ticks 30 s apart contended for
|
|
19
|
+
* the one `proper-lockfile` lock (20×100 ms ≈ 2 s of retries) while a real
|
|
20
|
+
* fetch/rebase/push on a drifted repo runs far longer — the usage tick then
|
|
21
|
+
* failed with "Lock file is already being held" (zion logged it 95× in 24 h) and
|
|
22
|
+
* workers never received a fresh usage snapshot, which the 40-min placement gate
|
|
23
|
+
* turned into "no ready device". Folding the auth verdict into this single
|
|
24
|
+
* committer removes the second caller entirely. Auth-sync keeps its non-git
|
|
25
|
+
* duties (worker-slot reconcile + the credential SSH pushes) under its own
|
|
26
|
+
* deadline and circuit breaker.
|
|
15
27
|
*/
|
|
16
28
|
import { BasePeriodicService } from './service.js';
|
|
17
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The usage-sync tick cadence. Exported because auth-sync's credential-push
|
|
31
|
+
* freshness gate is the CONSUMER of this producer's cadence: it skips the
|
|
32
|
+
* pushes when the last exchange is older than one usage-sync interval, so it
|
|
33
|
+
* must track this constant rather than its own equal-by-coincidence literal.
|
|
34
|
+
*/
|
|
35
|
+
export const USAGE_SYNC_TICK_MS = 15 * 60_000;
|
|
18
36
|
const USAGE_SYNC_DEADLINE_MS = 2 * 60_000;
|
|
19
37
|
const USAGE_SYNC_KICKOFF_MS = 90_000;
|
|
20
38
|
export class UsageSyncService extends BasePeriodicService {
|
|
@@ -54,6 +72,15 @@ export class UsageSyncService extends BasePeriodicService {
|
|
|
54
72
|
ctx.log('INFO', `session-mirror: published ${mirrored.count} session digest(s)`);
|
|
55
73
|
if (mirrored.error)
|
|
56
74
|
ctx.log('WARN', `session-mirror: publish: ${mirrored.error}`);
|
|
75
|
+
// The reserved-auth readiness verdict rides this single git exchange too
|
|
76
|
+
// (PHNX-4051): it is a conflict-free field in the same owned daemon-state
|
|
77
|
+
// file, so publishing it here — instead of from a second committer in
|
|
78
|
+
// auth-sync — is what keeps exactly one caller of syncFleetSharedStateRepo on
|
|
79
|
+
// the periodic path.
|
|
80
|
+
const { publishReservedAuthVerdict } = await import('../secrets-policy.js');
|
|
81
|
+
const authVerdict = await publishReservedAuthVerdict();
|
|
82
|
+
if (authVerdict.error)
|
|
83
|
+
ctx.log('WARN', `usage-sync: auth verdict: ${authVerdict.error}`);
|
|
57
84
|
const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
|
|
58
85
|
const transport = await syncFleetSharedStateRepo();
|
|
59
86
|
if (transport.skipped)
|
|
@@ -36,11 +36,28 @@ export declare function isFreshFleetAuthSnapshot(value: {
|
|
|
36
36
|
* publishes every tick — it does not ride that endpoint.
|
|
37
37
|
*/
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* The cached rows still backed by a home on this device, by `authTargetKey`.
|
|
40
|
+
*
|
|
41
|
+
* A row for an uninstalled version is an ORPHAN: the probe enumerates installed
|
|
42
|
+
* homes + account slots, so nothing re-probes it and its `checkedAt` is frozen
|
|
43
|
+
* at whatever the last probe left. Pure — unit-tested.
|
|
44
|
+
*/
|
|
45
|
+
export declare function installedAuthRows(authRows: readonly AuthProbeRow[], installedTargets: ReadonlySet<string>): AuthProbeRow[];
|
|
46
|
+
/**
|
|
47
|
+
* True when every cached auth row FOR AN INSTALLED HOME was probed within
|
|
48
|
+
* {@link AUTH_PROBE_MAX_AGE_MS} — i.e. reusing them would not let a verdict get
|
|
49
|
+
* staler than one probe window. No installed row is never fresh (nothing to
|
|
50
|
+
* reuse). Pure — unit-tested.
|
|
51
|
+
*
|
|
52
|
+
* Orphan rows are excluded rather than counted stale (PHNX-4051). Counting them
|
|
53
|
+
* made this permanently false on any box that had ever uninstalled a version:
|
|
54
|
+
* the orphan can never be re-probed, so the tick live-probed the rate-limited
|
|
55
|
+
* `/api/oauth/usage` for every account every 3 minutes, re-arming the per-account
|
|
56
|
+
* 429 backoff and parking the usage refresher — the RUSH-2998 failure the reuse
|
|
57
|
+
* window exists to prevent. Observed on yosemite-m0 (rows dated Sep 2 / Sep 6 for
|
|
58
|
+
* uninstalled Claude versions).
|
|
42
59
|
*/
|
|
43
|
-
export declare function isCachedFleetAuthProbeFresh(authRows: AuthProbeRow[], now: number, maxAgeMs?: number): boolean;
|
|
60
|
+
export declare function isCachedFleetAuthProbeFresh(authRows: readonly AuthProbeRow[], now: number, installedTargets: ReadonlySet<string>, maxAgeMs?: number): boolean;
|
|
44
61
|
/**
|
|
45
62
|
* Whether the tick may reuse the cached auth verdict instead of re-probing the
|
|
46
63
|
* rate-limited endpoint. `force` (an on-demand `agents devices ping`) ALWAYS
|
|
@@ -49,7 +66,7 @@ export declare function isCachedFleetAuthProbeFresh(authRows: AuthProbeRow[], no
|
|
|
49
66
|
* revoked account (the second `runFleetPing` call site, RUSH-2998). Pure —
|
|
50
67
|
* unit-tested so that inversion cannot regress unnoticed.
|
|
51
68
|
*/
|
|
52
|
-
export declare function shouldReuseCachedAuthProbe(force: boolean, cached: AuthProbeRow[], now: number, maxAgeMs?: number): boolean;
|
|
69
|
+
export declare function shouldReuseCachedAuthProbe(force: boolean, cached: readonly AuthProbeRow[], now: number, installedTargets: ReadonlySet<string>, maxAgeMs?: number): boolean;
|
|
53
70
|
/**
|
|
54
71
|
* Fleet cache warm: publish THIS host's row for the caches `agents fleet
|
|
55
72
|
* status` / `agents devices list` read (PUBLISH-OWN / READ-UNION, RUSH-2061).
|