@phnx-labs/agents-cli 1.22.70 → 1.22.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +31 -1
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/repo.js +2 -2
  5. package/dist/commands/sessions-export.d.ts +5 -1
  6. package/dist/commands/sessions-export.js +100 -24
  7. package/dist/commands/sessions-import.d.ts +2 -1
  8. package/dist/commands/sessions-import.js +85 -21
  9. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  10. package/dist/lib/accounting/usage-sync.js +3 -3
  11. package/dist/lib/browser/ipc.d.ts +34 -0
  12. package/dist/lib/browser/ipc.js +140 -19
  13. package/dist/lib/browser/types.d.ts +3 -1
  14. package/dist/lib/daemon/auth-sync-service.js +1 -1
  15. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  16. package/dist/lib/daemon/daemon.js +13 -3
  17. package/dist/lib/daemon/heartbeat-service.js +3 -3
  18. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  19. package/dist/lib/daemon/runner.d.ts +18 -1
  20. package/dist/lib/daemon/runner.js +231 -78
  21. package/dist/lib/daemon/self-heal-service.js +13 -3
  22. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  23. package/dist/lib/daemon/self-update-service.js +353 -0
  24. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  25. package/dist/lib/daemon/usage-sync-service.js +1 -1
  26. package/dist/lib/daemon/watchdog-service.js +4 -4
  27. package/dist/lib/daemon-services.d.ts +1 -1
  28. package/dist/lib/daemon-services.js +5 -0
  29. package/dist/lib/device-config.d.ts +12 -1
  30. package/dist/lib/device-config.js +63 -13
  31. package/dist/lib/exec-bounded.d.ts +52 -0
  32. package/dist/lib/exec-bounded.js +113 -0
  33. package/dist/lib/feed/events.d.ts +22 -14
  34. package/dist/lib/feed/events.js +84 -44
  35. package/dist/lib/fleet-shared-state.d.ts +12 -5
  36. package/dist/lib/fleet-shared-state.js +50 -20
  37. package/dist/lib/fs-atomic.d.ts +11 -0
  38. package/dist/lib/fs-atomic.js +60 -0
  39. package/dist/lib/hosts/reconcile.d.ts +11 -4
  40. package/dist/lib/hosts/reconcile.js +31 -5
  41. package/dist/lib/project-resources.d.ts +12 -0
  42. package/dist/lib/project-resources.js +129 -0
  43. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  44. package/dist/lib/routine-process-cleanup.js +45 -34
  45. package/dist/lib/secrets/reaper.d.ts +2 -2
  46. package/dist/lib/secrets/reaper.js +13 -10
  47. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  48. package/dist/lib/secrets/reserved-sync.js +4 -4
  49. package/dist/lib/self-update.d.ts +21 -8
  50. package/dist/lib/self-update.js +54 -31
  51. package/dist/lib/session/sync/backend.d.ts +61 -0
  52. package/dist/lib/session/sync/backend.js +89 -0
  53. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  54. package/dist/lib/session/sync/managed-config.js +23 -0
  55. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  56. package/dist/lib/session/sync/managed-key.js +128 -0
  57. package/dist/lib/session/sync/net-client.d.ts +65 -0
  58. package/dist/lib/session/sync/net-client.js +117 -0
  59. package/dist/lib/session/sync/provision.d.ts +19 -0
  60. package/dist/lib/session/sync/provision.js +38 -0
  61. package/dist/lib/session/sync/r2.d.ts +5 -2
  62. package/dist/lib/session/sync/r2.js +5 -2
  63. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  64. package/dist/lib/session/sync/worker-template.js +847 -0
  65. package/dist/lib/tmux/orphan-reap.js +6 -4
  66. package/dist/lib/tmux/session.js +4 -1
  67. package/dist/lib/traces/classify.d.ts +8 -1
  68. package/dist/lib/traces/insights.d.ts +13 -1
  69. package/dist/lib/traces/insights.js +78 -3
  70. package/dist/lib/traces/sync.js +8 -3
  71. package/dist/lib/traces/worker-template.js +9 -5
  72. package/package.json +1 -1
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Async, deadline-bounded, process-group-killable subprocess exec (PHNX-3695).
3
+ *
4
+ * The daemon runs every background service on ONE Node event loop. A
5
+ * synchronous `execFileSync`/`spawnSync` on a service tick blocks that loop for
6
+ * the whole life of the child — and while it is blocked NOTHING else on the loop
7
+ * runs, including the supervisor's per-tick deadline timer and the browser IPC
8
+ * server's socket handlers. That is the wedge PHNX-3411 fixed the *symptom* of:
9
+ * the browser `version` probe (a trivial synchronous handler) "accepts but never
10
+ * replies" because the loop that would reply is frozen inside a sync spawn.
11
+ *
12
+ * `execFileBounded` is the non-blocking replacement for those tick-path spawns.
13
+ * It spawns with async `child_process.spawn`, so the event loop keeps serving
14
+ * other work while the child runs, and it bounds the child two ways:
15
+ *
16
+ * - a `timeoutMs` deadline: on expiry it SIGTERMs the child, then SIGKILLs it
17
+ * after {@link KILL_GRACE_MS} if it ignored the term — the same escalation
18
+ * `sshExecAsync` uses (ssh-exec.ts).
19
+ * - a process GROUP kill: the child is spawned as its own group leader
20
+ * (`detached` on POSIX) so the signal reaches the whole subtree, not just the
21
+ * direct child. A bare `child.kill()` would leave grandchildren running — the
22
+ * orphaned-`ps`/`powershell` leak class. Windows uses `taskkill /T`.
23
+ *
24
+ * It never throws for a non-zero exit, a signal, or a spawn error: every outcome
25
+ * is reported in the returned {@link BoundedExecResult} so a caller on a tick
26
+ * path can branch on it instead of wrapping every call in try/catch.
27
+ */
28
+ import { spawn } from 'child_process';
29
+ /** Grace between SIGTERM and SIGKILL for a child that overran its deadline. */
30
+ export const KILL_GRACE_MS = 250;
31
+ /**
32
+ * Run `file args` with a hard deadline, killing the whole process group on
33
+ * timeout. Resolves (never rejects) with the captured output and outcome.
34
+ */
35
+ export function execFileBounded(file, args, opts) {
36
+ const isWin = process.platform === 'win32';
37
+ return new Promise((resolve) => {
38
+ const child = spawn(file, args, {
39
+ cwd: opts.cwd,
40
+ env: opts.env ?? process.env,
41
+ // POSIX: lead a new process group so a timeout kill(-pid) reaches the
42
+ // whole subtree, not just the direct child. Windows has no process
43
+ // groups here — taskkill /T handles the tree instead.
44
+ detached: !isWin,
45
+ stdio: ['pipe', 'pipe', 'pipe'],
46
+ windowsHide: true,
47
+ });
48
+ let stdout = '';
49
+ let stderr = '';
50
+ let settled = false;
51
+ let timedOut = false;
52
+ let killTimer = null;
53
+ // Signal the whole group on POSIX (negative pid); on Windows tear the tree
54
+ // down with taskkill. Best-effort — an already-dead child is fine.
55
+ const killGroup = (signal) => {
56
+ if (child.pid === undefined)
57
+ return;
58
+ try {
59
+ if (isWin) {
60
+ // taskkill is itself a child; spawn it detached and forget it.
61
+ spawn('taskkill', ['/F', '/T', '/PID', String(child.pid)], { stdio: 'ignore', windowsHide: true }).on('error', () => { });
62
+ }
63
+ else {
64
+ process.kill(-child.pid, signal);
65
+ }
66
+ }
67
+ catch { /* group already gone */ }
68
+ };
69
+ const timer = setTimeout(() => {
70
+ timedOut = true;
71
+ killGroup('SIGTERM');
72
+ // SIGTERM is advisory; a wedged child can ignore it and keep the Promise
73
+ // (and the caller's tick) pending forever. Enforce a hard SIGKILL bound.
74
+ killTimer = setTimeout(() => {
75
+ if (!settled)
76
+ killGroup('SIGKILL');
77
+ }, KILL_GRACE_MS);
78
+ killTimer.unref?.();
79
+ }, opts.timeoutMs);
80
+ timer.unref?.();
81
+ const clearTimers = () => {
82
+ clearTimeout(timer);
83
+ if (killTimer)
84
+ clearTimeout(killTimer);
85
+ };
86
+ child.stdout?.setEncoding('utf-8');
87
+ child.stderr?.setEncoding('utf-8');
88
+ child.stdout?.on('data', (chunk) => { stdout += chunk; });
89
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
90
+ // A child that closes stdin early makes end()/write emit EPIPE; with no
91
+ // listener Node escalates it to an uncaught exception. Swallow it — the real
92
+ // outcome is still reported by 'close'/'error' below.
93
+ child.stdin?.on('error', () => { });
94
+ if (opts.input !== undefined)
95
+ child.stdin?.end(opts.input);
96
+ else
97
+ child.stdin?.end();
98
+ child.on('error', (err) => {
99
+ if (settled)
100
+ return;
101
+ settled = true;
102
+ clearTimers();
103
+ resolve({ code: null, stdout, stderr: stderr + err.message, timedOut });
104
+ });
105
+ child.on('close', (code) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ clearTimers();
110
+ resolve({ code, stdout, stderr, timedOut });
111
+ });
112
+ });
113
+ }
@@ -110,21 +110,21 @@ export interface CallerIdentity {
110
110
  }
