@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +19 -2
  3. package/dist/commands/routines.js +31 -2
  4. package/dist/commands/share.d.ts +27 -0
  5. package/dist/commands/share.js +85 -0
  6. package/dist/commands/view.d.ts +8 -0
  7. package/dist/commands/view.js +31 -4
  8. package/dist/lib/accounting/usage.d.ts +31 -0
  9. package/dist/lib/accounting/usage.js +45 -4
  10. package/dist/lib/browser/cdp.d.ts +1 -1
  11. package/dist/lib/browser/cdp.js +1 -1
  12. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  13. package/dist/lib/browser/ffmpeg.js +184 -0
  14. package/dist/lib/browser/service.js +119 -25
  15. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  16. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  17. package/dist/lib/daemon/daemon.js +57 -172
  18. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  19. package/dist/lib/daemon/heartbeat-service.js +26 -0
  20. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  21. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  22. package/dist/lib/daemon/runner.d.ts +15 -0
  23. package/dist/lib/daemon/runner.js +23 -0
  24. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  25. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  26. package/dist/lib/daemon/service.d.ts +2 -2
  27. package/dist/lib/daemon/service.js +1 -1
  28. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  29. package/dist/lib/daemon/session-state-service.js +34 -0
  30. package/dist/lib/daemon/supervisor.d.ts +17 -7
  31. package/dist/lib/daemon/supervisor.js +87 -14
  32. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  33. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  34. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  35. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  36. package/dist/lib/daemon-services.d.ts +1 -1
  37. package/dist/lib/daemon-services.js +20 -0
  38. package/dist/lib/devices/harness-inventory.js +5 -2
  39. package/dist/lib/monitors/engine.d.ts +5 -1
  40. package/dist/lib/monitors/engine.js +5 -3
  41. package/dist/lib/scheduling/routines.d.ts +47 -0
  42. package/dist/lib/scheduling/routines.js +70 -1
  43. package/dist/lib/session/db.d.ts +2 -1
  44. package/dist/lib/session/db.js +2 -1
  45. package/dist/lib/share/capture.js +11 -2
  46. package/dist/lib/share/publish.d.ts +4 -1
  47. package/dist/lib/share/publish.js +27 -1
  48. package/dist/lib/share/worker-template.d.ts +3 -12
  49. package/dist/lib/share/worker-template.js +279 -3
  50. package/dist/lib/view-types.d.ts +6 -1
  51. 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, monitorRunningJobs, listLiveRoutineChildren } from './runner.js';
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, resolveBrowserTaskIdleMs } from '../device-config.js';
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 * MONITOR_TICK_MS;
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 owns the orphan reap (previously daemon.ts:1303-1318)
908
- // and the BrowserIPCServer lifecycle. BrowserService is also constructed here
909
- // so daemon.ts can access it for the browser-task-reap interval (task #3).
910
- const browserSvcForReap = isEnabled('browser-ipc') ? new BrowserService() : null;
911
- if (browserSvcForReap)
912
- supervisor.register(new BrowserIPCService(browserSvcForReap));
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
- // Active-sessions publish-own: continuous journal writer for `sessions watch`.
1080
- // Fire once immediately so a cold daemon does not leave watchers on
1081
- // "awaiting publisher" for a full tick (RUSH-2484). This boot-time fire alone
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
- let webhookReceivers = null;
1195
- if (isEnabled('webhook-receiver')) {
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
- void runDeadPaneReap(); // kick-off on startup so the backlog clears immediately
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 own long-lived BrowserService. `browserSvcForReap` is
1225
- // the BrowserService created for BrowserIPCService above; null when disabled.
1226
- let browserTaskReapInterval;
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
- const action = now ? supervisor.start(id) : supervisor.stop(id);
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
- clearInterval(activeSessionsWarmInterval);
1355
- stopActiveSessionsReaderWatch();
1356
- // supervisor.stopAll() stops every registered service: secrets-broker
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. This is a lifecycle-only service — the engine
6
- * runs its own internal event loop; the supervisor just calls `start()` at
7
- * boot and `stop()` at shutdown.
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 { BaseDaemonService, type DaemonContext } from './service.js';
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 BaseDaemonService {
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. This is a lifecycle-only service — the engine
6
- * runs its own internal event loop; the supervisor just calls `start()` at
7
- * boot and `stop()` at shutdown.
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 { BaseDaemonService } from './service.js';
13
- import { MonitorEngine } from '../monitors/engine.js';
14
- export class MonitorEngineService extends BaseDaemonService {
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 { BaseDaemonService, type DaemonContext } from './service.js';
11
+ import { BasePeriodicService, type DaemonContext } from './service.js';
12
12
  import type { DaemonServiceId } from '../daemon-services.js';
13
- export declare class SecretsBrokerService extends BaseDaemonService {
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 { BaseDaemonService } from './service.js';
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 BaseDaemonService {
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
- * Today the daemon's ~13 background services are bare `setInterval` closures
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 is abandoned (never awaited past this) so its in-flight guard always releases. */
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
- * Today the daemon's ~13 background services are bare `setInterval` closures
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