fullcourtdefense-cli 1.26.14 → 1.26.15
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/dist/commands/daemon.js +70 -3
- package/dist/pollLoop.d.ts +6 -0
- package/dist/pollLoop.js +52 -8
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -145,6 +145,14 @@ const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000)
|
|
|
145
145
|
*/
|
|
146
146
|
const RESUME_BEAT_MS = envMs('FCD_DAEMON_RESUME_BEAT_MS', 30_000);
|
|
147
147
|
const RESUME_JUMP_MS = envMs('FCD_DAEMON_RESUME_JUMP_MS', 120_000);
|
|
148
|
+
/**
|
|
149
|
+
* Report-in attempts after a resume, and the base delay between them (linear
|
|
150
|
+
* backoff: 10s, 20s, 30s, 40s). Sized to outlast a normal Wi-Fi reassociation
|
|
151
|
+
* plus DHCP/DNS, so waking on a slow network costs seconds of staleness rather
|
|
152
|
+
* than a whole reporting window.
|
|
153
|
+
*/
|
|
154
|
+
const RESUME_REPORT_ATTEMPTS = envMs('FCD_DAEMON_RESUME_ATTEMPTS', 5);
|
|
155
|
+
const RESUME_RETRY_BASE_MS = envMs('FCD_DAEMON_RESUME_RETRY_MS', 10_000);
|
|
148
156
|
/** Delay before the one-time initial discovery sweep on a fresh machine. */
|
|
149
157
|
const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
|
|
150
158
|
/** A discovery upload older than this is stale — the daemon catches up itself. */
|
|
@@ -1298,6 +1306,14 @@ async function runDaemon(args, config) {
|
|
|
1298
1306
|
recoverCredentialsIfMissing();
|
|
1299
1307
|
if (!creds.shieldId)
|
|
1300
1308
|
return;
|
|
1309
|
+
// Did THIS beat actually record daemon liveness upstream? `flushSpool`
|
|
1310
|
+
// reports every failure by returning null and never throws, so without
|
|
1311
|
+
// this flag a heartbeat that never left the machine was indistinguishable
|
|
1312
|
+
// from a delivered one — and the loop happily waited a full cadence before
|
|
1313
|
+
// trying again. `null` also covers the case where a hook flusher held the
|
|
1314
|
+
// flush lock, which drops the daemon marker specifically (hook flushes do
|
|
1315
|
+
// not set `daemon: true`), so retrying soon is right in that case too.
|
|
1316
|
+
let reported = false;
|
|
1301
1317
|
try {
|
|
1302
1318
|
let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1303
1319
|
// A failing verdict used to be REPORTED and nothing more, so a machine
|
|
@@ -1348,6 +1364,7 @@ async function runDaemon(args, config) {
|
|
|
1348
1364
|
}
|
|
1349
1365
|
: undefined,
|
|
1350
1366
|
});
|
|
1367
|
+
reported = result !== null;
|
|
1351
1368
|
if (result && unreportedCrash)
|
|
1352
1369
|
(0, daemonForensics_1.markPostmortemReported)();
|
|
1353
1370
|
if (result && result.accepted > 0)
|
|
@@ -1358,8 +1375,13 @@ async function runDaemon(args, config) {
|
|
|
1358
1375
|
}
|
|
1359
1376
|
catch { /* spool stays on disk for the next tick */ }
|
|
1360
1377
|
// Settle any in-flight upgrade_cli action (success once the new build is
|
|
1361
|
-
// live, failure when the target never arrived).
|
|
1378
|
+
// live, failure when the target never arrived). Runs BEFORE the failure
|
|
1379
|
+
// signal below so a lost heartbeat can never strand a pending upgrade.
|
|
1362
1380
|
await verifyPendingUpgrade();
|
|
1381
|
+
// Surface the miss to the caller (poll loop / resume path) so it retries in
|
|
1382
|
+
// seconds. Nothing else can tell them: liveness has exactly one writer.
|
|
1383
|
+
if (!reported)
|
|
1384
|
+
throw new Error('heartbeat did not reach the backend — daemon liveness was not recorded');
|
|
1363
1385
|
};
|
|
1364
1386
|
// --- boot ---------------------------------------------------------------
|
|
1365
1387
|
// Self-heal autostart + watchdog tasks: machines installed by older versions
|
|
@@ -1385,7 +1407,19 @@ async function runDaemon(args, config) {
|
|
|
1385
1407
|
// would report a false "damaged" state (and fire admin integrity alerts)
|
|
1386
1408
|
// for a condition the very next line repairs.
|
|
1387
1409
|
await reprotect(['startup pass']);
|
|
1388
|
-
|
|
1410
|
+
// The FIRST beat must not be able to abort the rest of this function.
|
|
1411
|
+
// `heartbeat` throws when the report did not reach the backend (that is how
|
|
1412
|
+
// the poll loop knows to retry in seconds), and an exception here propagates
|
|
1413
|
+
// out of `runDaemon` and skips every loop registered below it — the heartbeat
|
|
1414
|
+
// loop, the resume timer and the bundle poll. The process itself survives on
|
|
1415
|
+
// the `unhandledRejection` handler, which is worse than dying: it holds the
|
|
1416
|
+
// pid lock, so the scheduled task declines to start a replacement, and it
|
|
1417
|
+
// never polls the bundle, so no remote action can reach it. Booting before
|
|
1418
|
+
// the network is up (captive portal, VPN, resumed in a lift) is routine, and
|
|
1419
|
+
// the heartbeat loop below retries within seconds.
|
|
1420
|
+
await heartbeat().catch(error => {
|
|
1421
|
+
log(`First heartbeat did not reach the backend (${error?.message || String(error)}) — the heartbeat loop will retry shortly.`);
|
|
1422
|
+
});
|
|
1389
1423
|
// Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
|
|
1390
1424
|
// chat has no hook and never hits an MCP server, so it is the one machine
|
|
1391
1425
|
// surface neither hooks nor the gateway can see. Supervise the advisory guard
|
|
@@ -1535,6 +1569,39 @@ async function runDaemon(args, config) {
|
|
|
1535
1569
|
// closes the reporting gap and removes the evidence of a hang that never
|
|
1536
1570
|
// happened. Both calls go through runWithDeadline, so a resume that lands on
|
|
1537
1571
|
// sockets which died during sleep cannot leak an unhandled rejection.
|
|
1572
|
+
// The resume beat is RETRIED, because it fires the instant the wall-clock
|
|
1573
|
+
// jump is noticed — which on a laptop is reliably before Wi-Fi has
|
|
1574
|
+
// reassociated. As a single shot, the one beat whose whole job is to prove
|
|
1575
|
+
// the daemon survived the sleep was also the likeliest to be lost, and modern
|
|
1576
|
+
// standby routinely re-suspends the machine before the next cadence tick, so
|
|
1577
|
+
// liveness could stay stale indefinitely while IDE-hook flushes kept the
|
|
1578
|
+
// machine "online" — a live daemon displayed as down.
|
|
1579
|
+
let resumeReportInFlight = false;
|
|
1580
|
+
const reportInAfterResume = async () => {
|
|
1581
|
+
if (resumeReportInFlight)
|
|
1582
|
+
return; // a previous resume is still catching up
|
|
1583
|
+
resumeReportInFlight = true;
|
|
1584
|
+
try {
|
|
1585
|
+
for (let attempt = 1; attempt <= RESUME_REPORT_ATTEMPTS; attempt++) {
|
|
1586
|
+
let failed = false;
|
|
1587
|
+
const outcome = await (0, pollLoop_1.runWithDeadline)({
|
|
1588
|
+
run: heartbeat,
|
|
1589
|
+
deadlineMs: HEARTBEAT_DEADLINE_MS,
|
|
1590
|
+
onError: () => { failed = true; },
|
|
1591
|
+
});
|
|
1592
|
+
if (outcome === 'settled' && !failed)
|
|
1593
|
+
return; // liveness recorded
|
|
1594
|
+
if (attempt === RESUME_REPORT_ATTEMPTS) {
|
|
1595
|
+
log(`Resume report-in did not land after ${attempt} attempt(s) — the scheduled heartbeat keeps retrying.`);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
await new Promise(resolve => setTimeout(resolve, RESUME_RETRY_BASE_MS * attempt));
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
finally {
|
|
1602
|
+
resumeReportInFlight = false;
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1538
1605
|
let lastResumeBeatMs = Date.now();
|
|
1539
1606
|
const resumeTimer = setInterval(() => {
|
|
1540
1607
|
const now = Date.now();
|
|
@@ -1544,7 +1611,7 @@ async function runDaemon(args, config) {
|
|
|
1544
1611
|
return;
|
|
1545
1612
|
log(`Resume detected: ${Math.round(drift / 1000)}s of wall time elapsed while suspended — refreshing liveness and reporting in now.`);
|
|
1546
1613
|
(0, daemonForensics_1.touchDaemonAlive)();
|
|
1547
|
-
void (
|
|
1614
|
+
void reportInAfterResume();
|
|
1548
1615
|
void (0, pollLoop_1.runWithDeadline)({ run: pollBundle, deadlineMs: BUNDLE_POLL_DEADLINE_MS });
|
|
1549
1616
|
}, RESUME_BEAT_MS);
|
|
1550
1617
|
// PowerShell transcript retention (Windows): the Transcription policy FCD
|
package/dist/pollLoop.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface PollLoopInput {
|
|
|
51
51
|
onError?: (error: unknown) => void;
|
|
52
52
|
/** Cadence jitter fraction (0..1, default 0.2) — a proxied fleet must not poll in lockstep. */
|
|
53
53
|
jitter?: number;
|
|
54
|
+
/**
|
|
55
|
+
* First delay after an attempt that FAILED or was abandoned, doubled on each
|
|
56
|
+
* consecutive failure and capped at the normal cadence (so a retry is never
|
|
57
|
+
* lazier than the ordinary tick). Reset the moment an attempt succeeds.
|
|
58
|
+
*/
|
|
59
|
+
retryDelayMs?: number;
|
|
54
60
|
/** Injectable timers for tests. */
|
|
55
61
|
timers?: {
|
|
56
62
|
setTimeout: (fn: () => void, ms: number) => unknown;
|
package/dist/pollLoop.js
CHANGED
|
@@ -32,6 +32,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
32
32
|
exports.runWithDeadline = runWithDeadline;
|
|
33
33
|
exports.startPollLoop = startPollLoop;
|
|
34
34
|
const MIN_DELAY_MS = 5_000;
|
|
35
|
+
/**
|
|
36
|
+
* Default first retry after a failed/abandoned attempt.
|
|
37
|
+
*
|
|
38
|
+
* WHY A RETRY EXISTS AT ALL (the "Online · daemon down" bug)
|
|
39
|
+
* The loop used to reschedule at the full cadence no matter how the attempt
|
|
40
|
+
* ended, so ONE failed heartbeat left the daemon unreported for 5-6 minutes.
|
|
41
|
+
* That is not rare on a laptop: the wake-from-sleep path fires a heartbeat the
|
|
42
|
+
* instant a resume is noticed, which is usually BEFORE Wi-Fi has reassociated,
|
|
43
|
+
* so the very beat meant to prove the daemon survived the sleep is the one most
|
|
44
|
+
* likely to fail. Modern standby then re-suspends the machine in ~10-minute
|
|
45
|
+
* blocks, often before the next cadence tick — so `daemonReportedAt` could stay
|
|
46
|
+
* stale indefinitely while IDE-hook flushes kept the machine "online", showing
|
|
47
|
+
* a live daemon as down and making remote actions undeliverable.
|
|
48
|
+
*
|
|
49
|
+
* Retrying in seconds (not minutes) means a network that is merely late to come
|
|
50
|
+
* back costs one short delay instead of a whole reporting window.
|
|
51
|
+
*/
|
|
52
|
+
const RETRY_BASE_MS = 15_000;
|
|
35
53
|
/**
|
|
36
54
|
* Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
|
|
37
55
|
* attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
|
|
@@ -75,22 +93,42 @@ function startPollLoop(input) {
|
|
|
75
93
|
let stopped = false;
|
|
76
94
|
let stalled = 0;
|
|
77
95
|
let tickHandle;
|
|
78
|
-
|
|
96
|
+
/** Consecutive failed/abandoned attempts, driving the retry backoff. */
|
|
97
|
+
let consecutiveFailures = 0;
|
|
98
|
+
const cadenceMs = () => {
|
|
79
99
|
const base = input.intervalMs();
|
|
80
|
-
|
|
100
|
+
return Number.isFinite(base) && base > 0 ? base : MIN_DELAY_MS;
|
|
101
|
+
};
|
|
102
|
+
const nextDelay = () => {
|
|
103
|
+
const base = cadenceMs();
|
|
81
104
|
const factor = 1 - jitter + random() * jitter * 2;
|
|
82
|
-
return Math.max(MIN_DELAY_MS, Math.round(
|
|
105
|
+
return Math.max(MIN_DELAY_MS, Math.round(base * factor));
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Back off exponentially from RETRY_BASE_MS but never past the ordinary
|
|
109
|
+
* cadence: a retry may be more eager than a normal tick, never lazier.
|
|
110
|
+
*/
|
|
111
|
+
const retryDelay = () => {
|
|
112
|
+
const base = Math.max(1, input.retryDelayMs ?? RETRY_BASE_MS);
|
|
113
|
+
const backoff = base * Math.pow(2, Math.max(0, consecutiveFailures - 1));
|
|
114
|
+
return Math.max(MIN_DELAY_MS, Math.round(Math.min(backoff, cadenceMs())));
|
|
83
115
|
};
|
|
84
|
-
const schedule = () => {
|
|
116
|
+
const schedule = (delayMs) => {
|
|
85
117
|
if (stopped)
|
|
86
118
|
return;
|
|
87
119
|
tickHandle = timers.setTimeout(async () => {
|
|
88
120
|
if (stopped)
|
|
89
121
|
return;
|
|
122
|
+
// `runWithDeadline` reports settled-vs-abandoned; a settled attempt may
|
|
123
|
+
// still have REJECTED, and for retry purposes that is just as bad.
|
|
124
|
+
let rejected = false;
|
|
90
125
|
const outcome = await runWithDeadline({
|
|
91
126
|
run: input.run,
|
|
92
127
|
deadlineMs: input.deadlineMs,
|
|
93
|
-
onError:
|
|
128
|
+
onError: error => {
|
|
129
|
+
rejected = true;
|
|
130
|
+
input.onError?.(error);
|
|
131
|
+
},
|
|
94
132
|
timers,
|
|
95
133
|
});
|
|
96
134
|
if (outcome === 'abandoned') {
|
|
@@ -100,10 +138,16 @@ function startPollLoop(input) {
|
|
|
100
138
|
}
|
|
101
139
|
catch { /* never break the loop */ }
|
|
102
140
|
}
|
|
103
|
-
|
|
104
|
-
|
|
141
|
+
if (outcome === 'abandoned' || rejected) {
|
|
142
|
+
consecutiveFailures += 1;
|
|
143
|
+
schedule(retryDelay());
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
consecutiveFailures = 0;
|
|
147
|
+
schedule(nextDelay());
|
|
148
|
+
}, delayMs);
|
|
105
149
|
};
|
|
106
|
-
schedule();
|
|
150
|
+
schedule(nextDelay());
|
|
107
151
|
return {
|
|
108
152
|
stop() {
|
|
109
153
|
stopped = true;
|
package/dist/version.json
CHANGED