@phnx-labs/agents-cli 1.22.54 → 1.22.56
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 +24 -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 +92 -6
- 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/overdue.js +7 -37
- package/dist/lib/scheduler.d.ts +21 -2
- package/dist/lib/scheduler.js +28 -5
- package/dist/lib/scheduling/routines.d.ts +68 -0
- package/dist/lib/scheduling/routines.js +126 -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/provision.d.ts +3 -2
- package/dist/lib/share/provision.js +9 -4
- 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 +14 -0
- package/dist/lib/share/worker-template.js +330 -73
- package/dist/lib/view-types.d.ts +6 -1
- package/package.json +8 -2
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-session metadata publisher as a supervised periodic service.
|
|
3
|
+
*
|
|
4
|
+
* This is the single daemon-owned writer behind `sessions watch`: consumers
|
|
5
|
+
* announce reader presence, and the service publishes an incremental journal
|
|
6
|
+
* snapshot immediately on the idle->active edge and every 15 seconds while a
|
|
7
|
+
* reader remains. Callers consume the row; they do not run their own gather.
|
|
8
|
+
*/
|
|
9
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
10
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
11
|
+
export declare class SessionStateService extends BasePeriodicService {
|
|
12
|
+
private readonly wake;
|
|
13
|
+
readonly id: DaemonServiceId;
|
|
14
|
+
readonly intervalMs = 15000;
|
|
15
|
+
readonly deadlineMs = 30000;
|
|
16
|
+
private stopReaderWatch;
|
|
17
|
+
constructor(wake: () => void);
|
|
18
|
+
protected onStart(_ctx: DaemonContext): Promise<void>;
|
|
19
|
+
protected onStop(): Promise<void>;
|
|
20
|
+
protected onTick(_ctx: DaemonContext): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-session metadata publisher as a supervised periodic service.
|
|
3
|
+
*
|
|
4
|
+
* This is the single daemon-owned writer behind `sessions watch`: consumers
|
|
5
|
+
* announce reader presence, and the service publishes an incremental journal
|
|
6
|
+
* snapshot immediately on the idle->active edge and every 15 seconds while a
|
|
7
|
+
* reader remains. Callers consume the row; they do not run their own gather.
|
|
8
|
+
*/
|
|
9
|
+
import { runActiveSessionsWarmTick } from '../daemon-ticks.js';
|
|
10
|
+
import { watchActiveSessionsReaderPresence } from '../session/session-cache.js';
|
|
11
|
+
import { BasePeriodicService } from './service.js';
|
|
12
|
+
const SESSION_STATE_TICK_MS = 15_000;
|
|
13
|
+
const SESSION_STATE_DEADLINE_MS = 30_000;
|
|
14
|
+
export class SessionStateService extends BasePeriodicService {
|
|
15
|
+
wake;
|
|
16
|
+
id = 'session-state';
|
|
17
|
+
intervalMs = SESSION_STATE_TICK_MS;
|
|
18
|
+
deadlineMs = SESSION_STATE_DEADLINE_MS;
|
|
19
|
+
stopReaderWatch = null;
|
|
20
|
+
constructor(wake) {
|
|
21
|
+
super();
|
|
22
|
+
this.wake = wake;
|
|
23
|
+
}
|
|
24
|
+
async onStart(_ctx) {
|
|
25
|
+
this.stopReaderWatch = watchActiveSessionsReaderPresence(this.wake);
|
|
26
|
+
}
|
|
27
|
+
async onStop() {
|
|
28
|
+
this.stopReaderWatch?.();
|
|
29
|
+
this.stopReaderWatch = null;
|
|
30
|
+
}
|
|
31
|
+
async onTick(_ctx) {
|
|
32
|
+
await runActiveSessionsWarmTick();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* - A tick that hangs on an unbounded await (SSH, keychain) used to latch its
|
|
13
13
|
* local in-flight guard `true` forever, silently freezing that one service
|
|
14
14
|
* for the daemon's life (observed ~51h). Here, every tick races a
|
|
15
|
-
* `deadlineMs` timeout; when the deadline wins,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* `deadlineMs` timeout; when the deadline wins, that service is parked and
|
|
16
|
+
* its in-flight guard stays held until the real promise settles. This keeps
|
|
17
|
+
* a timed-out tick from overlapping another tick or lifecycle transition.
|
|
18
18
|
*
|
|
19
19
|
* Repeated failures (thrown or timed-out) open a circuit breaker: the service
|
|
20
20
|
* is parked (its timer stopped) and retried on exponential backoff via its own
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
25
25
|
import type { DaemonContext, DaemonService, ServiceHealth } from './service.js';
|
|
26
26
|
export interface ServiceSupervisorOptions {
|
|
27
|
-
/** Consecutive tick failures
|
|
27
|
+
/** Consecutive thrown tick failures before a service is parked. Deadline breaches park immediately. Default 3. */
|
|
28
28
|
parkAfterFailures?: number;
|
|
29
29
|
/** First restart backoff delay, doubled on each further failed restart attempt. Default 5s. */
|
|
30
30
|
backoffBaseMs?: number;
|
|
@@ -57,6 +57,12 @@ export declare class ServiceSupervisor {
|
|
|
57
57
|
isRegistered(id: DaemonServiceId): boolean;
|
|
58
58
|
/** Every currently registered service id. */
|
|
59
59
|
registeredIds(): DaemonServiceId[];
|
|
60
|
+
/**
|
|
61
|
+
* Resolve when the service's current tick has really settled. Daemon control
|
|
62
|
+
* edges use this to queue a requested live transition without polling or
|
|
63
|
+
* treating a deadline race as cancellation.
|
|
64
|
+
*/
|
|
65
|
+
awaitIdle(id: DaemonServiceId): Promise<void>;
|
|
60
66
|
/**
|
|
61
67
|
* Start one registered service live — drives `agents daemon services enable
|
|
62
68
|
* <id>` (RUSH-3193 P4). Only affects a service already registered on this
|
|
@@ -67,6 +73,8 @@ export declare class ServiceSupervisor {
|
|
|
67
73
|
start(id: DaemonServiceId): Promise<void>;
|
|
68
74
|
/** Stop one registered service live — drives `agents daemon services disable <id>` (RUSH-3193 P4). */
|
|
69
75
|
stop(id: DaemonServiceId): Promise<void>;
|
|
76
|
+
/** Request an immediate supervised tick, used by event edges that cannot wait for the regular cadence. */
|
|
77
|
+
runNow(id: DaemonServiceId): void;
|
|
70
78
|
/** Health for every registered service, keyed by service id. */
|
|
71
79
|
health(): Record<string, ServiceHealth>;
|
|
72
80
|
private startOne;
|
|
@@ -75,13 +83,15 @@ export declare class ServiceSupervisor {
|
|
|
75
83
|
/**
|
|
76
84
|
* Run one tick under a hard deadline. The deadline is enforced with
|
|
77
85
|
* `Promise.race`, not true cancellation — JS cannot abort an arbitrary
|
|
78
|
-
* in-flight await.
|
|
79
|
-
* `inFlight`
|
|
80
|
-
*
|
|
86
|
+
* in-flight await. A timeout parks the service immediately but deliberately
|
|
87
|
+
* keeps `inFlight` set until the REAL promise settles. Starting another tick
|
|
88
|
+
* or tearing resources down underneath the abandoned one would trade a
|
|
89
|
+
* visible parked service for duplicated side effects and lifecycle races.
|
|
81
90
|
*/
|
|
82
91
|
private runTick;
|
|
83
92
|
private park;
|
|
84
93
|
private scheduleRestart;
|
|
85
94
|
private attemptRestart;
|
|
86
95
|
private recordFailure;
|
|
96
|
+
private finishTick;
|
|
87
97
|
}
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* - A tick that hangs on an unbounded await (SSH, keychain) used to latch its
|
|
13
13
|
* local in-flight guard `true` forever, silently freezing that one service
|
|
14
14
|
* for the daemon's life (observed ~51h). Here, every tick races a
|
|
15
|
-
* `deadlineMs` timeout; when the deadline wins,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* `deadlineMs` timeout; when the deadline wins, that service is parked and
|
|
16
|
+
* its in-flight guard stays held until the real promise settles. This keeps
|
|
17
|
+
* a timed-out tick from overlapping another tick or lifecycle transition.
|
|
18
18
|
*
|
|
19
19
|
* Repeated failures (thrown or timed-out) open a circuit breaker: the service
|
|
20
20
|
* is parked (its timer stopped) and retried on exponential backoff via its own
|
|
@@ -67,17 +67,35 @@ export class ServiceSupervisor {
|
|
|
67
67
|
/** Stop every registered service and clear all timers. */
|
|
68
68
|
async stopAll() {
|
|
69
69
|
for (const id of this.registry.keys())
|
|
70
|
-
await this.stopOne(id);
|
|
70
|
+
await this.stopOne(id, true);
|
|
71
71
|
}
|
|
72
72
|
/** Force one service to restart right now, outside its normal backoff schedule. Drives `agents daemon services restart <id>` (RUSH-3193 P4). */
|
|
73
73
|
async restartOne(id) {
|
|
74
74
|
const entry = this.registry.get(id);
|
|
75
75
|
if (!entry)
|
|
76
76
|
throw new Error(`service '${id}' is not registered`);
|
|
77
|
+
if (entry.inFlight) {
|
|
78
|
+
throw new Error(`service '${id}' cannot restart while a tick is still in flight`);
|
|
79
|
+
}
|
|
80
|
+
// A live periodic service already owns an interval. attemptRestart() installs
|
|
81
|
+
// a fresh one after restart, so leaving the existing handles alive would
|
|
82
|
+
// multiply the tick rate on every operator-requested restart.
|
|
83
|
+
if (entry.timer) {
|
|
84
|
+
clearInterval(entry.timer);
|
|
85
|
+
entry.timer = undefined;
|
|
86
|
+
}
|
|
87
|
+
if (entry.startupTimer) {
|
|
88
|
+
clearTimeout(entry.startupTimer);
|
|
89
|
+
entry.startupTimer = undefined;
|
|
90
|
+
}
|
|
77
91
|
if (entry.restartTimer) {
|
|
78
92
|
clearTimeout(entry.restartTimer);
|
|
79
93
|
entry.restartTimer = undefined;
|
|
80
94
|
}
|
|
95
|
+
// Block a timer callback already queued in this event-loop turn while the
|
|
96
|
+
// service tears down and starts again.
|
|
97
|
+
entry.state = 'parked';
|
|
98
|
+
recordSubsystemState(id, 'parked');
|
|
81
99
|
await this.attemptRestart(id);
|
|
82
100
|
}
|
|
83
101
|
/** Whether `id` is registered on this supervisor. A service disabled at daemon boot is never registered — see {@link start}. */
|
|
@@ -88,6 +106,20 @@ export class ServiceSupervisor {
|
|
|
88
106
|
registeredIds() {
|
|
89
107
|
return Array.from(this.registry.keys());
|
|
90
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve when the service's current tick has really settled. Daemon control
|
|
111
|
+
* edges use this to queue a requested live transition without polling or
|
|
112
|
+
* treating a deadline race as cancellation.
|
|
113
|
+
*/
|
|
114
|
+
async awaitIdle(id) {
|
|
115
|
+
const entry = this.registry.get(id);
|
|
116
|
+
if (!entry)
|
|
117
|
+
throw new Error(`service '${id}' is not registered`);
|
|
118
|
+
const activeTick = entry.activeTick;
|
|
119
|
+
if (!activeTick)
|
|
120
|
+
return;
|
|
121
|
+
await activeTick.catch(() => undefined);
|
|
122
|
+
}
|
|
91
123
|
/**
|
|
92
124
|
* Start one registered service live — drives `agents daemon services enable
|
|
93
125
|
* <id>` (RUSH-3193 P4). Only affects a service already registered on this
|
|
@@ -101,6 +133,9 @@ export class ServiceSupervisor {
|
|
|
101
133
|
throw new Error(`service '${id}' is not registered`);
|
|
102
134
|
if (entry.state === 'running')
|
|
103
135
|
return;
|
|
136
|
+
if (entry.inFlight) {
|
|
137
|
+
throw new Error(`service '${id}' cannot start while a tick is still in flight`);
|
|
138
|
+
}
|
|
104
139
|
await this.startOne(id);
|
|
105
140
|
}
|
|
106
141
|
/** Stop one registered service live — drives `agents daemon services disable <id>` (RUSH-3193 P4). */
|
|
@@ -110,7 +145,13 @@ export class ServiceSupervisor {
|
|
|
110
145
|
throw new Error(`service '${id}' is not registered`);
|
|
111
146
|
if (entry.state === 'stopped')
|
|
112
147
|
return;
|
|
113
|
-
await this.stopOne(id);
|
|
148
|
+
await this.stopOne(id, false);
|
|
149
|
+
}
|
|
150
|
+
/** Request an immediate supervised tick, used by event edges that cannot wait for the regular cadence. */
|
|
151
|
+
runNow(id) {
|
|
152
|
+
if (!this.registry.has(id))
|
|
153
|
+
throw new Error(`service '${id}' is not registered`);
|
|
154
|
+
void this.runTick(id);
|
|
114
155
|
}
|
|
115
156
|
/** Health for every registered service, keyed by service id. */
|
|
116
157
|
health() {
|
|
@@ -164,10 +205,13 @@ export class ServiceSupervisor {
|
|
|
164
205
|
recordSubsystemOk(id);
|
|
165
206
|
}
|
|
166
207
|
}
|
|
167
|
-
async stopOne(id) {
|
|
208
|
+
async stopOne(id, force) {
|
|
168
209
|
const entry = this.registry.get(id);
|
|
169
210
|
if (!entry)
|
|
170
211
|
return;
|
|
212
|
+
if (entry.inFlight && !force) {
|
|
213
|
+
throw new Error(`service '${id}' cannot stop while a tick is still in flight`);
|
|
214
|
+
}
|
|
171
215
|
if (entry.timer) {
|
|
172
216
|
clearInterval(entry.timer);
|
|
173
217
|
entry.timer = undefined;
|
|
@@ -182,6 +226,11 @@ export class ServiceSupervisor {
|
|
|
182
226
|
}
|
|
183
227
|
entry.state = 'stopped';
|
|
184
228
|
recordSubsystemState(id, 'stopped');
|
|
229
|
+
// Whole-daemon shutdown must not hang forever on an unresolved tick, but it
|
|
230
|
+
// also must not tear down resources the tick may still be using. Stop its
|
|
231
|
+
// timers and mark it stopped; process exit owns final cleanup in this case.
|
|
232
|
+
if (entry.inFlight && force)
|
|
233
|
+
return;
|
|
185
234
|
if (!entry.everStarted)
|
|
186
235
|
return; // start() never succeeded — nothing to stop.
|
|
187
236
|
try {
|
|
@@ -200,9 +249,10 @@ export class ServiceSupervisor {
|
|
|
200
249
|
/**
|
|
201
250
|
* Run one tick under a hard deadline. The deadline is enforced with
|
|
202
251
|
* `Promise.race`, not true cancellation — JS cannot abort an arbitrary
|
|
203
|
-
* in-flight await.
|
|
204
|
-
* `inFlight`
|
|
205
|
-
*
|
|
252
|
+
* in-flight await. A timeout parks the service immediately but deliberately
|
|
253
|
+
* keeps `inFlight` set until the REAL promise settles. Starting another tick
|
|
254
|
+
* or tearing resources down underneath the abandoned one would trade a
|
|
255
|
+
* visible parked service for duplicated side effects and lifecycle races.
|
|
206
256
|
*/
|
|
207
257
|
async runTick(id) {
|
|
208
258
|
const entry = this.registry.get(id);
|
|
@@ -216,11 +266,17 @@ export class ServiceSupervisor {
|
|
|
216
266
|
const periodicService = entry.service;
|
|
217
267
|
entry.inFlight = true;
|
|
218
268
|
let deadlineTimer;
|
|
269
|
+
let timedOut = false;
|
|
270
|
+
const tickPromise = periodicService.tick(ctx);
|
|
271
|
+
entry.activeTick = tickPromise;
|
|
219
272
|
try {
|
|
220
273
|
const deadline = new Promise((_, reject) => {
|
|
221
|
-
deadlineTimer = setTimeout(() =>
|
|
274
|
+
deadlineTimer = setTimeout(() => {
|
|
275
|
+
timedOut = true;
|
|
276
|
+
reject(new Error(`tick exceeded deadline of ${periodicService.deadlineMs}ms`));
|
|
277
|
+
}, periodicService.deadlineMs);
|
|
222
278
|
});
|
|
223
|
-
await Promise.race([
|
|
279
|
+
await Promise.race([tickPromise, deadline]);
|
|
224
280
|
entry.consecutiveFailures = 0;
|
|
225
281
|
entry.lastError = undefined;
|
|
226
282
|
entry.lastRunMs = Date.now();
|
|
@@ -228,13 +284,20 @@ export class ServiceSupervisor {
|
|
|
228
284
|
}
|
|
229
285
|
catch (err) {
|
|
230
286
|
this.recordFailure(entry, id, err);
|
|
231
|
-
if (entry.consecutiveFailures >= this.parkAfterFailures)
|
|
287
|
+
if (timedOut || entry.consecutiveFailures >= this.parkAfterFailures)
|
|
232
288
|
this.park(id);
|
|
233
289
|
}
|
|
234
290
|
finally {
|
|
235
291
|
if (deadlineTimer)
|
|
236
292
|
clearTimeout(deadlineTimer);
|
|
237
|
-
|
|
293
|
+
if (timedOut) {
|
|
294
|
+
// Promise.race cannot cancel arbitrary work. Keep the service parked and
|
|
295
|
+
// in-flight until the REAL tick settles; only then may backoff restart it.
|
|
296
|
+
void tickPromise.then(() => this.finishTick(id, tickPromise), () => this.finishTick(id, tickPromise));
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
this.finishTick(id, tickPromise);
|
|
300
|
+
}
|
|
238
301
|
}
|
|
239
302
|
}
|
|
240
303
|
park(id) {
|
|
@@ -248,7 +311,8 @@ export class ServiceSupervisor {
|
|
|
248
311
|
entry.timer = undefined;
|
|
249
312
|
}
|
|
250
313
|
this.ctx?.log('WARN', `service '${id}' parked after ${entry.consecutiveFailures} consecutive failure(s)`);
|
|
251
|
-
|
|
314
|
+
if (!entry.inFlight)
|
|
315
|
+
this.scheduleRestart(id);
|
|
252
316
|
}
|
|
253
317
|
scheduleRestart(id) {
|
|
254
318
|
const entry = this.registry.get(id);
|
|
@@ -295,4 +359,13 @@ export class ServiceSupervisor {
|
|
|
295
359
|
recordSubsystemError(id, message);
|
|
296
360
|
this.ctx?.log('WARN', `service '${id}' failed: ${message}`);
|
|
297
361
|
}
|
|
362
|
+
finishTick(id, tickPromise) {
|
|
363
|
+
const entry = this.registry.get(id);
|
|
364
|
+
if (!entry || entry.activeTick !== tickPromise)
|
|
365
|
+
return;
|
|
366
|
+
entry.activeTick = undefined;
|
|
367
|
+
entry.inFlight = false;
|
|
368
|
+
if (entry.state === 'parked' && !entry.restartTimer)
|
|
369
|
+
this.scheduleRestart(id);
|
|
370
|
+
}
|
|
298
371
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Dead managed tmux session and orphan-helper cleanup under supervision. */
|
|
2
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
3
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
4
|
+
export declare class TmuxReapService extends BasePeriodicService {
|
|
5
|
+
readonly id: DaemonServiceId;
|
|
6
|
+
readonly intervalMs: number;
|
|
7
|
+
readonly deadlineMs: number;
|
|
8
|
+
protected onStart(_ctx: DaemonContext): Promise<void>;
|
|
9
|
+
protected onStop(): Promise<void>;
|
|
10
|
+
protected onTick(ctx: DaemonContext): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Dead managed tmux session and orphan-helper cleanup under supervision. */
|
|
2
|
+
import { getDefaultSocketPath } from '../tmux/paths.js';
|
|
3
|
+
import { reapDeadTmuxPanes } from '../tmux/session.js';
|
|
4
|
+
import { BasePeriodicService } from './service.js';
|
|
5
|
+
const TMUX_REAP_TICK_MS = 5 * 60_000;
|
|
6
|
+
const TMUX_REAP_DEADLINE_MS = 2 * 60_000;
|
|
7
|
+
export class TmuxReapService extends BasePeriodicService {
|
|
8
|
+
id = 'tmux-reap';
|
|
9
|
+
intervalMs = TMUX_REAP_TICK_MS;
|
|
10
|
+
deadlineMs = TMUX_REAP_DEADLINE_MS;
|
|
11
|
+
async onStart(_ctx) { }
|
|
12
|
+
async onStop() { }
|
|
13
|
+
async onTick(ctx) {
|
|
14
|
+
const result = await reapDeadTmuxPanes(getDefaultSocketPath());
|
|
15
|
+
for (const warning of result.warnings)
|
|
16
|
+
ctx.log('WARN', `Dead-pane reaper: ${warning}`);
|
|
17
|
+
if (result.processes > 0) {
|
|
18
|
+
ctx.log('INFO', `Dead-pane reaper: terminated ${result.processes} orphaned helper process(es)`);
|
|
19
|
+
for (const detail of result.processDetails)
|
|
20
|
+
ctx.log('INFO', ` ${detail}`);
|
|
21
|
+
}
|
|
22
|
+
if (result.reaped > 0) {
|
|
23
|
+
ctx.log('INFO', `Dead-pane reaper: reaped ${result.reaped} session(s)`);
|
|
24
|
+
for (const detail of result.details)
|
|
25
|
+
ctx.log('INFO', ` killed ${detail}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Signed webhook receiver lifecycle under the daemon service supervisor. */
|
|
2
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
3
|
+
import { BaseDaemonService, type DaemonContext } from './service.js';
|
|
4
|
+
export declare class WebhookReceiverService extends BaseDaemonService {
|
|
5
|
+
readonly id: DaemonServiceId;
|
|
6
|
+
private receivers;
|
|
7
|
+
protected onStart(ctx: DaemonContext): Promise<void>;
|
|
8
|
+
protected onStop(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Signed webhook receiver lifecycle under the daemon service supervisor. */
|
|
2
|
+
import { startHostedWebhookReceivers } from '../daemon-webhooks.js';
|
|
3
|
+
import { BaseDaemonService } from './service.js';
|
|
4
|
+
export class WebhookReceiverService extends BaseDaemonService {
|
|
5
|
+
id = 'webhook-receiver';
|
|
6
|
+
receivers = null;
|
|
7
|
+
async onStart(ctx) {
|
|
8
|
+
this.receivers = await startHostedWebhookReceivers({ log: ctx.log });
|
|
9
|
+
ctx.log('INFO', this.receivers.count > 0
|
|
10
|
+
? `Webhook receiver hosting ${this.receivers.count} receiver(s)`
|
|
11
|
+
: 'Webhook receiver service enabled; no receivers declared in daemon/webhooks.yaml');
|
|
12
|
+
}
|
|
13
|
+
async onStop() {
|
|
14
|
+
await this.receivers?.close();
|
|
15
|
+
this.receivers = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* enabled without pulling in the whole daemon lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
/** Every service the daemon can host. IDs are kebab-case and stable. */
|
|
10
|
-
export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync';
|
|
10
|
+
export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
|
|
11
11
|
/** Human-readable metadata for each service. */
|
|
12
12
|
export interface DaemonServiceDef {
|
|
13
13
|
id: DaemonServiceId;
|
|
@@ -67,6 +67,26 @@ export const DAEMON_SERVICES = [
|
|
|
67
67
|
title: 'State-dir self-check',
|
|
68
68
|
description: 'Self-terminates the daemon if its state directory is removed.',
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
id: 'daemon-heartbeat',
|
|
72
|
+
title: 'Daemon heartbeat',
|
|
73
|
+
description: 'Publishes daemon liveness and reconciles routine process state.',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'tmux-reap',
|
|
77
|
+
title: 'Tmux reap',
|
|
78
|
+
description: 'Reaps dead managed tmux sessions and their orphaned helper processes.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'browser-task-reap',
|
|
82
|
+
title: 'Browser task reap',
|
|
83
|
+
description: 'Closes abandoned browser-task tabs whose owner exited or idle lease expired.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'session-state',
|
|
87
|
+
title: 'Live session state',
|
|
88
|
+
description: 'Publishes this host\'s active session metadata for sessions watch and fleet consumers.',
|
|
89
|
+
},
|
|
70
90
|
{
|
|
71
91
|
id: 'session-index',
|
|
72
92
|
title: 'Session-index warm',
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import { ALL_AGENT_IDS, accountDisplayLabel, getAccountInfo, } from '../agents.js';
|
|
18
|
-
import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, } from '../accounting/usage.js';
|
|
18
|
+
import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, usageErrorForDisplay, } from '../accounting/usage.js';
|
|
19
19
|
import { getVersionHomePath, listInstalledVersions } from '../installations/store.js';
|
|
20
20
|
import { readMeta } from '../state.js';
|
|
21
21
|
import { listNativeAccounts } from '../account-registry.js';
|
|
@@ -123,7 +123,10 @@ export async function collectLocalHarnessInventory(opts) {
|
|
|
123
123
|
const key = getUsageLookupKey(info);
|
|
124
124
|
const usage = key ? usageByKey.get(key) : undefined;
|
|
125
125
|
const snapshot = usage?.snapshot ?? null;
|
|
126
|
-
|
|
126
|
+
// Normalize the internal 'stale' not-collected sentinel out before it reaches
|
|
127
|
+
// the JSON `quota.unavailableReason` — same leak PHNX-3348 fixed for
|
|
128
|
+
// `agents view --json`, here for `agents devices harnesses/accounts --json`.
|
|
129
|
+
const quota = summarizeQuota(snapshot, usageErrorForDisplay(usage?.error), info?.usageStatus ?? null);
|
|
127
130
|
const signedIn = !!info?.signedIn;
|
|
128
131
|
const { ready, reason } = computeReady(signedIn, quota);
|
|
129
132
|
const display = info ? accountDisplayLabel(info) || null : null;
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { type MonitorConfig, type MonitorEvent } from './config.js';
|
|
15
15
|
import { type Observation } from './sources/index.js';
|
|
16
|
+
/** How often the engine wakes to check which monitors are due. */
|
|
17
|
+
export declare const MONITOR_ENGINE_TICK_MS = 5000;
|
|
16
18
|
/** Poll-model source types the engine actually evaluates on a cadence; ws/webhook are push-only and inert here. */
|
|
17
19
|
export declare const POLL_SOURCE_TYPES: Set<string>;
|
|
18
20
|
/**
|
|
@@ -58,7 +60,9 @@ export declare class MonitorEngine {
|
|
|
58
60
|
private ticking;
|
|
59
61
|
constructor(logFn?: LogFn);
|
|
60
62
|
/** Load owned+enabled monitors and start the tick loop. */
|
|
61
|
-
start(
|
|
63
|
+
start(options?: {
|
|
64
|
+
externalScheduler?: boolean;
|
|
65
|
+
}): void;
|
|
62
66
|
/** Reload monitor configs (SIGHUP). */
|
|
63
67
|
reload(): void;
|
|
64
68
|
/** Stop the tick loop. */
|
|
@@ -18,7 +18,7 @@ import { dispatchAction, injectEvent } from './dispatch.js';
|
|
|
18
18
|
import { sendToOwner } from '../notify.js';
|
|
19
19
|
import { readRunMeta } from '../scheduling/routines.js';
|
|
20
20
|
/** How often the engine wakes to check which monitors are due. */
|
|
21
|
-
const
|
|
21
|
+
export const MONITOR_ENGINE_TICK_MS = 5_000;
|
|
22
22
|
/** Default evaluation cadence for sources that carry no explicit interval. */
|
|
23
23
|
const DEFAULT_INTERVAL_MS = 60_000;
|
|
24
24
|
/**
|
|
@@ -126,10 +126,12 @@ export class MonitorEngine {
|
|
|
126
126
|
this.logFn = logFn;
|
|
127
127
|
}
|
|
128
128
|
/** Load owned+enabled monitors and start the tick loop. */
|
|
129
|
-
start() {
|
|
129
|
+
start(options = {}) {
|
|
130
130
|
this.loadAll();
|
|
131
131
|
this.logFn('INFO', `Monitor engine started (${this.monitors.length} monitor(s) on this device)`);
|
|
132
|
-
|
|
132
|
+
if (!options.externalScheduler) {
|
|
133
|
+
this.timer = setInterval(() => void this.tick(), MONITOR_ENGINE_TICK_MS);
|
|
134
|
+
}
|
|
133
135
|
}
|
|
134
136
|
/** Reload monitor configs (SIGHUP). */
|
|
135
137
|
reload() {
|
package/dist/lib/overdue.js
CHANGED
|
@@ -13,48 +13,18 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import { Cron } from 'croner';
|
|
16
|
-
import { listJobs, getLatestRun, resolveJobFilePath, isPastEndAt, isOneShotRoutine, jobRunsOnThisDevice } from './scheduling/routines.js';
|
|
16
|
+
import { alignedSlotForFire, listJobs, getLatestRun, resolveJobFilePath, isPastEndAt, isOneShotRoutine, jobRunsOnThisDevice } from './scheduling/routines.js';
|
|
17
17
|
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
18
18
|
// Tolerance between "expected fire" and "recorded run start" — accounts for
|
|
19
19
|
// the small gap between the cron tick and when the runner writes meta.json.
|
|
20
20
|
const GRACE_MS = 60_000;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* routine was never flagged overdue on any device — no missed record, no
|
|
27
|
-
* catch-up, permanently. Monthly, quarterly and annual routines were all in that
|
|
28
|
-
* class.
|
|
29
|
-
*
|
|
30
|
-
* A wider window is only tried when the narrower one found nothing, so a dense
|
|
31
|
-
* schedule (every minute, hourly, daily) never walks more than a week of
|
|
32
|
-
* occurrences. A sparse schedule has few occurrences to walk by definition.
|
|
33
|
-
*/
|
|
34
|
-
const LOOKBACK_WINDOWS_MS = [7 * DAY_MS, 32 * DAY_MS, 93 * DAY_MS, 400 * DAY_MS];
|
|
35
|
-
/** Compute the most recent fire of `pattern` at or before `now`. Croner's
|
|
36
|
-
* `previousRun()` returns the cron instance's own last fire, which is null
|
|
37
|
-
* on a freshly-constructed instance — so we walk `nextRun(cursor)` forward
|
|
38
|
-
* from a week ago and keep the last fire still ≤ now. */
|
|
21
|
+
/** Compute the most recent fire of `pattern` at or before `now`. Delegates to
|
|
22
|
+
* {@link alignedSlotForFire} so overdue detection (`missedRunId`) and the live
|
|
23
|
+
* forward-timer dispatch (`slotRunId`) key on the SAME occurrence identity — a
|
|
24
|
+
* missed fire and its live twin for one UTC slot then collide by construction
|
|
25
|
+
* (SING-15). */
|
|
39
26
|
function previousExpectedFire(cron, now) {
|
|
40
|
-
|
|
41
|
-
let cursor = new Date(now.getTime() - window);
|
|
42
|
-
let last = null;
|
|
43
|
-
// Cap iterations: an every-minute schedule yields ≤ 10080 steps over a week;
|
|
44
|
-
// 20k is a paranoia bound against pathological patterns. Only a schedule
|
|
45
|
-
// that found nothing in the narrower window reaches a wider one, and such a
|
|
46
|
-
// schedule is sparse, so the cap is never the binding constraint.
|
|
47
|
-
for (let i = 0; i < 20000; i++) {
|
|
48
|
-
const next = cron.nextRun(cursor);
|
|
49
|
-
if (!next || next.getTime() > now.getTime())
|
|
50
|
-
break;
|
|
51
|
-
last = next;
|
|
52
|
-
cursor = next;
|
|
53
|
-
}
|
|
54
|
-
if (last)
|
|
55
|
-
return last;
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
27
|
+
return alignedSlotForFire(cron, now);
|
|
58
28
|
}
|
|
59
29
|
/**
|
|
60
30
|
* When a routine started existing, and therefore the earliest fire it can
|
package/dist/lib/scheduler.d.ts
CHANGED
|
@@ -5,13 +5,32 @@
|
|
|
5
5
|
* process creates a single JobScheduler instance that loads enabled jobs
|
|
6
6
|
* on startup and reloads them on SIGHUP.
|
|
7
7
|
*/
|
|
8
|
+
import { Cron } from 'croner';
|
|
8
9
|
import type { JobConfig } from './scheduling/routines.js';
|
|
9
10
|
/** How a fire was triggered, carrying the scheduler's intended UTC slot time. */
|
|
10
11
|
export interface TriggerContext {
|
|
11
|
-
/** The cron slot this callback fires for
|
|
12
|
-
*
|
|
12
|
+
/** The ALIGNED cron slot this callback fires for, for the single-fire claim
|
|
13
|
+
* keyed on (routine, scheduledFor). Derived by {@link fireSlot} — NOT croner's
|
|
14
|
+
* raw `currentRun()`, which carries wall-clock jitter. */
|
|
13
15
|
scheduledFor?: Date;
|
|
14
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The aligned occurrence boundary a fire callback belongs to — the value the
|
|
19
|
+
* single-fire `(routine, scheduledFor)` claim keys on.
|
|
20
|
+
*
|
|
21
|
+
* croner's `currentRun()` inside a fire callback is the JITTERED wall-clock
|
|
22
|
+
* trigger instant (it carries milliseconds — verified against croner 10.x), not
|
|
23
|
+
* the aligned schedule boundary. Keying `slotRunId` on it directly minted a
|
|
24
|
+
* distinct run id per delivery, so two callbacks for one occurrence each claimed
|
|
25
|
+
* a different run dir and both launched, and a live fire never collided with its
|
|
26
|
+
* catch-up twin (`missedRunId`, which keys on the aligned boundary). Flooring the
|
|
27
|
+
* fire to its schedule boundary via {@link alignedSlotForFire} makes the claim a
|
|
28
|
+
* structural claim on the occurrence identity (SING-15). Always returns a
|
|
29
|
+
* concrete Date — `currentRun()` falls back to now, and an unresolvable boundary
|
|
30
|
+
* falls back to the fire instant — so the forward path never dispatches without a
|
|
31
|
+
* durable slot key.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fireSlot(cron: Cron): Date;
|
|
15
34
|
/** In-memory cron scheduler that triggers a callback when jobs fire. */
|
|
16
35
|
export declare class JobScheduler {
|
|
17
36
|
private jobs;
|
package/dist/lib/scheduler.js
CHANGED
|
@@ -6,7 +6,27 @@
|
|
|
6
6
|
* on startup and reloads them on SIGHUP.
|
|
7
7
|
*/
|
|
8
8
|
import { Cron } from 'croner';
|
|
9
|
-
import { listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, hasAmbiguousDevicePin, routineOwnerDevice, } from './scheduling/routines.js';
|
|
9
|
+
import { alignedSlotForFire, listJobs, deleteJob, isPastEndAt, isPastOneShotRoutine, isOneShotRoutine, setJobEnabled, shouldPurgeCompletedOneShotRoutine, jobRunsOnThisDevice, hasAmbiguousDevicePin, routineOwnerDevice, } from './scheduling/routines.js';
|
|
10
|
+
/**
|
|
11
|
+
* The aligned occurrence boundary a fire callback belongs to — the value the
|
|
12
|
+
* single-fire `(routine, scheduledFor)` claim keys on.
|
|
13
|
+
*
|
|
14
|
+
* croner's `currentRun()` inside a fire callback is the JITTERED wall-clock
|
|
15
|
+
* trigger instant (it carries milliseconds — verified against croner 10.x), not
|
|
16
|
+
* the aligned schedule boundary. Keying `slotRunId` on it directly minted a
|
|
17
|
+
* distinct run id per delivery, so two callbacks for one occurrence each claimed
|
|
18
|
+
* a different run dir and both launched, and a live fire never collided with its
|
|
19
|
+
* catch-up twin (`missedRunId`, which keys on the aligned boundary). Flooring the
|
|
20
|
+
* fire to its schedule boundary via {@link alignedSlotForFire} makes the claim a
|
|
21
|
+
* structural claim on the occurrence identity (SING-15). Always returns a
|
|
22
|
+
* concrete Date — `currentRun()` falls back to now, and an unresolvable boundary
|
|
23
|
+
* falls back to the fire instant — so the forward path never dispatches without a
|
|
24
|
+
* durable slot key.
|
|
25
|
+
*/
|
|
26
|
+
export function fireSlot(cron) {
|
|
27
|
+
const fire = cron.currentRun() ?? new Date();
|
|
28
|
+
return alignedSlotForFire(cron, fire) ?? fire;
|
|
29
|
+
}
|
|
10
30
|
/** In-memory cron scheduler that triggers a callback when jobs fire. */
|
|
11
31
|
export class JobScheduler {
|
|
12
32
|
jobs = new Map();
|
|
@@ -75,10 +95,13 @@ export class JobScheduler {
|
|
|
75
95
|
return;
|
|
76
96
|
}
|
|
77
97
|
try {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
98
|
+
// scheduledFor is the ALIGNED occurrence boundary (fireSlot), not croner's
|
|
99
|
+
// jittered currentRun(): the single-fire claim keys on (routine,
|
|
100
|
+
// scheduledFor), so the key must be the occurrence identity or a live fire
|
|
101
|
+
// and its catch-up twin (missedRunId) won't collide and two deliveries of
|
|
102
|
+
// one slot each mint a distinct id. fireSlot always returns a Date, so the
|
|
103
|
+
// forward path always carries a durable claim (SING-15).
|
|
104
|
+
await this.onTrigger(config, { scheduledFor: fireSlot(self) });
|
|
82
105
|
}
|
|
83
106
|
catch (err) {
|
|
84
107
|
console.error(`Job '${config.name}' failed:`, err.message);
|