@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
package/dist/leader.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
1
3
|
import * as fs from "node:fs";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import * as os from "node:os";
|
|
@@ -5,17 +7,225 @@ export function defaultLockPath() {
|
|
|
5
7
|
return path.join(os.homedir(), ".pi", "pinet-broker.lock");
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
10
|
+
* Deterministically capture a process's start time for PID-reuse detection.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* Uses `/proc/<pid>/stat` (field 22, clock ticks since boot) on Linux and
|
|
13
|
+
* `LC_ALL=C ps -p <pid> -o lstart=` elsewhere. Values are only ever compared
|
|
14
|
+
* for exact equality against a value captured by this same function, so the
|
|
15
|
+
* format does not need to be parseable — only stable for a given process.
|
|
16
|
+
*
|
|
17
|
+
* On Linux the tick count is only unique within a single boot, so it is
|
|
18
|
+
* scoped with the kernel boot id: a PID reused after a reboot can then never
|
|
19
|
+
* present the same identity as the pre-reboot owner. `ps lstart` has
|
|
20
|
+
* one-second resolution, so a PID reused within the same wall-clock second
|
|
21
|
+
* as the original process is indistinguishable — an accepted residual risk.
|
|
22
|
+
*
|
|
23
|
+
* Returns null when the start time cannot be determined; callers must treat
|
|
24
|
+
* null as "unknown" and never use it as evidence of staleness.
|
|
25
|
+
*/
|
|
26
|
+
export function getProcessStartTime(pid) {
|
|
27
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
if (process.platform === "linux") {
|
|
30
|
+
try {
|
|
31
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
32
|
+
// comm (field 2) may contain spaces/parens — start after the last ")".
|
|
33
|
+
const closeParen = stat.lastIndexOf(")");
|
|
34
|
+
if (closeParen !== -1) {
|
|
35
|
+
const fields = stat
|
|
36
|
+
.slice(closeParen + 1)
|
|
37
|
+
.trim()
|
|
38
|
+
.split(/\s+/);
|
|
39
|
+
// Fields after comm+state start at index 1 here; starttime is overall
|
|
40
|
+
// field 22, i.e. index 19 of the post-state remainder.
|
|
41
|
+
const startTime = fields[19];
|
|
42
|
+
if (startTime && /^\d+$/.test(startTime)) {
|
|
43
|
+
// starttime is measured in clock ticks since boot — scope it with
|
|
44
|
+
// the boot id so identities never collide across reboots.
|
|
45
|
+
let bootId = "";
|
|
46
|
+
try {
|
|
47
|
+
bootId = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* boot id unavailable — token stays valid within this boot */
|
|
51
|
+
}
|
|
52
|
+
return bootId ? `${bootId}:${startTime}` : startTime;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* fall through to ps */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const output = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C" },
|
|
64
|
+
timeout: 2000,
|
|
65
|
+
}).trim();
|
|
66
|
+
return output || null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function readMetadataString(value) {
|
|
73
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
74
|
+
}
|
|
75
|
+
function parseLockContent(content) {
|
|
76
|
+
const trimmed = content.trim();
|
|
77
|
+
if (!trimmed)
|
|
78
|
+
return null;
|
|
79
|
+
const newlineIndex = trimmed.indexOf("\n");
|
|
80
|
+
const pidLine = (newlineIndex === -1 ? trimmed : trimmed.slice(0, newlineIndex)).trim();
|
|
81
|
+
if (!/^\d+$/.test(pidLine))
|
|
82
|
+
return null;
|
|
83
|
+
const pid = parseInt(pidLine, 10);
|
|
84
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
85
|
+
return null;
|
|
86
|
+
if (newlineIndex === -1) {
|
|
87
|
+
return {
|
|
88
|
+
pid,
|
|
89
|
+
processStartTime: null,
|
|
90
|
+
instanceId: null,
|
|
91
|
+
hostname: null,
|
|
92
|
+
createdAt: null,
|
|
93
|
+
legacy: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let metadata = null;
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(trimmed.slice(newlineIndex + 1).trim());
|
|
99
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
100
|
+
metadata = {
|
|
101
|
+
processStartTime: readMetadataString(raw.processStartTime),
|
|
102
|
+
instanceId: readMetadataString(raw.instanceId),
|
|
103
|
+
hostname: readMetadataString(raw.hostname),
|
|
104
|
+
createdAt: readMetadataString(raw.createdAt),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
metadata = null;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
pid,
|
|
113
|
+
processStartTime: metadata?.processStartTime ?? null,
|
|
114
|
+
instanceId: metadata?.instanceId ?? null,
|
|
115
|
+
hostname: metadata?.hostname ?? null,
|
|
116
|
+
createdAt: metadata?.createdAt ?? null,
|
|
117
|
+
legacy: metadata === null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Read the current broker lock owner, or null when no lock file exists or it
|
|
122
|
+
* cannot be parsed.
|
|
123
|
+
*/
|
|
124
|
+
export function readBrokerLockOwner(lockPath) {
|
|
125
|
+
const resolved = lockPath ?? defaultLockPath();
|
|
126
|
+
let content;
|
|
127
|
+
try {
|
|
128
|
+
content = fs.readFileSync(resolved, "utf-8");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return parseLockContent(content);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Inspect the broker leader lock and classify its owner.
|
|
137
|
+
*
|
|
138
|
+
* `stale-pid-reused` is only reported when both the recorded and current
|
|
139
|
+
* process start times are known and differ; unknown start times classify as
|
|
140
|
+
* `alive` so uncertainty never reclaims a live broker's lock.
|
|
141
|
+
*/
|
|
142
|
+
export function inspectBrokerLock(lockPath, probes = {}) {
|
|
143
|
+
const resolved = lockPath ?? defaultLockPath();
|
|
144
|
+
let content;
|
|
145
|
+
try {
|
|
146
|
+
content = fs.readFileSync(resolved, "utf-8");
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { state: "none", owner: null };
|
|
150
|
+
}
|
|
151
|
+
const owner = parseLockContent(content);
|
|
152
|
+
if (!owner) {
|
|
153
|
+
return { state: "unreadable", owner: null };
|
|
154
|
+
}
|
|
155
|
+
const isRunning = probes.isProcessRunning ?? isProcessRunning;
|
|
156
|
+
if (!isRunning(owner.pid)) {
|
|
157
|
+
return { state: "stale-dead", owner };
|
|
158
|
+
}
|
|
159
|
+
if (owner.processStartTime) {
|
|
160
|
+
const startTimeOf = probes.getProcessStartTime ?? getProcessStartTime;
|
|
161
|
+
const currentStartTime = startTimeOf(owner.pid);
|
|
162
|
+
if (currentStartTime && currentStartTime !== owner.processStartTime) {
|
|
163
|
+
return { state: "stale-pid-reused", owner, currentStartTime };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { state: "alive", owner };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Atomically create `filePath` with `content` already in place, failing when
|
|
170
|
+
* the path exists. A plain `writeFileSync("wx")` opens the file and then
|
|
171
|
+
* writes, so concurrent readers can observe an empty just-created file;
|
|
172
|
+
* writing a private temp file and `link()`ing it into place makes the path
|
|
173
|
+
* appear with its full content in one exclusive step.
|
|
174
|
+
*/
|
|
175
|
+
function exclusiveCreateFile(filePath, content) {
|
|
176
|
+
const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
177
|
+
try {
|
|
178
|
+
fs.writeFileSync(tmpPath, content, "utf-8");
|
|
179
|
+
fs.linkSync(tmpPath, filePath);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
try {
|
|
187
|
+
fs.unlinkSync(tmpPath);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* best effort */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ─── Leader lock ─────────────────────────────────────────
|
|
195
|
+
/**
|
|
196
|
+
* Leader election via lock file.
|
|
197
|
+
*
|
|
198
|
+
* Only one broker process should run at a time. The leader creates the lock
|
|
199
|
+
* file with an exclusive create (`O_CREAT | O_EXCL`), writing its PID on the
|
|
200
|
+
* first line (kept legacy-compatible so older builds still see a live owner)
|
|
201
|
+
* followed by a JSON metadata line recording process start time and a
|
|
202
|
+
* per-acquisition instance id.
|
|
203
|
+
*
|
|
204
|
+
* Exclusive creation is the only way the lock comes into existence, so
|
|
205
|
+
* simultaneous contenders on an empty path get exactly one winner from the
|
|
206
|
+
* kernel. Stale locks (dead PID, reused PID, unreadable content) are only
|
|
207
|
+
* ever unlinked while holding an exclusively-created reclaim mutex file,
|
|
208
|
+
* with staleness re-verified under that mutex — so a fresh lock can never be
|
|
209
|
+
* destroyed by a concurrent reclaimer, and the lock path never goes empty
|
|
210
|
+
* while a live owner's lock exists.
|
|
211
|
+
*
|
|
212
|
+
* Known mixed-version limitation: builds that predate the structured format
|
|
213
|
+
* replace a lock they consider stale with a plain rename over the lock path,
|
|
214
|
+
* which can overwrite a just-acquired v2 lock when an old and a new build
|
|
215
|
+
* race over the same stale lock. Exclusive acquisition is therefore only
|
|
216
|
+
* guaranteed among processes running this code; the window disappears once
|
|
217
|
+
* no pre-v2 sessions remain. A representation old builds cannot overwrite
|
|
218
|
+
* (such as a lock directory) would also break their ability to read the
|
|
219
|
+
* owner PID, which this format deliberately preserves.
|
|
13
220
|
*/
|
|
14
221
|
export class LeaderLock {
|
|
15
222
|
lockPath;
|
|
223
|
+
probes;
|
|
16
224
|
acquired = false;
|
|
17
|
-
|
|
225
|
+
instanceId = null;
|
|
226
|
+
constructor(lockPath, probes = {}) {
|
|
18
227
|
this.lockPath = lockPath ?? defaultLockPath();
|
|
228
|
+
this.probes = probes;
|
|
19
229
|
}
|
|
20
230
|
/**
|
|
21
231
|
* Try to acquire the lock. Returns true if this process is now the leader.
|
|
@@ -24,30 +234,146 @@ export class LeaderLock {
|
|
|
24
234
|
if (this.acquired)
|
|
25
235
|
return true;
|
|
26
236
|
fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
237
|
+
// Two passes: exclusive create, and one stale-reclaim + retry.
|
|
238
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
239
|
+
if (this.tryExclusiveCreate()) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
const inspection = inspectBrokerLock(this.lockPath, this.probes);
|
|
243
|
+
if (inspection.state === "alive") {
|
|
244
|
+
// Another live process holds the lock.
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (inspection.state === "none") {
|
|
248
|
+
// The holder released between our create attempt and inspection.
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!this.reclaimStaleLock()) {
|
|
252
|
+
// The lock is no longer stale, or another contender is reclaiming
|
|
253
|
+
// it. Back off conservatively.
|
|
33
254
|
return false;
|
|
34
255
|
}
|
|
35
|
-
// Stale lock — remove it
|
|
36
|
-
fs.unlinkSync(this.lockPath);
|
|
37
256
|
}
|
|
38
|
-
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Create the lock file exclusively. Returns true when this process now
|
|
261
|
+
* holds the lock; false when another lock file already exists.
|
|
262
|
+
*/
|
|
263
|
+
tryExclusiveCreate() {
|
|
39
264
|
const pid = process.pid;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
265
|
+
const instanceId = crypto.randomUUID();
|
|
266
|
+
const startTimeOf = this.probes.getProcessStartTime ?? getProcessStartTime;
|
|
267
|
+
const metadata = {
|
|
268
|
+
version: 2,
|
|
269
|
+
processStartTime: startTimeOf(pid),
|
|
270
|
+
instanceId,
|
|
271
|
+
hostname: os.hostname(),
|
|
272
|
+
createdAt: new Date().toISOString(),
|
|
273
|
+
};
|
|
274
|
+
const content = `${pid}\n${JSON.stringify(metadata)}\n`;
|
|
275
|
+
// Exclusive create with full content — the kernel picks exactly one
|
|
276
|
+
// winner among simultaneous contenders, and no reader can ever observe a
|
|
277
|
+
// partially-written lock.
|
|
278
|
+
if (!exclusiveCreateFile(this.lockPath, content)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
// Paranoia read-back: if the file somehow no longer records our identity,
|
|
282
|
+
// treat the acquisition as lost and leave the file to its current owner.
|
|
283
|
+
const written = readBrokerLockOwner(this.lockPath);
|
|
284
|
+
if (!written || written.pid !== pid || written.instanceId !== instanceId) {
|
|
46
285
|
return false;
|
|
47
286
|
}
|
|
48
287
|
this.acquired = true;
|
|
288
|
+
this.instanceId = instanceId;
|
|
49
289
|
return true;
|
|
50
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Remove a stale lock under an exclusive reclaim mutex.
|
|
293
|
+
*
|
|
294
|
+
* The mutex file (`<lockPath>.reclaim`) is created with `O_EXCL` and
|
|
295
|
+
* records the reclaimer's PID plus start identity, so at most one
|
|
296
|
+
* reclaimer proceeds at a time, and staleness is re-verified while holding
|
|
297
|
+
* it. Because stale locks are only ever unlinked under this mutex, the
|
|
298
|
+
* lock path cannot go empty while a live owner's lock exists — which is
|
|
299
|
+
* what makes the exclusive create in `tryAcquire` a sound arbiter. A mutex
|
|
300
|
+
* left behind by a crashed reclaimer (dead PID, or a PID provably reused
|
|
301
|
+
* by an unrelated process) is itself reclaimed.
|
|
302
|
+
*
|
|
303
|
+
* Returns true when the caller may retry an exclusive create.
|
|
304
|
+
*/
|
|
305
|
+
reclaimStaleLock() {
|
|
306
|
+
const mutexPath = `${this.lockPath}.reclaim`;
|
|
307
|
+
const isRunning = this.probes.isProcessRunning ?? isProcessRunning;
|
|
308
|
+
const startTimeOf = this.probes.getProcessStartTime ?? getProcessStartTime;
|
|
309
|
+
let holdsMutex = false;
|
|
310
|
+
for (let campaign = 0; campaign < 2 && !holdsMutex; campaign++) {
|
|
311
|
+
if (exclusiveCreateFile(mutexPath, `${process.pid}\n${startTimeOf(process.pid) ?? ""}\n`)) {
|
|
312
|
+
holdsMutex = true;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// Mutex exists — held by a live reclaimer, or left by a crashed one.
|
|
316
|
+
let holderRaw;
|
|
317
|
+
try {
|
|
318
|
+
holderRaw = fs.readFileSync(mutexPath, "utf-8").trim();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
continue; // vanished — retry the campaign
|
|
322
|
+
}
|
|
323
|
+
const [pidLine = "", startLine = ""] = holderRaw.split("\n");
|
|
324
|
+
const holderPid = /^\d+$/.test(pidLine.trim()) ? parseInt(pidLine.trim(), 10) : null;
|
|
325
|
+
const recordedStart = startLine.trim();
|
|
326
|
+
if (holderPid !== null && holderPid > 0 && isRunning(holderPid)) {
|
|
327
|
+
// The PID is live, but it may be an unrelated process that reused
|
|
328
|
+
// a crashed reclaimer's PID. Back off unless the recorded start
|
|
329
|
+
// identity provably belongs to a different process — uncertainty
|
|
330
|
+
// always means "assume the reclaimer is alive".
|
|
331
|
+
const currentStart = startTimeOf(holderPid);
|
|
332
|
+
if (!recordedStart || currentStart === null || currentStart === recordedStart) {
|
|
333
|
+
return false; // a live reclaimer is working — back off
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Crashed reclaimer. Remove its mutex only if it still records the
|
|
337
|
+
// dead holder we just read, then retry the campaign.
|
|
338
|
+
try {
|
|
339
|
+
if (fs.readFileSync(mutexPath, "utf-8").trim() === holderRaw) {
|
|
340
|
+
fs.unlinkSync(mutexPath);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* already gone */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!holdsMutex)
|
|
349
|
+
return false;
|
|
350
|
+
try {
|
|
351
|
+
// Re-verify staleness while holding the mutex. We are now the only
|
|
352
|
+
// process allowed to unlink the lock, so no fresh lock can be created
|
|
353
|
+
// (the path stays occupied) until we decide.
|
|
354
|
+
const current = inspectBrokerLock(this.lockPath, this.probes);
|
|
355
|
+
if (current.state === "alive") {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
if (current.state !== "none") {
|
|
359
|
+
try {
|
|
360
|
+
fs.unlinkSync(this.lockPath);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* already gone */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
try {
|
|
370
|
+
fs.unlinkSync(mutexPath);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* best effort */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
51
377
|
/**
|
|
52
378
|
* Release the lock if we hold it.
|
|
53
379
|
*/
|
|
@@ -55,18 +381,19 @@ export class LeaderLock {
|
|
|
55
381
|
if (!this.acquired)
|
|
56
382
|
return;
|
|
57
383
|
try {
|
|
58
|
-
// Only remove
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
384
|
+
// Only remove the lock when it still records THIS acquisition — PID
|
|
385
|
+
// alone is not enough, because a later broker instance in the same
|
|
386
|
+
// process may have legitimately re-acquired with a new instance id.
|
|
387
|
+
const owner = readBrokerLockOwner(this.lockPath);
|
|
388
|
+
if (owner && owner.pid === process.pid && owner.instanceId === this.instanceId) {
|
|
389
|
+
fs.unlinkSync(this.lockPath);
|
|
64
390
|
}
|
|
65
391
|
}
|
|
66
392
|
catch {
|
|
67
393
|
// Best-effort cleanup
|
|
68
394
|
}
|
|
69
395
|
this.acquired = false;
|
|
396
|
+
this.instanceId = null;
|
|
70
397
|
}
|
|
71
398
|
/**
|
|
72
399
|
* Check if this instance currently holds the lock.
|
|
@@ -80,6 +407,12 @@ export class LeaderLock {
|
|
|
80
407
|
getLockPath() {
|
|
81
408
|
return this.lockPath;
|
|
82
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Per-acquisition instance id, set while the lock is held.
|
|
412
|
+
*/
|
|
413
|
+
getInstanceId() {
|
|
414
|
+
return this.instanceId;
|
|
415
|
+
}
|
|
83
416
|
}
|
|
84
417
|
/**
|
|
85
418
|
* Check if a process with the given PID is currently running.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentInfo, AgentLifecycleState, HibernateEligibility } from "./types.js";
|
|
2
|
+
export declare const AGENT_LIFECYCLE_STATES: readonly AgentLifecycleState[];
|
|
3
|
+
export declare function isLegalLifecycleTransition(from: AgentLifecycleState, to: AgentLifecycleState): boolean;
|
|
4
|
+
export declare function assertLegalLifecycleTransition(from: AgentLifecycleState, to: AgentLifecycleState): void;
|
|
5
|
+
export declare function evaluateHibernateEligibility(agent: AgentInfo): HibernateEligibility;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const AGENT_LIFECYCLE_STATES = [
|
|
2
|
+
"live",
|
|
3
|
+
"active",
|
|
4
|
+
"grace",
|
|
5
|
+
"idle",
|
|
6
|
+
"hibernating",
|
|
7
|
+
"hibernated",
|
|
8
|
+
"waking",
|
|
9
|
+
"reap-candidate",
|
|
10
|
+
"terminated",
|
|
11
|
+
];
|
|
12
|
+
const LEGAL_TRANSITIONS = {
|
|
13
|
+
live: ["active", "grace", "reap-candidate"],
|
|
14
|
+
active: ["grace", "reap-candidate"],
|
|
15
|
+
grace: ["active", "idle", "reap-candidate"],
|
|
16
|
+
idle: ["active", "hibernating", "reap-candidate"],
|
|
17
|
+
hibernating: ["active", "hibernated", "reap-candidate"],
|
|
18
|
+
hibernated: ["waking", "reap-candidate"],
|
|
19
|
+
waking: ["live", "reap-candidate"],
|
|
20
|
+
"reap-candidate": ["live", "hibernated", "terminated"],
|
|
21
|
+
terminated: [],
|
|
22
|
+
};
|
|
23
|
+
export function isLegalLifecycleTransition(from, to) {
|
|
24
|
+
return LEGAL_TRANSITIONS[from].includes(to);
|
|
25
|
+
}
|
|
26
|
+
export function assertLegalLifecycleTransition(from, to) {
|
|
27
|
+
if (!isLegalLifecycleTransition(from, to)) {
|
|
28
|
+
throw new Error(`Illegal agent lifecycle transition: ${from} -> ${to}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function evaluateHibernateEligibility(agent) {
|
|
32
|
+
if (agent.hibernatePolicy === "never")
|
|
33
|
+
return { eligible: false, reason: "policy_never" };
|
|
34
|
+
if (!agent.stableId)
|
|
35
|
+
return { eligible: false, reason: "missing_stable_id" };
|
|
36
|
+
if (agent.parentAgentId || agent.supervisionState === "supervised") {
|
|
37
|
+
return { eligible: false, reason: "supervised_subtree_unsupported" };
|
|
38
|
+
}
|
|
39
|
+
if (agent.status !== "idle")
|
|
40
|
+
return { eligible: false, reason: "agent_working" };
|
|
41
|
+
if (agent.pendingInboxCount && agent.pendingInboxCount > 0) {
|
|
42
|
+
return { eligible: false, reason: "pending_inbox" };
|
|
43
|
+
}
|
|
44
|
+
const metadata = agent.metadata;
|
|
45
|
+
if (metadata?.brokerManaged !== true)
|
|
46
|
+
return { eligible: false, reason: "not_broker_managed" };
|
|
47
|
+
if (metadata.hibernateSafe !== true)
|
|
48
|
+
return { eligible: false, reason: "unsafe_or_unconfirmed" };
|
|
49
|
+
for (const key of ["cwd", "repoRoot", "worktreePath", "brokerManagedBy"]) {
|
|
50
|
+
if (typeof metadata[key] !== "string" || metadata[key].trim().length === 0) {
|
|
51
|
+
return { eligible: false, reason: `missing_${key}` };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const runtimeLocator = metadata.runtimeLocator ?? metadata.tmuxSession;
|
|
55
|
+
if (typeof runtimeLocator !== "string" || runtimeLocator.trim().length === 0) {
|
|
56
|
+
return { eligible: false, reason: "missing_runtime_locator" };
|
|
57
|
+
}
|
|
58
|
+
return { eligible: true, reason: "eligible" };
|
|
59
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
export declare const PINET_MAIL_CLASSES: readonly ["steering", "fwup", "maintenance_context"];
|
|
2
2
|
export type PinetMailClass = (typeof PINET_MAIL_CLASSES)[number];
|
|
3
|
+
export interface PinetMailMetadata extends Record<string, unknown> {
|
|
4
|
+
kind?: string;
|
|
5
|
+
type?: string;
|
|
6
|
+
event_type?: string;
|
|
7
|
+
}
|
|
3
8
|
export interface PinetMailClassificationInput {
|
|
4
9
|
source?: string | null;
|
|
5
10
|
threadId?: string | null;
|
|
6
11
|
sender?: string | null;
|
|
7
12
|
body?: string | null;
|
|
8
|
-
metadata?:
|
|
13
|
+
metadata?: PinetMailMetadata | null;
|
|
9
14
|
}
|
|
10
15
|
export interface PinetMailClassification {
|
|
11
16
|
class: PinetMailClass;
|
package/dist/message-send.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import type { TransportJsonObject, TransportRichBlock } from "@pinet/transport-core";
|
|
1
2
|
import type { BrokerMessage, MessageAdapter, NormalizedMessageContent, OutboundAttachmentFile, ThreadInfo } from "./types.js";
|
|
2
3
|
export interface BrokerMessageSenderDb {
|
|
3
4
|
getThread(threadId: string): ThreadInfo | null;
|
|
4
5
|
createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): ThreadInfo;
|
|
5
6
|
updateThread(threadId: string, updates: Partial<ThreadInfo>): void;
|
|
6
7
|
claimThread(threadId: string, agentId: string, source?: string, channel?: string): boolean;
|
|
7
|
-
insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?:
|
|
8
|
+
insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: TransportJsonObject): BrokerMessage;
|
|
8
9
|
}
|
|
9
10
|
export interface BrokerMessageSenderDeps {
|
|
10
11
|
db: BrokerMessageSenderDb;
|
|
@@ -17,12 +18,12 @@ export interface SendBrokerMessageInput {
|
|
|
17
18
|
source?: string;
|
|
18
19
|
channel?: string;
|
|
19
20
|
content?: NormalizedMessageContent;
|
|
20
|
-
blocks?: ReadonlyArray<
|
|
21
|
+
blocks?: ReadonlyArray<TransportRichBlock>;
|
|
21
22
|
files?: ReadonlyArray<OutboundAttachmentFile>;
|
|
22
23
|
agentName?: string;
|
|
23
24
|
agentEmoji?: string;
|
|
24
25
|
agentOwnerToken?: string;
|
|
25
|
-
metadata?:
|
|
26
|
+
metadata?: TransportJsonObject;
|
|
26
27
|
}
|
|
27
28
|
export interface SendBrokerMessageResult {
|
|
28
29
|
thread: ThreadInfo;
|
package/dist/router.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { AgentInfo, BrokerDBInterface, InboundMessage, RoutingDecision } from "./types.js";
|
|
2
|
+
type BrokerRouterMetadata = Record<string, unknown>;
|
|
3
|
+
interface PiAgentMessageMetadata extends BrokerRouterMetadata {
|
|
4
|
+
event_type?: string;
|
|
5
|
+
event_payload?: {
|
|
6
|
+
agent?: string;
|
|
7
|
+
agent_owner?: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
interface BrokerRouterReply extends BrokerRouterMetadata {
|
|
11
|
+
bot_id?: string;
|
|
12
|
+
metadata?: PiAgentMessageMetadata;
|
|
13
|
+
}
|
|
2
14
|
export interface ThreadOwnerHint {
|
|
3
15
|
agentId?: string;
|
|
4
16
|
stableId?: string;
|
|
@@ -21,7 +33,7 @@ export type ExplicitThreadDirective = {
|
|
|
21
33
|
* preferred over "Code" and similar-prefix collisions are avoided.
|
|
22
34
|
*/
|
|
23
35
|
export declare function findAgentMention(text: string, agents: AgentInfo[]): AgentInfo | null;
|
|
24
|
-
export declare function extractPiAgentThreadOwnerHint(replies: ReadonlyArray<
|
|
36
|
+
export declare function extractPiAgentThreadOwnerHint(replies: ReadonlyArray<BrokerRouterReply>): ThreadOwnerHint | null;
|
|
25
37
|
export declare function findExplicitThreadDirective(text: string, agents: AgentInfo[]): ExplicitThreadDirective | null;
|
|
26
38
|
export declare class MessageRouter {
|
|
27
39
|
private readonly db;
|
|
@@ -58,3 +70,4 @@ export declare class MessageRouter {
|
|
|
58
70
|
*/
|
|
59
71
|
getAvailableAgents(): AgentInfo[];
|
|
60
72
|
}
|
|
73
|
+
export {};
|
package/dist/router.js
CHANGED
|
@@ -178,7 +178,22 @@ function resolveRoutableThreadOwner(db, threadOwnerAgentId, now = new Date().toI
|
|
|
178
178
|
function escapeRegExp(s) {
|
|
179
179
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Lifecycle states in which an agent has no live process/socket but is still a
|
|
183
|
+
* durable, affinity-bound routing target. A message to such an owner must be
|
|
184
|
+
* queued and cold-woken, never rerouted to an unrelated worker or dropped.
|
|
185
|
+
*/
|
|
186
|
+
const DURABLE_HIBERNATION_OWNER_STATES = new Set([
|
|
187
|
+
"hibernating",
|
|
188
|
+
"hibernated",
|
|
189
|
+
"waking",
|
|
190
|
+
]);
|
|
181
191
|
function isRoutableOwner(agent, now = new Date().toISOString()) {
|
|
192
|
+
// Durable hibernation identities remain routable even without a live socket:
|
|
193
|
+
// the delivery layer queues to their inbox and triggers a fenced cold wake.
|
|
194
|
+
if (agent.lifecycleState && DURABLE_HIBERNATION_OWNER_STATES.has(agent.lifecycleState)) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
182
197
|
if (!agent.disconnectedAt)
|
|
183
198
|
return true;
|
|
184
199
|
return agent.resumableUntil != null && agent.resumableUntil > now;
|