@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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon self-update service (PHNX-3695, "Fix 2").
|
|
3
|
+
*
|
|
4
|
+
* The daemon (`agents __daemon-run`) is a long-running background process that
|
|
5
|
+
* historically opted OUT of the CLI's interactive auto-update: `bootstrap.ts`
|
|
6
|
+
* force-sets `AGENTS_CLI_DISABLE_AUTO_UPDATE=1` for `__daemon-run`, so a
|
|
7
|
+
* running daemon never picked up new agents-cli code until a human ran
|
|
8
|
+
* `agents daemon restart`. R5 in the root CLAUDE.md ("An installed CLI and its
|
|
9
|
+
* installed helpers auto-update from the public channel") is binding for the
|
|
10
|
+
* interactive CLI path (`self-update.ts` / `agents upgrade`) but was silently
|
|
11
|
+
* NOT held for the one process that runs unattended for days. This service
|
|
12
|
+
* closes that gap for the daemon specifically, reusing the exact same
|
|
13
|
+
* verified-install primitives `agents upgrade` already uses — it does not
|
|
14
|
+
* fork or reimplement them.
|
|
15
|
+
*
|
|
16
|
+
* Model: verify-then-exit, not swap-in-place. A daemon cannot safely hot-swap
|
|
17
|
+
* its own loaded JS mid-process (in-flight ticks, open sockets, a live
|
|
18
|
+
* ServiceSupervisor). Instead this tick installs + BYTE-VERIFIES the new
|
|
19
|
+
* package on disk, and only once that succeeds does it `process.exit(0)` —
|
|
20
|
+
* the OS supervisor (launchd `KeepAlive` / systemd `Restart=always`, see
|
|
21
|
+
* `daemon/AGENTS.md`'s crash-recovery model) relaunches the daemon, which
|
|
22
|
+
* then boots the new code. Clients do not need to be told anything: browser
|
|
23
|
+
* IPC clients re-probe the socket via `waitForBrowserService`
|
|
24
|
+
* (`browser/ipc.ts`), and the scheduler's atomic `(routine, scheduledFor)`
|
|
25
|
+
* claim (see `docs/specifications.md` §Scheduling & execution singularity)
|
|
26
|
+
* means a routine mid-fire at the moment of exit is deduped safely across the
|
|
27
|
+
* restart rather than double-fired.
|
|
28
|
+
*
|
|
29
|
+
* Fail-closed is the whole point: every step below that can fail — the
|
|
30
|
+
* registry check, the install, the post-install verify — leaves the OLD
|
|
31
|
+
* daemon running untouched and logs a WARN/ERROR for the next tick to retry.
|
|
32
|
+
* The daemon must never exit into code it has not proven is the real,
|
|
33
|
+
* verified, requested version; an unverified exit would let the OS supervisor
|
|
34
|
+
* relaunch-loop on a broken install.
|
|
35
|
+
*/
|
|
36
|
+
import { BasePeriodicService } from './service.js';
|
|
37
|
+
import { getCliVersion } from '../version.js';
|
|
38
|
+
import { isDevVersionStamp } from '../startup/dev-build.js';
|
|
39
|
+
import { detectAgentsBinaryShadows } from '../binary-shadow.js';
|
|
40
|
+
import { compareVersions } from '../agent-spec/primitives.js';
|
|
41
|
+
import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, downloadVerifiedTarball, ensureGlobalBinLinks, installPackageIntoPrefix, installPackageWithBun, refreshAliasShims, resolveRunningPackageRoot, sweepStaleInstallStaging, verifyInstalledVersion, } from '../self-update.js';
|
|
42
|
+
import { tryAutoPullSystemRepo } from '../git.js';
|
|
43
|
+
import { getSystemAgentsDir } from '../state.js';
|
|
44
|
+
import { runUmbrellaSync } from '../sync-umbrella.js';
|
|
45
|
+
import * as fs from 'fs';
|
|
46
|
+
import * as path from 'path';
|
|
47
|
+
import { fileURLToPath } from 'url';
|
|
48
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
/** Runs roughly every 75 minutes — self-update is not urgent (unlike self-heal's 6h drift repair, it changes running code, so it stays well under a day but still infrequent). */
|
|
50
|
+
const SELF_UPDATE_TICK_MS = 75 * 60_000;
|
|
51
|
+
/**
|
|
52
|
+
* Hard cap per tick: a real download + npm/bun install + verify can
|
|
53
|
+
* legitimately take minutes on a slow link. 15 minutes matches the task's
|
|
54
|
+
* stated budget and is short relative to the ~75min cadence. Exported so the
|
|
55
|
+
* on-demand `request-self-update` IPC handler (`browser/ipc.ts`) can bound
|
|
56
|
+
* its own `AbortController` on the SAME budget the periodic tick runs under —
|
|
57
|
+
* one deadline, not two independently-tuned numbers that could drift apart.
|
|
58
|
+
*/
|
|
59
|
+
export const SELF_UPDATE_DEADLINE_MS = 15 * 60_000;
|
|
60
|
+
/**
|
|
61
|
+
* First tick fires 5 minutes after daemon boot — deliberately longer than
|
|
62
|
+
* self-heal's 30s stagger (`self-heal-service.ts`): self-heal repairs local
|
|
63
|
+
* drift and is cheap/safe to run immediately, while self-update can replace
|
|
64
|
+
* the running package and exit the process, which should never be the very
|
|
65
|
+
* first thing a freshly-started daemon does (give the box a moment to finish
|
|
66
|
+
* settling — shims, PATH, other services' startup ticks — before considering
|
|
67
|
+
* a restart). Every later tick still fires on the normal cadence.
|
|
68
|
+
*/
|
|
69
|
+
const SELF_UPDATE_STARTUP_DELAY_MS = 5 * 60_000;
|
|
70
|
+
export async function fetchLatestNpmMetadata(signal) {
|
|
71
|
+
const response = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`, { signal });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`registry.npmjs.org responded ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
const data = await response.json();
|
|
76
|
+
if (typeof data.version !== 'string'
|
|
77
|
+
|| typeof data.dist?.integrity !== 'string'
|
|
78
|
+
|| typeof data.dist?.tarball !== 'string') {
|
|
79
|
+
throw new Error('npm registry response did not include version, integrity, and tarball');
|
|
80
|
+
}
|
|
81
|
+
return { version: data.version, integrity: data.dist.integrity, tarball: data.dist.tarball };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Install + byte-verify `metadata` into `packageRoot`'s install, exactly the
|
|
85
|
+
* sequence `bootstrap.ts`'s `installResolvedPackage` runs for `agents
|
|
86
|
+
* upgrade` (download+integrity-verify -> sweep stale staging -> package-
|
|
87
|
+
* manager install -> verify installed version -> refresh alias shims).
|
|
88
|
+
* `bootstrap.ts` cannot be imported here — it runs side-effecting top-level
|
|
89
|
+
* code (argv parsing, command registration) on import, which the daemon must
|
|
90
|
+
* never trigger — so this is the same primitives from `self-update.ts`
|
|
91
|
+
* composed directly, not a fork of the upgrade logic.
|
|
92
|
+
*
|
|
93
|
+
* `signal` is threaded into EVERY step's own `signal` option
|
|
94
|
+
* (`downloadVerifiedTarball` / `installPackageIntoPrefix` /
|
|
95
|
+
* `installPackageWithBun`, all in `self-update.ts`) rather than raced against
|
|
96
|
+
* from the outside: those primitives kill the underlying fetch/child process
|
|
97
|
+
* on abort and their promise rejects only once that real cancellation has
|
|
98
|
+
* happened. A wrapper that merely stopped AWAITING an unkillable operation
|
|
99
|
+
* (the prior approach here) left an orphaned `npm install -g`/`bun add -g`
|
|
100
|
+
* writing into the shared global prefix — which the very next tick's fresh
|
|
101
|
+
* install (started as soon as the supervisor's backoff fires, seconds later)
|
|
102
|
+
* would then race into the same directory. Real cancellation is what makes
|
|
103
|
+
* `attemptSelfUpdateAndExit`'s `inFlightAttempt` dedupe (below) an actual
|
|
104
|
+
* guarantee instead of a guard whose lifetime is shorter than the operation
|
|
105
|
+
* it's guarding (found in review, PHNX-3695).
|
|
106
|
+
*/
|
|
107
|
+
export async function installAndVerifyDefault(metadata, packageRoot, signal) {
|
|
108
|
+
const tarball = await downloadVerifiedTarball(metadata.tarball, metadata.integrity, 60_000, signal);
|
|
109
|
+
try {
|
|
110
|
+
await sweepStaleInstallStaging(packageRoot);
|
|
111
|
+
if (detectPackageManager(packageRoot) === 'bun') {
|
|
112
|
+
await installPackageWithBun(tarball, signal);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
await installPackageIntoPrefix(tarball, deriveGlobalPrefix(packageRoot), signal);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
try {
|
|
120
|
+
await fs.promises.rm(path.dirname(tarball), { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* leave it for the OS temp sweep */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await verifyInstalledVersion(packageRoot, metadata.version);
|
|
127
|
+
await refreshAliasShims(packageRoot, signal);
|
|
128
|
+
// PHNX-2768: mirror `bootstrap.ts`'s `installResolvedPackage` — an
|
|
129
|
+
// `--ignore-scripts` install (both package-manager paths above) can leave
|
|
130
|
+
// the package.json at the new version but the global bin links
|
|
131
|
+
// (agents/ag/browser/computer) GONE. Without this, a self-update tick could
|
|
132
|
+
// exit `updated: true` while every operator-typed `agents` command on that
|
|
133
|
+
// box now reads "command not found," with no signal anywhere pointing at
|
|
134
|
+
// why. Same fail-loud contract as the interactive path: a link that cannot
|
|
135
|
+
// be made to resolve fails the whole attempt rather than reporting success.
|
|
136
|
+
if (detectPackageManager(packageRoot) !== 'bun' && process.platform !== 'win32') {
|
|
137
|
+
const prefix = deriveGlobalPrefix(packageRoot);
|
|
138
|
+
const repairs = await ensureGlobalBinLinks(packageRoot, prefix);
|
|
139
|
+
const failed = repairs.filter((r) => r.action === 'failed');
|
|
140
|
+
if (failed.length > 0) {
|
|
141
|
+
const relink = failed
|
|
142
|
+
.map((r) => `ln -sf ${path.relative(path.dirname(r.linkPath), r.target)} ${r.linkPath}`)
|
|
143
|
+
.join(' && ');
|
|
144
|
+
throw new Error(`upgraded to ${metadata.version} but could not restore the ` +
|
|
145
|
+
`${failed.map((r) => r.name).join(', ')} command link${failed.length === 1 ? '' : 's'} in ` +
|
|
146
|
+
`${path.join(prefix, 'bin')} (${failed.map((r) => r.error).join('; ')}). ` +
|
|
147
|
+
`The box has the new package but no working \`agents\` — relink manually: ${relink}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function defaultSelfUpdateDeps() {
|
|
152
|
+
return {
|
|
153
|
+
currentVersion: () => getCliVersion(),
|
|
154
|
+
isDevBuild: () => isDevVersionStamp(getCliVersion()),
|
|
155
|
+
detectShadow: () => detectAgentsBinaryShadows().length > 0,
|
|
156
|
+
packageRoot: () => resolveRunningPackageRoot(__dirname),
|
|
157
|
+
fetchLatestMetadata: fetchLatestNpmMetadata,
|
|
158
|
+
installAndVerify: installAndVerifyDefault,
|
|
159
|
+
syncSystemRepo: async () => {
|
|
160
|
+
const result = await tryAutoPullSystemRepo(getSystemAgentsDir());
|
|
161
|
+
if (result.refused) {
|
|
162
|
+
throw new Error(`system repo origin '${result.actualRemote}' is not the expected system remote — refused`);
|
|
163
|
+
}
|
|
164
|
+
if (result.error) {
|
|
165
|
+
throw new Error(result.error);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
syncLocal: async () => {
|
|
169
|
+
// Reconcile-only (no fetch — the .system pull above already fetched);
|
|
170
|
+
// best-effort, same as the system-repo pull: a declined resource here
|
|
171
|
+
// is not a self-update failure.
|
|
172
|
+
await runUmbrellaSync({
|
|
173
|
+
flags: { local: true },
|
|
174
|
+
log: () => { },
|
|
175
|
+
yes: true,
|
|
176
|
+
quiet: true,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Dedupes concurrent callers onto ONE in-flight attempt. The periodic tick
|
|
183
|
+
* and an on-demand `request-self-update` IPC call (possibly several, if more
|
|
184
|
+
* than one version-skewed client reconnects at once) can overlap in the same
|
|
185
|
+
* process — without this, two concurrent `installAndVerify` calls race on the
|
|
186
|
+
* same package-manager install directory. Keyed process-wide (not per-deps)
|
|
187
|
+
* since production always shares one `defaultSelfUpdateDeps()` install target;
|
|
188
|
+
* tests inject distinct `deps` per case and don't run concurrently with each
|
|
189
|
+
* other, so this never cross-contaminates test outcomes.
|
|
190
|
+
*/
|
|
191
|
+
let inFlightAttempt = null;
|
|
192
|
+
/**
|
|
193
|
+
* Core self-update decision + action, shared by the periodic tick
|
|
194
|
+
* ({@link SelfUpdateService.onTick}) and the on-demand IPC path
|
|
195
|
+
* (`request-self-update`, `browser/ipc.ts`) — one implementation, so a
|
|
196
|
+
* version-skew client asking "update now" runs exactly the same fail-closed
|
|
197
|
+
* logic as the scheduled sweep. Returns rather than throws so callers decide
|
|
198
|
+
* their own exit timing (the periodic service exits immediately; the IPC
|
|
199
|
+
* handler must respond to the client on the socket BEFORE exiting, or the
|
|
200
|
+
* client hangs on a socket that is closing mid-write). Concurrent callers
|
|
201
|
+
* share one in-flight attempt rather than racing separate installs.
|
|
202
|
+
*/
|
|
203
|
+
export async function attemptSelfUpdateAndExit(ctx, signal, deps = defaultSelfUpdateDeps()) {
|
|
204
|
+
if (inFlightAttempt)
|
|
205
|
+
return inFlightAttempt;
|
|
206
|
+
const attempt = runSelfUpdateAttempt(ctx, signal, deps);
|
|
207
|
+
inFlightAttempt = attempt;
|
|
208
|
+
try {
|
|
209
|
+
return await attempt;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
if (inFlightAttempt === attempt)
|
|
213
|
+
inFlightAttempt = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function runSelfUpdateAttempt(ctx, signal, deps) {
|
|
217
|
+
const syncDecline = selfUpdateSyncDeclineReason(deps);
|
|
218
|
+
if (syncDecline)
|
|
219
|
+
return { updated: false, reason: syncDecline };
|
|
220
|
+
const current = deps.currentVersion();
|
|
221
|
+
let metadata;
|
|
222
|
+
try {
|
|
223
|
+
metadata = await deps.fetchLatestMetadata(signal);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
227
|
+
ctx.log('WARN', `self-update: registry check failed, staying on ${current}: ${message}`);
|
|
228
|
+
return { updated: false, reason: 'registry check failed' };
|
|
229
|
+
}
|
|
230
|
+
if (compareVersions(metadata.version, current) <= 0) {
|
|
231
|
+
return { updated: false, reason: `already current (${current})` };
|
|
232
|
+
}
|
|
233
|
+
const packageRoot = deps.packageRoot();
|
|
234
|
+
try {
|
|
235
|
+
await deps.installAndVerify(metadata, packageRoot, signal);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
239
|
+
ctx.log('ERROR', `self-update: install/verify of ${metadata.version} failed, staying on ${current}: ${message}`);
|
|
240
|
+
return { updated: false, reason: 'install or verify failed' };
|
|
241
|
+
}
|
|
242
|
+
// Best-effort from here: the CLI package itself is already installed and
|
|
243
|
+
// byte-verified, so a failure pulling the companion .system repo or
|
|
244
|
+
// reconciling resources must not undo a good CLI upgrade or block the exit
|
|
245
|
+
// that lets the OS supervisor relaunch onto it — it is logged and left for
|
|
246
|
+
// the NEXT tick (which runs on the new code) to retry.
|
|
247
|
+
try {
|
|
248
|
+
await deps.syncSystemRepo();
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
252
|
+
ctx.log('WARN', `self-update: .system repo pull failed: ${message}`);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
await deps.syncLocal();
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
259
|
+
ctx.log('WARN', `self-update: local reconcile ('agents sync --local') failed: ${message}`);
|
|
260
|
+
}
|
|
261
|
+
ctx.log('INFO', `self-update: verified ${current} -> ${metadata.version}; exiting for OS-supervisor relaunch`);
|
|
262
|
+
return { updated: true };
|
|
263
|
+
}
|
|
264
|
+
/** How long `scheduleSelfUpdateExit` waits before `process.exit(0)` — long enough for an IPC handler's `socket.write` to flush to the OS. */
|
|
265
|
+
const SELF_UPDATE_EXIT_DELAY_MS = 250;
|
|
266
|
+
let exitScheduled = false;
|
|
267
|
+
/**
|
|
268
|
+
* Schedule the process exit for a verified self-update, exactly once, no
|
|
269
|
+
* matter how many callers observe `outcome.updated` on the shared
|
|
270
|
+
* `inFlightAttempt` promise. The periodic tick and an on-demand
|
|
271
|
+
* `request-self-update` IPC call (`browser/ipc.ts`) can both be awaiting that
|
|
272
|
+
* SAME promise — if the tick's continuation ran an immediate `process.exit(0)`
|
|
273
|
+
* while the IPC handler's continuation had not yet reached `socket.write`,
|
|
274
|
+
* the tick's exit could win the race and the client would see a closed socket
|
|
275
|
+
* before any response (found in review, PHNX-3695). Routing every caller
|
|
276
|
+
* through this one guarded, always-delayed scheduling point means the delay
|
|
277
|
+
* protects EVERY caller's in-flight response, not just the IPC handler's own.
|
|
278
|
+
*/
|
|
279
|
+
export function scheduleSelfUpdateExit() {
|
|
280
|
+
if (exitScheduled)
|
|
281
|
+
return;
|
|
282
|
+
exitScheduled = true;
|
|
283
|
+
setTimeout(() => process.exit(0), SELF_UPDATE_EXIT_DELAY_MS);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Fire the on-demand self-update in the BACKGROUND and return its (bounded)
|
|
287
|
+
* promise WITHOUT the caller having to await it. This is what keeps the
|
|
288
|
+
* `request-self-update` IPC handler (`browser/ipc.ts`) from parking a
|
|
289
|
+
* version-skewed `agents browser` verb behind the full
|
|
290
|
+
* check→download→install→verify: that handler routes through
|
|
291
|
+
* `reconcileDaemonVersion` on every version-skewed call, so awaiting the whole
|
|
292
|
+
* install there reintroduces exactly the client-stall PHNX-3605 was written to
|
|
293
|
+
* prevent (tens of seconds, worst case ~15 min). The handler instead responds
|
|
294
|
+
* "triggered" immediately and lets this run in the background — the daemon does
|
|
295
|
+
* install→verify→exit(0) on its own, the OS supervisor relaunches it, and the
|
|
296
|
+
* browser reconnects.
|
|
297
|
+
*
|
|
298
|
+
* The work still shares the module-level {@link attemptSelfUpdateAndExit}
|
|
299
|
+
* `inFlightAttempt` guard, so a concurrent trigger (or the periodic tick) can't
|
|
300
|
+
* race a second install into the same prefix. It is bounded by
|
|
301
|
+
* {@link SELF_UPDATE_DEADLINE_MS} via an `AbortController` nobody awaits (the
|
|
302
|
+
* timer is `unref`'d so it never keeps the daemon alive on its own and never
|
|
303
|
+
* dangles in a test). Fail-closed is preserved end to end:
|
|
304
|
+
* `runSelfUpdateAttempt` already turns an install/verify failure into a
|
|
305
|
+
* not-updated outcome that leaves the running daemon untouched. The caller
|
|
306
|
+
* schedules the one decoupled {@link scheduleSelfUpdateExit} off the returned
|
|
307
|
+
* promise once `updated` is true, so the exit still fires after the IPC
|
|
308
|
+
* response has flushed.
|
|
309
|
+
*/
|
|
310
|
+
export function triggerSelfUpdateInBackground(ctx, deps = defaultSelfUpdateDeps()) {
|
|
311
|
+
const controller = new AbortController();
|
|
312
|
+
const timeout = setTimeout(() => controller.abort(), SELF_UPDATE_DEADLINE_MS);
|
|
313
|
+
if (typeof timeout.unref === 'function')
|
|
314
|
+
timeout.unref();
|
|
315
|
+
return attemptSelfUpdateAndExit(ctx, controller.signal, deps).finally(() => clearTimeout(timeout));
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The subset of self-update decline checks that are INSTANT and network-free —
|
|
319
|
+
* a dev build, or a shadowed install. The on-demand IPC handler
|
|
320
|
+
* (`request-self-update`) runs these SYNCHRONOUSLY so a version-skewed browser
|
|
321
|
+
* client gets the PHNX-3605 "nothing changed, not evicting" advisory
|
|
322
|
+
* immediately, instead of a "triggered" it would never act on. The remaining
|
|
323
|
+
* checks (registry probe, already-current, install/verify) stay inside the
|
|
324
|
+
* backgrounded {@link attemptSelfUpdateAndExit} so the handler never blocks on
|
|
325
|
+
* the network or the install. This is the single source of the two instant
|
|
326
|
+
* decline reasons — {@link runSelfUpdateAttempt} calls it too, so the on-demand
|
|
327
|
+
* decline text can never drift from the periodic tick's. Returns the decline
|
|
328
|
+
* reason, or `null` to proceed to the (backgrounded) install path.
|
|
329
|
+
*/
|
|
330
|
+
export function selfUpdateSyncDeclineReason(deps = defaultSelfUpdateDeps()) {
|
|
331
|
+
if (deps.isDevBuild())
|
|
332
|
+
return 'dev build — self-update is a no-op';
|
|
333
|
+
if (deps.detectShadow())
|
|
334
|
+
return 'another agents binary shadows this install — self-update is a no-op';
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
export class SelfUpdateService extends BasePeriodicService {
|
|
338
|
+
id = 'self-update';
|
|
339
|
+
intervalMs = SELF_UPDATE_TICK_MS;
|
|
340
|
+
deadlineMs = SELF_UPDATE_DEADLINE_MS;
|
|
341
|
+
startupDelayMs = SELF_UPDATE_STARTUP_DELAY_MS;
|
|
342
|
+
async onStart(_ctx) {
|
|
343
|
+
// No connections/handles to open — each tick checks the registry fresh.
|
|
344
|
+
}
|
|
345
|
+
async onStop() {
|
|
346
|
+
// Nothing to release — the supervisor's timer teardown is the only cleanup needed.
|
|
347
|
+
}
|
|
348
|
+
async onTick(ctx, signal) {
|
|
349
|
+
const outcome = await attemptSelfUpdateAndExit(ctx, signal);
|
|
350
|
+
if (outcome.updated)
|
|
351
|
+
scheduleSelfUpdateExit();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
* `supervisor.start()` pairing this requires.
|
|
24
24
|
*/
|
|
25
25
|
import { BasePeriodicService } from './service.js';
|
|
26
|
-
import * as
|
|
26
|
+
import * as fsp from 'fs/promises';
|
|
27
27
|
/** Matches the historical inline interval (daemon.ts STATE_DIR_CHECK_TICK_MS), overridable for tests. */
|
|
28
28
|
const STATE_DIR_CHECK_TICK_MS = 60_000;
|
|
29
|
-
/** Hard cap per tick — a single
|
|
29
|
+
/** Hard cap per tick — a single async file read, far above what it could ever need. */
|
|
30
30
|
const STATE_DIR_CHECK_DEADLINE_MS = 5_000;
|
|
31
31
|
export class StateDirCheckService extends BasePeriodicService {
|
|
32
32
|
id = 'state-dir-check';
|
|
@@ -50,7 +50,7 @@ export class StateDirCheckService extends BasePeriodicService {
|
|
|
50
50
|
async onTick(ctx) {
|
|
51
51
|
let markerMatches = false;
|
|
52
52
|
try {
|
|
53
|
-
markerMatches =
|
|
53
|
+
markerMatches = (await fsp.readFile(this.lifetimePath, 'utf-8')) === this.lifetimeToken;
|
|
54
54
|
}
|
|
55
55
|
catch {
|
|
56
56
|
// A missing state dir or marker is the condition this guard detects.
|
|
@@ -23,7 +23,7 @@ export class UsageSyncService extends BasePeriodicService {
|
|
|
23
23
|
}
|
|
24
24
|
async onTick(ctx) {
|
|
25
25
|
const { consumeUsageSnapshotsFromSharedStore, publishUsageSnapshotToSharedStore } = await import('../accounting/usage-sync.js');
|
|
26
|
-
const published = publishUsageSnapshotToSharedStore();
|
|
26
|
+
const published = await publishUsageSnapshotToSharedStore();
|
|
27
27
|
if (published.changed)
|
|
28
28
|
ctx.log('INFO', `usage-sync: published usage snapshot to ${published.path}`);
|
|
29
29
|
if (published.error)
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* inline behavior (daemon.ts previously `WATCHDOG_TICK_MS`-interval closure).
|
|
9
9
|
*/
|
|
10
10
|
import { BasePeriodicService } from './service.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { getConfigValueAsync } from '../device-config.js';
|
|
12
|
+
import { emitAsync } from '../feed/events.js';
|
|
13
13
|
/** Matches the historical inline interval (daemon.ts WATCHDOG_TICK_MS). */
|
|
14
14
|
const WATCHDOG_TICK_MS = 3 * 60_000;
|
|
15
15
|
/** Hard cap per tick — `runWatchdogPass` is host-local (adds no SSH fan-out of its own, `watchdog/runner.ts:580`); short enough that a hang never freezes the service for long. */
|
|
@@ -25,12 +25,12 @@ export class WatchdogService extends BasePeriodicService {
|
|
|
25
25
|
// Nothing to release — the supervisor's timer teardown is the only cleanup needed.
|
|
26
26
|
}
|
|
27
27
|
async onTick(ctx) {
|
|
28
|
-
if (
|
|
28
|
+
if ((await getConfigValueAsync('watchdog.enabled')).value !== true)
|
|
29
29
|
return;
|
|
30
30
|
const { runWatchdogPass } = await import('../watchdog/service.js');
|
|
31
31
|
const result = await runWatchdogPass({ nudge: true });
|
|
32
32
|
ctx.log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
|
|
33
|
-
|
|
33
|
+
await emitAsync('watchdog.action', {
|
|
34
34
|
module: 'watchdog',
|
|
35
35
|
total: result.counts.total,
|
|
36
36
|
stalled: result.counts.stalled,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* enabled without pulling in the whole daemon lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
/** Every service the daemon can host. IDs are kebab-case and stable. */
|
|
10
|
-
export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
|
|
10
|
+
export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
|
|
11
11
|
/** Human-readable metadata for each service. */
|
|
12
12
|
export interface DaemonServiceDef {
|
|
13
13
|
id: DaemonServiceId;
|
|
@@ -47,6 +47,11 @@ export const DAEMON_SERVICES = [
|
|
|
47
47
|
title: 'Self-heal registry',
|
|
48
48
|
description: 'Repairs shims, PATH, shadowing, and resource drift on a schedule.',
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
id: 'self-update',
|
|
52
|
+
title: 'Self-update',
|
|
53
|
+
description: 'Checks npm for a newer agents-cli, installs + verifies it, then exits so the OS supervisor relaunches onto the new code (PHNX-3695).',
|
|
54
|
+
},
|
|
50
55
|
{
|
|
51
56
|
id: 'keychain-reap',
|
|
52
57
|
title: 'Keychain reap',
|
|
@@ -113,6 +113,16 @@ export declare function readFleetConfigDefaults(): Record<string, unknown>;
|
|
|
113
113
|
export declare function readDeviceConfigValues(device: string): Record<string, unknown>;
|
|
114
114
|
/** Get one config key's effective value and the layer that set it. */
|
|
115
115
|
export declare function getConfigValue(name: string, opts?: ConfigTarget): ConfigEntry;
|
|
116
|
+
/**
|
|
117
|
+
* Async, non-blocking twin of {@link getConfigValue} for the daemon's tick paths
|
|
118
|
+
* (PHNX-3695) — e.g. the watchdog tick reading `watchdog.enabled` every ~3min.
|
|
119
|
+
* Same layer precedence (device doc → fleet defaults → default), but the
|
|
120
|
+
* per-device doc READ is async. The user/fleet layers go through `readMeta`,
|
|
121
|
+
* which serves from an mtime-stamped in-memory cache (≈ 2 stat syscalls on the
|
|
122
|
+
* hot path, no file read), and `ensureDeviceConfigMigrated` is a one-shot no-op
|
|
123
|
+
* after the first process-wide fold — neither blocks the loop meaningfully.
|
|
124
|
+
*/
|
|
125
|
+
export declare function getConfigValueAsync(name: string, opts?: ConfigTarget): Promise<ConfigEntry>;
|
|
116
126
|
/**
|
|
117
127
|
* List every known key with its effective value and the layer that set it.
|
|
118
128
|
*
|
|
@@ -250,7 +260,8 @@ export declare function assertDaemonEnabled(): void;
|
|
|
250
260
|
* minutes. Read by the daemon's periodic tick and, as the fallback when a
|
|
251
261
|
* caller omits `--idle-minutes`, by the `gc` IPC action.
|
|
252
262
|
*/
|
|
253
|
-
|
|
263
|
+
/** Async so the daemon's browser-task-reap tick reads the config off the shared event loop (PHNX-3695). */
|
|
264
|
+
export declare function resolveBrowserTaskIdleMs(): Promise<number | null>;
|
|
254
265
|
/**
|
|
255
266
|
* Read the effective `agents.max-concurrent` cap for each named device (fleet
|
|
256
267
|
* defaults layered under the per-device doc; no SSH). Devices without a cap
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* Unset always means today's behavior (the documented default).
|
|
32
32
|
*/
|
|
33
33
|
import * as fs from 'fs';
|
|
34
|
+
import * as fsp from 'fs/promises';
|
|
34
35
|
import * as path from 'path';
|
|
35
36
|
import * as yaml from 'yaml';
|
|
36
37
|
import { META_HEADER, getUserAgentsDir, readMeta, updateMeta, withMetaLock } from './state.js';
|
|
@@ -372,17 +373,8 @@ function deviceDocPath(device) {
|
|
|
372
373
|
* file is a hard error — silently returning null would let the next write wipe
|
|
373
374
|
* the device's routines/config (same contract as routine-activation's reader).
|
|
374
375
|
*/
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
let raw;
|
|
378
|
-
try {
|
|
379
|
-
raw = fs.readFileSync(p, 'utf-8');
|
|
380
|
-
}
|
|
381
|
-
catch (err) {
|
|
382
|
-
if (err && err.code === 'ENOENT')
|
|
383
|
-
return null;
|
|
384
|
-
throw err;
|
|
385
|
-
}
|
|
376
|
+
/** Parse + validate a device doc's raw YAML. Shared by the sync and async readers. */
|
|
377
|
+
function parseDeviceDoc(raw, p) {
|
|
386
378
|
const corrupted = (detail) => new Error(`Device config corrupted at ${p}: ${detail}. Inspect and restore from backup.`);
|
|
387
379
|
let parsed;
|
|
388
380
|
try {
|
|
@@ -402,6 +394,33 @@ function readDeviceDoc(device) {
|
|
|
402
394
|
}
|
|
403
395
|
return doc;
|
|
404
396
|
}
|
|
397
|
+
function readDeviceDoc(device) {
|
|
398
|
+
const p = deviceDocPath(device);
|
|
399
|
+
let raw;
|
|
400
|
+
try {
|
|
401
|
+
raw = fs.readFileSync(p, 'utf-8');
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
if (err && err.code === 'ENOENT')
|
|
405
|
+
return null;
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
return parseDeviceDoc(raw, p);
|
|
409
|
+
}
|
|
410
|
+
/** Async twin of {@link readDeviceDoc} for the daemon's tick paths (PHNX-3695) — the device-doc read must not block the shared event loop. */
|
|
411
|
+
async function readDeviceDocAsync(device) {
|
|
412
|
+
const p = deviceDocPath(device);
|
|
413
|
+
let raw;
|
|
414
|
+
try {
|
|
415
|
+
raw = await fsp.readFile(p, 'utf-8');
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
if (err && err.code === 'ENOENT')
|
|
419
|
+
return null;
|
|
420
|
+
throw err;
|
|
421
|
+
}
|
|
422
|
+
return parseDeviceDoc(raw, p);
|
|
423
|
+
}
|
|
405
424
|
/** Write a device doc (atomic), preserving keys this module does not own
|
|
406
425
|
* (`routines:`). A doc left empty is removed instead of leaving an empty
|
|
407
426
|
* tracked file behind. */
|
|
@@ -476,6 +495,36 @@ export function getConfigValue(name, opts) {
|
|
|
476
495
|
return { spec, value: fleetConfig[spec.yamlKey], source: 'fleet' };
|
|
477
496
|
return { spec, value: undefined, source: 'default' };
|
|
478
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Async, non-blocking twin of {@link getConfigValue} for the daemon's tick paths
|
|
500
|
+
* (PHNX-3695) — e.g. the watchdog tick reading `watchdog.enabled` every ~3min.
|
|
501
|
+
* Same layer precedence (device doc → fleet defaults → default), but the
|
|
502
|
+
* per-device doc READ is async. The user/fleet layers go through `readMeta`,
|
|
503
|
+
* which serves from an mtime-stamped in-memory cache (≈ 2 stat syscalls on the
|
|
504
|
+
* hot path, no file read), and `ensureDeviceConfigMigrated` is a one-shot no-op
|
|
505
|
+
* after the first process-wide fold — neither blocks the loop meaningfully.
|
|
506
|
+
*/
|
|
507
|
+
export async function getConfigValueAsync(name, opts) {
|
|
508
|
+
ensureDeviceConfigMigrated();
|
|
509
|
+
const spec = configKeySpec(name);
|
|
510
|
+
if (spec.scope === 'user') {
|
|
511
|
+
const value = readMeta().config?.[spec.yamlKey];
|
|
512
|
+
return { spec, value, source: value !== undefined ? 'user' : 'default' };
|
|
513
|
+
}
|
|
514
|
+
if (opts?.fleet) {
|
|
515
|
+
const value = readFleetConfigDefaults()[spec.yamlKey];
|
|
516
|
+
return { spec, value, source: value !== undefined ? 'fleet' : 'default' };
|
|
517
|
+
}
|
|
518
|
+
const device = targetDevice(opts);
|
|
519
|
+
assertLocalTarget(spec, device);
|
|
520
|
+
const docConfig = (await readDeviceDocAsync(device))?.config ?? {};
|
|
521
|
+
if (spec.yamlKey in docConfig)
|
|
522
|
+
return { spec, value: docConfig[spec.yamlKey], source: 'device' };
|
|
523
|
+
const fleetConfig = readFleetConfigDefaults();
|
|
524
|
+
if (spec.yamlKey in fleetConfig)
|
|
525
|
+
return { spec, value: fleetConfig[spec.yamlKey], source: 'fleet' };
|
|
526
|
+
return { spec, value: undefined, source: 'default' };
|
|
527
|
+
}
|
|
479
528
|
/**
|
|
480
529
|
* List every known key with its effective value and the layer that set it.
|
|
481
530
|
*
|
|
@@ -844,8 +893,9 @@ export function assertDaemonEnabled() {
|
|
|
844
893
|
* minutes. Read by the daemon's periodic tick and, as the fallback when a
|
|
845
894
|
* caller omits `--idle-minutes`, by the `gc` IPC action.
|
|
846
895
|
*/
|
|
847
|
-
|
|
848
|
-
|
|
896
|
+
/** Async so the daemon's browser-task-reap tick reads the config off the shared event loop (PHNX-3695). */
|
|
897
|
+
export async function resolveBrowserTaskIdleMs() {
|
|
898
|
+
const minutes = (await getConfigValueAsync('browser.task-idle-minutes')).value ?? 30;
|
|
849
899
|
return minutes === 0 ? null : minutes * 60_000;
|
|
850
900
|
}
|
|
851
901
|
/**
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
/** Grace between SIGTERM and SIGKILL for a child that overran its deadline. */
|
|
29
|
+
export declare const KILL_GRACE_MS = 250;
|
|
30
|
+
export interface ExecFileBoundedOptions {
|
|
31
|
+
/** Hard wall-clock cap. On expiry the child's process group is SIGTERMed, then SIGKILLed after {@link KILL_GRACE_MS}. Required — an unbounded tick-path spawn is the bug this helper exists to prevent. */
|
|
32
|
+
timeoutMs: number;
|
|
33
|
+
/** Working directory for the child. */
|
|
34
|
+
cwd?: string;
|
|
35
|
+
/** Environment for the child (defaults to the current process env). */
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
/** Optional stdin to write to the child. */
|
|
38
|
+
input?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface BoundedExecResult {
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
/** Exit code, or null when the process was killed by a signal (including our timeout kill) or never spawned. */
|
|
44
|
+
code: number | null;
|
|
45
|
+
/** True when the deadline elapsed and we killed the child, distinguishing a timeout from an ordinary non-zero exit. */
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run `file args` with a hard deadline, killing the whole process group on
|
|
50
|
+
* timeout. Resolves (never rejects) with the captured output and outcome.
|
|
51
|
+
*/
|
|
52
|
+
export declare function execFileBounded(file: string, args: string[], opts: ExecFileBoundedOptions): Promise<BoundedExecResult>;
|