@phnx-labs/agents-cli 1.22.54 → 1.22.55
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 +19 -2
- package/dist/commands/routines.js +31 -2
- package/dist/commands/share.d.ts +27 -0
- package/dist/commands/share.js +85 -0
- package/dist/commands/view.d.ts +8 -0
- package/dist/commands/view.js +31 -4
- package/dist/lib/accounting/usage.d.ts +31 -0
- package/dist/lib/accounting/usage.js +45 -4
- package/dist/lib/browser/cdp.d.ts +1 -1
- package/dist/lib/browser/cdp.js +1 -1
- package/dist/lib/browser/ffmpeg.d.ts +12 -0
- package/dist/lib/browser/ffmpeg.js +184 -0
- package/dist/lib/browser/service.js +119 -25
- package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
- package/dist/lib/daemon/browser-task-reap-service.js +26 -0
- package/dist/lib/daemon/daemon.js +57 -172
- package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
- package/dist/lib/daemon/heartbeat-service.js +26 -0
- package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
- package/dist/lib/daemon/monitor-engine-service.js +15 -7
- package/dist/lib/daemon/runner.d.ts +15 -0
- package/dist/lib/daemon/runner.js +23 -0
- package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
- package/dist/lib/daemon/secrets-broker-service.js +17 -32
- package/dist/lib/daemon/service.d.ts +2 -2
- package/dist/lib/daemon/service.js +1 -1
- package/dist/lib/daemon/session-state-service.d.ts +21 -0
- package/dist/lib/daemon/session-state-service.js +34 -0
- package/dist/lib/daemon/supervisor.d.ts +17 -7
- package/dist/lib/daemon/supervisor.js +87 -14
- package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
- package/dist/lib/daemon/tmux-reap-service.js +28 -0
- package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
- package/dist/lib/daemon/webhook-receiver-service.js +17 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +20 -0
- package/dist/lib/devices/harness-inventory.js +5 -2
- package/dist/lib/monitors/engine.d.ts +5 -1
- package/dist/lib/monitors/engine.js +5 -3
- package/dist/lib/scheduling/routines.d.ts +47 -0
- package/dist/lib/scheduling/routines.js +70 -1
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +2 -1
- package/dist/lib/share/capture.js +11 -2
- package/dist/lib/share/publish.d.ts +4 -1
- package/dist/lib/share/publish.js +27 -1
- package/dist/lib/share/worker-template.d.ts +3 -12
- package/dist/lib/share/worker-template.js +279 -3
- package/dist/lib/view-types.d.ts +6 -1
- package/package.json +8 -2
|
@@ -17,23 +17,19 @@ import { listJobs as listAllJobs } from '../scheduling/routines.js';
|
|
|
17
17
|
import { syncAllProjectRoutines } from '../routines-project.js';
|
|
18
18
|
import { JobScheduler } from '../scheduler.js';
|
|
19
19
|
// MonitorEngine is now owned by MonitorEngineService (daemon/monitor-engine-service.ts).
|
|
20
|
-
import { executeJobDetached,
|
|
20
|
+
import { executeJobDetached, listLiveRoutineChildren } from './runner.js';
|
|
21
21
|
import { detectOverdueJobs, notifyOverdue } from '../overdue.js';
|
|
22
22
|
import { runCatchup } from '../catchup.js';
|
|
23
23
|
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from '../routine-notify.js';
|
|
24
24
|
import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from '../routine-notify-owner.js';
|
|
25
25
|
import { BrowserService } from '../browser/service.js';
|
|
26
26
|
import { getSocketPath as getBrowserIpcSocketPath } from '../browser/ipc.js';
|
|
27
|
-
import { startHostedWebhookReceivers } from '../daemon-webhooks.js';
|
|
28
27
|
import { secretsBrokerSocketPath, brokerPidAlive } from '../secrets/agent.js';
|
|
29
28
|
import { redactSecrets } from '../redact.js';
|
|
30
29
|
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from '../cli-entry.js';
|
|
31
30
|
import { localBinDir } from '../platform/posixpath.js';
|
|
32
|
-
import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled
|
|
33
|
-
import { reapTerminalRoutineProcesses } from '../routine-process-cleanup.js';
|
|
31
|
+
import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from '../device-config.js';
|
|
34
32
|
import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
|
|
35
|
-
import { runActiveSessionsWarmTick } from '../daemon-ticks.js';
|
|
36
|
-
import { watchActiveSessionsReaderPresence } from '../session/session-cache.js';
|
|
37
33
|
import { ServiceSupervisor } from './supervisor.js';
|
|
38
34
|
import { SessionIndexService } from './session-index-service.js';
|
|
39
35
|
import { SecretsBrokerService } from './secrets-broker-service.js';
|
|
@@ -46,6 +42,11 @@ import { SelfHealService } from './self-heal-service.js';
|
|
|
46
42
|
import { KeychainReapService } from './keychain-reap-service.js';
|
|
47
43
|
import { AuthSyncService } from './auth-sync-service.js';
|
|
48
44
|
import { StateDirCheckService } from './state-dir-check-service.js';
|
|
45
|
+
import { SessionStateService } from './session-state-service.js';
|
|
46
|
+
import { WebhookReceiverService } from './webhook-receiver-service.js';
|
|
47
|
+
import { HeartbeatService } from './heartbeat-service.js';
|
|
48
|
+
import { TmuxReapService } from './tmux-reap-service.js';
|
|
49
|
+
import { BrowserTaskReapService } from './browser-task-reap-service.js';
|
|
49
50
|
import { emit, emitRoutineEnd } from '../feed/events.js';
|
|
50
51
|
import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
|
|
51
52
|
/**
|
|
@@ -112,7 +113,6 @@ export function daemonSystemdUnitName() {
|
|
|
112
113
|
const suffix = isolatedHomeSuffix();
|
|
113
114
|
return suffix ? `agents-daemon-sandbox-${suffix}.service` : SYSTEMD_UNIT;
|
|
114
115
|
}
|
|
115
|
-
const MONITOR_TICK_MS = 60_000;
|
|
116
116
|
/**
|
|
117
117
|
* How often to re-scan for missed fires. Deliberately slower than the monitor
|
|
118
118
|
* tick: detection walks a week of cron occurrences per routine
|
|
@@ -136,17 +136,10 @@ const CATCHUP_TICK_MS = 5 * 60_000;
|
|
|
136
136
|
* from wedges every keychain-backed secret on the box, so it runs minutely.
|
|
137
137
|
*/
|
|
138
138
|
// BROKER_SELF_HEAL_TICK_MS moved to secrets-broker-service.ts (RUSH-3193 P2).
|
|
139
|
-
// RUSH-2501: reap tmux sessions whose panes are all dead every 5 minutes.
|
|
140
|
-
const DEAD_PANE_REAP_TICK_MS = 5 * 60_000;
|
|
141
|
-
// RUSH-2622: close abandoned browser-task tabs every 5 minutes.
|
|
142
|
-
const BROWSER_TASK_REAP_TICK_MS = 5 * 60_000;
|
|
143
|
-
// Keep the active-session cache/journal fresh for sessions watch + Factory.
|
|
144
|
-
// Matches DEFAULT_ACTIVE_CACHE_MAX_AGE_MS (15s) so long-lived watchers never
|
|
145
|
-
// outlive the publisher (RUSH-2484 — CLI-owned continuous publish).
|
|
146
|
-
const ACTIVE_SESSIONS_WARM_TICK_MS = 15_000;
|
|
147
139
|
// Session-index warm interval/deadline live in session-index-service.ts now
|
|
148
140
|
// (RUSH-3193 — migrated onto ServiceSupervisor).
|
|
149
141
|
const WEDGE_THRESHOLD_TICKS = 3;
|
|
142
|
+
const DAEMON_HEARTBEAT_TICK_MS = 60_000;
|
|
150
143
|
/**
|
|
151
144
|
* Crash-loop prevention (RUSH-2418). Three layers, because none of them alone
|
|
152
145
|
* bounds a daemon that dies during startup:
|
|
@@ -348,7 +341,7 @@ export function removeHeartbeat() {
|
|
|
348
341
|
*/
|
|
349
342
|
function isHeartbeatFresh(hb) {
|
|
350
343
|
const elapsed = Date.now() - Date.parse(hb.lastTick);
|
|
351
|
-
return elapsed <= WEDGE_THRESHOLD_TICKS *
|
|
344
|
+
return elapsed <= WEDGE_THRESHOLD_TICKS * DAEMON_HEARTBEAT_TICK_MS;
|
|
352
345
|
}
|
|
353
346
|
export function isDaemonWedged() {
|
|
354
347
|
const pid = readDaemonPid();
|
|
@@ -725,87 +718,6 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
|
|
|
725
718
|
// registered after `handleShutdown` is declared — see its registration site
|
|
726
719
|
// below. runBrokerSelfHeal was moved into SecretsBrokerService (RUSH-3193 P2).
|
|
727
720
|
// ---------------------------------------------------------------------------
|
|
728
|
-
// In-flight guards: each flag prevents a slow tick from being re-entered
|
|
729
|
-
// by the next timer fire before the previous one finishes.
|
|
730
|
-
let reapingDeadPanes = false;
|
|
731
|
-
let reapingBrowserTasks = false;
|
|
732
|
-
/**
|
|
733
|
-
* RUSH-2501: kill tmux sessions on the helper socket whose panes are ALL dead.
|
|
734
|
-
* RUSH-2521: and terminate the helper processes (MCP servers, harness background
|
|
735
|
-
* daemons) whose owning agent has exited — killing the pane only SIGHUPs its
|
|
736
|
-
* foreground process group, so anything that left that group outlives it.
|
|
737
|
-
*
|
|
738
|
-
* Runs every DEAD_PANE_REAP_TICK_MS (5 min). The daemon is the single executor
|
|
739
|
-
* so no UI surface can race it.
|
|
740
|
-
*/
|
|
741
|
-
async function runDeadPaneReap() {
|
|
742
|
-
if (reapingDeadPanes)
|
|
743
|
-
return;
|
|
744
|
-
reapingDeadPanes = true;
|
|
745
|
-
try {
|
|
746
|
-
const { reapDeadTmuxPanes } = await import('../tmux/session.js');
|
|
747
|
-
const { getDefaultSocketPath } = await import('../tmux/paths.js');
|
|
748
|
-
const result = await reapDeadTmuxPanes(getDefaultSocketPath());
|
|
749
|
-
for (const w of result.warnings)
|
|
750
|
-
log('WARN', `Dead-pane reaper: ${w}`);
|
|
751
|
-
if (result.processes > 0) {
|
|
752
|
-
log('INFO', `Dead-pane reaper: terminated ${result.processes} orphaned helper process(es)`);
|
|
753
|
-
for (const d of result.processDetails)
|
|
754
|
-
log('INFO', ` ${d}`);
|
|
755
|
-
}
|
|
756
|
-
if (result.reaped > 0) {
|
|
757
|
-
log('INFO', `Dead-pane reaper: reaped ${result.reaped} session(s)`);
|
|
758
|
-
for (const d of result.details)
|
|
759
|
-
log('INFO', ` killed ${d}`);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
catch (err) {
|
|
763
|
-
log('ERROR', `Dead-pane reaper failed: ${err.message}`);
|
|
764
|
-
}
|
|
765
|
-
finally {
|
|
766
|
-
reapingDeadPanes = false;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
/**
|
|
770
|
-
* RUSH-2622: close abandoned browser-task tabs — a task whose owning agent
|
|
771
|
-
* session has exited, or one idle past `browser.task-idle-minutes` (default 30,
|
|
772
|
-
* 0 disables idle reaping only). Same policy `agents browser prune` triggers on
|
|
773
|
-
* demand; this is the periodic side, and the daemon is the single executor so
|
|
774
|
-
* no UI surface can race it. `service` is the daemon's own long-lived
|
|
775
|
-
* BrowserService — the browser IPC server it started earlier in `runDaemon()`.
|
|
776
|
-
*
|
|
777
|
-
* Runs every BROWSER_TASK_REAP_TICK_MS (5 min).
|
|
778
|
-
*/
|
|
779
|
-
async function runBrowserTaskReap(service) {
|
|
780
|
-
if (reapingBrowserTasks)
|
|
781
|
-
return;
|
|
782
|
-
reapingBrowserTasks = true;
|
|
783
|
-
try {
|
|
784
|
-
const result = await service.reapAbandoned({ idleMs: resolveBrowserTaskIdleMs() });
|
|
785
|
-
if (result.closed.length > 0) {
|
|
786
|
-
log('INFO', `Browser-task reaper: closed ${result.closed.length} task(s)`);
|
|
787
|
-
for (const c of result.closed)
|
|
788
|
-
log('INFO', ` ${c.task} (${c.reason}, profile ${c.profile})`);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
catch (err) {
|
|
792
|
-
log('ERROR', `Browser-task reaper failed: ${err.message}`);
|
|
793
|
-
}
|
|
794
|
-
finally {
|
|
795
|
-
reapingBrowserTasks = false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
/**
|
|
799
|
-
* One monitor tick: write heartbeat + drain the running-job monitor + reap
|
|
800
|
-
* stale terminal routine process groups. Runs on MONITOR_TICK_MS.
|
|
801
|
-
*/
|
|
802
|
-
function runMonitorTick() {
|
|
803
|
-
writeHeartbeat();
|
|
804
|
-
monitorRunningJobs();
|
|
805
|
-
const reaped = reapTerminalRoutineProcesses();
|
|
806
|
-
if (reaped.length > 0)
|
|
807
|
-
log('WARN', `Reaped ${reaped.length} terminal routine process group(s): ${reaped.join(', ')}`);
|
|
808
|
-
}
|
|
809
721
|
export async function runDaemon() {
|
|
810
722
|
// Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
|
|
811
723
|
// (manual, or a service-manager restart racing a live predecessor) EVICTS the
|
|
@@ -891,6 +803,11 @@ export async function runDaemon() {
|
|
|
891
803
|
// broker starts socket-first (#416) and `agents secrets` resolves within
|
|
892
804
|
// ms of daemon start, exactly as before.
|
|
893
805
|
const supervisor = new ServiceSupervisor();
|
|
806
|
+
if (isEnabled('session-state')) {
|
|
807
|
+
supervisor.register(new SessionStateService(() => supervisor.runNow('session-state')));
|
|
808
|
+
}
|
|
809
|
+
else
|
|
810
|
+
log('INFO', 'Live session-state service disabled');
|
|
894
811
|
if (isEnabled('secrets-broker'))
|
|
895
812
|
supervisor.register(new SecretsBrokerService());
|
|
896
813
|
else
|
|
@@ -904,12 +821,13 @@ export async function runDaemon() {
|
|
|
904
821
|
supervisor.register(new AccountStateDaemonService());
|
|
905
822
|
else
|
|
906
823
|
log('INFO', 'Account-state service disabled');
|
|
907
|
-
// BrowserIPCService
|
|
908
|
-
//
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
824
|
+
// BrowserIPCService and BrowserTaskReapService share one long-lived
|
|
825
|
+
// BrowserService when both are enabled. Either service can run independently.
|
|
826
|
+
const browserService = isEnabled('browser-ipc') || isEnabled('browser-task-reap')
|
|
827
|
+
? new BrowserService()
|
|
828
|
+
: null;
|
|
829
|
+
if (isEnabled('browser-ipc') && browserService)
|
|
830
|
+
supervisor.register(new BrowserIPCService(browserService));
|
|
913
831
|
else
|
|
914
832
|
log('INFO', 'Browser IPC service disabled');
|
|
915
833
|
if (isEnabled('session-index'))
|
|
@@ -940,6 +858,24 @@ export async function runDaemon() {
|
|
|
940
858
|
supervisor.register(new AuthSyncService());
|
|
941
859
|
else
|
|
942
860
|
log('INFO', 'Auth-sync service disabled');
|
|
861
|
+
if (isEnabled('webhook-receiver'))
|
|
862
|
+
supervisor.register(new WebhookReceiverService());
|
|
863
|
+
else
|
|
864
|
+
log('INFO', 'Webhook receiver service disabled');
|
|
865
|
+
if (isEnabled('daemon-heartbeat'))
|
|
866
|
+
supervisor.register(new HeartbeatService(() => writeHeartbeat()));
|
|
867
|
+
else
|
|
868
|
+
log('INFO', 'Daemon heartbeat service disabled');
|
|
869
|
+
if (isEnabled('tmux-reap'))
|
|
870
|
+
supervisor.register(new TmuxReapService());
|
|
871
|
+
else
|
|
872
|
+
log('INFO', 'Tmux reap service disabled');
|
|
873
|
+
if (isEnabled('browser-task-reap') && browserService) {
|
|
874
|
+
supervisor.register(new BrowserTaskReapService(browserService));
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
log('INFO', 'Browser-task reap service disabled');
|
|
878
|
+
}
|
|
943
879
|
await supervisor.startAll({ log });
|
|
944
880
|
activeServiceSupervisor = supervisor;
|
|
945
881
|
// scheduler.enabled=false in this machine's device doc means NO routines fire
|
|
@@ -1076,36 +1012,9 @@ export async function runDaemon() {
|
|
|
1076
1012
|
}
|
|
1077
1013
|
if (schedulerEnabledAtBoot)
|
|
1078
1014
|
bootScheduler();
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
// no-ops on a genuinely cold boot (no reader has connected yet) — the
|
|
1083
|
-
// presence watch below is what actually closes the gap for a watcher that
|
|
1084
|
-
// connects later, whether at cold boot or after the reader-idle window.
|
|
1085
|
-
let activeSessionsWarmInFlight = false;
|
|
1086
|
-
const runActiveSessionsWarm = async () => {
|
|
1087
|
-
if (activeSessionsWarmInFlight)
|
|
1088
|
-
return;
|
|
1089
|
-
activeSessionsWarmInFlight = true;
|
|
1090
|
-
try {
|
|
1091
|
-
await runActiveSessionsWarmTick();
|
|
1092
|
-
}
|
|
1093
|
-
catch (err) {
|
|
1094
|
-
log('WARN', `active-sessions warm failed: ${err.message}`);
|
|
1095
|
-
}
|
|
1096
|
-
finally {
|
|
1097
|
-
activeSessionsWarmInFlight = false;
|
|
1098
|
-
}
|
|
1099
|
-
};
|
|
1100
|
-
void runActiveSessionsWarm();
|
|
1101
|
-
const activeSessionsWarmInterval = setInterval(() => { void runActiveSessionsWarm(); }, ACTIVE_SESSIONS_WARM_TICK_MS);
|
|
1102
|
-
// Out-of-band trigger (RUSH-2484): a reader connecting only writes a presence
|
|
1103
|
-
// timestamp (noteActiveSessionsJournalReader in watch.ts) with no path back to
|
|
1104
|
-
// this timer, so on its own the interval above leaves a fresh connection
|
|
1105
|
-
// waiting up to ACTIVE_SESSIONS_WARM_TICK_MS for its first rows. This watches
|
|
1106
|
-
// for the idle->recent edge and gathers immediately instead of waiting for the
|
|
1107
|
-
// next scheduled tick — a genuinely idle box with no reader still never gathers.
|
|
1108
|
-
const stopActiveSessionsReaderWatch = watchActiveSessionsReaderPresence(() => { void runActiveSessionsWarm(); });
|
|
1015
|
+
// Live-session metadata publishing is owned by SessionStateService. Its
|
|
1016
|
+
// presence watcher requests an immediate supervised tick when a reader
|
|
1017
|
+
// connects; callers consume the journal instead of duplicating the gather.
|
|
1109
1018
|
// Session-index warm (RUSH-2682) is registered on the supervisor above
|
|
1110
1019
|
// (RUSH-3193 P2) alongside the socket services.
|
|
1111
1020
|
// Watchdog and device-probe are now managed by WatchdogService /
|
|
@@ -1191,23 +1100,8 @@ export async function runDaemon() {
|
|
|
1191
1100
|
// Started after the broker (above) so it resolves each receiver's signing
|
|
1192
1101
|
// secret headlessly — no AGENTS_SECRETS_PASSPHRASE, no nohup. Binds nothing
|
|
1193
1102
|
// unless daemon/webhooks.yaml declares a receiver, so an unconfigured box no-ops.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
try {
|
|
1197
|
-
webhookReceivers = await startHostedWebhookReceivers({ log });
|
|
1198
|
-
log('INFO', webhookReceivers.count > 0
|
|
1199
|
-
? `Webhook receiver hosting ${webhookReceivers.count} receiver(s)`
|
|
1200
|
-
: 'Webhook receiver service enabled; no receivers declared in daemon/webhooks.yaml');
|
|
1201
|
-
}
|
|
1202
|
-
catch (err) {
|
|
1203
|
-
log('WARN', `Webhook receiver host skipped: ${err.message}`);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
else {
|
|
1207
|
-
log('INFO', 'Webhook receiver service disabled');
|
|
1208
|
-
}
|
|
1209
|
-
runMonitorTick();
|
|
1210
|
-
const monitorInterval = setInterval(runMonitorTick, MONITOR_TICK_MS);
|
|
1103
|
+
// Signed webhook ingress is owned by WebhookReceiverService, including
|
|
1104
|
+
// per-service failure isolation, measured health, and shutdown cleanup.
|
|
1211
1105
|
// Resource self-heal is now managed by SelfHealService on the supervisor
|
|
1212
1106
|
// (RUSH-3193 P3), registered above alongside the socket services. The
|
|
1213
1107
|
// supervisor's immediate first tick on start replaces the old
|
|
@@ -1218,16 +1112,11 @@ export async function runDaemon() {
|
|
|
1218
1112
|
// supervisor (RUSH-3193 P3), registered above alongside the socket services.
|
|
1219
1113
|
// RUSH-2501: reap tmux sessions whose panes are all dead. Runs on the same
|
|
1220
1114
|
// 5-min cadence as the keychain reaper. Daemon-only (single executor).
|
|
1221
|
-
|
|
1222
|
-
const deadPaneReapInterval = setInterval(() => { void runDeadPaneReap(); }, DEAD_PANE_REAP_TICK_MS);
|
|
1115
|
+
// Dead managed panes and their orphan helpers are reaped by TmuxReapService.
|
|
1223
1116
|
// RUSH-2622: close abandoned browser-task tabs on the same 5-min cadence,
|
|
1224
|
-
// reusing the daemon's
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1227
|
-
if (browserSvcForReap) {
|
|
1228
|
-
void runBrowserTaskReap(browserSvcForReap); // kick-off on startup
|
|
1229
|
-
browserTaskReapInterval = setInterval(() => { void runBrowserTaskReap(browserSvcForReap); }, BROWSER_TASK_REAP_TICK_MS);
|
|
1230
|
-
}
|
|
1117
|
+
// reusing the daemon's long-lived BrowserService when browser IPC is enabled.
|
|
1118
|
+
// Abandoned browser tasks are reaped by BrowserTaskReapService, sharing the
|
|
1119
|
+
// same BrowserService instance as BrowserIPCService.
|
|
1231
1120
|
// RUSH-2367 / RUSH-3193 P3: state-dir-check (self-terminate guard) is
|
|
1232
1121
|
// registered on the supervisor further below, once `handleShutdown` exists
|
|
1233
1122
|
// — see the registration site after its declaration for why.
|
|
@@ -1265,7 +1154,11 @@ export async function runDaemon() {
|
|
|
1265
1154
|
// never registered, so it falls through to the same "restart to
|
|
1266
1155
|
// apply" advice as before.
|
|
1267
1156
|
if (supervisor.isRegistered(id)) {
|
|
1268
|
-
|
|
1157
|
+
// A periodic service may be inside a real tick when SIGHUP arrives.
|
|
1158
|
+
// Queue the desired transition behind that exact promise: deadlines
|
|
1159
|
+
// detect a wedge but cannot cancel arbitrary work, and polling here
|
|
1160
|
+
// would create another lifecycle timer outside the supervisor.
|
|
1161
|
+
const action = supervisor.awaitIdle(id).then(() => now ? supervisor.start(id) : supervisor.stop(id));
|
|
1269
1162
|
void action
|
|
1270
1163
|
.then(() => log('INFO', `Service '${id}' ${now ? 'started' : 'stopped'} live (SIGHUP reload)`))
|
|
1271
1164
|
.catch((err) => log('WARN', `Service '${id}' live ${now ? 'start' : 'stop'} failed: ${err.message}`));
|
|
@@ -1280,7 +1173,7 @@ export async function runDaemon() {
|
|
|
1280
1173
|
// Drain queued `agents daemon services restart <id>` requests (RUSH-3193 P4).
|
|
1281
1174
|
for (const id of drainDaemonServiceRestartQueue()) {
|
|
1282
1175
|
if (supervisor.isRegistered(id)) {
|
|
1283
|
-
void supervisor.restartOne(id)
|
|
1176
|
+
void supervisor.awaitIdle(id).then(() => supervisor.restartOne(id))
|
|
1284
1177
|
.then(() => log('INFO', `Service '${id}' restarted live (SIGHUP reload)`))
|
|
1285
1178
|
.catch((err) => log('WARN', `Service '${id}' live restart failed: ${err.message}`));
|
|
1286
1179
|
}
|
|
@@ -1351,20 +1244,12 @@ export async function runDaemon() {
|
|
|
1351
1244
|
// rather than one that every step added later has to re-earn.
|
|
1352
1245
|
const handleShutdown = singleShot(async () => {
|
|
1353
1246
|
log('INFO', 'Daemon shutting down');
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
//
|
|
1357
|
-
// (closes hostedBroker + self-heal timer), monitor engine, account-state,
|
|
1358
|
-
// browser IPC, session-index, watchdog, device-probe, self-heal,
|
|
1359
|
-
// keychain-reap, and (once registered just below) state-dir-check.
|
|
1247
|
+
// supervisor.stopAll() stops every registered service, including socket
|
|
1248
|
+
// hosts, session publishers/indexers, monitor/account work, and every
|
|
1249
|
+
// maintenance timer; state-dir-check joins the registry just below.
|
|
1360
1250
|
await supervisor.stopAll();
|
|
1361
1251
|
activeServiceSupervisor = null;
|
|
1362
1252
|
stopScheduler();
|
|
1363
|
-
await webhookReceivers?.close();
|
|
1364
|
-
clearInterval(monitorInterval);
|
|
1365
|
-
clearInterval(deadPaneReapInterval);
|
|
1366
|
-
if (browserTaskReapInterval)
|
|
1367
|
-
clearInterval(browserTaskReapInterval);
|
|
1368
1253
|
try {
|
|
1369
1254
|
if (fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken)
|
|
1370
1255
|
fs.unlinkSync(lifetimePath);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Daemon heartbeat and routine-process reconciliation under supervision. */
|
|
2
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
3
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
4
|
+
export declare class HeartbeatService extends BasePeriodicService {
|
|
5
|
+
private readonly publishHeartbeat;
|
|
6
|
+
readonly id: DaemonServiceId;
|
|
7
|
+
readonly intervalMs = 60000;
|
|
8
|
+
readonly deadlineMs = 30000;
|
|
9
|
+
constructor(publishHeartbeat: () => void);
|
|
10
|
+
protected onStart(_ctx: DaemonContext): Promise<void>;
|
|
11
|
+
protected onStop(): Promise<void>;
|
|
12
|
+
protected onTick(ctx: DaemonContext): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Daemon heartbeat and routine-process reconciliation under supervision. */
|
|
2
|
+
import { monitorRunningJobs } from './runner.js';
|
|
3
|
+
import { reapTerminalRoutineProcesses } from '../routine-process-cleanup.js';
|
|
4
|
+
import { BasePeriodicService } from './service.js';
|
|
5
|
+
const HEARTBEAT_TICK_MS = 60_000;
|
|
6
|
+
const HEARTBEAT_DEADLINE_MS = 30_000;
|
|
7
|
+
export class HeartbeatService extends BasePeriodicService {
|
|
8
|
+
publishHeartbeat;
|
|
9
|
+
id = 'daemon-heartbeat';
|
|
10
|
+
intervalMs = HEARTBEAT_TICK_MS;
|
|
11
|
+
deadlineMs = HEARTBEAT_DEADLINE_MS;
|
|
12
|
+
constructor(publishHeartbeat) {
|
|
13
|
+
super();
|
|
14
|
+
this.publishHeartbeat = publishHeartbeat;
|
|
15
|
+
}
|
|
16
|
+
async onStart(_ctx) { }
|
|
17
|
+
async onStop() { }
|
|
18
|
+
async onTick(ctx) {
|
|
19
|
+
this.publishHeartbeat();
|
|
20
|
+
monitorRunningJobs();
|
|
21
|
+
const reaped = reapTerminalRoutineProcesses();
|
|
22
|
+
if (reaped.length > 0) {
|
|
23
|
+
ctx.log('WARN', `Reaped ${reaped.length} terminal routine process group(s): ${reaped.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -2,21 +2,25 @@
|
|
|
2
2
|
* MonitorEngine lifecycle as a `DaemonService` (RUSH-3193 P2).
|
|
3
3
|
*
|
|
4
4
|
* Wraps the `MonitorEngine` (event-triggered watchers) under the
|
|
5
|
-
* `ServiceSupervisor` contract.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `ServiceSupervisor` contract. The engine can own a timer when embedded by a
|
|
6
|
+
* foreground caller, but the daemon starts it in external-scheduler mode so
|
|
7
|
+
* every evaluation cycle receives the supervisor's deadline, health, and
|
|
8
|
+
* circuit-breaker semantics.
|
|
8
9
|
*
|
|
9
10
|
* `getEngine()` exposes the underlying `MonitorEngine` so `daemon.ts`'s
|
|
10
11
|
* SIGHUP handler can call `engine.reload()` when needed.
|
|
11
12
|
*/
|
|
12
|
-
import {
|
|
13
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
13
14
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
14
15
|
import { MonitorEngine } from '../monitors/engine.js';
|
|
15
|
-
export declare class MonitorEngineService extends
|
|
16
|
+
export declare class MonitorEngineService extends BasePeriodicService {
|
|
16
17
|
readonly id: DaemonServiceId;
|
|
18
|
+
readonly intervalMs = 5000;
|
|
19
|
+
readonly deadlineMs: number;
|
|
17
20
|
private engine;
|
|
18
21
|
/** Returns the live engine after `start()`, or `null` before/after. */
|
|
19
22
|
getEngine(): MonitorEngine | null;
|
|
20
23
|
protected onStart(ctx: DaemonContext): Promise<void>;
|
|
21
24
|
protected onStop(): Promise<void>;
|
|
25
|
+
protected onTick(_ctx: DaemonContext): Promise<void>;
|
|
22
26
|
}
|
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
* MonitorEngine lifecycle as a `DaemonService` (RUSH-3193 P2).
|
|
3
3
|
*
|
|
4
4
|
* Wraps the `MonitorEngine` (event-triggered watchers) under the
|
|
5
|
-
* `ServiceSupervisor` contract.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `ServiceSupervisor` contract. The engine can own a timer when embedded by a
|
|
6
|
+
* foreground caller, but the daemon starts it in external-scheduler mode so
|
|
7
|
+
* every evaluation cycle receives the supervisor's deadline, health, and
|
|
8
|
+
* circuit-breaker semantics.
|
|
8
9
|
*
|
|
9
10
|
* `getEngine()` exposes the underlying `MonitorEngine` so `daemon.ts`'s
|
|
10
11
|
* SIGHUP handler can call `engine.reload()` when needed.
|
|
11
12
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { MonitorEngine } from '../monitors/engine.js';
|
|
14
|
-
export class MonitorEngineService extends
|
|
13
|
+
import { BasePeriodicService } from './service.js';
|
|
14
|
+
import { MONITOR_ENGINE_TICK_MS, MonitorEngine } from '../monitors/engine.js';
|
|
15
|
+
export class MonitorEngineService extends BasePeriodicService {
|
|
15
16
|
id = 'monitors';
|
|
17
|
+
intervalMs = MONITOR_ENGINE_TICK_MS;
|
|
18
|
+
deadlineMs = 2 * 60_000;
|
|
16
19
|
engine = null;
|
|
17
20
|
/** Returns the live engine after `start()`, or `null` before/after. */
|
|
18
21
|
getEngine() {
|
|
@@ -20,10 +23,15 @@ export class MonitorEngineService extends BaseDaemonService {
|
|
|
20
23
|
}
|
|
21
24
|
async onStart(ctx) {
|
|
22
25
|
this.engine = new MonitorEngine((level, message) => ctx.log(level, message));
|
|
23
|
-
this.engine.start();
|
|
26
|
+
this.engine.start({ externalScheduler: true });
|
|
24
27
|
}
|
|
25
28
|
async onStop() {
|
|
26
29
|
this.engine?.stop();
|
|
27
30
|
this.engine = null;
|
|
28
31
|
}
|
|
32
|
+
async onTick(_ctx) {
|
|
33
|
+
if (!this.engine)
|
|
34
|
+
throw new Error('monitor engine tick requested before start');
|
|
35
|
+
await this.engine.tick();
|
|
36
|
+
}
|
|
29
37
|
}
|
|
@@ -233,6 +233,21 @@ export declare function inferFinalStatusFromLog(stdoutPath: string, agent: Agent
|
|
|
233
233
|
* `host:`-placed runs have no local pid and are excluded.
|
|
234
234
|
*/
|
|
235
235
|
export declare function listLiveRoutineChildren(): number[];
|
|
236
|
+
/**
|
|
237
|
+
* Whether a run record marked `running` is genuinely in flight right now. A
|
|
238
|
+
* `running` status alone is not enough: `writeActiveClaim` stamps a provisional
|
|
239
|
+
* claim as `running` with `pid: null` BEFORE the child spawns, and
|
|
240
|
+
* `monitorRunningJobs()` does not reap a null-pid record (it has no process to
|
|
241
|
+
* probe, and ages it out only past the configured timeout — up to a week). So a
|
|
242
|
+
* daemon crash in that spawn window leaves a `running` record for a run that is
|
|
243
|
+
* not running (RUSH-2640). A caller answering "is one running now?" MUST gate on
|
|
244
|
+
* this, mirroring `listLiveRoutineChildren`'s local-liveness test:
|
|
245
|
+
* - a local child is in flight only while its pid is still ours (alive, same
|
|
246
|
+
* birth time — a dead-and-reused pid does not count);
|
|
247
|
+
* - a `host:`-placed run has no local pid by design and is in flight until its
|
|
248
|
+
* remote `.exit` is reconciled (`finalizeHostRun`).
|
|
249
|
+
*/
|
|
250
|
+
export declare function isRunGenuinelyInFlight(meta: RunMeta): boolean;
|
|
236
251
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|
|
237
252
|
export declare function monitorRunningJobs(): void;
|
|
238
253
|
export {};
|
|
@@ -2263,6 +2263,29 @@ export function listLiveRoutineChildren() {
|
|
|
2263
2263
|
}
|
|
2264
2264
|
return pids;
|
|
2265
2265
|
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Whether a run record marked `running` is genuinely in flight right now. A
|
|
2268
|
+
* `running` status alone is not enough: `writeActiveClaim` stamps a provisional
|
|
2269
|
+
* claim as `running` with `pid: null` BEFORE the child spawns, and
|
|
2270
|
+
* `monitorRunningJobs()` does not reap a null-pid record (it has no process to
|
|
2271
|
+
* probe, and ages it out only past the configured timeout — up to a week). So a
|
|
2272
|
+
* daemon crash in that spawn window leaves a `running` record for a run that is
|
|
2273
|
+
* not running (RUSH-2640). A caller answering "is one running now?" MUST gate on
|
|
2274
|
+
* this, mirroring `listLiveRoutineChildren`'s local-liveness test:
|
|
2275
|
+
* - a local child is in flight only while its pid is still ours (alive, same
|
|
2276
|
+
* birth time — a dead-and-reused pid does not count);
|
|
2277
|
+
* - a `host:`-placed run has no local pid by design and is in flight until its
|
|
2278
|
+
* remote `.exit` is reconciled (`finalizeHostRun`).
|
|
2279
|
+
*/
|
|
2280
|
+
export function isRunGenuinelyInFlight(meta) {
|
|
2281
|
+
if (meta.status !== 'running')
|
|
2282
|
+
return false;
|
|
2283
|
+
if (meta.hostTaskId)
|
|
2284
|
+
return true;
|
|
2285
|
+
if (!meta.pid)
|
|
2286
|
+
return false;
|
|
2287
|
+
return isPidOurs(meta.pid, meta.spawnedAt);
|
|
2288
|
+
}
|
|
2266
2289
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|
|
2267
2290
|
export function monitorRunningJobs() {
|
|
2268
2291
|
const runsDir = getRunsDir();
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
* over hosting whenever the daemon is NOT already hosting AND no healthy
|
|
9
9
|
* standalone broker answers a ping.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
12
12
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
13
|
-
export declare class SecretsBrokerService extends
|
|
13
|
+
export declare class SecretsBrokerService extends BasePeriodicService {
|
|
14
14
|
readonly id: DaemonServiceId;
|
|
15
|
+
readonly intervalMs = 60000;
|
|
16
|
+
readonly deadlineMs = 30000;
|
|
15
17
|
private hostedBroker;
|
|
16
|
-
private selfHealTimer;
|
|
17
|
-
private selfHealInFlight;
|
|
18
18
|
protected onStart(ctx: DaemonContext): Promise<void>;
|
|
19
19
|
protected onStop(): Promise<void>;
|
|
20
|
+
protected onTick(ctx: DaemonContext): Promise<void>;
|
|
20
21
|
}
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
* over hosting whenever the daemon is NOT already hosting AND no healthy
|
|
9
9
|
* standalone broker answers a ping.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { BasePeriodicService } from './service.js';
|
|
12
12
|
/** Matches the constant previously in daemon.ts. */
|
|
13
13
|
const BROKER_SELF_HEAL_TICK_MS = 60_000;
|
|
14
|
+
const BROKER_SELF_HEAL_DEADLINE_MS = 30_000;
|
|
14
15
|
/** Take over only when we are not already hosting AND no broker is reachable. */
|
|
15
16
|
function shouldTakeOver(isHosting, brokerReachable) {
|
|
16
17
|
return !isHosting && !brokerReachable;
|
|
17
18
|
}
|
|
18
|
-
export class SecretsBrokerService extends
|
|
19
|
+
export class SecretsBrokerService extends BasePeriodicService {
|
|
19
20
|
id = 'secrets-broker';
|
|
21
|
+
intervalMs = BROKER_SELF_HEAL_TICK_MS;
|
|
22
|
+
deadlineMs = BROKER_SELF_HEAL_DEADLINE_MS;
|
|
20
23
|
hostedBroker = null;
|
|
21
|
-
selfHealTimer;
|
|
22
|
-
selfHealInFlight = false;
|
|
23
24
|
async onStart(ctx) {
|
|
24
25
|
const { agentPing, startHostedBroker } = await import('../secrets/agent.js');
|
|
25
26
|
if ((await agentPing()).reachable) {
|
|
@@ -30,37 +31,21 @@ export class SecretsBrokerService extends BaseDaemonService {
|
|
|
30
31
|
if (this.hostedBroker)
|
|
31
32
|
ctx.log('INFO', 'Secrets broker hosted in daemon (socket-first)');
|
|
32
33
|
}
|
|
33
|
-
// RUSH-1817: if the standalone the daemon deferred to at start later dies,
|
|
34
|
-
// take over hosting on the next self-heal probe.
|
|
35
|
-
const runSelfHeal = async () => {
|
|
36
|
-
if (this.selfHealInFlight)
|
|
37
|
-
return;
|
|
38
|
-
this.selfHealInFlight = true;
|
|
39
|
-
try {
|
|
40
|
-
const { agentPing: ping, startHostedBroker: startBroker } = await import('../secrets/agent.js');
|
|
41
|
-
const reachable = (await ping()).reachable;
|
|
42
|
-
if (!shouldTakeOver(this.hostedBroker != null, reachable))
|
|
43
|
-
return;
|
|
44
|
-
this.hostedBroker = await startBroker();
|
|
45
|
-
if (this.hostedBroker) {
|
|
46
|
-
ctx.log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
catch (err) {
|
|
50
|
-
ctx.log('WARN', `Secrets broker self-heal skipped: ${err.message}`);
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
this.selfHealInFlight = false;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
this.selfHealTimer = setInterval(() => { void runSelfHeal(); }, BROKER_SELF_HEAL_TICK_MS);
|
|
57
34
|
}
|
|
58
35
|
async onStop() {
|
|
59
|
-
if (this.selfHealTimer !== undefined) {
|
|
60
|
-
clearInterval(this.selfHealTimer);
|
|
61
|
-
this.selfHealTimer = undefined;
|
|
62
|
-
}
|
|
63
36
|
this.hostedBroker?.close();
|
|
64
37
|
this.hostedBroker = null;
|
|
65
38
|
}
|
|
39
|
+
async onTick(ctx) {
|
|
40
|
+
// RUSH-1817: if the standalone the daemon deferred to at start later dies,
|
|
41
|
+
// take over hosting on the next supervisor-owned self-heal probe.
|
|
42
|
+
const { agentPing, startHostedBroker } = await import('../secrets/agent.js');
|
|
43
|
+
const reachable = (await agentPing()).reachable;
|
|
44
|
+
if (!shouldTakeOver(this.hostedBroker != null, reachable))
|
|
45
|
+
return;
|
|
46
|
+
this.hostedBroker = await startHostedBroker();
|
|
47
|
+
if (this.hostedBroker) {
|
|
48
|
+
ctx.log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
66
51
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DaemonService contract (RUSH-3193 P1).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Historically the daemon's background services were bare `setInterval` closures
|
|
5
5
|
* inside one long `runDaemon()` (daemon.ts), sharing one event loop with no
|
|
6
6
|
* per-service error boundary or deadline. A throw escaping a tick's local
|
|
7
7
|
* try/catch hits the process-wide handler and kills every service; a tick that
|
|
@@ -41,7 +41,7 @@ export interface DaemonService {
|
|
|
41
41
|
/** A service the supervisor ticks on a fixed interval, under a hard per-tick deadline. */
|
|
42
42
|
export interface PeriodicService extends DaemonService {
|
|
43
43
|
readonly intervalMs: number;
|
|
44
|
-
/** Hard cap per tick. An over-budget tick
|
|
44
|
+
/** Hard cap per tick. An over-budget tick parks the service; its in-flight guard remains held until the real promise settles. */
|
|
45
45
|
readonly deadlineMs: number;
|
|
46
46
|
/**
|
|
47
47
|
* Delay before the FIRST tick after `start()`, in ms. Every later tick
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DaemonService contract (RUSH-3193 P1).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Historically the daemon's background services were bare `setInterval` closures
|
|
5
5
|
* inside one long `runDaemon()` (daemon.ts), sharing one event loop with no
|
|
6
6
|
* per-service error boundary or deadline. A throw escaping a tick's local
|
|
7
7
|
* try/catch hits the process-wide handler and kills every service; a tick that
|