@phnx-labs/agents-cli 1.22.29 → 1.22.31
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 +88 -0
- package/README.md +44 -5
- package/dist/bin/agents +0 -0
- package/dist/commands/accounts.d.ts +13 -0
- package/dist/commands/accounts.js +32 -0
- package/dist/commands/daemon.d.ts +18 -0
- package/dist/commands/daemon.js +581 -0
- package/dist/commands/exec.js +66 -20
- package/dist/commands/focus.d.ts +4 -1
- package/dist/commands/focus.js +19 -4
- package/dist/commands/routines.js +29 -11
- package/dist/commands/secrets.d.ts +37 -0
- package/dist/commands/secrets.js +86 -105
- package/dist/commands/sessions-bookmark.d.ts +20 -0
- package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
- package/dist/commands/sessions-browser.d.ts +10 -8
- package/dist/commands/sessions-browser.js +61 -32
- package/dist/commands/sessions-picker.d.ts +33 -1
- package/dist/commands/sessions-picker.js +102 -27
- package/dist/commands/sessions-stats.js +1 -1
- package/dist/commands/sessions.d.ts +21 -8
- package/dist/commands/sessions.js +328 -74
- package/dist/commands/view.d.ts +11 -0
- package/dist/commands/view.js +56 -29
- package/dist/index.js +37 -2
- package/dist/lib/account-labels.d.ts +24 -0
- package/dist/lib/account-labels.js +72 -0
- package/dist/lib/agents.d.ts +32 -1
- package/dist/lib/agents.js +96 -31
- package/dist/lib/daemon-health.d.ts +24 -0
- package/dist/lib/daemon-health.js +84 -0
- package/dist/lib/daemon-ticks.d.ts +81 -0
- package/dist/lib/daemon-ticks.js +190 -0
- package/dist/lib/daemon.d.ts +68 -18
- package/dist/lib/daemon.js +303 -338
- package/dist/lib/device-config.d.ts +10 -0
- package/dist/lib/device-config.js +27 -0
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +49 -2
- package/dist/lib/hosts/dispatch.d.ts +4 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +1 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/import.js +7 -6
- package/dist/lib/memory-cache.d.ts +19 -0
- package/dist/lib/memory-cache.js +31 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +1 -1
- package/dist/lib/migrate.js +13 -2
- package/dist/lib/picker.d.ts +6 -3
- package/dist/lib/picker.js +7 -2
- package/dist/lib/routine-activation.d.ts +2 -0
- package/dist/lib/routine-activation.js +16 -0
- package/dist/lib/runner.d.ts +18 -0
- package/dist/lib/runner.js +52 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -1
- package/dist/lib/secrets/agent.js +32 -6
- package/dist/lib/secrets/scope.d.ts +3 -3
- package/dist/lib/secrets/scope.js +3 -3
- package/dist/lib/secrets/session-store.d.ts +0 -4
- package/dist/lib/secrets/session-store.js +0 -5
- package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
- package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
- package/dist/lib/session/db.d.ts +15 -0
- package/dist/lib/session/db.js +90 -15
- package/dist/lib/session/discover.js +91 -39
- package/dist/lib/session/parse.d.ts +63 -0
- package/dist/lib/session/parse.js +165 -20
- package/dist/lib/session/session-cache.d.ts +9 -6
- package/dist/lib/session/session-cache.js +23 -6
- package/dist/lib/shims.js +12 -0
- package/dist/lib/startup/command-registry.d.ts +15 -1
- package/dist/lib/startup/command-registry.js +49 -0
- package/dist/lib/usage-refresh.js +3 -2
- package/dist/lib/usage.d.ts +12 -10
- package/dist/lib/usage.js +63 -144
- package/package.json +4 -1
- package/dist/commands/sessions-favorite.d.ts +0 -20
package/dist/lib/daemon.js
CHANGED
|
@@ -16,18 +16,19 @@ import { listJobs as listAllJobs } from './routines.js';
|
|
|
16
16
|
import { syncAllProjectRoutines } from './routines-project.js';
|
|
17
17
|
import { JobScheduler } from './scheduler.js';
|
|
18
18
|
import { MonitorEngine } from './monitors/engine.js';
|
|
19
|
-
import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
19
|
+
import { executeJobDetached, monitorRunningJobs, listLiveRoutineChildren } from './runner.js';
|
|
20
20
|
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
21
21
|
import { runCatchup } from './catchup.js';
|
|
22
22
|
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
|
|
23
23
|
import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from './routine-notify-owner.js';
|
|
24
24
|
import { BrowserService } from './browser/service.js';
|
|
25
|
-
import { BrowserIPCServer } from './browser/ipc.js';
|
|
25
|
+
import { BrowserIPCServer, getSocketPath as getBrowserIpcSocketPath } from './browser/ipc.js';
|
|
26
|
+
import { secretsBrokerSocketPath, brokerPidAlive } from './secrets/agent.js';
|
|
26
27
|
import { redactSecrets } from './redact.js';
|
|
27
28
|
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
|
|
28
|
-
import {
|
|
29
|
+
import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from './device-config.js';
|
|
29
30
|
import { reapTerminalRoutineProcesses } from './routine-process-cleanup.js';
|
|
30
|
-
import {
|
|
31
|
+
import { recordSubsystemOk, recordSubsystemError, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC } from './daemon-health.js';
|
|
31
32
|
const PID_FILE = 'daemon.pid';
|
|
32
33
|
const LOCK_FILE = 'daemon.lock';
|
|
33
34
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -37,7 +38,6 @@ const LOG_ROTATE_COUNT = 3;
|
|
|
37
38
|
const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
38
39
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
39
40
|
const MONITOR_TICK_MS = 60_000;
|
|
40
|
-
const WATCHDOG_TICK_MS = 3 * 60_000;
|
|
41
41
|
/**
|
|
42
42
|
* How often to re-scan for missed fires. Deliberately slower than the monitor
|
|
43
43
|
* tick: detection walks a week of cron occurrences per routine
|
|
@@ -250,30 +250,42 @@ export function isDaemonRunning() {
|
|
|
250
250
|
* writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
|
|
251
251
|
* run a second JobScheduler concurrently, so every cron routine fires twice.
|
|
252
252
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
253
|
+
* LAST-WINS takeover (SING-11, RUSH-2352): when a live daemon already owns the
|
|
254
|
+
* pid file, this does NOT defer to it — it evicts the incumbent and becomes the
|
|
255
|
+
* survivor, so a second install can never leave two daemons running. Returns true
|
|
256
|
+
* and records our PID once the incumbent is provably dead (its resources
|
|
257
|
+
* released). Returns false ONLY when another `__daemon-run` currently holds the
|
|
258
|
+
* O_EXCL start lock — i.e. a concurrent claimer is mid-takeover and will be the
|
|
259
|
+
* singleton — in which case the caller must exit without touching further state.
|
|
260
|
+
* The read-evict-write is serialized behind the same start lock startDaemon()
|
|
261
|
+
* uses, so two `_run` processes can't both claim in the window between the
|
|
262
|
+
* liveness check and the write.
|
|
258
263
|
*/
|
|
259
264
|
export function claimDaemonInstance() {
|
|
260
265
|
const release = acquireStartLock();
|
|
261
266
|
// acquireStartLock() returns null only when another __daemon-run currently
|
|
262
267
|
// holds the O_EXCL lock — a dead holder's lock is reclaimed and retried inside
|
|
263
268
|
// acquireStartLock, so null means a *live* claimer is mid-claim. Bail rather
|
|
264
|
-
// than run the read-
|
|
269
|
+
// than run the read-evict-write unlocked: otherwise two first-start processes
|
|
265
270
|
// could each see no pid file (before either writes one) and both claim,
|
|
266
|
-
// running the concurrent JobScheduler this guard exists to prevent.
|
|
271
|
+
// running the concurrent JobScheduler this guard exists to prevent. The live
|
|
272
|
+
// claimer we bailed for becomes the singleton, so last-wins still holds.
|
|
267
273
|
if (!release)
|
|
268
274
|
return false;
|
|
269
275
|
try {
|
|
270
276
|
// resolveLiveDaemonPid() also consults a fresh heartbeat, so a live daemon
|
|
271
|
-
// whose pid file was lost still
|
|
272
|
-
// pid file would let this instance
|
|
273
|
-
// double-fire every routine.
|
|
277
|
+
// whose pid file was lost is still found and evicted — otherwise a missing
|
|
278
|
+
// pid file would let both this instance AND the orphaned incumbent run a
|
|
279
|
+
// JobScheduler at once and double-fire every routine.
|
|
274
280
|
const existing = resolveLiveDaemonPid();
|
|
275
281
|
if (existing !== null && existing !== process.pid) {
|
|
276
|
-
|
|
282
|
+
// Evict, and WAIT for the incumbent to be provably dead — its graceful
|
|
283
|
+
// handleShutdown releasing the browser IPC binding and the secrets broker
|
|
284
|
+
// socket — before we write our pid and (later, in runDaemon) bind our own.
|
|
285
|
+
// Binding before the release recreates the two-brokers-on-one-socket orphan
|
|
286
|
+
// documented at stopDaemon below, so the pid file is not written until the
|
|
287
|
+
// prior owner is gone.
|
|
288
|
+
evictIncumbentDaemon(existing);
|
|
277
289
|
}
|
|
278
290
|
writeDaemonPid(process.pid);
|
|
279
291
|
return true;
|
|
@@ -283,56 +295,153 @@ export function claimDaemonInstance() {
|
|
|
283
295
|
}
|
|
284
296
|
}
|
|
285
297
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
|
|
298
|
+
* SIGTERM a live incumbent daemon and block until it is provably dead, so its
|
|
299
|
+
* graceful handleShutdown has released the browser IPC binding and the secrets
|
|
300
|
+
* broker socket BEFORE the newcomer binds anything of its own (SING-11). Escalates
|
|
301
|
+
* to killTree after the grace window. Passes the POSITIVE pid so the kill reaches
|
|
302
|
+
* only the incumbent daemon — never its detached routine children, which run in
|
|
303
|
+
* their own process groups and must survive takeover (SING-11a); the new daemon
|
|
304
|
+
* re-adopts them via monitorRunningJobs. Synchronous to match claimDaemonInstance's
|
|
305
|
+
* read-evict-write, which runs under the O_EXCL start lock; mirrors stopDaemon's
|
|
306
|
+
* grace-then-escalate shape and constants exactly, because the same
|
|
307
|
+
* proof-of-release requirement applies.
|
|
308
|
+
*/
|
|
309
|
+
function evictIncumbentDaemon(pid) {
|
|
310
|
+
if (process.platform === 'win32') {
|
|
311
|
+
// No graceful termination signal on Windows — take the incumbent down and
|
|
312
|
+
// still wait for the kill to land before the caller binds anything (mirrors
|
|
313
|
+
// stopDaemon's win32 branch).
|
|
314
|
+
killTree(pid);
|
|
315
|
+
waitForExit(pid, STOP_KILL_GRACE_MS);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
process.kill(pid, 'SIGTERM');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return; // already gone between resolveLiveDaemonPid() and here
|
|
323
|
+
}
|
|
324
|
+
if (waitForExit(pid, STOP_GRACE_MS))
|
|
325
|
+
return; // graceful release complete
|
|
326
|
+
killTree(pid); // positive pid: SIGKILL reaches the daemon, not its job children
|
|
327
|
+
waitForExit(pid, STOP_KILL_GRACE_MS);
|
|
328
|
+
}
|
|
329
|
+
/** Directory that registers every live daemon of THIS device (one per daemon dir). */
|
|
330
|
+
function getDaemonInstancesDir() {
|
|
331
|
+
return path.join(getDaemonDirRoot(), 'instances');
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Record this daemon in the device's instance registry — a marker file named by
|
|
335
|
+
* pid under `<daemonDir>/instances/`. The registry, not a process scan, is how
|
|
336
|
+
* the reaper enumerates the device singleton: because the dir lives INSIDE the
|
|
337
|
+
* daemon dir (`AGENTS_DAEMON_DIR` ?? `<HOME>/.agents/.cache/helpers/daemon`), every
|
|
338
|
+
* daemon of one device — however it was launched — registers in the same place,
|
|
339
|
+
* while a genuinely separate install/home or a test fixture registers under its
|
|
340
|
+
* own daemon dir and is invisible here. This is what fixes the two-entry pile-up:
|
|
341
|
+
* the compiled `dist/bin/agents` binary and the `node <shim>` JS entry have
|
|
342
|
+
* different `process.argv[1]`, so the old launch-entry-scoped `ps` match never
|
|
343
|
+
* reaped across them and duplicates accumulated (78 observed on one box), every
|
|
344
|
+
* routine double-firing. Best-effort — the reaper self-heals a missing/stale
|
|
345
|
+
* marker, and reading another process's ENV to key on the daemon dir directly is
|
|
346
|
+
* not portable (hardened macOS hides it from `ps`), so identity rides the shared
|
|
347
|
+
* on-disk registry instead. No-op on Windows (POSIX-only reaper).
|
|
348
|
+
*/
|
|
349
|
+
export function registerDaemonInstance(pid = process.pid) {
|
|
350
|
+
if (process.platform === 'win32')
|
|
351
|
+
return;
|
|
352
|
+
try {
|
|
353
|
+
const dir = getDaemonInstancesDir();
|
|
354
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
355
|
+
// The command line is stored for diagnostics; the filename (pid) is identity.
|
|
356
|
+
fs.writeFileSync(path.join(dir, String(pid)), process.argv.slice(1).join(' '), 'utf-8');
|
|
357
|
+
}
|
|
358
|
+
catch { /* best effort — the reaper self-heals a missing marker */ }
|
|
359
|
+
}
|
|
360
|
+
/** Remove this daemon's registry marker on graceful shutdown. */
|
|
361
|
+
export function unregisterDaemonInstance(pid = process.pid) {
|
|
362
|
+
if (process.platform === 'win32')
|
|
363
|
+
return;
|
|
364
|
+
try {
|
|
365
|
+
fs.rmSync(path.join(getDaemonInstancesDir(), String(pid)), { force: true });
|
|
366
|
+
}
|
|
367
|
+
catch { /* ignore */ }
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Reap stray duplicate daemons of THIS device — every registrant in the instance
|
|
371
|
+
* registry that is a live `agents __daemon-run` and is neither this process nor
|
|
372
|
+
* the current pid-file owner. A predecessor SIGKILLed/OOM-ed without cleanup, or a
|
|
373
|
+
* duplicate that lost the pid-file write race, would otherwise keep a second
|
|
374
|
+
* scheduler alive and double-fire jobs even after claimDaemonInstance() hands the
|
|
375
|
+
* pid file to the survivor. Also garbage-collects markers whose pid is dead or was
|
|
376
|
+
* reused by an unrelated process. No-op on Windows (POSIX-only).
|
|
297
377
|
*/
|
|
298
378
|
export function reapStrayDaemons(keepPid = process.pid) {
|
|
299
379
|
const details = [];
|
|
300
380
|
let reaped = 0;
|
|
301
381
|
if (process.platform === 'win32')
|
|
302
382
|
return { reaped, details };
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
return { reaped, details };
|
|
306
|
-
let out;
|
|
383
|
+
const dir = getDaemonInstancesDir();
|
|
384
|
+
let entries;
|
|
307
385
|
try {
|
|
308
|
-
|
|
386
|
+
entries = fs.readdirSync(dir);
|
|
309
387
|
}
|
|
310
388
|
catch {
|
|
311
|
-
return { reaped, details }; // no
|
|
389
|
+
return { reaped, details }; // no registry yet — nothing to reap
|
|
312
390
|
}
|
|
313
391
|
const ownerPid = readDaemonPid();
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
392
|
+
const dropMarker = (name) => {
|
|
393
|
+
try {
|
|
394
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
395
|
+
}
|
|
396
|
+
catch { /* ignore */ }
|
|
397
|
+
};
|
|
398
|
+
for (const name of entries) {
|
|
399
|
+
const pid = parseInt(name, 10);
|
|
400
|
+
if (isNaN(pid) || String(pid) !== name)
|
|
401
|
+
continue; // not a pid marker
|
|
402
|
+
if (pid === keepPid || pid === process.pid || pid === ownerPid)
|
|
321
403
|
continue;
|
|
322
|
-
//
|
|
323
|
-
if (!
|
|
404
|
+
// Dead registrant → stale marker.
|
|
405
|
+
if (!isAlive(pid)) {
|
|
406
|
+
dropMarker(name);
|
|
324
407
|
continue;
|
|
325
|
-
|
|
408
|
+
}
|
|
409
|
+
// Live pid, but guard against pid reuse: only a real `__daemon-run` is ours.
|
|
410
|
+
// Process ARGS are readable cross-platform (unlike ENV on hardened macOS).
|
|
411
|
+
if (!isDaemonRunProcess(pid)) {
|
|
412
|
+
dropMarker(name);
|
|
326
413
|
continue;
|
|
414
|
+
}
|
|
327
415
|
try {
|
|
328
416
|
process.kill(pid, 'SIGTERM');
|
|
329
417
|
reaped++;
|
|
330
418
|
details.push(`reaped stray daemon pid ${pid}`);
|
|
331
419
|
}
|
|
332
|
-
catch { /* already gone */ }
|
|
420
|
+
catch { /* already gone between the alive check and the signal */ }
|
|
421
|
+
dropMarker(name);
|
|
333
422
|
}
|
|
334
423
|
return { reaped, details };
|
|
335
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* Whether `pid` is a live `agents __daemon-run` process. Reads the process's
|
|
427
|
+
* command line (`ps -p <pid> -o command=`), which — unlike its environment — is
|
|
428
|
+
* visible cross-platform, including on hardened macOS. Guards the reaper against
|
|
429
|
+
* killing an unrelated process that reused a dead registrant's pid.
|
|
430
|
+
*/
|
|
431
|
+
function isDaemonRunProcess(pid) {
|
|
432
|
+
if (process.platform === 'win32')
|
|
433
|
+
return false;
|
|
434
|
+
try {
|
|
435
|
+
const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
436
|
+
encoding: 'utf-8',
|
|
437
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
438
|
+
});
|
|
439
|
+
return /\b__daemon-run\b/.test(out);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
336
445
|
function rotateLogsIfNeeded(logPath) {
|
|
337
446
|
try {
|
|
338
447
|
const stat = fs.statSync(logPath);
|
|
@@ -423,12 +532,13 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
|
423
532
|
}
|
|
424
533
|
}
|
|
425
534
|
export async function runDaemon() {
|
|
426
|
-
// Single-instance guard: a direct `agents __daemon-run`
|
|
427
|
-
// service-manager restart racing a live predecessor)
|
|
428
|
-
//
|
|
535
|
+
// Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
|
|
536
|
+
// (manual, or a service-manager restart racing a live predecessor) EVICTS the
|
|
537
|
+
// incumbent and becomes the survivor. claimDaemonInstance returns false only
|
|
538
|
+
// when a concurrent `__daemon-run` currently holds the start lock — that peer
|
|
539
|
+
// is mid-takeover and will be the singleton, so this instance stands down.
|
|
429
540
|
if (!claimDaemonInstance()) {
|
|
430
|
-
|
|
431
|
-
log('WARN', `Another daemon already owns the pid file (PID: ${owner}); this instance (PID ${process.pid}) is exiting`);
|
|
541
|
+
log('WARN', `Another daemon is mid-takeover (holds the start lock); this instance (PID ${process.pid}) is exiting`);
|
|
432
542
|
// Exit cleanly (0) so a service manager treats it as an orderly no-op
|
|
433
543
|
// rather than a failure to restart-flap on.
|
|
434
544
|
process.exit(0);
|
|
@@ -443,8 +553,11 @@ export async function runDaemon() {
|
|
|
443
553
|
// refreshes itself per-device; a routine whose account login has gone dead is
|
|
444
554
|
// skipped up front by the auth-health preflight (runner.ts) with a re-login
|
|
445
555
|
// hint, rather than papered over by an injected fallback token.
|
|
446
|
-
//
|
|
447
|
-
// lock or was orphaned by a hard-crash —
|
|
556
|
+
// Register this daemon in the device instance registry, then reap any stray
|
|
557
|
+
// duplicate that slipped past the start lock or was orphaned by a hard-crash —
|
|
558
|
+
// before it can double-fire jobs. Registration comes first so a racing peer's
|
|
559
|
+
// reaper can see this pid, and so this reaper never mistakes itself for a stray.
|
|
560
|
+
registerDaemonInstance();
|
|
448
561
|
try {
|
|
449
562
|
const strays = reapStrayDaemons();
|
|
450
563
|
if (strays.reaped > 0) {
|
|
@@ -474,9 +587,12 @@ export async function runDaemon() {
|
|
|
474
587
|
if (hostedBroker)
|
|
475
588
|
log('INFO', 'Secrets broker hosted in daemon (socket-first)');
|
|
476
589
|
}
|
|
590
|
+
recordSubsystemOk(SUBSYSTEM_SECRETS_BROKER);
|
|
477
591
|
}
|
|
478
592
|
catch (err) {
|
|
479
|
-
|
|
593
|
+
const message = err.message;
|
|
594
|
+
log('WARN', `Secrets broker host skipped: ${message}`);
|
|
595
|
+
recordSubsystemError(SUBSYSTEM_SECRETS_BROKER, message);
|
|
480
596
|
}
|
|
481
597
|
// scheduler.enabled=false in this machine's device doc means NO routines fire
|
|
482
598
|
// here — the scheduler and its catchup recovery simply never start, while the
|
|
@@ -595,24 +711,13 @@ export async function runDaemon() {
|
|
|
595
711
|
}
|
|
596
712
|
if (schedulerEnabledAtBoot)
|
|
597
713
|
bootScheduler();
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
|
|
606
|
-
}
|
|
607
|
-
catch (err) {
|
|
608
|
-
log('ERROR', `watchdog tick failed: ${err.message}`);
|
|
609
|
-
}
|
|
610
|
-
finally {
|
|
611
|
-
watchdogInFlight = false;
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
const watchdogInterval = setInterval(() => { void runDaemonWatchdog(); }, WATCHDOG_TICK_MS);
|
|
615
|
-
const watchdogKickoff = setTimeout(() => { void runDaemonWatchdog(); }, 30_000);
|
|
714
|
+
// watchdog, device-probe, tmux-reconcile, launch-health, fleet-cache-warm,
|
|
715
|
+
// session-cache-warm, usage-refresh, and auto-dispatch used to be hardcoded
|
|
716
|
+
// setInterval ticks here (RUSH-2353). They are now shipped system routines
|
|
717
|
+
// (gh:phnx-labs/.agents-system routines/*.yml), invoked one-shot via
|
|
718
|
+
// `agents __daemon-tick <name>` (see daemon-ticks.ts) and fired by the
|
|
719
|
+
// JobScheduler above like any other routine — declared, listed, run-tracked,
|
|
720
|
+
// pausable, and device-pinnable, instead of a second unowned scheduling path.
|
|
616
721
|
// Monitor engine: event-triggered watchers, beside the cron scheduler. Same
|
|
617
722
|
// daemon, same dispatch seam — a monitor is a routine whose trigger is a
|
|
618
723
|
// watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
|
|
@@ -715,9 +820,12 @@ export async function runDaemon() {
|
|
|
715
820
|
try {
|
|
716
821
|
await browserIPC.start();
|
|
717
822
|
log('INFO', 'Browser IPC server started');
|
|
823
|
+
recordSubsystemOk(SUBSYSTEM_BROWSER_IPC);
|
|
718
824
|
}
|
|
719
825
|
catch (err) {
|
|
720
|
-
|
|
826
|
+
const message = err.message;
|
|
827
|
+
log('ERROR', `Browser IPC failed to start: ${message}`);
|
|
828
|
+
recordSubsystemError(SUBSYSTEM_BROWSER_IPC, message);
|
|
721
829
|
}
|
|
722
830
|
writeHeartbeat();
|
|
723
831
|
const monitorInterval = setInterval(() => {
|
|
@@ -760,253 +868,6 @@ export async function runDaemon() {
|
|
|
760
868
|
};
|
|
761
869
|
const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
|
|
762
870
|
const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
|
|
763
|
-
// Auto-dispatch: for any managed project that has opted in (autoDispatch:true +
|
|
764
|
-
// maxAgents>0 in ~/.agents/factory/projects.json), pick up Linear tickets that
|
|
765
|
-
// are delegated to an agent and still in Todo, and DISPATCH each through
|
|
766
|
-
// agents-cli's own cloud-provider layer (resolveProvider().dispatch(), same as
|
|
767
|
-
// `agents cloud run`) — then mark it Doing so it isn't re-picked. Capped at
|
|
768
|
-
// maxAgents concurrent per project. No hidden Prix dependency: Rush is one
|
|
769
|
-
// provider among rush/codex/factory, pinned per-project via `provider`. OFF
|
|
770
|
-
// unless a project opts in; no opted-in project or no LINEAR_API_KEY is a clean
|
|
771
|
-
// no-op. Overlap-guarded like the probes above. ~every 3 min.
|
|
772
|
-
let autoDispatching = false;
|
|
773
|
-
const runAutoDispatch = async () => {
|
|
774
|
-
if (autoDispatching)
|
|
775
|
-
return;
|
|
776
|
-
autoDispatching = true;
|
|
777
|
-
try {
|
|
778
|
-
const { readAutoDispatchProjects, isEligible, autoDispatchTick } = await import('./auto-dispatch.js');
|
|
779
|
-
const projects = readAutoDispatchProjects();
|
|
780
|
-
if (!projects.some(isEligible))
|
|
781
|
-
return; // opt-in: nothing enabled → skip
|
|
782
|
-
const { createLinearGateway } = await import('./auto-dispatch-linear.js');
|
|
783
|
-
const linear = createLinearGateway();
|
|
784
|
-
if (!linear)
|
|
785
|
-
return; // no LINEAR_API_KEY configured → skip
|
|
786
|
-
const { createProviderDispatcher } = await import('./auto-dispatch-provider.js');
|
|
787
|
-
const dispatcher = createProviderDispatcher();
|
|
788
|
-
const dispatched = await autoDispatchTick({ projects, linear, dispatcher, log: (lvl, m) => log(lvl, m) });
|
|
789
|
-
if (dispatched.length) {
|
|
790
|
-
log('INFO', `auto-dispatch: started ${dispatched.length} delegated ticket(s): ${dispatched.map((d) => d.identifier).join(', ')}`);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
catch (err) {
|
|
794
|
-
log('ERROR', `auto-dispatch failed: ${err.message}`);
|
|
795
|
-
}
|
|
796
|
-
finally {
|
|
797
|
-
autoDispatching = false;
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
const autoDispatchInterval = setInterval(() => { void runAutoDispatch(); }, 3 * 60_000);
|
|
801
|
-
const autoDispatchKickoff = setTimeout(() => { void runAutoDispatch(); }, 45_000);
|
|
802
|
-
// Device probe: refresh registered devices' reachability and detect newly
|
|
803
|
-
// appeared tailnet nodes, dropping a sentinel per pending device so the
|
|
804
|
-
// menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
|
|
805
|
-
// never auto-registers a newcomer. Soft + overlap-guarded like session sync;
|
|
806
|
-
// a machine without tailscale is a clean no-op. ~every 3 min.
|
|
807
|
-
let probingDevices = false;
|
|
808
|
-
const runDeviceProbe = async () => {
|
|
809
|
-
if (probingDevices)
|
|
810
|
-
return;
|
|
811
|
-
probingDevices = true;
|
|
812
|
-
try {
|
|
813
|
-
const { runDeviceSync } = await import('./devices/sync.js');
|
|
814
|
-
const { reconcilePendingSentinels } = await import('./devices/pending.js');
|
|
815
|
-
const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
|
|
816
|
-
if (dev.ok) {
|
|
817
|
-
reconcilePendingSentinels(dev.pending);
|
|
818
|
-
if (dev.pending.length) {
|
|
819
|
-
log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
catch (err) {
|
|
824
|
-
log('ERROR', `device probe failed: ${err.message}`);
|
|
825
|
-
}
|
|
826
|
-
finally {
|
|
827
|
-
probingDevices = false;
|
|
828
|
-
}
|
|
829
|
-
};
|
|
830
|
-
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
831
|
-
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
832
|
-
// tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
|
|
833
|
-
// `agents run` sessions a pre-fix binary left with the old unconditional hook
|
|
834
|
-
// (which detached the whole client — kicking the user out of the view — when
|
|
835
|
-
// they exited a split they'd opened). Non-destructive: set-hook only, never a
|
|
836
|
-
// kill or detach. A per-session schema marker makes steady-state a no-op, so
|
|
837
|
-
// this stays cheap at ~every 5 min, plus once ~20s after startup so a
|
|
838
|
-
// just-upgraded daemon heals still-running sessions without waiting for them to
|
|
839
|
-
// cycle or the shared server to be recycled.
|
|
840
|
-
let reconcilingTmux = false;
|
|
841
|
-
const runTmuxReconcile = async () => {
|
|
842
|
-
if (reconcilingTmux)
|
|
843
|
-
return;
|
|
844
|
-
reconcilingTmux = true;
|
|
845
|
-
try {
|
|
846
|
-
const { isTmuxInstalled } = await import('./tmux/binary.js');
|
|
847
|
-
if (!isTmuxInstalled())
|
|
848
|
-
return;
|
|
849
|
-
const { reconcileSessionHooks } = await import('./tmux/session.js');
|
|
850
|
-
const r = await reconcileSessionHooks();
|
|
851
|
-
if (r.reconciled > 0)
|
|
852
|
-
log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
|
|
853
|
-
}
|
|
854
|
-
catch (err) {
|
|
855
|
-
log('ERROR', `tmux reconcile failed: ${err.message}`);
|
|
856
|
-
}
|
|
857
|
-
finally {
|
|
858
|
-
reconcilingTmux = false;
|
|
859
|
-
}
|
|
860
|
-
};
|
|
861
|
-
const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
|
|
862
|
-
const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
|
|
863
|
-
// Launch-health self-heal: probe that each agent's DEFAULT version actually
|
|
864
|
-
// LAUNCHES (not just that its files exist), and repair a gutted install — the
|
|
865
|
-
// JS wrapper present but its native binary renamed/missing (a vendor
|
|
866
|
-
// auto-update that never landed its replacement, or a partially-extracted
|
|
867
|
-
// tarball) — BEFORE the user's next `agents run` dies with a raw ENOENT. This
|
|
868
|
-
// is the proactive companion to the run-time heal (ensureAgentRunnable), which
|
|
869
|
-
// only fires once a run is already starting. Cheap steady-state: one
|
|
870
|
-
// `--version` probe per default version; a clean reinstall runs only on a real
|
|
871
|
-
// launch failure. ~every 6h, plus once ~90s after startup (staggered off launch).
|
|
872
|
-
let checkingLaunchHealth = false;
|
|
873
|
-
const runLaunchHealthCheck = async () => {
|
|
874
|
-
if (checkingLaunchHealth)
|
|
875
|
-
return;
|
|
876
|
-
checkingLaunchHealth = true;
|
|
877
|
-
try {
|
|
878
|
-
const { healBrokenDefaultLaunches } = await import('./versions.js');
|
|
879
|
-
// Unattended pass: repair the current default in place, but NEVER repoint
|
|
880
|
-
// the global default (allowDefaultSwitch: false). A background default
|
|
881
|
-
// switch installs a fresh version home → a fresh empty Claude credential
|
|
882
|
-
// scope, i.e. a silent logout uncorrelated with anything the user did.
|
|
883
|
-
const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`), { allowDefaultSwitch: false });
|
|
884
|
-
if (repaired.length)
|
|
885
|
-
log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
|
|
886
|
-
if (unhealed.length) {
|
|
887
|
-
log('WARN', `launch-health: ${unhealed.join(', ')} won't launch and will not be auto-switched — choose a version with \`agents use <agent> <version>\` or \`agents add <agent>@latest\``);
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
catch (err) {
|
|
891
|
-
log('ERROR', `launch-health check failed: ${err.message}`);
|
|
892
|
-
}
|
|
893
|
-
finally {
|
|
894
|
-
checkingLaunchHealth = false;
|
|
895
|
-
}
|
|
896
|
-
};
|
|
897
|
-
const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
|
|
898
|
-
const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
|
|
899
|
-
// Fleet cache warm: publish THIS host's row for the caches `agents fleet
|
|
900
|
-
// status` / `agents devices list` read. PUBLISH-OWN / READ-UNION (RUSH-2061):
|
|
901
|
-
// each daemon probes only ITSELF and never SSHes another box, so the fleet no
|
|
902
|
-
// longer pays N² SSH resource probes every 3 minutes (N daemons × N devices) —
|
|
903
|
-
// the source of the fan-out storm AND the orphaned-probe pile-up. Two cheap
|
|
904
|
-
// self-only refreshes: (1) this host's auth-health verdicts (also feeds the
|
|
905
|
-
// `doctor --json` Auth rollup other hosts read), and (2) its fleet-status row
|
|
906
|
-
// (local resource probe + live-agent workload). Cross-host rows are unioned on
|
|
907
|
-
// demand by the reader (`agents fleet status`), not pushed by every daemon.
|
|
908
|
-
// Best-effort + overlap-guarded like the probes above; ~every 3 min, once ~60s
|
|
909
|
-
// after startup.
|
|
910
|
-
let warmingFleetCache = false;
|
|
911
|
-
const runFleetCacheWarm = async () => {
|
|
912
|
-
if (warmingFleetCache)
|
|
913
|
-
return;
|
|
914
|
-
warmingFleetCache = true;
|
|
915
|
-
try {
|
|
916
|
-
const { machineId } = await import('./machine-id.js');
|
|
917
|
-
const self = machineId();
|
|
918
|
-
const { probeLocalFleetAuth, writeFleetAuthRows } = await import('./auth-health.js');
|
|
919
|
-
const { getCliVersion } = await import('./version.js');
|
|
920
|
-
const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
|
|
921
|
-
writeFleetAuthRows(self, authRows);
|
|
922
|
-
const { publishLocalFleetStatus } = await import('./fleet-status.js');
|
|
923
|
-
const row = await publishLocalFleetStatus(self);
|
|
924
|
-
log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${row.agents.running} running agent(s) on ${self}`);
|
|
925
|
-
}
|
|
926
|
-
catch (err) {
|
|
927
|
-
log('ERROR', `fleet cache warm failed: ${err.message}`);
|
|
928
|
-
}
|
|
929
|
-
finally {
|
|
930
|
-
warmingFleetCache = false;
|
|
931
|
-
}
|
|
932
|
-
};
|
|
933
|
-
const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
|
|
934
|
-
const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
|
|
935
|
-
// Session-status cache warm (RUSH-2062): publish THIS host's local active
|
|
936
|
-
// sessions so menubar / Factory / watchdog / CLI share one warm snapshot
|
|
937
|
-
// instead of each re-running a full ~9s / ~170MB gather. Publish-own only
|
|
938
|
-
// (no cross-host SSH — same N² lesson as RUSH-2061). Short interval keeps
|
|
939
|
-
// live status fresh; forceRefresh still re-gathers. Additive sibling of
|
|
940
|
-
// fleetCacheInterval — does not touch the reaper tick.
|
|
941
|
-
let warmingSessionCache = false;
|
|
942
|
-
// Import once so the interval/kickoff constants stay the single source of truth
|
|
943
|
-
// (SESSION_CACHE_WARM_* in session-cache.ts). Dynamic import of the warm fn
|
|
944
|
-
// itself stays inside the tick so a cold daemon boot does not pay the load.
|
|
945
|
-
const sessionCacheWarmTiming = import('./session/session-cache.js').then((m) => ({
|
|
946
|
-
intervalMs: m.SESSION_CACHE_WARM_INTERVAL_MS,
|
|
947
|
-
kickoffMs: m.SESSION_CACHE_WARM_KICKOFF_MS,
|
|
948
|
-
publish: m.publishLocalActiveSessions,
|
|
949
|
-
}));
|
|
950
|
-
const runSessionCacheWarm = async () => {
|
|
951
|
-
if (warmingSessionCache)
|
|
952
|
-
return;
|
|
953
|
-
warmingSessionCache = true;
|
|
954
|
-
try {
|
|
955
|
-
const { publish } = await sessionCacheWarmTiming;
|
|
956
|
-
const r = await publish();
|
|
957
|
-
log('INFO', `session cache warm: ${r.sessions.length} local session(s)`);
|
|
958
|
-
}
|
|
959
|
-
catch (err) {
|
|
960
|
-
log('ERROR', `session cache warm failed: ${err.message}`);
|
|
961
|
-
}
|
|
962
|
-
finally {
|
|
963
|
-
warmingSessionCache = false;
|
|
964
|
-
}
|
|
965
|
-
};
|
|
966
|
-
// Intervals resolved from the module constants (fallback matches the defaults
|
|
967
|
-
// if the import is still pending — the first tick uses the live import).
|
|
968
|
-
let sessionCacheInterval;
|
|
969
|
-
let sessionCacheKickoff;
|
|
970
|
-
void sessionCacheWarmTiming.then(({ intervalMs, kickoffMs }) => {
|
|
971
|
-
sessionCacheInterval = setInterval(() => { void runSessionCacheWarm(); }, intervalMs);
|
|
972
|
-
sessionCacheKickoff = setTimeout(() => { void runSessionCacheWarm(); }, kickoffMs);
|
|
973
|
-
});
|
|
974
|
-
// Usage refresh: keep the usage cache the `agents run` router reads
|
|
975
|
-
// (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
|
|
976
|
-
// This host is the sole writer for its own local accounts. The tick wakes
|
|
977
|
-
// every 60s (USAGE_REFRESH_TICK_MS) to consider due accounts; each account
|
|
978
|
-
// is scheduled at a fixed 5-minute cadence (REFRESH_INTERVAL_MS), capped at
|
|
979
|
-
// ~12 provider calls/account/hour, skipped under 429 backoff, and fetched
|
|
980
|
-
// with fileOnly credentials so a background tick never pops macOS Touch ID.
|
|
981
|
-
// Overlap-guarded: a slow pass cannot stack concurrent refresh loops.
|
|
982
|
-
let refreshingUsage = false;
|
|
983
|
-
const runUsageRefreshTick = async () => {
|
|
984
|
-
if (refreshingUsage)
|
|
985
|
-
return;
|
|
986
|
-
refreshingUsage = true;
|
|
987
|
-
try {
|
|
988
|
-
const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
|
|
989
|
-
const { writeClaudeUsageCache } = await import('./usage.js');
|
|
990
|
-
const { usageRateLimitedUntil } = await import('./usage-backoff.js');
|
|
991
|
-
const r = await runUsageRefresh({
|
|
992
|
-
listAccounts: buildLocalUsageAccounts,
|
|
993
|
-
writeUsageCache: writeClaudeUsageCache,
|
|
994
|
-
backoffUntil: usageRateLimitedUntil,
|
|
995
|
-
});
|
|
996
|
-
// Always log a compact summary so "is refresh working?" is greppable even
|
|
997
|
-
// when every account was not-due (proves the tick ran).
|
|
998
|
-
log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
|
|
999
|
-
}
|
|
1000
|
-
catch (err) {
|
|
1001
|
-
log('ERROR', `usage refresh failed: ${err.message}`);
|
|
1002
|
-
}
|
|
1003
|
-
finally {
|
|
1004
|
-
refreshingUsage = false;
|
|
1005
|
-
}
|
|
1006
|
-
};
|
|
1007
|
-
// 60s wake matches USAGE_REFRESH_TICK_MS in usage-refresh.ts (keep in sync).
|
|
1008
|
-
const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 60_000);
|
|
1009
|
-
const usageRefreshKickoff = setTimeout(() => { void runUsageRefreshTick(); }, 30_000);
|
|
1010
871
|
// RUSH-1817: the startup host decision above is one-shot. If a standalone
|
|
1011
872
|
// broker answered agentPing() at daemon start, the daemon declined to host —
|
|
1012
873
|
// but should that standalone later die or crash-loop, nothing takes over and
|
|
@@ -1110,31 +971,14 @@ export async function runDaemon() {
|
|
|
1110
971
|
monitorEngine.stop();
|
|
1111
972
|
await browserIPC.stop();
|
|
1112
973
|
clearInterval(monitorInterval);
|
|
1113
|
-
clearInterval(watchdogInterval);
|
|
1114
|
-
clearTimeout(watchdogKickoff);
|
|
1115
974
|
clearInterval(healInterval);
|
|
1116
975
|
clearTimeout(healKickoff);
|
|
1117
|
-
clearInterval(autoDispatchInterval);
|
|
1118
|
-
clearTimeout(autoDispatchKickoff);
|
|
1119
|
-
clearInterval(deviceProbeInterval);
|
|
1120
|
-
clearTimeout(deviceProbeKickoff);
|
|
1121
|
-
clearInterval(tmuxReconcileInterval);
|
|
1122
|
-
clearTimeout(tmuxReconcileKickoff);
|
|
1123
|
-
clearInterval(launchHealthInterval);
|
|
1124
|
-
clearTimeout(launchHealthKickoff);
|
|
1125
|
-
clearInterval(fleetCacheInterval);
|
|
1126
|
-
clearTimeout(fleetCacheKickoff);
|
|
1127
|
-
if (sessionCacheInterval)
|
|
1128
|
-
clearInterval(sessionCacheInterval);
|
|
1129
|
-
if (sessionCacheKickoff)
|
|
1130
|
-
clearTimeout(sessionCacheKickoff);
|
|
1131
|
-
clearInterval(usageRefreshInterval);
|
|
1132
|
-
clearTimeout(usageRefreshKickoff);
|
|
1133
976
|
clearInterval(brokerSelfHealInterval);
|
|
1134
977
|
clearInterval(keychainReapInterval);
|
|
1135
978
|
hostedBroker?.close();
|
|
1136
979
|
removeDaemonPid();
|
|
1137
980
|
removeHeartbeat();
|
|
981
|
+
unregisterDaemonInstance();
|
|
1138
982
|
process.exit(0);
|
|
1139
983
|
};
|
|
1140
984
|
process.on('SIGHUP', handleReload);
|
|
@@ -1292,6 +1136,12 @@ export function startDaemon(agentsBin) {
|
|
|
1292
1136
|
* happened to bring it up. See issue #415.
|
|
1293
1137
|
*/
|
|
1294
1138
|
export function ensureDaemonStarted() {
|
|
1139
|
+
// RUSH-2354: honor daemon.enabled — a background-adjacent caller (secrets
|
|
1140
|
+
// unlock, browser start, ...) must not resurrect a daemon the owner
|
|
1141
|
+
// explicitly turned off. `agents daemon start` is the deliberate override
|
|
1142
|
+
// and calls startDaemon() directly instead of going through this helper.
|
|
1143
|
+
if (!isDaemonEnabled())
|
|
1144
|
+
return null;
|
|
1295
1145
|
try {
|
|
1296
1146
|
return startDaemon();
|
|
1297
1147
|
}
|
|
@@ -1499,9 +1349,59 @@ function waitForPid(timeoutMs) {
|
|
|
1499
1349
|
}
|
|
1500
1350
|
return readDaemonPid();
|
|
1501
1351
|
}
|
|
1502
|
-
/**
|
|
1352
|
+
/**
|
|
1353
|
+
* Live `__daemon-run` processes still registered in THIS state dir's instance
|
|
1354
|
+
* registry, excluding `exclude`. State-dir-scoped by construction: the registry
|
|
1355
|
+
* lives inside this daemon dir, so a daemon serving a DIFFERENT state dir (a test
|
|
1356
|
+
* fixture with its own HOME, a separate install/home) registers elsewhere and is
|
|
1357
|
+
* invisible here — it is never a stop/takeover target. POSIX-only (the registry
|
|
1358
|
+
* and its `ps` liveness probe are); `[]` on Windows.
|
|
1359
|
+
*/
|
|
1360
|
+
function findSurvivingStateDirDaemons(exclude) {
|
|
1361
|
+
if (process.platform === 'win32')
|
|
1362
|
+
return [];
|
|
1363
|
+
const dir = getDaemonInstancesDir();
|
|
1364
|
+
let entries;
|
|
1365
|
+
try {
|
|
1366
|
+
entries = fs.readdirSync(dir);
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
return [];
|
|
1370
|
+
}
|
|
1371
|
+
const found = [];
|
|
1372
|
+
for (const name of entries) {
|
|
1373
|
+
const pid = parseInt(name, 10);
|
|
1374
|
+
if (isNaN(pid) || String(pid) !== name)
|
|
1375
|
+
continue; // not a pid marker
|
|
1376
|
+
if (exclude.has(pid))
|
|
1377
|
+
continue;
|
|
1378
|
+
if (!isAlive(pid))
|
|
1379
|
+
continue; // dead marker — reaper self-heals it
|
|
1380
|
+
if (!isDaemonRunProcess(pid))
|
|
1381
|
+
continue; // pid reused by an unrelated process
|
|
1382
|
+
found.push(pid);
|
|
1383
|
+
}
|
|
1384
|
+
return found;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it
|
|
1388
|
+
* from launchd/systemd if applicable.
|
|
1389
|
+
*
|
|
1390
|
+
* The SIGTERM → grace → killTree sequence is unchanged; what it adds is
|
|
1391
|
+
* verification. After the daemon is gone it checks that the secrets broker socket
|
|
1392
|
+
* and browser IPC binding actually released — a stale socket present on disk but
|
|
1393
|
+
* with no live owner is the orphan that keeps clients holding unlocked bundles
|
|
1394
|
+
* hanging (`daemon.ts` broker-hosting; the two-brokers-on-one-socket bug) — and
|
|
1395
|
+
* that no `__daemon-run` for THIS state dir survives. A killTree escalation exits
|
|
1396
|
+
* without running the daemon's graceful handleShutdown, so those sockets can be
|
|
1397
|
+
* left stale; this reclaims each (the owner is provably dead) and reports it. It
|
|
1398
|
+
* never reports success on an unverified stop.
|
|
1399
|
+
*/
|
|
1503
1400
|
export function stopDaemon() {
|
|
1504
1401
|
const platform = os.platform();
|
|
1402
|
+
const released = [];
|
|
1403
|
+
const surviving = [];
|
|
1404
|
+
let escalated = false;
|
|
1505
1405
|
if (platform === 'darwin') {
|
|
1506
1406
|
const plistPath = getLaunchdPlistPath();
|
|
1507
1407
|
if (fs.existsSync(plistPath)) {
|
|
@@ -1541,6 +1441,7 @@ export function stopDaemon() {
|
|
|
1541
1441
|
// its job/browser child tree in one shot (taskkill /T), so stop doesn't
|
|
1542
1442
|
// report success while children keep running.
|
|
1543
1443
|
killTree(pid);
|
|
1444
|
+
escalated = true;
|
|
1544
1445
|
}
|
|
1545
1446
|
else {
|
|
1546
1447
|
try {
|
|
@@ -1558,12 +1459,76 @@ export function stopDaemon() {
|
|
|
1558
1459
|
// after an install into a second prefix.
|
|
1559
1460
|
if (!waitForExit(pid, STOP_GRACE_MS)) {
|
|
1560
1461
|
killTree(pid);
|
|
1462
|
+
escalated = true;
|
|
1561
1463
|
waitForExit(pid, STOP_KILL_GRACE_MS);
|
|
1562
1464
|
}
|
|
1563
1465
|
}
|
|
1564
1466
|
}
|
|
1565
1467
|
removeDaemonPid();
|
|
1566
|
-
|
|
1468
|
+
// ── Assert the postcondition (SING-12) ────────────────────────────────────
|
|
1469
|
+
// No `__daemon-run` for this state dir may survive the stop.
|
|
1470
|
+
const survivors = findSurvivingStateDirDaemons(new Set([process.pid]));
|
|
1471
|
+
if (pid && process.platform === 'win32' && isAlive(pid) && !survivors.includes(pid)) {
|
|
1472
|
+
survivors.push(pid); // registry is POSIX-only; check the killed pid directly
|
|
1473
|
+
}
|
|
1474
|
+
if (survivors.length > 0) {
|
|
1475
|
+
for (const s of survivors)
|
|
1476
|
+
surviving.push(`__daemon-run pid ${s} still alive`);
|
|
1477
|
+
}
|
|
1478
|
+
else if (pid) {
|
|
1479
|
+
released.push('daemon process');
|
|
1480
|
+
}
|
|
1481
|
+
// Browser IPC binding: on POSIX the listening socket is a filesystem object.
|
|
1482
|
+
// A graceful handleShutdown unlinks it; if it survives, the daemon exited
|
|
1483
|
+
// ungracefully (killTree) and left a stale binding — the owner is provably
|
|
1484
|
+
// dead, so reclaim it and report.
|
|
1485
|
+
if (process.platform !== 'win32') {
|
|
1486
|
+
const browserSock = getBrowserIpcSocketPath();
|
|
1487
|
+
if (fs.existsSync(browserSock)) {
|
|
1488
|
+
try {
|
|
1489
|
+
fs.unlinkSync(browserSock);
|
|
1490
|
+
}
|
|
1491
|
+
catch { /* raced with a fresh start */ }
|
|
1492
|
+
if (fs.existsSync(browserSock))
|
|
1493
|
+
surviving.push('browser IPC socket not released');
|
|
1494
|
+
else
|
|
1495
|
+
released.push('browser IPC socket (reclaimed)');
|
|
1496
|
+
}
|
|
1497
|
+
else {
|
|
1498
|
+
released.push('browser IPC socket');
|
|
1499
|
+
}
|
|
1500
|
+
// Secrets broker socket: released when it is gone, or still owned by a
|
|
1501
|
+
// DIFFERENT live broker (a standalone service the daemon never hosted). A
|
|
1502
|
+
// socket present with NO live owner is the orphan — reclaim it.
|
|
1503
|
+
const brokerSock = secretsBrokerSocketPath();
|
|
1504
|
+
if (!fs.existsSync(brokerSock)) {
|
|
1505
|
+
released.push('secrets broker socket');
|
|
1506
|
+
}
|
|
1507
|
+
else if (brokerPidAlive()) {
|
|
1508
|
+
released.push('secrets broker socket (standalone owner)');
|
|
1509
|
+
}
|
|
1510
|
+
else {
|
|
1511
|
+
try {
|
|
1512
|
+
fs.unlinkSync(brokerSock);
|
|
1513
|
+
}
|
|
1514
|
+
catch { /* raced with a fresh bind */ }
|
|
1515
|
+
if (fs.existsSync(brokerSock))
|
|
1516
|
+
surviving.push('secrets broker socket not released');
|
|
1517
|
+
else
|
|
1518
|
+
released.push('secrets broker socket (reclaimed)');
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// In-flight detached routine children survive on purpose (SING-11a) — report,
|
|
1522
|
+
// never kill: severing a live agent mid-run is worse than a daemon restart.
|
|
1523
|
+
const detachedChildren = listLiveRoutineChildren();
|
|
1524
|
+
return {
|
|
1525
|
+
ok: surviving.length === 0,
|
|
1526
|
+
stoppedPid: pid,
|
|
1527
|
+
escalated,
|
|
1528
|
+
released,
|
|
1529
|
+
surviving,
|
|
1530
|
+
detachedChildren,
|
|
1531
|
+
};
|
|
1567
1532
|
}
|
|
1568
1533
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
1569
1534
|
export function getDaemonStatus() {
|