@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.
@@ -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 { getCacheDir } from './state.js';
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
- if (!key.startsWith(prefix))
246
- continue;
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: identity.slice(separator + 1),
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
- /** Merge one or more entries into the cache (best-effort write). */
281
- export function writeAuthHealthEntries(entries) {
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(readAuthHealthCache(), entries),
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, then resolve its account label. getAccountInfo is a
414
- // local credential-file read (no network), so this fan-out is cheap and cannot
415
- // contribute to the rate limit the probe grouping below exists to avoid.
416
- const installs = [];
417
- for (const agent of agentIds) {
418
- for (const version of listInstalledVersions(agent)) {
419
- installs.push({ agent, version, home: getVersionHomePath(agent, version), info: null, account: undefined });
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
- inst.accountId = findNativeAccountByIdentity(meta, inst.agent, inst.info)?.id;
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
- /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
457
- export function writeFleetAuthRows(host, rows) {
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
- writeAuthHealthEntries(entries);
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
  }
@@ -1,7 +1,7 @@
1
1
  export interface ArcSpace {
2
2
  /** Stable UUID used for every native operation. */
3
3
  id: string;
4
- /** Display-only title. */
4
+ /** Display-only title; empty when the Space is untitled. Never part of identity. */
5
5
  title: string;
6
6
  }
7
7
  export interface ArcProfile {
@@ -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
- if (typeof value.title !== 'string') {
105
- return { error: `Arc Space ${JSON.stringify(encodedId)} has no title` };
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: value.title, profileId });
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
- * Each daemon publishes a safe readiness verdict to the fleet-shared user repo,
5
- * then runs the same serialized, timeout-bounded git exchange as usage sync.
6
- * One deterministic ready device asynchronously provisions peers whose delivered
7
- * verdict says `missing`; the secret never enters Git.
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
- * Each daemon publishes a safe readiness verdict to the fleet-shared user repo,
5
- * then runs the same serialized, timeout-bounded git exchange as usage sync.
6
- * One deterministic ready device asynchronously provisions peers whose delivered
7
- * verdict says `missing`; the secret never enters Git.
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 { publishReservedAuthVerdict, reconcileLocalWorkerSlots, syncReservedAuthBundle, syncReservedStores, } = await import('../secrets-policy.js');
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 the git exchange
30
- // below. It used to sit after `if (!transport.success) return`, so a single
31
- // `git rebase timed out` postponed every slot on the box by another tick.
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
- const published = await publishReservedAuthVerdict();
52
- if (published.error)
53
- ctx.log('WARN', `auth-sync: verdict: ${published.error}`);
54
- const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
55
- const transport = await syncFleetSharedStateRepo();
56
- if (transport.skipped)
57
- ctx.log('WARN', `auth-sync: ${transport.skipped}`);
58
- if (transport.error)
59
- ctx.log('WARN', `auth-sync: shared-store transport: ${transport.error}`);
60
- if (transport.untrackedBackedUp?.length) {
61
- ctx.log('WARN', `auth-sync: backed up ${transport.untrackedBackedUp.length} untracked shared-store collision(s) to ${transport.untrackedBackupDir}: ${transport.untrackedBackedUp.join(', ')}`);
62
- }
63
- if (!transport.success)
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 one tick that owns the bounded Git exchange over the fleet-synced
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, and EVERY box's lightweight session digests (PHNX-3792);
10
- * (2) runs one serialized, timeout-bounded commit/rebase/push; (3) consumes the
11
- * peer fields the exchange delivered — a worker merges usage newest-wins, and
12
- * every non-worker box folds peers' session digests into its local index so the
13
- * picker renders remote-host previews inline. No tick opens a device-to-device
14
- * SSH mesh.
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 one tick that owns the bounded Git exchange over the fleet-synced
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, and EVERY box's lightweight session digests (PHNX-3792);
10
- * (2) runs one serialized, timeout-bounded commit/rebase/push; (3) consumes the
11
- * peer fields the exchange delivered — a worker merges usage newest-wins, and
12
- * every non-worker box folds peers' session digests into its local index so the
13
- * picker renders remote-host previews inline. No tick opens a device-to-device
14
- * SSH mesh.
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
- const USAGE_SYNC_TICK_MS = 15 * 60_000;
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
- * True when every cached auth row was probed within {@link AUTH_PROBE_MAX_AGE_MS}
40
- * — i.e. reusing them would not let a verdict get staler than one probe window.
41
- * Empty cache is never fresh (nothing to reuse). Pure — unit-tested.
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).