@phnx-labs/agents-cli 1.22.99 → 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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.100
4
+
5
+ - **The daemon's 20-minute auth-probe reuse works again on a box that has uninstalled a version (PHNX-4051).** `isCachedFleetAuthProbeFresh` required EVERY cached auth row to be under 20 minutes old, including rows left behind for versions that are no longer installed. Nothing re-probes those — the probe only walks homes that exist — so their timestamp froze and the check was permanently false: the 3-minute tick live-probed the rate-limited `/api/oauth/usage` for every account on every tick, re-arming the per-account 429 backoff and keeping the usage refresher parked (the RUSH-2998 failure the reuse window exists to prevent; observed on a worker holding rows dated Sep 2 / Sep 6 for uninstalled Claude versions). Freshness now considers only rows for currently installed `(agent, version)` homes and account slots, and the tick prunes the orphan rows when it writes the cache, so `agents view` and fleet status stop reporting a verdict for a version that is gone. `agents devices ping [--strict]` still forces a genuinely live probe. Source: `cli/src/lib/daemon-ticks.ts` (`installedAuthRows`, `isCachedFleetAuthProbeFresh`, `refreshLocalFleetAuthState`), `cli/src/lib/auth-health.ts` (`authTargetKey`, `enumerateLocalAuthInstalls`, `localAuthTargetKeys`, `writeFleetAuthRows`).
6
+
7
+ - **Fleet usage-sync transport no longer starves `--device auto` (PHNX-4051).** The daemon's shared-state git exchange failed on every tick two ways, so workers never received a fresh Claude usage snapshot and the 40-min placement gate turned every worker into "no ready device". Fixed both at the source: (1) the usage-sync tick is now the single committer — it also publishes the reserved-auth verdict, and auth-sync no longer runs its own `syncFleetSharedStateRepo`, so the two ticks can't contend for the one shared-repo lock ("Lock file is already being held"); (2) the untracked-collision scan is scoped to the tracked roots origin carries (`git ls-tree origin/<branch>`) — every path origin tracks stays in scope, so no path the rebase can overwrite is missed, while an untracked-only tree origin never tracks (like artifacts-cli's `~/.agents/artifact-history/`) can never push `git ls-files --others` past the 1 MiB output cap. auth-sync's credential pushes are also gated on the exchange's freshness marker, so they never push off peer state older than one tick interval. Source: `cli/src/lib/fleet-shared-repo-sync.ts`, `cli/src/lib/daemon/usage-sync-service.ts`, `cli/src/lib/daemon/auth-sync-service.ts`.
8
+
9
+ - **An untitled Arc Space no longer hides every Arc Space from `agents browser`.** `parseSidebarSpaces` treated the display-only Space title as part of a Space's identity and rejected the whole sidebar when one Space had none, so `agents browser start` warned that a configured `arc-gmail` "can't launch on this machine" while `agents browser profiles doctor arc-gmail` saw Arc and the Space. Identity is the stable Space id and its profile binding; a Space Arc stored without a title (key absent or `null`) is now listed as `arc-space` (two or more get their Space-id prefix appended, as same-titled Spaces already do). A mismatched id, a duplicate id, an unknown or malformed profile mapping, and a title that is present but not text still fail discovery closed. Source: `cli/src/lib/browser/arc-discovery.ts`, `cli/src/lib/browser/arc-discovery.test.ts`, `cli/src/lib/browser/testdata/arc/`.
10
+
3
11
  ## 1.22.99
4
12
 
5
13
  - **`agents setup secrets` installs the published secrets CLI instead of only printing a hint (PHNX-3989).** A missing `secrets` binary tries `agents clis install secrets` then a pinned `npm i -g @phnx-labs/secrets-cli@0.1.2`, then hands off to `secrets migrate`. Docs point at the standalone engine; the broker is `secrets _agent-run` on macOS, not the agents daemon. Source: `cli/src/commands/setup-secrets.ts`, `cli/docs/secrets.md`.
@@ -134,6 +134,12 @@ export declare function formatCheckedAge(checkedAt: number, now?: number): strin
134
134
  export declare function authAccountLabel(info: Pick<AccountInfo, 'email' | 'accountId' | 'userId'> | null | undefined): string | undefined;
135
135
  /** Cache key: one entry per install — (host, agent, version). Unique per token. */
136
136
  export declare function authCacheKey(host: string, agent: AgentId | string, version: string): string;
137
+ /**
138
+ * Host-independent identity of one probe target — the (agent, version) pair
139
+ * {@link authCacheKey} is keyed by. The separator cannot appear in either half,
140
+ * so an agent id can never run into a version the way a `:` join allows.
141
+ */
142
+ export declare function authTargetKey(agent: AgentId | string, version: string): string;
137
143
  /**
138
144
  * The `version` slot an account SLOT occupies in the auth cache and the probe
139
145
  * rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
@@ -156,10 +162,38 @@ export interface SlotAuthInstall extends FleetAuthInstall {
156
162
  * gone is skipped: there is nothing to probe and the row would only say so.
157
163
  */
158
164
  export declare function enumerateSlotInstalls(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agentIds: readonly AgentId[]): SlotAuthInstall[];
165
+ /** One probe target on this device: an installed version home, or an account slot. */
166
+ export interface LocalAuthInstall extends FleetAuthInstall {
167
+ home: string;
168
+ accountId?: string;
169
+ }
170
+ /**
171
+ * Every (agent, version) home the local auth probe covers on this device —
172
+ * installed version homes plus account slots. {@link probeLocalFleetAuth} probes
173
+ * exactly this set, so it is also the answer to "which cached rows are still
174
+ * backed by something on disk" (PHNX-4051).
175
+ */
176
+ export declare function enumerateLocalAuthInstalls(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agentIds: readonly AgentId[]): LocalAuthInstall[];
177
+ /**
178
+ * {@link authTargetKey} for every local probe target — what a cached row must
179
+ * match to still be about this device. A row outside it is an ORPHAN: its home
180
+ * was uninstalled, so nothing will ever re-probe it and its `checkedAt` is
181
+ * frozen at whatever the last probe left (PHNX-4051).
182
+ */
183
+ export declare function localAuthTargetKeys(agentIds?: readonly AgentId[]): Set<string>;
159
184
  /** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
160
185
  export declare function readAuthHealthCache(): Record<string, AuthHealth>;
161
186
  /** Read one entry, or null. */
162
187
  export declare function readAuthHealth(host: string, agent: AgentId | string, version: string): AuthHealth | null;
188
+ /**
189
+ * Split a cache key back into the install it names, for one host. Returns null
190
+ * for a key that belongs to another host or does not name a known agent — the
191
+ * one place the `host:agent:version` join is undone.
192
+ */
193
+ export declare function parseAuthCacheKey(key: string, host: string): {
194
+ agent: AgentId;
195
+ version: string;
196
+ } | null;
163
197
  /** Reconstruct one host's published probe rows for a lease waiter/CLI reader. */
164
198
  export declare function readFleetAuthRows(host: string): AuthProbeRow[];
165
199
  /**
@@ -170,8 +204,15 @@ export declare function readFleetAuthRows(host: string): AuthProbeRow[];
170
204
  * behaviour promised by the `error` doc on AuthVerdict ("keep the last known
171
205
  * one"). Pure, so it's unit-tested directly. */
172
206
  export declare function mergeAuthHealthEntries(current: Record<string, AuthHealth>, incoming: Record<string, AuthHealth>): Record<string, AuthHealth>;
173
- /** Merge one or more entries into the cache (best-effort write). */
174
- export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>): void;
207
+ /**
208
+ * Merge one or more entries into the cache (best-effort write).
209
+ *
210
+ * `drop` removes entries the writer knows are gone — it runs on the PRE-merge
211
+ * cache, so an incoming row always wins over a drop of the same key and the two
212
+ * can never fight. Only a writer that knows the full truth for the keys it drops
213
+ * may pass one (see {@link writeFleetAuthRows}).
214
+ */
215
+ export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>, drop?: (key: string) => boolean): void;
175
216
  /**
176
217
  * Complete a live auth probe for one (agent, home). For claude/kimi/droid this
177
218
  * hits the provider — unless the account's usage cache already holds a fresh
@@ -261,5 +302,16 @@ export declare function probeLocalFleetAuth(opts?: {
261
302
  */
