@pinet/broker-core 0.2.2 → 0.2.6
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/agent-messaging.d.ts +27 -4
- package/dist/hibernation-commands.d.ts +123 -0
- package/dist/hibernation-commands.js +287 -0
- package/dist/hibernation-orchestrator.d.ts +327 -0
- package/dist/hibernation-orchestrator.js +1096 -0
- package/dist/hibernation-projection.d.ts +20 -0
- package/dist/hibernation-projection.js +60 -0
- package/dist/hibernation-status.d.ts +141 -0
- package/dist/hibernation-status.js +390 -0
- package/dist/hibernation-telemetry.d.ts +54 -0
- package/dist/hibernation-telemetry.js +119 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/leader.d.ts +134 -5
- package/dist/leader.js +359 -26
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.js +59 -0
- package/dist/mail-classification.d.ts +6 -1
- package/dist/message-send.d.ts +4 -3
- package/dist/router.d.ts +14 -1
- package/dist/router.js +15 -0
- package/dist/schema.d.ts +181 -2
- package/dist/schema.js +1243 -17
- package/dist/types.d.ts +288 -1
- package/dist/types.js +6 -0
- package/package.json +5 -5
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { sanitizeOperatorReason } from "./hibernation-status.js";
|
|
2
|
+
const ACCEPTED_OUTCOME = "accepted";
|
|
3
|
+
/**
|
|
4
|
+
* Summarize a window of lifecycle events into an operable status rollup. Pass
|
|
5
|
+
* the newest events (any order); retention info is optional passthrough.
|
|
6
|
+
*/
|
|
7
|
+
export function summarizeHibernationTelemetry(events, retention) {
|
|
8
|
+
let hibernations = 0;
|
|
9
|
+
let wakeSuccesses = 0;
|
|
10
|
+
let failures = 0;
|
|
11
|
+
let maxQueueDepth = 0;
|
|
12
|
+
let maxOldestQueueAgeMs = 0;
|
|
13
|
+
let recoveredRssBytes = 0;
|
|
14
|
+
const wakeDurations = [];
|
|
15
|
+
const refusalCounts = new Map();
|
|
16
|
+
const agents = new Set();
|
|
17
|
+
for (const event of events) {
|
|
18
|
+
agents.add(event.agentId);
|
|
19
|
+
if (event.queueDepth != null && event.queueDepth > maxQueueDepth) {
|
|
20
|
+
maxQueueDepth = event.queueDepth;
|
|
21
|
+
}
|
|
22
|
+
if (event.oldestQueueAgeMs != null && event.oldestQueueAgeMs > maxOldestQueueAgeMs) {
|
|
23
|
+
maxOldestQueueAgeMs = event.oldestQueueAgeMs;
|
|
24
|
+
}
|
|
25
|
+
if (event.outcome !== ACCEPTED_OUTCOME) {
|
|
26
|
+
failures += 1;
|
|
27
|
+
// Defense-in-depth: reasons SHOULD be sanitized at write time, but a
|
|
28
|
+
// path-bearing reason must never reach this rendered aggregate even if an
|
|
29
|
+
// upstream writer regresses.
|
|
30
|
+
const reason = sanitizeOperatorReason(event.errorCode ?? event.reason) ?? "unspecified";
|
|
31
|
+
refusalCounts.set(reason, (refusalCounts.get(reason) ?? 0) + 1);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (event.toState === "hibernated") {
|
|
35
|
+
hibernations += 1;
|
|
36
|
+
if (event.rssBytesBefore != null && event.rssBytesAfter != null) {
|
|
37
|
+
recoveredRssBytes += Math.max(event.rssBytesBefore - event.rssBytesAfter, 0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (event.fromState === "waking" && event.toState === "live") {
|
|
41
|
+
wakeSuccesses += 1;
|
|
42
|
+
if (event.durationMs != null)
|
|
43
|
+
wakeDurations.push(event.durationMs);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
wakeDurations.sort((left, right) => left - right);
|
|
47
|
+
const meanWakeMs = wakeDurations.length === 0
|
|
48
|
+
? null
|
|
49
|
+
: wakeDurations.reduce((sum, value) => sum + value, 0) / wakeDurations.length;
|
|
50
|
+
// Nearest-rank p95: rank = ceil(0.95 * N), clamped to [1, N].
|
|
51
|
+
const p95WakeMs = wakeDurations.length === 0
|
|
52
|
+
? null
|
|
53
|
+
: wakeDurations[Math.min(Math.ceil(0.95 * wakeDurations.length), wakeDurations.length) - 1];
|
|
54
|
+
const refusalReasons = [...refusalCounts.entries()]
|
|
55
|
+
.map(([reason, count]) => ({ reason, count }))
|
|
56
|
+
.sort((left, right) => right.count - left.count || left.reason.localeCompare(right.reason));
|
|
57
|
+
return {
|
|
58
|
+
totalEvents: events.length,
|
|
59
|
+
hibernations,
|
|
60
|
+
wakeSuccesses,
|
|
61
|
+
failures,
|
|
62
|
+
meanWakeMs: meanWakeMs === null ? null : Math.round(meanWakeMs * 10) / 10,
|
|
63
|
+
p95WakeMs,
|
|
64
|
+
maxQueueDepth,
|
|
65
|
+
maxOldestQueueAgeMs,
|
|
66
|
+
recoveredRssBytes,
|
|
67
|
+
refusalReasons,
|
|
68
|
+
agentCount: agents.size,
|
|
69
|
+
retainedCount: retention?.retainedCount ?? null,
|
|
70
|
+
prunedCount: retention?.prunedCount ?? null,
|
|
71
|
+
lastPrunedAt: retention?.lastPrunedAt ?? null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Render a compact, human-readable status block from a telemetry summary. Safe
|
|
76
|
+
* for CLI/Slack output: it contains only aggregate counters, never bodies.
|
|
77
|
+
*/
|
|
78
|
+
export function formatHibernationTelemetry(summary) {
|
|
79
|
+
if (summary.totalEvents === 0) {
|
|
80
|
+
return "Hibernation telemetry: no lifecycle events recorded.";
|
|
81
|
+
}
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push(`Hibernation telemetry (${summary.totalEvents} events, ${summary.agentCount} agents):`);
|
|
84
|
+
lines.push(` hibernations=${summary.hibernations} wake_successes=${summary.wakeSuccesses} failures=${summary.failures}`);
|
|
85
|
+
const wakeParts = [];
|
|
86
|
+
if (summary.meanWakeMs !== null)
|
|
87
|
+
wakeParts.push(`mean=${summary.meanWakeMs}ms`);
|
|
88
|
+
if (summary.p95WakeMs !== null)
|
|
89
|
+
wakeParts.push(`p95=${summary.p95WakeMs}ms`);
|
|
90
|
+
if (wakeParts.length > 0)
|
|
91
|
+
lines.push(` wake latency: ${wakeParts.join(" ")}`);
|
|
92
|
+
if (summary.maxQueueDepth > 0 || summary.maxOldestQueueAgeMs > 0) {
|
|
93
|
+
lines.push(` queue: max_depth=${summary.maxQueueDepth} max_oldest_age=${summary.maxOldestQueueAgeMs}ms`);
|
|
94
|
+
}
|
|
95
|
+
if (summary.recoveredRssBytes > 0) {
|
|
96
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
97
|
+
let value = summary.recoveredRssBytes;
|
|
98
|
+
let unitIndex = 0;
|
|
99
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
100
|
+
value /= 1024;
|
|
101
|
+
unitIndex += 1;
|
|
102
|
+
}
|
|
103
|
+
const rounded = unitIndex === 0 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
104
|
+
lines.push(` recovered RSS (est.): ${rounded} ${units[unitIndex]}`);
|
|
105
|
+
}
|
|
106
|
+
if (summary.refusalReasons.length > 0) {
|
|
107
|
+
const top = summary.refusalReasons
|
|
108
|
+
.slice(0, 5)
|
|
109
|
+
.map((entry) => `${entry.reason} x${entry.count}`)
|
|
110
|
+
.join(", ");
|
|
111
|
+
lines.push(` refusals: ${top}`);
|
|
112
|
+
}
|
|
113
|
+
if (summary.retainedCount !== null) {
|
|
114
|
+
const pruned = summary.prunedCount ?? 0;
|
|
115
|
+
const prunedSuffix = summary.lastPrunedAt ? ` (last pruned ${summary.lastPrunedAt})` : "";
|
|
116
|
+
lines.push(` retention: retained=${summary.retainedCount} pruned=${pruned}${prunedSuffix}`);
|
|
117
|
+
}
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export * from "./agent-messaging.js";
|
|
2
2
|
export * from "./auth.js";
|
|
3
3
|
export * from "./leader.js";
|
|
4
|
+
export * from "./lifecycle.js";
|
|
5
|
+
export * from "./hibernation-commands.js";
|
|
6
|
+
export * from "./hibernation-orchestrator.js";
|
|
7
|
+
export * from "./hibernation-projection.js";
|
|
8
|
+
export * from "./hibernation-status.js";
|
|
9
|
+
export * from "./hibernation-telemetry.js";
|
|
4
10
|
export * from "./maintenance.js";
|
|
5
11
|
export * from "./mail-classification.js";
|
|
6
12
|
export * from "./message-send.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export * from "./agent-messaging.js";
|
|
2
2
|
export * from "./auth.js";
|
|
3
3
|
export * from "./leader.js";
|
|
4
|
+
export * from "./lifecycle.js";
|
|
5
|
+
export * from "./hibernation-commands.js";
|
|
6
|
+
export * from "./hibernation-orchestrator.js";
|
|
7
|
+
export * from "./hibernation-projection.js";
|
|
8
|
+
export * from "./hibernation-status.js";
|
|
9
|
+
export * from "./hibernation-telemetry.js";
|
|
4
10
|
export * from "./maintenance.js";
|
|
5
11
|
export * from "./mail-classification.js";
|
|
6
12
|
export * from "./message-send.js";
|
package/dist/leader.d.ts
CHANGED
|
@@ -1,19 +1,144 @@
|
|
|
1
1
|
export declare function defaultLockPath(): string;
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Identity of the process recorded in the broker leader lock.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Legacy locks (written by older builds) contain only a PID; structured locks
|
|
6
|
+
* additionally record the owner's process start time, a per-acquisition
|
|
7
|
+
* instance id, hostname, and creation timestamp so a second session can tell
|
|
8
|
+
* a live owner apart from a reused PID.
|
|
9
|
+
*/
|
|
10
|
+
export interface BrokerLockOwner {
|
|
11
|
+
pid: number;
|
|
12
|
+
processStartTime: string | null;
|
|
13
|
+
instanceId: string | null;
|
|
14
|
+
hostname: string | null;
|
|
15
|
+
createdAt: string | null;
|
|
16
|
+
/** True when the lock file only contained a bare PID (older builds). */
|
|
17
|
+
legacy: boolean;
|
|
18
|
+
}
|
|
19
|
+
export type BrokerLockInspection =
|
|
20
|
+
/** No lock file exists. */
|
|
21
|
+
{
|
|
22
|
+
state: "none";
|
|
23
|
+
owner: null;
|
|
24
|
+
}
|
|
25
|
+
/** Lock file exists but cannot be parsed — safe to reclaim. */
|
|
26
|
+
| {
|
|
27
|
+
state: "unreadable";
|
|
28
|
+
owner: null;
|
|
29
|
+
}
|
|
30
|
+
/** Recorded PID is no longer running — safe to reclaim. */
|
|
31
|
+
| {
|
|
32
|
+
state: "stale-dead";
|
|
33
|
+
owner: BrokerLockOwner;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Recorded PID is running but its process start time differs from the one
|
|
37
|
+
* recorded at lock creation — the PID was reused by an unrelated process,
|
|
38
|
+
* so the lock is stale and safe to reclaim.
|
|
39
|
+
*/
|
|
40
|
+
| {
|
|
41
|
+
state: "stale-pid-reused";
|
|
42
|
+
owner: BrokerLockOwner;
|
|
43
|
+
currentStartTime: string;
|
|
44
|
+
}
|
|
45
|
+
/** Recorded PID is running and not provably stale. */
|
|
46
|
+
| {
|
|
47
|
+
state: "alive";
|
|
48
|
+
owner: BrokerLockOwner;
|
|
49
|
+
};
|
|
50
|
+
/** Injectable process probes (for tests). */
|
|
51
|
+
export interface BrokerLockProbes {
|
|
52
|
+
isProcessRunning?: (pid: number) => boolean;
|
|
53
|
+
getProcessStartTime?: (pid: number) => string | null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Deterministically capture a process's start time for PID-reuse detection.
|
|
57
|
+
*
|
|
58
|
+
* Uses `/proc/<pid>/stat` (field 22, clock ticks since boot) on Linux and
|
|
59
|
+
* `LC_ALL=C ps -p <pid> -o lstart=` elsewhere. Values are only ever compared
|
|
60
|
+
* for exact equality against a value captured by this same function, so the
|
|
61
|
+
* format does not need to be parseable — only stable for a given process.
|
|
62
|
+
*
|
|
63
|
+
* On Linux the tick count is only unique within a single boot, so it is
|
|
64
|
+
* scoped with the kernel boot id: a PID reused after a reboot can then never
|
|
65
|
+
* present the same identity as the pre-reboot owner. `ps lstart` has
|
|
66
|
+
* one-second resolution, so a PID reused within the same wall-clock second
|
|
67
|
+
* as the original process is indistinguishable — an accepted residual risk.
|
|
68
|
+
*
|
|
69
|
+
* Returns null when the start time cannot be determined; callers must treat
|
|
70
|
+
* null as "unknown" and never use it as evidence of staleness.
|
|
71
|
+
*/
|
|
72
|
+
export declare function getProcessStartTime(pid: number): string | null;
|
|
73
|
+
/**
|
|
74
|
+
* Read the current broker lock owner, or null when no lock file exists or it
|
|
75
|
+
* cannot be parsed.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readBrokerLockOwner(lockPath?: string): BrokerLockOwner | null;
|
|
78
|
+
/**
|
|
79
|
+
* Inspect the broker leader lock and classify its owner.
|
|
80
|
+
*
|
|
81
|
+
* `stale-pid-reused` is only reported when both the recorded and current
|
|
82
|
+
* process start times are known and differ; unknown start times classify as
|
|
83
|
+
* `alive` so uncertainty never reclaims a live broker's lock.
|
|
84
|
+
*/
|
|
85
|
+
export declare function inspectBrokerLock(lockPath?: string, probes?: BrokerLockProbes): BrokerLockInspection;
|
|
86
|
+
/**
|
|
87
|
+
* Leader election via lock file.
|
|
88
|
+
*
|
|
89
|
+
* Only one broker process should run at a time. The leader creates the lock
|
|
90
|
+
* file with an exclusive create (`O_CREAT | O_EXCL`), writing its PID on the
|
|
91
|
+
* first line (kept legacy-compatible so older builds still see a live owner)
|
|
92
|
+
* followed by a JSON metadata line recording process start time and a
|
|
93
|
+
* per-acquisition instance id.
|
|
94
|
+
*
|
|
95
|
+
* Exclusive creation is the only way the lock comes into existence, so
|
|
96
|
+
* simultaneous contenders on an empty path get exactly one winner from the
|
|
97
|
+
* kernel. Stale locks (dead PID, reused PID, unreadable content) are only
|
|
98
|
+
* ever unlinked while holding an exclusively-created reclaim mutex file,
|
|
99
|
+
* with staleness re-verified under that mutex — so a fresh lock can never be
|
|
100
|
+
* destroyed by a concurrent reclaimer, and the lock path never goes empty
|
|
101
|
+
* while a live owner's lock exists.
|
|
102
|
+
*
|
|
103
|
+
* Known mixed-version limitation: builds that predate the structured format
|
|
104
|
+
* replace a lock they consider stale with a plain rename over the lock path,
|
|
105
|
+
* which can overwrite a just-acquired v2 lock when an old and a new build
|
|
106
|
+
* race over the same stale lock. Exclusive acquisition is therefore only
|
|
107
|
+
* guaranteed among processes running this code; the window disappears once
|
|
108
|
+
* no pre-v2 sessions remain. A representation old builds cannot overwrite
|
|
109
|
+
* (such as a lock directory) would also break their ability to read the
|
|
110
|
+
* owner PID, which this format deliberately preserves.
|
|
8
111
|
*/
|
|
9
112
|
export declare class LeaderLock {
|
|
10
113
|
private readonly lockPath;
|
|
114
|
+
private readonly probes;
|
|
11
115
|
private acquired;
|
|
12
|
-
|
|
116
|
+
private instanceId;
|
|
117
|
+
constructor(lockPath?: string, probes?: BrokerLockProbes);
|
|
13
118
|
/**
|
|
14
119
|
* Try to acquire the lock. Returns true if this process is now the leader.
|
|
15
120
|
*/
|
|
16
121
|
tryAcquire(): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Create the lock file exclusively. Returns true when this process now
|
|
124
|
+
* holds the lock; false when another lock file already exists.
|
|
125
|
+
*/
|
|
126
|
+
private tryExclusiveCreate;
|
|
127
|
+
/**
|
|
128
|
+
* Remove a stale lock under an exclusive reclaim mutex.
|
|
129
|
+
*
|
|
130
|
+
* The mutex file (`<lockPath>.reclaim`) is created with `O_EXCL` and
|
|
131
|
+
* records the reclaimer's PID plus start identity, so at most one
|
|
132
|
+
* reclaimer proceeds at a time, and staleness is re-verified while holding
|
|
133
|
+
* it. Because stale locks are only ever unlinked under this mutex, the
|
|
134
|
+
* lock path cannot go empty while a live owner's lock exists — which is
|
|
135
|
+
* what makes the exclusive create in `tryAcquire` a sound arbiter. A mutex
|
|
136
|
+
* left behind by a crashed reclaimer (dead PID, or a PID provably reused
|
|
137
|
+
* by an unrelated process) is itself reclaimed.
|
|
138
|
+
*
|
|
139
|
+
* Returns true when the caller may retry an exclusive create.
|
|
140
|
+
*/
|
|
141
|
+
private reclaimStaleLock;
|
|
17
142
|
/**
|
|
18
143
|
* Release the lock if we hold it.
|
|
19
144
|
*/
|
|
@@ -26,4 +151,8 @@ export declare class LeaderLock {
|
|
|
26
151
|
* Get the lock file path (for testing).
|
|
27
152
|
*/
|
|
28
153
|
getLockPath(): string;
|
|
154
|
+
/**
|
|
155
|
+
* Per-acquisition instance id, set while the lock is held.
|
|
156
|
+
*/
|
|
157
|
+
getInstanceId(): string | null;
|
|
29
158
|
}
|