@pinet/slack-bridge 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/README.md +316 -451
- package/dist/activity-log.d.ts +10 -3
- package/dist/agent-event-runtime.d.ts +0 -2
- package/dist/agent-event-runtime.js +0 -1
- package/dist/agent-prompt-guidance.d.ts +1 -6
- package/dist/agent-prompt-guidance.js +1 -6
- package/dist/broker/adapters/slack.d.ts +4 -1
- package/dist/broker/adapters/slack.js +16 -9
- package/dist/broker/client.d.ts +21 -4
- package/dist/broker/client.js +65 -25
- package/dist/broker/hibernation-activation-authority.d.ts +22 -0
- package/dist/broker/hibernation-activation-authority.js +51 -0
- package/dist/broker/hibernation-activation.d.ts +75 -0
- package/dist/broker/hibernation-activation.js +95 -0
- package/dist/broker/hibernation-command-router.d.ts +54 -0
- package/dist/broker/hibernation-command-router.js +124 -0
- package/dist/broker/hibernation-runtime-adapters.d.ts +79 -0
- package/dist/broker/hibernation-runtime-adapters.js +473 -0
- package/dist/broker/hibernation-runtime-helpers.d.ts +146 -0
- package/dist/broker/hibernation-runtime-helpers.js +243 -0
- package/dist/broker/index.d.ts +16 -1
- package/dist/broker/index.js +68 -10
- package/dist/broker/lock-conflict.d.ts +109 -0
- package/dist/broker/lock-conflict.js +409 -0
- package/dist/broker/socket-server.d.ts +26 -0
- package/dist/broker/socket-server.js +287 -4
- package/dist/broker-delivery.d.ts +1 -0
- package/dist/broker-delivery.js +10 -1
- package/dist/broker-runtime.d.ts +14 -2
- package/dist/broker-runtime.js +114 -3
- package/dist/canvases.d.ts +12 -2
- package/dist/canvases.js +1 -3
- package/dist/compaction-gate.d.ts +7 -0
- package/dist/compaction-gate.js +35 -0
- package/dist/deploy-manifest.d.ts +8 -2
- package/dist/deploy-manifest.js +9 -7
- package/dist/follower-runtime.d.ts +5 -2
- package/dist/follower-runtime.js +39 -5
- package/dist/git-metadata.d.ts +5 -9
- package/dist/git-metadata.js +23 -42
- package/dist/guardrails.js +5 -0
- package/dist/helpers.d.ts +77 -27
- package/dist/helpers.js +150 -39
- package/dist/hibernation-config.d.ts +13 -0
- package/dist/hibernation-config.js +14 -0
- package/dist/home-tab.d.ts +13 -2
- package/dist/imessage-tools.d.ts +4 -1
- package/dist/inbox-drain-runtime.d.ts +1 -0
- package/dist/inbox-drain-runtime.js +5 -1
- package/dist/index.js +341 -108
- package/dist/pinet-commands.d.ts +14 -1
- package/dist/pinet-commands.js +93 -22
- package/dist/pinet-mesh-ops.d.ts +6 -4
- package/dist/pinet-mesh-ops.js +9 -1
- package/dist/pinet-runtime-composition.d.ts +2 -1
- package/dist/pinet-runtime-composition.js +14 -3
- package/dist/pinet-session-formatting.d.ts +26 -2
- package/dist/pinet-session-formatting.js +2 -0
- package/dist/pinet-tools.d.ts +14 -0
- package/dist/pinet-tools.js +355 -50
- package/dist/prompts/broker/default.md +3 -3
- package/dist/prompts/broker/tmux.md +3 -3
- package/dist/runtime-agent-context.d.ts +3 -3
- package/dist/runtime-agent-context.js +12 -6
- package/dist/runtime-mode.js +4 -0
- package/dist/session-ui-runtime.js +3 -1
- package/dist/single-player-runtime.d.ts +1 -1
- package/dist/single-player-runtime.js +21 -5
- package/dist/slack-access.d.ts +10 -2
- package/dist/slack-access.js +64 -22
- package/dist/slack-block-kit.d.ts +44 -2
- package/dist/slack-block-kit.js +5 -5
- package/dist/slack-file-access.d.ts +12 -1
- package/dist/slack-file-access.js +4 -4
- package/dist/slack-message-context.d.ts +9 -2
- package/dist/slack-modals.d.ts +4 -1
- package/dist/slack-modals.js +5 -5
- package/dist/slack-pinet-runtime-adapter.d.ts +1 -0
- package/dist/slack-pinet-runtime-adapter.js +10 -0
- package/dist/slack-presence.d.ts +5 -4
- package/dist/slack-request-runtime.d.ts +3 -2
- package/dist/slack-scope-diagnostics.d.ts +27 -2
- package/dist/slack-socket-dedup.d.ts +8 -5
- package/dist/slack-tools.d.ts +10 -0
- package/dist/slack-tools.js +51 -64
- package/dist/slack-upload.d.ts +17 -1
- package/dist/subtree-broker-runtime.d.ts +83 -2
- package/dist/subtree-broker-runtime.js +774 -109
- package/dist/tool-output-limits.d.ts +11 -0
- package/dist/tool-output-limits.js +20 -0
- package/dist/tool-registration-runtime.d.ts +3 -0
- package/dist/tool-registration-runtime.js +27 -0
- package/package.json +8 -8
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import { inspectBrokerLock, } from "./leader.js";
|
|
3
|
+
import { getDefaultSocketPath } from "./paths.js";
|
|
4
|
+
import { RPC_METHOD_NOT_FOUND } from "./types.js";
|
|
5
|
+
// ─── Constants ───────────────────────────────────────────
|
|
6
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 2000;
|
|
7
|
+
export const DEFAULT_SHUTDOWN_RPC_TIMEOUT_MS = 5000;
|
|
8
|
+
export const DEFAULT_GRACEFUL_WAIT_MS = 8000;
|
|
9
|
+
export const DEFAULT_TERMINATE_WAIT_MS = 8000;
|
|
10
|
+
export const DEFAULT_REPLACE_POLL_INTERVAL_MS = 250;
|
|
11
|
+
class BrokerSocketFailure extends Error {
|
|
12
|
+
kind;
|
|
13
|
+
constructor(kind, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "BrokerSocketFailure";
|
|
16
|
+
this.kind = kind;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Minimal sequential JSON-RPC client used for lock-conflict diagnostics.
|
|
21
|
+
* Deliberately independent from the full follower client: no reconnects, no
|
|
22
|
+
* heartbeats — one bounded connection for probe/shutdown calls.
|
|
23
|
+
*/
|
|
24
|
+
class OneShotBrokerRpcClient {
|
|
25
|
+
socket;
|
|
26
|
+
buffer = "";
|
|
27
|
+
nextId = 1;
|
|
28
|
+
waiter = null;
|
|
29
|
+
constructor(socket) {
|
|
30
|
+
this.socket = socket;
|
|
31
|
+
socket.on("data", (chunk) => {
|
|
32
|
+
this.buffer += chunk.toString("utf-8");
|
|
33
|
+
let newlineIndex = this.buffer.indexOf("\n");
|
|
34
|
+
while (newlineIndex !== -1) {
|
|
35
|
+
const line = this.buffer.slice(0, newlineIndex).trim();
|
|
36
|
+
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
37
|
+
if (line && this.waiter) {
|
|
38
|
+
let raw = null;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(line);
|
|
41
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
42
|
+
raw = parsed;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
raw = null;
|
|
47
|
+
}
|
|
48
|
+
if (raw && raw.id === this.waiter.id) {
|
|
49
|
+
const rawError = raw.error;
|
|
50
|
+
const response = {
|
|
51
|
+
error: rawError && typeof rawError === "object" && typeof rawError.code === "number"
|
|
52
|
+
? {
|
|
53
|
+
code: rawError.code,
|
|
54
|
+
message: typeof rawError.message === "string" ? rawError.message : "",
|
|
55
|
+
}
|
|
56
|
+
: null,
|
|
57
|
+
};
|
|
58
|
+
const { resolve } = this.waiter;
|
|
59
|
+
this.waiter = null;
|
|
60
|
+
resolve(response);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
newlineIndex = this.buffer.indexOf("\n");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const failPending = (kind, message) => {
|
|
67
|
+
if (!this.waiter)
|
|
68
|
+
return;
|
|
69
|
+
const { reject } = this.waiter;
|
|
70
|
+
this.waiter = null;
|
|
71
|
+
reject(new BrokerSocketFailure(kind, message));
|
|
72
|
+
};
|
|
73
|
+
socket.on("error", (err) => failPending("unreachable", err.message));
|
|
74
|
+
socket.on("close", () => failPending("unresponsive", "Broker socket closed before responding."));
|
|
75
|
+
}
|
|
76
|
+
static connect(target, timeoutMs) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const socket = target.type === "unix"
|
|
79
|
+
? net.createConnection({ path: target.path })
|
|
80
|
+
: net.createConnection({ port: target.port, host: target.host });
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
socket.destroy();
|
|
83
|
+
reject(new BrokerSocketFailure("unresponsive", `Broker socket connect timed out after ${timeoutMs}ms.`));
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
socket.once("connect", () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve(new OneShotBrokerRpcClient(socket));
|
|
89
|
+
});
|
|
90
|
+
socket.once("error", (err) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
socket.destroy();
|
|
93
|
+
reject(new BrokerSocketFailure("unreachable", err.message));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
call(method, params, timeoutMs) {
|
|
98
|
+
const id = this.nextId++;
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
if (this.waiter?.id === id)
|
|
102
|
+
this.waiter = null;
|
|
103
|
+
reject(new BrokerSocketFailure("unresponsive", `Broker did not respond to ${method} within ${timeoutMs}ms.`));
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
this.waiter = {
|
|
107
|
+
id,
|
|
108
|
+
resolve: (response) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
resolve(response);
|
|
111
|
+
},
|
|
112
|
+
reject: (error) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(error);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
this.socket.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
this.waiter = null;
|
|
123
|
+
reject(new BrokerSocketFailure("unreachable", err instanceof Error ? err.message : String(err)));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
close() {
|
|
128
|
+
this.socket.destroy();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function resolveTarget(options) {
|
|
132
|
+
if (options.target)
|
|
133
|
+
return options.target;
|
|
134
|
+
return { type: "unix", path: options.socketPath ?? getDefaultSocketPath() };
|
|
135
|
+
}
|
|
136
|
+
function failureToProbeResult(err) {
|
|
137
|
+
return err instanceof BrokerSocketFailure && err.kind === "unreachable"
|
|
138
|
+
? "unreachable"
|
|
139
|
+
: "unresponsive";
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Bounded liveness probe for a broker socket. Any well-formed JSON-RPC
|
|
143
|
+
* response — including an auth error — proves the broker event loop is
|
|
144
|
+
* serving, so it classifies as healthy.
|
|
145
|
+
*/
|
|
146
|
+
export async function probeBrokerSocket(options = {}) {
|
|
147
|
+
const target = resolveTarget(options);
|
|
148
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
149
|
+
let client;
|
|
150
|
+
try {
|
|
151
|
+
client = await OneShotBrokerRpcClient.connect(target, timeoutMs);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return failureToProbeResult(err instanceof Error ? err : new Error(String(err)));
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await client.call("auth", {}, timeoutMs);
|
|
158
|
+
return "healthy";
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
return failureToProbeResult(err instanceof Error ? err : new Error(String(err)));
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
client.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Ask a running broker to shut down gracefully via the authenticated
|
|
169
|
+
* `admin.shutdown` RPC. Brokers that predate the RPC report `unsupported`.
|
|
170
|
+
*/
|
|
171
|
+
export async function requestBrokerShutdown(options = {}) {
|
|
172
|
+
const target = resolveTarget(options);
|
|
173
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SHUTDOWN_RPC_TIMEOUT_MS;
|
|
174
|
+
let client;
|
|
175
|
+
try {
|
|
176
|
+
client = await OneShotBrokerRpcClient.connect(target, timeoutMs);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
return err instanceof BrokerSocketFailure && err.kind === "unreachable"
|
|
180
|
+
? "unreachable"
|
|
181
|
+
: "failed";
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const secret = options.meshSecret?.trim();
|
|
185
|
+
const authResponse = await client.call("auth", secret ? { secret } : {}, timeoutMs);
|
|
186
|
+
if (authResponse.error)
|
|
187
|
+
return "rejected";
|
|
188
|
+
const shutdownResponse = await client.call("admin.shutdown", {}, timeoutMs);
|
|
189
|
+
if (shutdownResponse.error) {
|
|
190
|
+
return shutdownResponse.error.code === RPC_METHOD_NOT_FOUND ? "unsupported" : "rejected";
|
|
191
|
+
}
|
|
192
|
+
return "accepted";
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return "failed";
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
client.close();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Combine lock-owner inspection with a bounded socket probe to classify a
|
|
203
|
+
* leader-lock conflict.
|
|
204
|
+
*/
|
|
205
|
+
export async function classifyBrokerLockConflict(options = {}) {
|
|
206
|
+
const inspection = inspectBrokerLock(options.lockPath, options.probes ?? {});
|
|
207
|
+
if (inspection.state !== "alive") {
|
|
208
|
+
return { kind: "reclaimable", inspection, probe: null };
|
|
209
|
+
}
|
|
210
|
+
const target = resolveTarget(options);
|
|
211
|
+
const timeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
212
|
+
const probe = options.probeSocket
|
|
213
|
+
? await options.probeSocket(target, timeoutMs)
|
|
214
|
+
: await probeBrokerSocket({ target, timeoutMs });
|
|
215
|
+
return {
|
|
216
|
+
kind: "conflict",
|
|
217
|
+
classification: probe === "healthy" ? "active-broker" : "unresponsive-broker",
|
|
218
|
+
owner: inspection.owner,
|
|
219
|
+
probe,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export function formatBrokerLockConflictMessage(input) {
|
|
223
|
+
const owner = input.owner;
|
|
224
|
+
const ownerBits = owner
|
|
225
|
+
? ` (pid ${owner.pid}${owner.createdAt ? `, lock created ${owner.createdAt}` : ""})`
|
|
226
|
+
: "";
|
|
227
|
+
if (input.classification === "active-broker") {
|
|
228
|
+
return (`Another pinet broker is already running${ownerBits} and its socket is responding. ` +
|
|
229
|
+
`Use /pinet follow to join it, or /pinet start replace to take over.`);
|
|
230
|
+
}
|
|
231
|
+
const socketState = input.probe === "unreachable" ? "not reachable" : "not responding";
|
|
232
|
+
return (`Another process holds the pinet broker lock${ownerBits} but the broker socket is ` +
|
|
233
|
+
`${socketState} — the broker looks stranded. Use /pinet start replace to recover.`);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Thrown by `startBroker` when the leader lock is held by another live
|
|
237
|
+
* process, carrying enough context for callers to present a per-state
|
|
238
|
+
* recovery path instead of a generic failure.
|
|
239
|
+
*/
|
|
240
|
+
export class BrokerLockConflictError extends Error {
|
|
241
|
+
classification;
|
|
242
|
+
owner;
|
|
243
|
+
probe;
|
|
244
|
+
constructor(input) {
|
|
245
|
+
super(formatBrokerLockConflictMessage(input));
|
|
246
|
+
this.name = "BrokerLockConflictError";
|
|
247
|
+
this.classification = input.classification;
|
|
248
|
+
this.owner = input.owner;
|
|
249
|
+
this.probe = input.probe;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Conservatively replace the current broker lock owner:
|
|
254
|
+
*
|
|
255
|
+
* 1. No live owner → nothing to do (a normal start reclaims stale state).
|
|
256
|
+
* 2. Live owner → request graceful `admin.shutdown` and wait (bounded).
|
|
257
|
+
* 3. Fallback → re-verify the owner fence (pid + process start time +
|
|
258
|
+
* instance id), SIGTERM the verified owner, and wait (bounded).
|
|
259
|
+
* 4. Never SIGKILL; abort if the owner identity changes mid-flight.
|
|
260
|
+
*/
|
|
261
|
+
export async function replaceBrokerOwner(options = {}) {
|
|
262
|
+
const steps = [];
|
|
263
|
+
const probes = options.probes ?? {};
|
|
264
|
+
const inspect = () => inspectBrokerLock(options.lockPath, probes);
|
|
265
|
+
// Deliberately not unref'd: the poll loop must keep the event loop alive
|
|
266
|
+
// until replacement resolves (caught by the cross-process E2E run).
|
|
267
|
+
const sleep = options.sleep ??
|
|
268
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
269
|
+
const kill = options.kill ??
|
|
270
|
+
((pid, signal) => {
|
|
271
|
+
process.kill(pid, signal);
|
|
272
|
+
});
|
|
273
|
+
const requestShutdown = options.requestShutdown ?? requestBrokerShutdown;
|
|
274
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_REPLACE_POLL_INTERVAL_MS;
|
|
275
|
+
const gracefulWaitMs = options.gracefulWaitMs ?? DEFAULT_GRACEFUL_WAIT_MS;
|
|
276
|
+
const terminateWaitMs = options.terminateWaitMs ?? DEFAULT_TERMINATE_WAIT_MS;
|
|
277
|
+
const initial = inspect();
|
|
278
|
+
if (initial.state !== "alive") {
|
|
279
|
+
steps.push(`Lock state: ${initial.state} — nothing to replace; a normal start reclaims it.`);
|
|
280
|
+
return { outcome: "no-conflict", owner: initial.owner ?? null, steps, error: null };
|
|
281
|
+
}
|
|
282
|
+
const owner = initial.owner;
|
|
283
|
+
if (owner.pid === process.pid) {
|
|
284
|
+
// Never signal ourselves: a same-process conflict means this session
|
|
285
|
+
// already hosts the broker (or another runtime within it does).
|
|
286
|
+
steps.push(`Lock is held by this session's own process (pid ${owner.pid}).`);
|
|
287
|
+
return {
|
|
288
|
+
outcome: "failed",
|
|
289
|
+
owner,
|
|
290
|
+
steps,
|
|
291
|
+
error: "The broker lock is held by this session's own process — nothing to replace. " +
|
|
292
|
+
"Use /pinet start to reload the current broker runtime instead.",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
steps.push(`Lock held by live pid ${owner.pid}${owner.legacy ? " (legacy lock format — weaker identity fence)" : ""}.`);
|
|
296
|
+
const sameOwner = (current) => current.state === "alive" &&
|
|
297
|
+
current.owner.pid === owner.pid &&
|
|
298
|
+
current.owner.instanceId === owner.instanceId &&
|
|
299
|
+
current.owner.processStartTime === owner.processStartTime;
|
|
300
|
+
const ownerChangedResult = () => {
|
|
301
|
+
steps.push("Lock owner changed during replacement — aborting.");
|
|
302
|
+
return {
|
|
303
|
+
outcome: "owner-changed",
|
|
304
|
+
owner,
|
|
305
|
+
steps,
|
|
306
|
+
error: "The broker lock changed owners during replacement — another session may have recovered it. Check /pinet status and retry if needed.",
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
const waitForRelease = async (waitMs) => {
|
|
310
|
+
const deadline = Date.now() + waitMs;
|
|
311
|
+
for (;;) {
|
|
312
|
+
const current = inspect();
|
|
313
|
+
if (current.state !== "alive")
|
|
314
|
+
return "released";
|
|
315
|
+
if (!sameOwner(current))
|
|
316
|
+
return "owner-changed";
|
|
317
|
+
if (Date.now() >= deadline)
|
|
318
|
+
return "held";
|
|
319
|
+
await sleep(pollIntervalMs);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
// ── Step 1: graceful shutdown over the socket ──
|
|
323
|
+
const shutdownResult = await requestShutdown({
|
|
324
|
+
...(options.target ? { target: options.target } : {}),
|
|
325
|
+
...(options.socketPath ? { socketPath: options.socketPath } : {}),
|
|
326
|
+
meshSecret: options.meshSecret ?? null,
|
|
327
|
+
timeoutMs: options.shutdownRpcTimeoutMs ?? DEFAULT_SHUTDOWN_RPC_TIMEOUT_MS,
|
|
328
|
+
});
|
|
329
|
+
steps.push(`Graceful shutdown request: ${shutdownResult}.`);
|
|
330
|
+
if (shutdownResult === "accepted") {
|
|
331
|
+
const wait = await waitForRelease(gracefulWaitMs);
|
|
332
|
+
if (wait === "released") {
|
|
333
|
+
steps.push("Broker released the lock after graceful shutdown.");
|
|
334
|
+
return { outcome: "replaced-graceful", owner, steps, error: null };
|
|
335
|
+
}
|
|
336
|
+
if (wait === "owner-changed")
|
|
337
|
+
return ownerChangedResult();
|
|
338
|
+
steps.push("Broker accepted shutdown but did not release the lock in time.");
|
|
339
|
+
}
|
|
340
|
+
if (shutdownResult === "rejected") {
|
|
341
|
+
// The broker is responsive and refused the request (typically a mesh
|
|
342
|
+
// secret mismatch). A responsive broker is not stranded — do not
|
|
343
|
+
// escalate to signals.
|
|
344
|
+
return {
|
|
345
|
+
outcome: "failed",
|
|
346
|
+
owner,
|
|
347
|
+
steps,
|
|
348
|
+
error: "The running broker rejected the shutdown request (often a mesh secret mismatch). " +
|
|
349
|
+
"It is responsive, so it was not terminated. Fix the mesh secret configuration, " +
|
|
350
|
+
"or stop that broker from its own session, then retry.",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// ── Step 2: fenced termination fallback ──
|
|
354
|
+
if (!owner.processStartTime) {
|
|
355
|
+
// Legacy locks (and structured locks with an unknown start time) carry no
|
|
356
|
+
// PID-reuse fence, so an automatic SIGTERM could hit an unrelated process
|
|
357
|
+
// that reused the PID. Require a manual, human-verified step instead.
|
|
358
|
+
steps.push("Lock owner has no recorded process start identity — refusing automatic termination.");
|
|
359
|
+
return {
|
|
360
|
+
outcome: "failed",
|
|
361
|
+
owner,
|
|
362
|
+
steps,
|
|
363
|
+
error: `The lock owner (pid ${owner.pid}) predates identity fencing, so automatic termination ` +
|
|
364
|
+
`cannot verify it is still the original broker. Inspect it manually (ps -p ${owner.pid}), ` +
|
|
365
|
+
`terminate it yourself if it is truly the stranded broker, then run /pinet start.`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const preTerminate = inspect();
|
|
369
|
+
if (preTerminate.state !== "alive") {
|
|
370
|
+
steps.push("Lock was released before termination was needed.");
|
|
371
|
+
return {
|
|
372
|
+
outcome: shutdownResult === "accepted" ? "replaced-graceful" : "no-conflict",
|
|
373
|
+
owner,
|
|
374
|
+
steps,
|
|
375
|
+
error: null,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (!sameOwner(preTerminate))
|
|
379
|
+
return ownerChangedResult();
|
|
380
|
+
// The fence re-check above and the signal below are not atomic: the
|
|
381
|
+
// verified owner could exit and its PID be reused in between. Closing that
|
|
382
|
+
// window needs a kernel handle bound to process identity (e.g. Linux
|
|
383
|
+
// pidfd_send_signal), which Node does not expose without native code — the
|
|
384
|
+
// immediately-preceding pid + start-time + instanceId fence keeps the
|
|
385
|
+
// residual window negligible.
|
|
386
|
+
steps.push(`Sending SIGTERM to verified lock owner pid ${owner.pid}.`);
|
|
387
|
+
try {
|
|
388
|
+
kill(owner.pid, "SIGTERM");
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// ESRCH etc. — the process may have just exited; the release wait decides.
|
|
392
|
+
steps.push(`Signal delivery to pid ${owner.pid} failed — it may have already exited.`);
|
|
393
|
+
}
|
|
394
|
+
const wait = await waitForRelease(terminateWaitMs);
|
|
395
|
+
if (wait === "released") {
|
|
396
|
+
steps.push("Broker released the lock after SIGTERM.");
|
|
397
|
+
return { outcome: "replaced-terminated", owner, steps, error: null };
|
|
398
|
+
}
|
|
399
|
+
if (wait === "owner-changed")
|
|
400
|
+
return ownerChangedResult();
|
|
401
|
+
return {
|
|
402
|
+
outcome: "failed",
|
|
403
|
+
owner,
|
|
404
|
+
steps,
|
|
405
|
+
error: `Broker pid ${owner.pid} is still holding the lock after SIGTERM. ` +
|
|
406
|
+
`Not escalating to SIGKILL automatically — inspect the process (ps -p ${owner.pid}) ` +
|
|
407
|
+
`and terminate it manually if it is truly stranded, then run /pinet start.`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
@@ -47,6 +47,7 @@ export declare class BrokerSocketServer {
|
|
|
47
47
|
private agentStatusChangeCallback;
|
|
48
48
|
private outboundMessageAdapters;
|
|
49
49
|
private agentRegistrationResolver;
|
|
50
|
+
private adminShutdownHandler;
|
|
50
51
|
constructor(db: BrokerDB, target?: ListenTarget | string, options?: BrokerSocketServerOptions);
|
|
51
52
|
start(): Promise<void>;
|
|
52
53
|
stop(): Promise<void>;
|
|
@@ -75,6 +76,15 @@ export declare class BrokerSocketServer {
|
|
|
75
76
|
*/
|
|
76
77
|
onAgentStatusChange(cb: AgentStatusChangeCallback): void;
|
|
77
78
|
setAgentRegistrationResolver(resolver: AgentRegistrationResolver | null): void;
|
|
79
|
+
disconnectAgentConnections(agentId: string): void;
|
|
80
|
+
/**
|
|
81
|
+
* Register a handler invoked when an authenticated local client requests a
|
|
82
|
+
* graceful broker shutdown via the `admin.shutdown` RPC (used by
|
|
83
|
+
* `/pinet start replace` to take over a broker whose controlling session
|
|
84
|
+
* was lost). When no handler is wired, the RPC reports method-not-found so
|
|
85
|
+
* callers treat the broker as not supporting remote shutdown.
|
|
86
|
+
*/
|
|
87
|
+
setAdminShutdownHandler(handler: (() => void | Promise<void>) | null): void;
|
|
78
88
|
setOutboundMessageAdapters(adapters: ReadonlyArray<Pick<MessageAdapter, "name" | "send" | "invokeCapability">>): void;
|
|
79
89
|
private startPruning;
|
|
80
90
|
private stopPruning;
|
|
@@ -88,6 +98,14 @@ export declare class BrokerSocketServer {
|
|
|
88
98
|
private send;
|
|
89
99
|
private handleRequest;
|
|
90
100
|
private handleAuth;
|
|
101
|
+
/**
|
|
102
|
+
* Enforce the accepted-generation wake fence for durable hibernation
|
|
103
|
+
* identities. Returns a rejection response when the fence fails; on success
|
|
104
|
+
* for a hibernation identity it accepts the generation and binds this
|
|
105
|
+
* connection to the revived agent id. Ordinary registration returns no
|
|
106
|
+
* rejection and leaves state untouched.
|
|
107
|
+
*/
|
|
108
|
+
private enforceWakeFence;
|
|
91
109
|
private handleRegister;
|
|
92
110
|
private handleUnregister;
|
|
93
111
|
private handleHeartbeat;
|
|
@@ -114,8 +132,16 @@ export declare class BrokerSocketServer {
|
|
|
114
132
|
private handlePortLeaseExpire;
|
|
115
133
|
private handleScheduleCreate;
|
|
116
134
|
private handleStatusUpdate;
|
|
135
|
+
/**
|
|
136
|
+
* Graceful remote shutdown. Requires authentication (enforced by the
|
|
137
|
+
* generic auth gate). Responds first, then invokes the wired handler on the
|
|
138
|
+
* next tick so the requester receives the acknowledgement before the broker
|
|
139
|
+
* tears down its socket.
|
|
140
|
+
*/
|
|
141
|
+
private handleAdminShutdown;
|
|
117
142
|
private handleAdapterCapability;
|
|
118
143
|
private handleLegacySlackProxy;
|
|
119
144
|
private dispatchAdapterCapability;
|
|
145
|
+
private checkAdapterCapabilityThreadOwnership;
|
|
120
146
|
private applyAdapterCapabilityEffects;
|
|
121
147
|
}
|