262
303
  signal?: AbortSignal;
263
304
  }): Promise<AuthProbeRow[]>;
264
- /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
265
- export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[]): void;
305
+ /**
306
+ * Persist a host's probed rows into the cache (keyed by host+agent+version).
307
+ *
308
+ * `installed` — the {@link authTargetKey} set of homes that still exist on
309
+ * `host` ({@link localAuthTargetKeys}) — prunes this host's ORPHAN entries: rows
310
+ * for a version that has since been uninstalled. Nothing re-probes those, so
311
+ * they never age out on their own; they froze the reuse window permanently false
312
+ * and kept surfacing in `agents view` / fleet status (PHNX-4051). Only a caller
313
+ * that enumerated `host`'s real installs may pass it — the `fleet ping` fan-out
314
+ * writes a PEER's rows and cannot enumerate that box's homes, so it omits it and
315
+ * merges as before.
316
+ */
317
+ export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[], installed?: ReadonlySet<string>): void;
@@ -21,7 +21,7 @@ import * as path from 'path';
21
21
  import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
22
22
  import { listNativeAccounts } from './account-registry.js';
23
23
  import { readSlots } from './accounts/slots.js';
24
- import { getCacheDir } from './state.js';
24
+ import { getCacheDir, readMeta } from './state.js';
25
25
  import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