111
111
  /** Identify the environment that invoked agents-cli, not the source callsite. */
112
112
  export declare function detectCaller(env?: NodeJS.ProcessEnv, stdoutIsTTY?: boolean): CallerIdentity;
113
- /**
114
- * Emit a structured event to the append-only audit log.
115
- *
116
- * @param event - The event type
117
- * @param payload - Event-specific data (agent, version, cwd, etc.)
118
- * @param overrides - Envelope fields the CALLER owns rather than the writer.
119
- * Only `ts` today: a batched out-of-process producer (`agents events emit`)
120
- * records when each event HAPPENED, but flushes them together later, so
121
- * stamping write-time would collapse a whole batch onto the flush instant and
122
- * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
123
- * *payload* still cannot inject it — this explicit channel is the only way in.
124
- */
125
113
  export declare function emit(event: EventType, payload?: EventPayload, overrides?: {
126
114
  ts?: string;
127
115
  }): void;
116
+ /**
117
+ * Async, non-blocking counterpart of {@link emit} for callers on the daemon's
118
+ * shared event loop (PHNX-3695). `emit` acquires the event-log lock with
119
+ * `withFileLock` → `sleepSync` (`Atomics.wait`), which HALTS the loop for up to
120
+ * 30s under contention (a peer appending to the same log). `emitAsync` acquires
121
+ * it with `withFileLockAsync`, so the loop keeps turning. The under-lock body is
122
+ * identical (a µs-scale append + a rare rotate). Best-effort like `emit`: never
123
+ * throws; a caller on a tick fires it and moves on.
124
+ */
125
+ export declare function emitAsync(event: EventType, payload?: EventPayload, overrides?: {
126
+ ts?: string;
127
+ }): Promise<void>;
128
128
  /**
129
129
  * Convenience wrapper for timed operations.
130
130
  * Returns a function to call when the operation completes.
@@ -135,14 +135,21 @@ export declare function emit(event: EventType, payload?: EventPayload, overrides
135
135
  * done({ exitCode: 0 }); // emits agent.run.end with durationMs
136
136
  */
