@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.
@@ -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
- * Leader election via PID lock file.
3
+ * Identity of the process recorded in the broker leader lock.
4
4
  *
5
- * Only one broker process should run at a time. The leader writes its
6
- * PID to the lock file. Stale locks (PID no longer running) are
7
- * automatically reclaimed.
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
- constructor(lockPath?: string);
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
  }