@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.
- package/CHANGELOG.md +18 -0
- package/README.md +31 -1
- package/dist/bootstrap.js +4 -4
- package/dist/commands/repo.js +2 -2
- package/dist/commands/sessions-export.d.ts +5 -1
- package/dist/commands/sessions-export.js +100 -24
- package/dist/commands/sessions-import.d.ts +2 -1
- package/dist/commands/sessions-import.js +85 -21
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +140 -19
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/daemon/auth-sync-service.js +1 -1
- package/dist/lib/daemon/browser-task-reap-service.js +1 -1
- package/dist/lib/daemon/daemon.js +13 -3
- package/dist/lib/daemon/heartbeat-service.js +3 -3
- package/dist/lib/daemon/keychain-reap-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +231 -78
- package/dist/lib/daemon/self-heal-service.js +13 -3
- package/dist/lib/daemon/self-update-service.d.ts +174 -0
- package/dist/lib/daemon/self-update-service.js +353 -0
- package/dist/lib/daemon/state-dir-check-service.js +3 -3
- package/dist/lib/daemon/usage-sync-service.js +1 -1
- package/dist/lib/daemon/watchdog-service.js +4 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +12 -1
- package/dist/lib/device-config.js +63 -13
- package/dist/lib/exec-bounded.d.ts +52 -0
- package/dist/lib/exec-bounded.js +113 -0
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/fleet-shared-state.d.ts +12 -5
- package/dist/lib/fleet-shared-state.js +50 -20
- package/dist/lib/fs-atomic.d.ts +11 -0
- package/dist/lib/fs-atomic.js +60 -0
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/project-resources.d.ts +12 -0
- package/dist/lib/project-resources.js +129 -0
- package/dist/lib/routine-process-cleanup.d.ts +2 -2
- package/dist/lib/routine-process-cleanup.js +45 -34
- package/dist/lib/secrets/reaper.d.ts +2 -2
- package/dist/lib/secrets/reaper.js +13 -10
- package/dist/lib/secrets/reserved-sync.d.ts +1 -1
- package/dist/lib/secrets/reserved-sync.js +4 -4
- package/dist/lib/self-update.d.ts +21 -8
- package/dist/lib/self-update.js +54 -31
- package/dist/lib/session/sync/backend.d.ts +61 -0
- package/dist/lib/session/sync/backend.js +89 -0
- package/dist/lib/session/sync/managed-config.d.ts +29 -0
- package/dist/lib/session/sync/managed-config.js +23 -0
- package/dist/lib/session/sync/managed-key.d.ts +45 -0
- package/dist/lib/session/sync/managed-key.js +128 -0
- package/dist/lib/session/sync/net-client.d.ts +65 -0
- package/dist/lib/session/sync/net-client.js +117 -0
- package/dist/lib/session/sync/provision.d.ts +19 -0
- package/dist/lib/session/sync/provision.js +38 -0
- package/dist/lib/session/sync/r2.d.ts +5 -2
- package/dist/lib/session/sync/r2.js +5 -2
- package/dist/lib/session/sync/worker-template.d.ts +6 -0
- package/dist/lib/session/sync/worker-template.js +847 -0
- package/dist/lib/tmux/orphan-reap.js +6 -4
- package/dist/lib/tmux/session.js +4 -1
- package/dist/lib/traces/classify.d.ts +8 -1
- package/dist/lib/traces/insights.d.ts +13 -1
- package/dist/lib/traces/insights.js +78 -3
- package/dist/lib/traces/sync.js +8 -3
- package/dist/lib/traces/worker-template.js +9 -5
- 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
|
-
|
|
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
|
-
}
|
|
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 {};
|
package/dist/lib/feed/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
|
51
|
-
version: FLEET_SHARED_STATE_VERSION,
|
|
52
|
-
device,
|
|
53
|
-
};
|
|
71
|
+
let raw = '';
|
|
54
72
|
try {
|
|
55
|
-
|
|
56
|
-
if (raw)
|
|
57
|
-
current = parseFleetSharedDeviceState(raw, device);
|
|
73
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
58
74
|
}
|
|
59
|
-
catch {
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 };
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|