137
137
  export declare function emitStart(startEvent: EventType, payload?: EventPayload): (endPayload?: EventPayload) => void;
138
- export declare function emitRoutineEnd(meta: {
138
+ interface RoutineEndMeta {
139
139
  jobName: string;
140
140
  runId?: string;
141
141
  status: string;
142
142
  duration?: number;
143
143
  exitCode?: number | null;
144
144
  detail?: string;
145
- }): void;
145
+ }
146
+ export declare function emitRoutineEnd(meta: RoutineEndMeta): void;
147
+ /**
148
+ * Async, non-blocking `routine.end` for the daemon heartbeat tick (PHNX-3695):
149
+ * the reaper (`reapExitedRunningJobs`) runs on the shared event loop, so it emits
150
+ * through {@link emitAsync} rather than the `sleepSync`-locked {@link emit}.
151
+ */
152
+ export declare function emitRoutineEndAsync(meta: RoutineEndMeta): Promise<void>;
146
153
  /**
147
154
  * Measure execution time of a synchronous function.
148
155
  * Emits a perf.timing event with the duration.
@@ -272,3 +279,4 @@ export declare function stats(options?: {
272
279
  }): EventStats;
273
280
  export declare function getLogsPath(): string;
274
281
  export declare function _resetForTest(overrideEventsPath?: string, overrideUserAgentsDir?: string): void;
282
+ export {};
@@ -16,7 +16,7 @@ import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { gzipSync, gunzipSync } from 'node:zlib';
19
- import { ensureLockTarget, withFileLock } from '../fs-atomic.js';
19
+ import { ensureLockTarget, withFileLock, withFileLockAsync } from '../fs-atomic.js';
20
20
  import { getUserAgentsDir } from '../state.js';
21
21
  import { stampProvenance, resetEventProvenanceForTest } from '../event-provenance.js';
22
22
  import { recordSample } from '../perf/spool.js';
@@ -493,48 +493,77 @@ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.st
493
493
  * corrupt every `--since` boundary. `ts` stays in RESERVED_META_KEYS so a
494
494
  * *payload* still cannot inject it — this explicit channel is the only way in.
495
495
  */
