@phnx-labs/agents-cli 1.22.70 → 1.22.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +31 -1
- package/dist/bootstrap.js +4 -4
- package/dist/commands/repo.js +2 -2
- package/dist/commands/sessions-export.d.ts +5 -1
- package/dist/commands/sessions-export.js +100 -24
- package/dist/commands/sessions-import.d.ts +2 -1
- package/dist/commands/sessions-import.js +85 -21
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +140 -19
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/daemon/auth-sync-service.js +1 -1
- package/dist/lib/daemon/browser-task-reap-service.js +1 -1
- package/dist/lib/daemon/daemon.js +13 -3
- package/dist/lib/daemon/heartbeat-service.js +3 -3
- package/dist/lib/daemon/keychain-reap-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +231 -78
- package/dist/lib/daemon/self-heal-service.js +13 -3
- package/dist/lib/daemon/self-update-service.d.ts +174 -0
- package/dist/lib/daemon/self-update-service.js +353 -0
- package/dist/lib/daemon/state-dir-check-service.js +3 -3
- package/dist/lib/daemon/usage-sync-service.js +1 -1
- package/dist/lib/daemon/watchdog-service.js +4 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +12 -1
- package/dist/lib/device-config.js +63 -13
- package/dist/lib/exec-bounded.d.ts +52 -0
- package/dist/lib/exec-bounded.js +113 -0
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/fleet-shared-state.d.ts +12 -5
- package/dist/lib/fleet-shared-state.js +50 -20
- package/dist/lib/fs-atomic.d.ts +11 -0
- package/dist/lib/fs-atomic.js +60 -0
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/project-resources.d.ts +12 -0
- package/dist/lib/project-resources.js +129 -0
- package/dist/lib/routine-process-cleanup.d.ts +2 -2
- package/dist/lib/routine-process-cleanup.js +45 -34
- package/dist/lib/secrets/reaper.d.ts +2 -2
- package/dist/lib/secrets/reaper.js +13 -10
- package/dist/lib/secrets/reserved-sync.d.ts +1 -1
- package/dist/lib/secrets/reserved-sync.js +4 -4
- package/dist/lib/self-update.d.ts +21 -8
- package/dist/lib/self-update.js +54 -31
- package/dist/lib/session/sync/backend.d.ts +61 -0
- package/dist/lib/session/sync/backend.js +89 -0
- package/dist/lib/session/sync/managed-config.d.ts +29 -0
- package/dist/lib/session/sync/managed-config.js +23 -0
- package/dist/lib/session/sync/managed-key.d.ts +45 -0
- package/dist/lib/session/sync/managed-key.js +128 -0
- package/dist/lib/session/sync/net-client.d.ts +65 -0
- package/dist/lib/session/sync/net-client.js +117 -0
- package/dist/lib/session/sync/provision.d.ts +19 -0
- package/dist/lib/session/sync/provision.js +38 -0
- package/dist/lib/session/sync/r2.d.ts +5 -2
- package/dist/lib/session/sync/r2.js +5 -2
- package/dist/lib/session/sync/worker-template.d.ts +6 -0
- package/dist/lib/session/sync/worker-template.js +847 -0
- package/dist/lib/tmux/orphan-reap.js +6 -4
- package/dist/lib/tmux/session.js +4 -1
- package/dist/lib/traces/classify.d.ts +8 -1
- package/dist/lib/traces/insights.d.ts +13 -1
- package/dist/lib/traces/insights.js +78 -3
- package/dist/lib/traces/sync.js +8 -3
- package/dist/lib/traces/worker-template.js +9 -5
- package/package.json +1 -1
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -7,6 +7,8 @@ import { resolveBrowserTaskIdleMs } from '../device-config.js';
|
|
|
7
7
|
import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
|
|
8
8
|
import { getCliVersion } from '../version.js';
|
|
9
9
|
import { compareVersions } from '../agent-spec/primitives.js';
|
|
10
|
+
import { scheduleSelfUpdateExit, selfUpdateSyncDeclineReason, triggerSelfUpdateInBackground } from '../daemon/self-update-service.js';
|
|
11
|
+
import { log as daemonLog } from '../daemon/daemon.js';
|
|
10
12
|
import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
|
|
11
13
|
import { isFleetRemoteInvocation } from './remote-control.js';
|
|
12
14
|
import { resolveCallerIdentity } from './caller-identity.js';
|
|
@@ -82,6 +84,17 @@ function isSameSocket(socketPath, expected) {
|
|
|
82
84
|
* coming near the grace window.
|
|
83
85
|
*/
|
|
84
86
|
const IPC_CLOSE_TIMEOUT_MS = 1_500;
|
|
87
|
+
/**
|
|
88
|
+
* Client-side ceiling on the on-demand `request-self-update` call inside
|
|
89
|
+
* {@link reconcileDaemonVersion}. The daemon handler is DECOUPLED from the
|
|
90
|
+
* install — it kicks the self-update off in the background and answers
|
|
91
|
+
* immediately — so this only needs to be long enough to confirm the daemon
|
|
92
|
+
* ACCEPTED the trigger, never long enough to wait out a check/install/verify.
|
|
93
|
+
* It must stay short because a version-skewed browser verb routes through this
|
|
94
|
+
* reconcile on its way to the real request; a wedged or gone daemon degrades to
|
|
95
|
+
* the advisory PHNX-3605 shipped rather than stalling the verb.
|
|
96
|
+
*/
|
|
97
|
+
const DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS = 5_000;
|
|
85
98
|
/**
|
|
86
99
|
* How long {@link waitForBrowserService} waits for the browser service to come up before
|
|
87
100
|
* failing loud (PHNX-3289).
|
|
@@ -655,6 +668,44 @@ export class BrowserIPCServer {
|
|
|
655
668
|
case 'version': {
|
|
656
669
|
return { ok: true, version: getCliVersion() };
|
|
657
670
|
}
|
|
671
|
+
// On-demand self-update, triggered by a client that noticed version
|
|
672
|
+
// skew (reconcileDaemonVersion below). DECOUPLED from the install
|
|
673
|
+
// (PHNX-3605): this handler MUST NOT await the check→download→install→
|
|
674
|
+
// verify, because it sits on the path of EVERY version-skewed
|
|
675
|
+
// `agents browser <verb>` (reconcileDaemonVersion → prepareIPC → here), so
|
|
676
|
+
// awaiting the whole thing would synchronously stall an ordinary browser
|
|
677
|
+
// verb for tens of seconds — worst case ~15 min — behind daemon
|
|
678
|
+
// housekeeping, exactly the client-stall PHNX-3605 exists to prevent.
|
|
679
|
+
// Instead: kick the self-update off in the BACKGROUND (bounded, and
|
|
680
|
+
// sharing the module-level in-flight guard so two triggers can't race a
|
|
681
|
+
// second install) and respond IMMEDIATELY that it was accepted. The daemon
|
|
682
|
+
// does install→verify→exit(0) on its own; the OS supervisor relaunches it
|
|
683
|
+
// and the browser reconnects. scheduleSelfUpdateExit (fired from the
|
|
684
|
+
// background continuation on a verified update) still delays the exit so
|
|
685
|
+
// this response has flushed to the socket first, and serializes against
|
|
686
|
+
// the periodic tick's own exit.
|
|
687
|
+
case 'request-self-update': {
|
|
688
|
+
// Instant, network-free decline gate FIRST (dev build / shadowed
|
|
689
|
+
// install), so a version-skewed browser client gets the PHNX-3605
|
|
690
|
+
// "nothing changed, not evicting" advisory immediately rather than a
|
|
691
|
+
// "triggered" it would never act on — reconcileDaemonVersion keys the
|
|
692
|
+
// advisory on !selfUpdateTriggered. Only a genuinely-eligible install is
|
|
693
|
+
// backgrounded below; the slow registry/download/install/verify never
|
|
694
|
+
// runs on this response path.
|
|
695
|
+
const declineReason = selfUpdateSyncDeclineReason();
|
|
696
|
+
if (declineReason) {
|
|
697
|
+
return { ok: true, selfUpdateTriggered: false, reason: declineReason };
|
|
698
|
+
}
|
|
699
|
+
void triggerSelfUpdateInBackground({ log: daemonLog })
|
|
700
|
+
.then((outcome) => {
|
|
701
|
+
if (outcome.updated)
|
|
702
|
+
scheduleSelfUpdateExit();
|
|
703
|
+
})
|
|
704
|
+
.catch((err) => {
|
|
705
|
+
daemonLog('ERROR', `self-update: background trigger failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
706
|
+
});
|
|
707
|
+
return { ok: true, selfUpdateTriggered: true };
|
|
708
|
+
}
|
|
658
709
|
case 'show': {
|
|
659
710
|
// Task-less by design — see BrowserService.showUrl. Absent from both
|
|
660
711
|
// PAGE_CREATE_VERBS and PAGE_RESOLVE_VERBS, so bindTask never ran above
|
|
@@ -707,7 +758,7 @@ export class BrowserIPCServer {
|
|
|
707
758
|
// this box's own configured default (or 30 minutes when unset).
|
|
708
759
|
const idleMs = request.idleMinutes !== undefined
|
|
709
760
|
? (request.idleMinutes === 0 ? null : request.idleMinutes * 60_000)
|
|
710
|
-
: resolveBrowserTaskIdleMs();
|
|
761
|
+
: await resolveBrowserTaskIdleMs();
|
|
711
762
|
const reaped = await this.service.reapAbandoned({ idleMs, dryRun: request.dryRun });
|
|
712
763
|
return { ok: true, reaped };
|
|
713
764
|
}
|
|
@@ -1077,12 +1128,19 @@ export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
|
|
|
1077
1128
|
}
|
|
1078
1129
|
/**
|
|
1079
1130
|
* Reconcile the running daemon's version with ours without evicting it. If the
|
|
1080
|
-
* daemon is serving stale code,
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1131
|
+
* daemon is serving stale code, ask the daemon to run its OWN self-update path
|
|
1132
|
+
* (`request-self-update`, same fail-closed check/install/verify/exit
|
|
1133
|
+
* SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
|
|
1134
|
+
* telling a human to run `agents daemon restart` by hand. A browser client
|
|
1135
|
+
* still owns neither the shared supervisor nor its sibling services, so
|
|
1136
|
+
* version skew can never be permission for THIS client to stop or restart
|
|
1137
|
+
* that process directly (PHNX-3605 holds) — it only asks the daemon to run a
|
|
1138
|
+
* path the daemon already runs on its own schedule, which only exits once the
|
|
1139
|
+
* daemon itself has verified the new version is good. If self-update declines
|
|
1140
|
+
* (dev build, shadowed install, already current, or a real failure) this
|
|
1141
|
+
* degrades to the same "nothing changed" outcome PHNX-3605 shipped.
|
|
1084
1142
|
*/
|
|
1085
|
-
async function reconcileDaemonVersion() {
|
|
1143
|
+
export async function reconcileDaemonVersion() {
|
|
1086
1144
|
if (versionReconciledThisProcess)
|
|
1087
1145
|
return;
|
|
1088
1146
|
versionReconciledThisProcess = true;
|
|
@@ -1099,8 +1157,41 @@ async function reconcileDaemonVersion() {
|
|
|
1099
1157
|
if (!shouldRecommendDaemonRefresh(daemon, client))
|
|
1100
1158
|
return;
|
|
1101
1159
|
process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
|
|
1102
|
-
+ '
|
|
1103
|
-
|
|
1160
|
+
+ 'Asking the daemon to self-update in the background (verify-then-exit; the OS supervisor relaunches it onto the new code)...\n');
|
|
1161
|
+
try {
|
|
1162
|
+
// SHORT timeout — the handler is decoupled from the install now (it kicks
|
|
1163
|
+
// the self-update off in the background and answers at once), so this only
|
|
1164
|
+
// waits for the daemon to CONFIRM it accepted the trigger, not for the
|
|
1165
|
+
// install to finish. It must stay short so the real browser verb that
|
|
1166
|
+
// triggered this reconcile proceeds promptly; a wedged/gone daemon degrades
|
|
1167
|
+
// to the same advisory PHNX-3605 shipped.
|
|
1168
|
+
const resp = await sendRawIPCRequest({ action: 'request-self-update' }, { autoStartDaemon: false, timeoutMs: DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS });
|
|
1169
|
+
if (resp.selfUpdateTriggered) {
|
|
1170
|
+
process.stderr.write('Daemon self-update requested — it verifies a newer version in the background and restarts if one is available. '
|
|
1171
|
+
+ 'Continuing without waiting.\n\n');
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
// Two shapes reach here: a CURRENT daemon that ran the instant decline
|
|
1175
|
+
// gate (dev build / shadowed install) and answered {selfUpdateTriggered:
|
|
1176
|
+
// false, reason}, and an OLDER daemon that predates the field entirely and
|
|
1177
|
+
// answered without it (no reason). Both degrade to the same non-evicting
|
|
1178
|
+
// advisory PHNX-3605 shipped; only the lead sentence differs.
|
|
1179
|
+
const declineDetail = resp.reason
|
|
1180
|
+
? `Daemon self-update declined (${resp.reason}). `
|
|
1181
|
+
: 'Daemon did not accept the self-update request. ';
|
|
1182
|
+
process.stderr.write(declineDetail
|
|
1183
|
+
+ 'Continuing without evicting the daemon or its other services. '
|
|
1184
|
+
+ 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
catch (err) {
|
|
1188
|
+
process.stderr.write(`Could not reach the daemon to request a self-update (${err instanceof Error ? err.message : String(err)}). `
|
|
1189
|
+
+ 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
|
|
1193
|
+
export function resetVersionReconciliationForTest() {
|
|
1194
|
+
versionReconciledThisProcess = false;
|
|
1104
1195
|
}
|
|
1105
1196
|
export async function sendIPCRequest(request, opts = {}) {
|
|
1106
1197
|
// Stamp caller identity ONCE here so the 28+ call sites don't each have to.
|
|
@@ -1155,13 +1246,37 @@ export async function connectBrowserIPC(opts = {}) {
|
|
|
1155
1246
|
});
|
|
1156
1247
|
});
|
|
1157
1248
|
}
|
|
1158
|
-
|
|
1249
|
+
/**
|
|
1250
|
+
* Exported (rather than kept module-private) so `ipc.test.ts` can drive the
|
|
1251
|
+
* `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
|
|
1252
|
+
* test double that never responds — the hermetic way to prove the timeout
|
|
1253
|
+
* actually fires without needing a slow real IPC action to provoke it.
|
|
1254
|
+
*/
|
|
1255
|
+
export async function sendRawIPCRequest(request, opts = {}) {
|
|
1159
1256
|
const endpoint = getIpcEndpoint();
|
|
1160
1257
|
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
1161
1258
|
await prepareIPC(request.action, opts);
|
|
1162
1259
|
return new Promise((resolve, reject) => {
|
|
1163
1260
|
const socket = net.createConnection(endpoint);
|
|
1164
1261
|
let buffer = '';
|
|
1262
|
+
let settled = false;
|
|
1263
|
+
let deadline;
|
|
1264
|
+
const settle = (fn) => {
|
|
1265
|
+
if (settled)
|
|
1266
|
+
return;
|
|
1267
|
+
settled = true;
|
|
1268
|
+
if (deadline)
|
|
1269
|
+
clearTimeout(deadline);
|
|
1270
|
+
fn();
|
|
1271
|
+
};
|
|
1272
|
+
if (opts.timeoutMs) {
|
|
1273
|
+
deadline = setTimeout(() => {
|
|
1274
|
+
settle(() => {
|
|
1275
|
+
socket.destroy();
|
|
1276
|
+
reject(new Error(`IPC request '${request.action}' timed out after ${opts.timeoutMs}ms`));
|
|
1277
|
+
});
|
|
1278
|
+
}, opts.timeoutMs);
|
|
1279
|
+
}
|
|
1165
1280
|
socket.on('connect', () => {
|
|
1166
1281
|
socket.write(JSON.stringify(request) + '\n');
|
|
1167
1282
|
});
|
|
@@ -1170,21 +1285,27 @@ async function sendRawIPCRequest(request, opts = {}) {
|
|
|
1170
1285
|
const idx = buffer.indexOf('\n');
|
|
1171
1286
|
if (idx !== -1) {
|
|
1172
1287
|
const response = JSON.parse(buffer.slice(0, idx));
|
|
1173
|
-
|
|
1174
|
-
|
|
1288
|
+
settle(() => {
|
|
1289
|
+
socket.end();
|
|
1290
|
+
resolve(response);
|
|
1291
|
+
});
|
|
1175
1292
|
}
|
|
1176
1293
|
});
|
|
1177
1294
|
socket.on('error', (err) => {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1295
|
+
settle(() => {
|
|
1296
|
+
if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
|
|
1297
|
+
reject(new BrowserServiceNotRunningError());
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
reject(new Error(`IPC error: ${err.message}`));
|
|
1301
|
+
});
|
|
1183
1302
|
});
|
|
1184
1303
|
socket.on('close', () => {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1304
|
+
settle(() => {
|
|
1305
|
+
if (!buffer.includes('\n')) {
|
|
1306
|
+
reject(new Error('Connection closed before response'));
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1188
1309
|
});
|
|
1189
1310
|
});
|
|
1190
1311
|
}
|
|
@@ -287,7 +287,7 @@ export interface ReapResult {
|
|
|
287
287
|
/** Tasks left alone this pass: still live, still inside the idle window, or recording. */
|
|
288
288
|
skipped: number;
|
|
289
289
|
}
|
|
290
|
-
export type IPCAction = 'start' | 'show' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version';
|
|
290
|
+
export type IPCAction = 'start' | 'show' | 'gc' | 'record-start' | 'record-stop' | 'done' | 'stop' | 'status' | 'history' | 'navigate' | 'tab-add' | 'tab-focus' | 'tab-close' | 'tab-list' | 'evaluate' | 'screenshot' | 'pdf' | 'refs' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'set-viewport' | 'set-device' | 'console' | 'errors' | 'requests' | 'response-body' | 'wait' | 'set-download-path' | 'wait-download' | 'upload' | 'getAppLogs' | 'version' | 'request-self-update';
|
|
291
291
|
export interface IPCRequest {
|
|
292
292
|
action: IPCAction;
|
|
293
293
|
task?: string;
|
|
@@ -425,6 +425,8 @@ export interface IPCResponse {
|
|
|
425
425
|
uploadMode?: 'input' | 'drop' | 'chooser';
|
|
426
426
|
appLogs?: any[];
|
|
427
427
|
version?: string;
|
|
428
|
+
selfUpdateTriggered?: boolean;
|
|
429
|
+
reason?: string;
|
|
428
430
|
skill?: {
|
|
429
431
|
name: string;
|
|
430
432
|
path: string;
|
|
@@ -23,7 +23,7 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
23
23
|
}
|
|
24
24
|
async onTick(ctx) {
|
|
25
25
|
const { publishReservedAuthVerdict, syncReservedAuthBundle } = await import('../secrets/reserved-sync.js');
|
|
26
|
-
const published = publishReservedAuthVerdict();
|
|
26
|
+
const published = await publishReservedAuthVerdict();
|
|
27
27
|
if (published.error)
|
|
28
28
|
ctx.log('WARN', `auth-sync: verdict: ${published.error}`);
|
|
29
29
|
const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
|
|
@@ -15,7 +15,7 @@ export class BrowserTaskReapService extends BasePeriodicService {
|
|
|
15
15
|
async onStart(_ctx) { }
|
|
16
16
|
async onStop() { }
|
|
17
17
|
async onTick(ctx) {
|
|
18
|
-
const result = await this.browserService.reapAbandoned({ idleMs: resolveBrowserTaskIdleMs() });
|
|
18
|
+
const result = await this.browserService.reapAbandoned({ idleMs: await resolveBrowserTaskIdleMs() });
|
|
19
19
|
if (result.closed.length > 0) {
|
|
20
20
|
ctx.log('INFO', `Browser-task reaper: closed ${result.closed.length} task(s)`);
|
|
21
21
|
for (const closed of result.closed) {
|
|
@@ -40,6 +40,7 @@ import { BrowserIPCService } from './browser-ipc-service.js';
|
|
|
40
40
|
import { WatchdogService } from './watchdog-service.js';
|
|
41
41
|
import { DeviceProbeService } from './device-probe-service.js';
|
|
42
42
|
import { SelfHealService } from './self-heal-service.js';
|
|
43
|
+
import { SelfUpdateService } from './self-update-service.js';
|
|
43
44
|
import { KeychainReapService } from './keychain-reap-service.js';
|
|
44
45
|
import { AuthSyncService } from './auth-sync-service.js';
|
|
45
46
|
import { UsageSyncService } from './usage-sync-service.js';
|
|
@@ -49,7 +50,7 @@ import { WebhookReceiverService } from './webhook-receiver-service.js';
|
|
|
49
50
|
import { HeartbeatService } from './heartbeat-service.js';
|
|
50
51
|
import { TmuxReapService } from './tmux-reap-service.js';
|
|
51
52
|
import { BrowserTaskReapService } from './browser-task-reap-service.js';
|
|
52
|
-
import { emit, emitRoutineEnd } from '../feed/events.js';
|
|
53
|
+
import { emit, emitAsync, emitRoutineEnd } from '../feed/events.js';
|
|
53
54
|
import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
|
|
54
55
|
import { sleepSync } from '../fs-atomic.js';
|
|
55
56
|
/**
|
|
@@ -749,11 +750,16 @@ export function log(level, message) {
|
|
|
749
750
|
: lvl === 'START' || /starting|started/i.test(message) ? 'daemon.start'
|
|
750
751
|
: lvl === 'STOP' || /stopping|stopped|shutting down/i.test(message) ? 'daemon.stop'
|
|
751
752
|
: 'daemon.info';
|
|
752
|
-
|
|
753
|
+
// Fire-and-forget the event mirror: `log()` is a synchronous primitive on
|
|
754
|
+
// every daemon tick's `ctx.log`, and the mirror's event-log lock would
|
|
755
|
+
// otherwise block the shared event loop for up to 30s under contention
|
|
756
|
+
// (PHNX-3695). The daemon-log append above is the primary, synchronous sink;
|
|
757
|
+
// the mirror is best-effort, so a fire-and-forget async write is correct.
|
|
758
|
+
void emitAsync(event, {
|
|
753
759
|
module: 'daemon',
|
|
754
760
|
detail: redactSecrets(message).slice(0, 500),
|
|
755
761
|
status: lvl,
|
|
756
|
-
});
|
|
762
|
+
}).catch(() => { });
|
|
757
763
|
}
|
|
758
764
|
catch { /* never crash the daemon on event-log failure */ }
|
|
759
765
|
}
|
|
@@ -1073,6 +1079,10 @@ export async function runDaemon() {
|
|
|
1073
1079
|
supervisor.register(new SelfHealService());
|
|
1074
1080
|
else
|
|
1075
1081
|
log('INFO', 'Self-heal service disabled');
|
|
1082
|
+
if (isEnabled('self-update'))
|
|
1083
|
+
supervisor.register(new SelfUpdateService());
|
|
1084
|
+
else
|
|
1085
|
+
log('INFO', 'Self-update service disabled');
|
|
1076
1086
|
if (isEnabled('keychain-reap'))
|
|
1077
1087
|
supervisor.register(new KeychainReapService());
|
|
1078
1088
|
else
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Daemon heartbeat and routine-process reconciliation under supervision. */
|
|
2
|
-
import {
|
|
2
|
+
import { reapExitedRunningJobs } from './runner.js';
|
|
3
3
|
import { reapTerminalRoutineProcesses } from '../routine-process-cleanup.js';
|
|
4
4
|
import { BasePeriodicService } from './service.js';
|
|
5
5
|
const HEARTBEAT_TICK_MS = 60_000;
|
|
@@ -17,8 +17,8 @@ export class HeartbeatService extends BasePeriodicService {
|
|
|
17
17
|
async onStop() { }
|
|
18
18
|
async onTick(ctx) {
|
|
19
19
|
this.publishHeartbeat();
|
|
20
|
-
|
|
21
|
-
const reaped = reapTerminalRoutineProcesses();
|
|
20
|
+
await reapExitedRunningJobs();
|
|
21
|
+
const reaped = await reapTerminalRoutineProcesses();
|
|
22
22
|
if (reaped.length > 0) {
|
|
23
23
|
ctx.log('WARN', `Reaped ${reaped.length} terminal routine process group(s): ${reaped.join(', ')}`);
|
|
24
24
|
}
|
|
@@ -22,7 +22,7 @@ export class KeychainReapService extends BasePeriodicService {
|
|
|
22
22
|
}
|
|
23
23
|
async onTick(ctx) {
|
|
24
24
|
const { reapOrphanedKeychainProcesses } = await import('../secrets/reaper.js');
|
|
25
|
-
const result = reapOrphanedKeychainProcesses();
|
|
25
|
+
const result = await reapOrphanedKeychainProcesses();
|
|
26
26
|
if (result.reaped > 0) {
|
|
27
27
|
ctx.log('WARN', `Reaped ${result.reaped} keychain orphan/stuck process(es)`);
|
|
28
28
|
for (const d of result.details)
|
|
@@ -251,6 +251,23 @@ export declare function listLiveRoutineChildren(): number[];
|
|
|
251
251
|
* remote `.exit` is reconciled (`finalizeHostRun`).
|
|
252
252
|
*/
|
|
253
253
|
export declare function isRunGenuinelyInFlight(meta: RunMeta): boolean;
|
|
254
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Scan all runs marked "running" and finalize any whose process has exited.
|
|
256
|
+
*
|
|
257
|
+
* SYNCHRONOUS — for OFF-loop callers only (the `agents routines list/status`
|
|
258
|
+
* builders and CLI best-effort orphan reaps), which run in short-lived CLI
|
|
259
|
+
* processes where a bounded synchronous `ps`/scan cannot starve a served event
|
|
260
|
+
* loop. The daemon's own heartbeat tick MUST use {@link reapExitedRunningJobs}
|
|
261
|
+
* instead — a sync scan there would freeze the shared event loop (PHNX-3695).
|
|
262
|
+
*/
|
|
255
263
|
export declare function monitorRunningJobs(): void;
|
|
264
|
+
/**
|
|
265
|
+
* Async, non-blocking twin of {@link monitorRunningJobs} for the daemon's
|
|
266
|
+
* heartbeat tick (PHNX-3695). Identical reconciliation via
|
|
267
|
+
* {@link reconcileRunningRecord}, but the directory walk uses `fs/promises` and
|
|
268
|
+
* the pid-identity probe uses the deadline-bounded {@link isPidOursAsync}, so
|
|
269
|
+
* the every-tick scan never freezes the shared event loop — the freeze that
|
|
270
|
+
* left the browser IPC `version` probe unanswered.
|
|
271
|
+
*/
|
|
272
|
+
export declare function reapExitedRunningJobs(): Promise<void>;
|
|
256
273
|
export {};
|