26
26
  import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
27
27
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
@@ -220,6 +220,14 @@ export function authAccountLabel(info) {
220
220
  export function authCacheKey(host, agent, version) {
221
221
  return `${host}:${agent}:${version}`;
222
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
+ }
223
231
  /**
224
232
  * The `version` slot an account SLOT occupies in the auth cache and the probe
225
233
  * rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
@@ -251,6 +259,32 @@ export function enumerateSlotInstalls(meta, agentIds) {
251
259
  }
252
260
  return out;
253
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
+ }
254
288
  function cacheFilePath() {
255
289
  return path.join(getCacheDir(), '.auth-health.json');
256
290
  }
@@ -270,23 +304,34 @@ export function readAuthHealthCache() {
270
304
  export function readAuthHealth(host, agent, version) {
271
305
  return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
272
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
+ }
273
325
  /** Reconstruct one host's published probe rows for a lease waiter/CLI reader. */
274
326
  export function readFleetAuthRows(host) {
275
- const prefix = `${host}:`;
276
327
  const rows = [];
277
328
  for (const [key, health] of Object.entries(readAuthHealthCache())) {
278
- if (!key.startsWith(prefix))
279
- continue;
280
- const identity = key.slice(prefix.length);
281
- const separator = identity.indexOf(':');
282
- if (separator <= 0)
283
- continue;
284
- const agent = identity.slice(0, separator);
285
- if (!ALL_AGENT_IDS.includes(agent))
329
+ const install = parseAuthCacheKey(key, host);
330
+ if (!install)
286
331
  continue;
287
332
  rows.push({
288
- agent: agent,
289
- version: identity.slice(separator + 1),
333
+ agent: install.agent,
334
+ version: install.version,
290
335
  account: health.account,
291
336
  accountId: health.accountId,
292
337
  health,
@@ -310,15 +355,26 @@ export function mergeAuthHealthEntries(current, incoming) {
310
355
  }
311
356
  return merged;
312
357
  }
313
- /** Merge one or more entries into the cache (best-effort write). */
314
- 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) {
315
367
  try {
316
368
  const target = cacheFilePath();
317
369
  ensureLockTarget(target, JSON.stringify({ version: 1, entries: {} }));
318
370
  withFileLock(target, () => {
371
+ let current = readAuthHealthCache();
372
+ if (drop) {
373
+ current = Object.fromEntries(Object.entries(current).filter(([key]) => !drop(key)));
374
+ }
319
375
  const merged = {
320
376
  version: 1,
321
- entries: mergeAuthHealthEntries(readAuthHealthCache(), entries),
377
+ entries: mergeAuthHealthEntries(current, entries),
322
378
  };
323
379
  atomicWriteFileSync(target, JSON.stringify(merged, null, 2));
324
380
  });
@@ -447,20 +503,9 @@ export async function probeLocalFleetAuth(opts) {
447
503
  // each one's account label. getAccountInfo is a local credential-file read
448
504
  // (no network), so this fan-out is cheap and cannot contribute to the rate
449
505
  // limit the probe grouping below exists to avoid.
450
- const [{ readMeta }, { findNativeAccountByIdentity }] = await Promise.all([
451
- import('./state.js'),
452
- import('./account-registry.js'),
453
- ]);
506
+ const { findNativeAccountByIdentity } = await import('./account-registry.js');
454
507
  const meta = readMeta();
455
- const installs = [];
456
- for (const agent of agentIds) {
457
- for (const version of listInstalledVersions(agent)) {
458
- installs.push({ agent, version, home: getVersionHomePath(agent, version), info: null, account: undefined });
459
- }
460
- }
461
- for (const slot of enumerateSlotInstalls(meta, agentIds)) {
462
- installs.push({ ...slot, info: null });
463
- }
508
+ const installs = enumerateLocalAuthInstalls(meta, agentIds).map((inst) => ({ ...inst, info: null }));
464
509
  await Promise.all(installs.map(async (inst) => {
465
510
  inst.info = await getAccountInfo(inst.agent, inst.home).catch(() => null);
466
511
  inst.account = authAccountLabel(inst.info);
@@ -491,11 +536,28 @@ export async function probeLocalFleetAuth(opts) {
491
536
  }));
492
537
  return perGroup.flat();
493
538
  }
494
- /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
495
- 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) {
496
552
  const entries = {};
497
553
  for (const row of rows) {
498
554
  entries[authCacheKey(host, row.agent, row.version)] = row.health;
499
555
  }
500
- 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);
501
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).
@@ -14,7 +14,7 @@
14
14
  * by the cross-process refresh lease so an explicit CLI refresh and the daemon
15
15
  * timer converge on the same published result.
16
16
  */
17
- import { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
17
+ import { AUTH_PROBE_MAX_AGE_MS, authTargetKey } from './auth-health.js';
18
18
  export { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
19
19
  export function isFreshFleetAuthSnapshot(value, minimumCapturedAt) {
20
20
  return value.row.capturedAt >= minimumCapturedAt
@@ -36,12 +36,32 @@ export function isFreshFleetAuthSnapshot(value, minimumCapturedAt) {
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 function installedAuthRows(authRows, installedTargets) {
46
+ return authRows.filter((r) => installedTargets.has(authTargetKey(r.agent, r.version)));
47
+ }
48
+ /**
49
+ * True when every cached auth row FOR AN INSTALLED HOME was probed within
50
+ * {@link AUTH_PROBE_MAX_AGE_MS} — i.e. reusing them would not let a verdict get
51
+ * staler than one probe window. No installed row is never fresh (nothing to
52
+ * reuse). Pure — unit-tested.
53
+ *
54
+ * Orphan rows are excluded rather than counted stale (PHNX-4051). Counting them
55
+ * made this permanently false on any box that had ever uninstalled a version:
56
+ * the orphan can never be re-probed, so the tick live-probed the rate-limited
57
+ * `/api/oauth/usage` for every account every 3 minutes, re-arming the per-account
58
+ * 429 backoff and parking the usage refresher — the RUSH-2998 failure the reuse
59
+ * window exists to prevent. Observed on yosemite-m0 (rows dated Sep 2 / Sep 6 for
60
+ * uninstalled Claude versions).
42
61
  */
43
- export function isCachedFleetAuthProbeFresh(authRows, now, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
44
- return authRows.length > 0 && authRows.every((r) => now - r.health.checkedAt < maxAgeMs);
62
+ export function isCachedFleetAuthProbeFresh(authRows, now, installedTargets, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
63
+ const installed = installedAuthRows(authRows, installedTargets);
64
+ return installed.length > 0 && installed.every((r) => now - r.health.checkedAt < maxAgeMs);
45
65
  }
46
66
  /**
47
67
  * Whether the tick may reuse the cached auth verdict instead of re-probing the
@@ -51,8 +71,8 @@ export function isCachedFleetAuthProbeFresh(authRows, now, maxAgeMs = AUTH_PROBE
51
71
  * revoked account (the second `runFleetPing` call site, RUSH-2998). Pure —
52
72
  * unit-tested so that inversion cannot regress unnoticed.
53
73
  */
54
- export function shouldReuseCachedAuthProbe(force, cached, now, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
55
- return !force && isCachedFleetAuthProbeFresh(cached, now, maxAgeMs);
74
+ export function shouldReuseCachedAuthProbe(force, cached, now, installedTargets, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
75
+ return !force && isCachedFleetAuthProbeFresh(cached, now, installedTargets, maxAgeMs);
56
76
  }
57
77
  /**
58
78
  * Fleet cache warm: publish THIS host's row for the caches `agents fleet
@@ -67,7 +87,7 @@ export async function refreshLocalFleetAuthState(opts) {
67
87
  const force = opts?.force === true;
68
88
  const signal = opts?.signal;
69
89
  const { machineId } = await import('./machine-id.js');
70
- const { probeLocalFleetAuth, readFleetAuthRows, writeFleetAuthRows } = await import('./auth-health.js');
90
+ const { probeLocalFleetAuth, readFleetAuthRows, writeFleetAuthRows, localAuthTargetKeys } = await import('./auth-health.js');
71
91
  const { getCliVersion } = await import('./version.js');
72
92
  const self = machineId();
73
93
  const requestedAt = Date.now();
@@ -91,11 +111,16 @@ export async function refreshLocalFleetAuthState(opts) {
91
111
  // Re-probe the rate-limited /oauth/usage endpoint at most every
92
112
  // AUTH_PROBE_MAX_AGE_MS; reuse the last real verdict in between (RUSH-2998).
93
113
  // Fleet status publishes every tick regardless — it does not ride that endpoint.
114
+ const installed = localAuthTargetKeys();
94
115
  const cached = readFleetAuthRows(self);
95
- const reuse = shouldReuseCachedAuthProbe(force, cached, requestedAt);
96
- const authRows = reuse ? cached : await probeLocalFleetAuth({ cliVersion: getCliVersion(), forceLive: force, signal });
97
- if (!reuse)
98
- writeFleetAuthRows(self, authRows);
116
+ const live = installedAuthRows(cached, installed);
117
+ const reuse = shouldReuseCachedAuthProbe(force, cached, requestedAt, installed);
118
+ const authRows = reuse ? live : await probeLocalFleetAuth({ cliVersion: getCliVersion(), forceLive: force, signal });
119
+ // Write when we probed, and ALSO when the cache holds orphan rows for homes
120
+ // that are gone: a reusing tick is the common case, so leaving the prune on
121
+ // the probe branch would keep them in `agents view` indefinitely (PHNX-4051).
122
+ if (!reuse || live.length !== cached.length)
123
+ writeFleetAuthRows(self, authRows, installed);
99
124
  const row = await publishLocalFleetStatus(self);
100
125
  return { row, authRows };
101
126
  },
@@ -1,5 +1,12 @@
1
1
  export declare const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45000;
2
2
  export declare const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
3
+ export declare const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES: number;
4
+ /**
5
+ * When the shared-state exchange last completed successfully, in epoch ms, or
6
+ * `null` if it never has on this box (no marker yet) or the marker is unreadable.
7
+ * Reads the default daemon-dir marker the periodic exchange writes.
8
+ */
9
+ export declare function readLastSuccessfulExchangeMs(): number | null;
3
10
  export interface BoundedProcessResult {
4
11
  code: number | null;
5
12
  stdout: string;
@@ -19,9 +19,46 @@ import { machineId } from './session/sync/config.js';
19
19
  import { getDaemonDir, getUserAgentsDir } from './state.js';
20
20
  export const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45_000;
21
21
  export const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
22
- const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
22
+ export const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
23
23
  const FLEET_SHARED_REPO_PUSH_ATTEMPTS = 3;
24
24
  const FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS = 5_000;
25
+ /**
26
+ * The exchange records its last SUCCESSFUL completion beside its lock so a
27
+ * sibling service (auth-sync) can gate work on the freshness of the peer state
28
+ * this exchange delivers into the local checkout (PHNX-4051). auth-sync's
29
+ * credential pushes read the peer verdicts the LAST usage-sync exchange wrote; if
30
+ * that exchange has not landed within a tick interval, those verdicts are stale
31
+ * and pushing off them would act on a peer that may already hold the key.
32
+ */
33
+ function exchangeMarkerPath(lockPath) {
34
+ return `${lockPath}.last-success`;
35
+ }
36
+ function writeExchangeMarker(lockPath, nowMs = Date.now()) {
37
+ try {
38
+ const p = exchangeMarkerPath(lockPath);
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ fs.writeFileSync(p, JSON.stringify({ at: nowMs }), 'utf-8');
41
+ }
42
+ catch {
43
+ // Best-effort telemetry — never fail the exchange over its own marker.
44
+ }
45
+ }
46
+ /**
47
+ * When the shared-state exchange last completed successfully, in epoch ms, or
48
+ * `null` if it never has on this box (no marker yet) or the marker is unreadable.
49
+ * Reads the default daemon-dir marker the periodic exchange writes.
50
+ */
51
+ export function readLastSuccessfulExchangeMs() {
52
+ try {
53
+ const lockPath = path.join(getDaemonDir(), 'fleet-shared-repo-sync');
54
+ const raw = fs.readFileSync(exchangeMarkerPath(lockPath), 'utf-8');
55
+ const at = JSON.parse(raw).at;
56
+ return typeof at === 'number' && Number.isFinite(at) ? at : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
25
62
  function signalProcessTree(child, signal) {
26
63
  const pid = child.pid;
27
64
  if (!pid)
@@ -177,9 +214,30 @@ function retainedAutostash(stashList, objectId) {
177
214
  * cannot create a fresh collision — so nothing is silently destroyed. Then let
178
215
  * the rebase check out origin's version. A file we cannot clear is left in
179
216
  * place; the rebase may still abort, no worse than today and never losing data.
217
+ *
218
+ * The scan is scoped to the TRACKED ROOTS origin carries — the top-level entries
219
+ * of `git ls-tree --name-only origin/<branch>` — NOT the whole working tree
220
+ * (PHNX-4051). Only a file origin tracks can be overwritten by the rebase
221
+ * checkout, so a tracked root is the tightest superset of the collidable paths:
222
+ * every path origin tracks stays in scope (a stale untracked `projects/rush.yaml`
223
+ * under a tracked `projects/` root is still cleared), while an untracked-ONLY tree
224
+ * origin never tracks — artifacts-cli's revision store
225
+ * (`~/.agents/artifact-history/`, ~11k files / >1 MiB of paths) is the live case —
226
+ * is never a tracked root, so it never enters the `git ls-files --others` listing.
227
+ * An unbounded listing there blows past {@link FLEET_SHARED_REPO_OUTPUT_MAX_BYTES}
228
+ * and kills the exchange on every tick; scoping to the tracked roots keeps the
229
+ * PHNX-3923 coverage whole while an unrelated untracked tree cannot push the
230
+ * listing over the cap. `ls-tree`'s own output is bounded — it lists only the root
231
+ * entries, never the recursive tree.
180
232
  */
181
233
  async function clearCollidingUntracked(git, root, branch) {
182
- const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
234
+ const tree = await git(['ls-tree', '--name-only', '-z', `origin/${branch}`]);
235
+ if (tree.code !== 0)
236
+ return { error: tree };
237
+ const trackedRoots = tree.stdout.split('\0').filter(Boolean);
238
+ if (trackedRoots.length === 0)
239
+ return { cleared: 0, backedUp: [], backupDir: null };
240
+ const others = await git(['ls-files', '--others', '--exclude-standard', '-z', '--', ...trackedRoots]);
183
241
  if (others.code !== 0)
184
242
  return { error: others };
185
243
  const relPaths = others.stdout.split('\0').filter(Boolean);
@@ -403,7 +461,12 @@ export async function syncFleetSharedStateRepo(options = {}) {
403
461
  retries: { retries: 20, factor: 1, minTimeout: 100, maxTimeout: 100 },
404
462
  onCompromised: logAndContinueOnLockCompromised('fleet shared repo sync'),
405
463
  });
406
- return await performFleetSharedRepoSync(root, device, timeoutMs);
464
+ const result = await performFleetSharedRepoSync(root, device, timeoutMs);
465
+ // Record freshness only on a real success, beside the lock so a test-redirected
466
+ // lockPath keeps its marker isolated too (PHNX-4051).
467
+ if (result.success)
468
+ writeExchangeMarker(lockPath);
469
+ return result;
407
470
  }
408
471
  catch (err) {
409
472
  return {
@@ -36,7 +36,7 @@
36
36
  * helper release now, off this table entirely.
37
37
  */
38
38
  export const HELPER_RELEASES = {
39
- menubar: { tagPrefix: 'menubar', floor: '1.1.2' },
39
+ menubar: { tagPrefix: 'menubar', floor: '1.1.4' },
40
40
  'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
41
41
  // The Windows helper is a bare .exe, not an .app bundle, so it does not share
42
42
  // helper-download.ts's zip/codesign/notarize machinery -- but it has the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.99",
3
+ "version": "1.22.100",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",