496
- export function emit(event, payload = {}, overrides = {}) {
496
+ /** Build the JSONL line + target path for an event, or null when logging is disabled. Shared by {@link emit} and {@link emitAsync}. */
497
+ function prepareEventWrite(event, payload, overrides) {
497
498
  if (isDisabled())
498
- return;
499
+ return null;
500
+ ensureLogsDir();
501
+ const caller = detectCaller();
502
+ const safePayload = sanitizePayload(payload);
503
+ const record = {
504
+ // Provenance floor first: env-sourced defaults an explicit payload overrides.
505
+ ...stampProvenance(),
506
+ ...safePayload,
507
+ ts: overrides.ts ?? new Date().toISOString(),
508
+ tz: getTimezoneOffset(),
509
+ tzName: getTimezoneName(),
510
+ hostname: os.hostname(),
511
+ platform: os.platform(),
512
+ arch: os.arch(),
513
+ pid: process.pid,
514
+ ppid: process.ppid,
515
+ event,
516
+ level: levelFor(event),
517
+ caller: caller.kind,
518
+ ...(caller.session ? { session: caller.session } : {}),
519
+ };
520
+ const line = JSON.stringify(record) + '\n';
521
+ const logPath = eventsPath();
522
+ const isNew = !fs.existsSync(logPath);
523
+ ensureLockTarget(logPath, '', DIR_MODE);
524
+ return { logPath, line, isNew };
525
+ }
526
+ /** The under-lock append + chmod + rotate/prune. Tiny local writes (µs), safe inside either the sync or async lock. */
527
+ function appendEventLocked(logPath, line, isNew) {
528
+ fs.appendFileSync(logPath, line, { mode: FILE_MODE });
529
+ if (isNew || logPath !== _chmoddedPath) {
530
+ _chmoddedPath = logPath;
531
+ try {
532
+ fs.chmodSync(logPath, FILE_MODE);
533
+ }
534
+ catch {
535
+ // May fail if not owner
536
+ }
537
+ }
538
+ const rotated = maybeGzipRotateLocked(logPath);
539
+ maybePruneLocked(rotated);
540
+ }
541
+ export function emit(event, payload = {}, overrides = {}) {
499
542
  try {
500
- ensureLogsDir();
501
- const caller = detectCaller();
502
- const safePayload = sanitizePayload(payload);
503
- const record = {
504
- // Provenance floor first: env-sourced defaults an explicit payload overrides.
505
- ...stampProvenance(),
506
- ...safePayload,
507
- ts: overrides.ts ?? new Date().toISOString(),
508
- tz: getTimezoneOffset(),
509
- tzName: getTimezoneName(),
510
- hostname: os.hostname(),
511
- platform: os.platform(),
512
- arch: os.arch(),
513
- pid: process.pid,
514
- ppid: process.ppid,
515
- event,
516
- level: levelFor(event),
517
- caller: caller.kind,
518
- ...(caller.session ? { session: caller.session } : {}),
519
- };
520
- const line = JSON.stringify(record) + '\n';
521
- const logPath = eventsPath();
522
- const isNew = !fs.existsSync(logPath);
523
- ensureLockTarget(logPath, '', DIR_MODE);
524
- withFileLock(logPath, () => {
525
- fs.appendFileSync(logPath, line, { mode: FILE_MODE });
526
- if (isNew || logPath !== _chmoddedPath) {
527
- _chmoddedPath = logPath;
528
- try {
529
- fs.chmodSync(logPath, FILE_MODE);
530
- }
531
- catch {
532
- // May fail if not owner
533
- }
534
- }
535
- const rotated = maybeGzipRotateLocked(logPath);
536
- maybePruneLocked(rotated);
537
- });
543
+ const prepared = prepareEventWrite(event, payload, overrides);
544
+ if (!prepared)
545
+ return;
546
+ withFileLock(prepared.logPath, () => appendEventLocked(prepared.logPath, prepared.line, prepared.isNew));
547
+ }
548
+ catch {
549
+ // Silent failure - logging should never break the CLI
550
+ }
551
+ }
552
+ /**
553
+ * Async, non-blocking counterpart of {@link emit} for callers on the daemon's
554
+ * shared event loop (PHNX-3695). `emit` acquires the event-log lock with
555
+ * `withFileLock` → `sleepSync` (`Atomics.wait`), which HALTS the loop for up to
556
+ * 30s under contention (a peer appending to the same log). `emitAsync` acquires
557
+ * it with `withFileLockAsync`, so the loop keeps turning. The under-lock body is
558
+ * identical (a µs-scale append + a rare rotate). Best-effort like `emit`: never
559
+ * throws; a caller on a tick fires it and moves on.
560
+ */
561
+ export async function emitAsync(event, payload = {}, overrides = {}) {
562
+ try {
563
+ const prepared = prepareEventWrite(event, payload, overrides);
564
+ if (!prepared)
565
+ return;
566
+ await withFileLockAsync(prepared.logPath, () => appendEventLocked(prepared.logPath, prepared.line, prepared.isNew));
538
567
  }
539
568
  catch {
540
569
  // Silent failure - logging should never break the CLI
@@ -558,8 +587,8 @@ export function emitStart(startEvent, payload = {}) {
558
587
  emit(startEvent.replace('.start', '.end'), { ...payload, ...endPayload, durationMs: Date.now() - startTime });
559
588
  };
560
589
  }
561
- export function emitRoutineEnd(meta) {
562
- emit('routine.end', {
590
+ function routineEndPayload(meta) {
591
+ return {
563
592
  module: 'routine',
564
593
  name: meta.jobName,
565
594
  status: meta.status,
@@ -567,7 +596,18 @@ export function emitRoutineEnd(meta) {
567
596
  ...(meta.duration != null ? { durationMs: meta.duration } : {}),
568
597
  ...(meta.exitCode != null ? { exitCode: meta.exitCode } : {}),
569
598
  ...(meta.detail ? { detail: meta.detail } : {}),
570
- });
599
+ };
600
+ }
601
+ export function emitRoutineEnd(meta) {
602
+ emit('routine.end', routineEndPayload(meta));
603
+ }
604
+ /**
605
+ * Async, non-blocking `routine.end` for the daemon heartbeat tick (PHNX-3695):
606
+ * the reaper (`reapExitedRunningJobs`) runs on the shared event loop, so it emits
607
+ * through {@link emitAsync} rather than the `sleepSync`-locked {@link emit}.
608
+ */
609
+ export async function emitRoutineEndAsync(meta) {
610
+ await emitAsync('routine.end', routineEndPayload(meta));
571
611
  }
572
612
  // ─── Timing Utilities ─────────────────────────────────────────────────────────
573
613
  /**
@@ -25,14 +25,21 @@ export interface FleetSharedStateReadResult {
25
25
  }
26
26
  /** Path owned by one device in the conflict-free tracked device-doc tree. */
27
27
  export declare function fleetSharedStatePath(device: string, userAgentsDir?: string): string;
28
- /**
29
- * Merge one daemon-owned field into this device's shared file under a real
30
- * inter-process lock. Stable serialization avoids dirtying the user repo when
31
- * neither usage nor auth state changed.
32
- */
33
28
  export declare function updateFleetSharedDeviceState(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): {
34
29
  changed: boolean;
35
30
  path: string;
36
31
  };
32
+ /**
33
+ * Async, non-blocking twin of {@link updateFleetSharedDeviceState} for the
34
+ * daemon's usage-sync / auth-sync ticks (PHNX-3695). The sync version acquires
35
+ * the file lock with `sleepSync` (`Atomics.wait`), freezing the shared event
36
+ * loop for up to 30s under contention on EVERY tick; this uses
37
+ * `withFileLockAsync`. The under-lock read is async; the atomic write is a tiny
38
+ * bounded write held inside the lock.
39
+ */
40
+ export declare function updateFleetSharedDeviceStateAsync(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): Promise<{
41
+ changed: boolean;
42
+ path: string;
43
+ }>;
37
44
  /** Read every valid peer-owned state file; malformed peers fail separately. */
38
45
  export declare function readFleetSharedDeviceStates(userAgentsDir?: string): FleetSharedStateReadResult;
@@ -8,9 +8,10 @@
8
8
  * remains the one path that may carry secret material.
9
9
  */
10
10
  import * as fs from 'node:fs';
11
+ import * as fsp from 'node:fs/promises';
11
12
  import * as path from 'node:path';
12
13
  import { assertValidDeviceName } from './devices/registry.js';
13
- import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
14
+ import { atomicWriteFileSync, ensureLockTarget, withFileLock, withFileLockAsync } from './fs-atomic.js';
14
15
  import { getUserAgentsDir } from './state.js';
15
16
  export const FLEET_SHARED_STATE_VERSION = 1;
16
17
  export const FLEET_SHARED_STATE_FILE = 'daemon-state.json';
@@ -43,32 +44,61 @@ function parseFleetSharedDeviceState(raw, owner) {
43
44
  * inter-process lock. Stable serialization avoids dirtying the user repo when
44
45
  * neither usage nor auth state changed.
45
46
  */
47
+ /** Merge `patch` onto the current on-disk state; returns the serialized next state, or null when unchanged. Shared by the sync and async writers. */
48
+ function mergeFleetState(currentRaw, device, patch) {
49
+ let current = { version: FLEET_SHARED_STATE_VERSION, device };
50
+ const trimmed = currentRaw.trim();
51
+ if (trimmed) {
52
+ try {
53
+ current = parseFleetSharedDeviceState(trimmed, device);
54
+ }
55
+ catch { /* owning device repairs its own malformed file; peers are never repaired here */ }
56
+ }
57
+ const next = {
58
+ ...current,
59
+ ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
60
+ ...(patch.auth !== undefined ? { auth: patch.auth } : {}),
61
+ version: FLEET_SHARED_STATE_VERSION,
62
+ device,
63
+ };
64
+ const serialized = `${JSON.stringify(next, null, 2)}\n`;
65
+ return { serialized, changed: currentRaw !== serialized };
66
+ }
46
67
  export function updateFleetSharedDeviceState(device, patch, userAgentsDir = getUserAgentsDir()) {
47
68
  const file = fleetSharedStatePath(device, userAgentsDir);
48
69
  ensureLockTarget(file, '');
49
70
  return withFileLock(file, () => {
50
- let current = {
51
- version: FLEET_SHARED_STATE_VERSION,
52
- device,
53
- };
71
+ let raw = '';
54
72
  try {
55
- const raw = fs.readFileSync(file, 'utf-8').trim();
56
- if (raw)
57
- current = parseFleetSharedDeviceState(raw, device);
73
+ raw = fs.readFileSync(file, 'utf-8');
58
74
  }
59
- catch {
60
- // The owning device repairs its own malformed file from authoritative
61
- // local state. Peer files are never repaired by this writer.
75
+ catch { /* missing → treat as empty */ }
76
+ const { serialized, changed } = mergeFleetState(raw, device, patch);
77
+ if (!changed)
78
+ return { changed: false, path: file };
79
+ atomicWriteFileSync(file, serialized, 'utf-8');
80
+ return { changed: true, path: file };
81
+ });
82
+ }
83
+ /**
84
+ * Async, non-blocking twin of {@link updateFleetSharedDeviceState} for the
85
+ * daemon's usage-sync / auth-sync ticks (PHNX-3695). The sync version acquires
86
+ * the file lock with `sleepSync` (`Atomics.wait`), freezing the shared event
87
+ * loop for up to 30s under contention on EVERY tick; this uses
88
+ * `withFileLockAsync`. The under-lock read is async; the atomic write is a tiny
89
+ * bounded write held inside the lock.
90
+ */
91
+ export async function updateFleetSharedDeviceStateAsync(device, patch, userAgentsDir = getUserAgentsDir()) {
92
+ const file = fleetSharedStatePath(device, userAgentsDir);
93
+ ensureLockTarget(file, '');
94
+ return withFileLockAsync(file, async () => {
95
+ let raw = '';
96
+ try {
97
+ raw = await fsp.readFile(file, 'utf-8');
62
98
  }
63
- const next = {
64
- ...current,
65
- ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
66
- ...(patch.auth !== undefined ? { auth: patch.auth } : {}),
67
- version: FLEET_SHARED_STATE_VERSION,
68
- device,
69
- };
70
- const serialized = `${JSON.stringify(next, null, 2)}\n`;
71
- if (fs.readFileSync(file, 'utf-8') === serialized)
99
+ catch { /* missing → treat as empty */ }
100
+ const { serialized, changed } = mergeFleetState(raw, device, patch);
101
+ if (!changed)
72
102
  return { changed: false, path: file };
73
103
  atomicWriteFileSync(file, serialized, 'utf-8');
74
104
  return { changed: true, path: file };
@@ -42,3 +42,14 @@ export interface FileLockOptions {
42
42
  acquireTimeoutMs?: number;
43
43
  }
44
44
  export declare function withFileLock<T>(filePath: string, fn: (heartbeat: () => void) => T, opts?: FileLockOptions): T;
45
+ /**
46
+ * Async counterpart of {@link withFileLock} for callers on the daemon's shared
47
+ * event loop (PHNX-3695). The sync version retries acquisition with
48
+ * {@link sleepSync} (`Atomics.wait`), which HALTS the thread for up to
49
+ * {@link LOCK_ACQUIRE_TIMEOUT_MS} on contention — on a daemon tick that freezes
50
+ * every service and the browser IPC server. This variant awaits proper-lockfile's
51
+ * async `lock()` and yields with a real timer between retries, so the loop keeps
52
+ * turning while a peer holds the lock. Same stale-break, same acquire budget,
53
+ * same compromised-lock surfacing.
54
+ */
55
+ export declare function withFileLockAsync<T>(filePath: string, fn: (heartbeat: () => void) => Promise<T> | T, opts?: FileLockOptions): Promise<T>;
@@ -126,3 +126,63 @@ export function withFileLock(filePath, fn, opts = {}) {
126
126
  catch { /* already gone */ }
127
127
  }
128
128
  }
129
+ /**
130
+ * Async counterpart of {@link withFileLock} for callers on the daemon's shared
131
+ * event loop (PHNX-3695). The sync version retries acquisition with
132
+ * {@link sleepSync} (`Atomics.wait`), which HALTS the thread for up to
133
+ * {@link LOCK_ACQUIRE_TIMEOUT_MS} on contention — on a daemon tick that freezes
134
+ * every service and the browser IPC server. This variant awaits proper-lockfile's
135
+ * async `lock()` and yields with a real timer between retries, so the loop keeps
136
+ * turning while a peer holds the lock. Same stale-break, same acquire budget,
137
+ * same compromised-lock surfacing.
138
+ */
139
+ export async function withFileLockAsync(filePath, fn, opts = {}) {
140
+ let release = null;
141
+ let lastError;
142
+ let compromised = null;
143
+ const staleMs = opts.staleMs ?? LOCK_STALE_MS;
144
+ const acquireTimeoutMs = opts.acquireTimeoutMs ?? LOCK_ACQUIRE_TIMEOUT_MS;
145
+ const deadline = Date.now() + acquireTimeoutMs;
146
+ for (let attempt = 0;; attempt++) {
147
+ try {
148
+ release = await lockfile.lock(filePath, {
149
+ stale: staleMs,
150
+ onCompromised: (err) => { compromised = err; },
151
+ });
152
+ break;
153
+ }
154
+ catch (err) {
155
+ lastError = err;
156
+ if (Date.now() >= deadline)
157
+ break;
158
+ const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
159
+ await new Promise((r) => setTimeout(r, Math.min(backoff, Math.max(0, deadline - Date.now()))));
160
+ }
161
+ }
162
+ if (!release) {
163
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
164
+ throw new Error(`Could not acquire lock for ${filePath} after ${acquireTimeoutMs}ms: ${message}`);
165
+ }
166
+ const lockDir = `${filePath}.lock`;
167
+ const heartbeat = () => {
168
+ try {
169
+ const now = new Date();
170
+ fs.utimesSync(lockDir, now, now);
171
+ }
172
+ catch { /* best effort */ }
173
+ };
174
+ try {
175
+ const result = await fn(heartbeat);
176
+ if (compromised) {
177
+ throw new Error(`Lock for ${filePath} was broken by another process while held: ` +
178
+ `${compromised.message}`);
179
+ }
180
+ return result;
181
+ }
182
+ finally {
183
+ try {
184
+ await release();
185
+ }
186
+ catch { /* already gone */ }
187
+ }
188
+ }
@@ -30,18 +30,25 @@ export type RemoteExitState = {
30
30
  * empty stdout, and a truncate-then-write mid-race is a sub-ms empty window).
31
31
  */
32
32
  export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout' | 'timedOut'>): RemoteExitState;
33
+ export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): RemoteExitState;
33
34
  /**
34
- * Read a task's remote `.exit` over ssh and classify it. `remoteExit` is a
35
- * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
36
- * remote shell expands $HOME (same contract as progress.ts's fetch).
35
+ * Async twin of {@link readRemoteExit} for the daemon's heartbeat tick — the ssh
36
+ * read runs on the shared event loop, so it MUST NOT block it with a synchronous
37
+ * `spawnSync('ssh', …)` (PHNX-3695). `sshExecAsync` applies the same
38
+ * timeout/kill-grace bound.
37
39
  */
38
- export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): RemoteExitState;
40
+ export declare function readRemoteExitAsync(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): Promise<RemoteExitState>;
39
41
  /**
40
42
  * Heal one record. Terminal records are immutable (and never re-probed); a
41
43
  * `running` record is resolved to completed/failed only when the remote `.exit`
42
44
  * holds a code. Returns the (possibly updated) task.
43
45
  */
44
46
  export declare function reconcileTask(task: HostTask): HostTask;
47
+ /**
48
+ * Async twin of {@link reconcileTask} for the daemon heartbeat tick (PHNX-3695):
49
+ * identical healing logic, non-blocking ssh read via {@link readRemoteExitAsync}.
50
+ */
51
+ export declare function reconcileTaskAsync(task: HostTask): Promise<HostTask>;
45
52
  /**
46
53
  * Heal a list of records for a listing (`agents devices ps`). Only `running` tasks
47
54
  * are probed; each host is reachability-checked ONCE (deduped by target) so a
@@ -10,7 +10,7 @@
10
10
  * record. We only ever CONFIRM completion; an unreachable host or an absent
11
11
  * `.exit` leaves the record `running` (we never guess failure).
12
12
  */
13
- import { sshExec } from '../ssh-exec.js';
13
+ import { sshExec, sshExecAsync } from '../ssh-exec.js';
14
14
  import { updateTask, terminalPatch } from './tasks.js';
15
15
  import { encodePowershell } from './remote-cmd.js';
16
16
  /**
@@ -36,15 +36,29 @@ export function classifyExit(res) {
36
36
  * $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
37
37
  * remote shell expands $HOME (same contract as progress.ts's fetch).
38
38
  */
39
- export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
40
- const command = remoteShell === 'powershell'
39
+ function remoteExitCommand(remoteExit, remoteShell) {
40
+ return remoteShell === 'powershell'
41
41
  ? `powershell -NoProfile -EncodedCommand ${encodePowershell(`$path = Join-Path $HOME '${remoteExit.replace(/^\$HOME\//, '').replace(/'/g, "''")}'; if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path -Raw }`)}`
42
42
  : `cat ${remoteExit} 2>/dev/null`;
43
- return classifyExit(sshExec(target, command, {
43
+ }
44
+ function remoteExitSshOpts(timeoutMs, identityFile) {
45
+ return {
44
46
  timeoutMs,
45
47
  multiplex: true,
46
48
  extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
47
- }));
49
+ };
50
+ }
51
+ export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
52
+ return classifyExit(sshExec(target, remoteExitCommand(remoteExit, remoteShell), remoteExitSshOpts(timeoutMs, identityFile)));
53
+ }
54
+ /**
55
+ * Async twin of {@link readRemoteExit} for the daemon's heartbeat tick — the ssh
56
+ * read runs on the shared event loop, so it MUST NOT block it with a synchronous
57
+ * `spawnSync('ssh', …)` (PHNX-3695). `sshExecAsync` applies the same
58
+ * timeout/kill-grace bound.
59
+ */
60
+ export async function readRemoteExitAsync(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
61
+ return classifyExit(await sshExecAsync(target, remoteExitCommand(remoteExit, remoteShell), remoteExitSshOpts(timeoutMs, identityFile)));
48
62
  }
49
63
  /**
50
64
  * Heal one record. Terminal records are immutable (and never re-probed); a
@@ -59,6 +73,18 @@ export function reconcileTask(task) {
59
73
  return task;
60
74
  return updateTask(task.id, terminalPatch(st.code)) ?? task;
61
75
  }
76
+ /**
77
+ * Async twin of {@link reconcileTask} for the daemon heartbeat tick (PHNX-3695):
78
+ * identical healing logic, non-blocking ssh read via {@link readRemoteExitAsync}.
79
+ */
80
+ export async function reconcileTaskAsync(task) {
81
+ if (task.status !== 'running')
82
+ return task;
83
+ const st = await readRemoteExitAsync(task.target, task.remoteExit, 6000, task.identityFile, task.remoteShell);
84
+ if (st.state !== 'done')
85
+ return task;
86
+ return updateTask(task.id, terminalPatch(st.code)) ?? task;
87
+ }
62
88
  /**
63
89
  * Heal a list of records for a listing (`agents devices ps`). Only `running` tasks
64
90
  * are probed; each host is reachability-checked ONCE (deduped by target) so a
@@ -5,6 +5,18 @@ export interface ProjectResourceSyncResult {
5
5
  }
6
6
  export declare function projectAgentRoot(projectRoot: string, agent: AgentId): string;
7
7
  export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
8
+ /**
9
+ * Turn the manifest's managed paths (relative to agentRoot) into anchored,
10
+ * POSIX, projectRoot-relative `.gitignore` entries. Two guards keep it honest:
11
+ * - drop any path that escapes the harness config dir (e.g. grok writes
12
+ * commands back into the tracked `.agents/` tree via a `../` subdir —
13
+ * ignoring that would hide tracked source; separate bug, PHNX-3718);
14
+ * - the manifest only ever holds paths the sync itself generated (pre-existing
15
+ * user/committed files are skipped and never recorded), so ignoring exactly
16
+ * these never masks a hand-authored or committed file (e.g. a repo that
17
+ * commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
18
+ */
19
+ export declare function managedGitignoreEntries(agentRoot: string, projectRoot: string, managed: string[]): string[];
8
20
  /**
9
21
  * One human line for the files a project sync left alone because you already
10
22
  * wrote them. This is the normal steady state — every sync of a project whose