sentinelayer-cli 0.35.0 → 0.35.1
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/package.json +1 -1
- package/src/commands/session.js +13 -2
- package/src/session/listener-process.js +127 -32
- package/src/session/listeners.js +79 -7
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -4401,6 +4401,7 @@ export function registerSessionCommand(program) {
|
|
|
4401
4401
|
)
|
|
4402
4402
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
4403
4403
|
.option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
|
|
4404
|
+
.option("--max-pages <n>", "Maximum older event pages to scan for heartbeats (default 5)", "5")
|
|
4404
4405
|
.option("--json", "Emit machine-readable output")
|
|
4405
4406
|
.action(async (sessionId, options, command) => {
|
|
4406
4407
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -4410,7 +4411,12 @@ export function registerSessionCommand(program) {
|
|
|
4410
4411
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
4411
4412
|
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
4412
4413
|
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
4413
|
-
const
|
|
4414
|
+
const maxPages = parsePositiveInteger(options.maxPages, "max-pages", 5);
|
|
4415
|
+
const result = await fetchSessionListeners(normalizedSessionId, {
|
|
4416
|
+
targetPath,
|
|
4417
|
+
limit,
|
|
4418
|
+
maxPages,
|
|
4419
|
+
});
|
|
4414
4420
|
const listeners = Array.isArray(result.listeners) ? result.listeners : [];
|
|
4415
4421
|
const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
|
|
4416
4422
|
const payload = {
|
|
@@ -4420,6 +4426,11 @@ export function registerSessionCommand(program) {
|
|
|
4420
4426
|
reason: result.ok ? undefined : result.reason,
|
|
4421
4427
|
count: listeners.length,
|
|
4422
4428
|
liveCount: live,
|
|
4429
|
+
pageCount: Number(result.pageCount || 0),
|
|
4430
|
+
scannedEventCount: Number(result.scannedEventCount || 0),
|
|
4431
|
+
listenerEventCount: Number(result.listenerEventCount || 0),
|
|
4432
|
+
partial: Boolean(result.partial),
|
|
4433
|
+
beforeSequence: result.beforeSequence || null,
|
|
4423
4434
|
listeners,
|
|
4424
4435
|
};
|
|
4425
4436
|
if (shouldEmitJson(options, command)) {
|
|
@@ -4689,7 +4700,7 @@ export function registerSessionCommand(program) {
|
|
|
4689
4700
|
});
|
|
4690
4701
|
if (existingListener.running && !options.force) {
|
|
4691
4702
|
throw new Error(
|
|
4692
|
-
`A
|
|
4703
|
+
`A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${existingListener.pid}). Use --force to take over or --allow-duplicate if you intentionally need more than one.`,
|
|
4693
4704
|
);
|
|
4694
4705
|
}
|
|
4695
4706
|
if (existingListener.running && options.force) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import process from "node:process";
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
@@ -9,6 +10,7 @@ import { isProcessAlive } from "./daemon-spawn.js";
|
|
|
9
10
|
import { resolveSessionPaths } from "./paths.js";
|
|
10
11
|
|
|
11
12
|
const LISTENER_DIR_NAME = "listeners";
|
|
13
|
+
const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
|
|
12
14
|
const execFileAsync = promisify(execFile);
|
|
13
15
|
|
|
14
16
|
function normalizeString(value) {
|
|
@@ -99,12 +101,21 @@ export function resolveListenerPidPath(
|
|
|
99
101
|
);
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
export
|
|
104
|
+
export function resolveGlobalListenerPidPath(
|
|
103
105
|
sessionId,
|
|
104
106
|
agentId,
|
|
105
|
-
{
|
|
107
|
+
{ homeDir = os.homedir() } = {},
|
|
106
108
|
) {
|
|
107
|
-
|
|
109
|
+
return path.join(
|
|
110
|
+
path.resolve(String(homeDir || os.homedir())),
|
|
111
|
+
".sentinelayer",
|
|
112
|
+
GLOBAL_LISTENER_DIR_NAME,
|
|
113
|
+
normalizeListenerProcessKey(sessionId),
|
|
114
|
+
`${normalizeListenerProcessKey(agentId)}.json`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readPidRecordAtPath(pidPath) {
|
|
108
119
|
try {
|
|
109
120
|
const raw = await fsp.readFile(pidPath, "utf-8");
|
|
110
121
|
const parsed = JSON.parse(raw);
|
|
@@ -117,6 +128,44 @@ export async function readListenerPidRecord(
|
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
async function writePidRecordAtPath(pidPath, record) {
|
|
132
|
+
await fsp.mkdir(path.dirname(pidPath), { recursive: true });
|
|
133
|
+
await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function removePidRecordAtPath(pidPath, { onlyForPid = null } = {}) {
|
|
137
|
+
if (onlyForPid != null) {
|
|
138
|
+
const existing = await readPidRecordAtPath(pidPath);
|
|
139
|
+
if (existing && Number(existing.pid) !== Number(onlyForPid)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await fsp.unlink(pidPath);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function readListenerPidRecord(
|
|
152
|
+
sessionId,
|
|
153
|
+
agentId,
|
|
154
|
+
{ targetPath = process.cwd() } = {},
|
|
155
|
+
) {
|
|
156
|
+
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
157
|
+
return readPidRecordAtPath(pidPath);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function readGlobalListenerPidRecord(
|
|
161
|
+
sessionId,
|
|
162
|
+
agentId,
|
|
163
|
+
{ homeDir = os.homedir() } = {},
|
|
164
|
+
) {
|
|
165
|
+
const pidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
|
|
166
|
+
return readPidRecordAtPath(pidPath);
|
|
167
|
+
}
|
|
168
|
+
|
|
120
169
|
export async function writeListenerPidRecord(
|
|
121
170
|
sessionId,
|
|
122
171
|
agentId,
|
|
@@ -129,9 +178,11 @@ export async function writeListenerPidRecord(
|
|
|
129
178
|
activeIntervalSeconds = null,
|
|
130
179
|
presenceIntervalSeconds = null,
|
|
131
180
|
logFile = "",
|
|
181
|
+
homeDir = os.homedir(),
|
|
132
182
|
} = {},
|
|
133
183
|
) {
|
|
134
184
|
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
185
|
+
const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
|
|
135
186
|
const record = {
|
|
136
187
|
pid: Number(pid),
|
|
137
188
|
sessionId: normalizeString(sessionId),
|
|
@@ -146,46 +197,42 @@ export async function writeListenerPidRecord(
|
|
|
146
197
|
logFile: normalizeString(logFile) || undefined,
|
|
147
198
|
startedAt: new Date().toISOString(),
|
|
148
199
|
};
|
|
149
|
-
|
|
150
|
-
|
|
200
|
+
const globalRecord = {
|
|
201
|
+
...record,
|
|
202
|
+
recordScope: "global",
|
|
203
|
+
localPidPath: pidPath,
|
|
204
|
+
};
|
|
205
|
+
await writePidRecordAtPath(globalPidPath, globalRecord);
|
|
206
|
+
try {
|
|
207
|
+
await writePidRecordAtPath(pidPath, record);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
await removePidRecordAtPath(globalPidPath, { onlyForPid: pid }).catch(() => {});
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
151
212
|
return record;
|
|
152
213
|
}
|
|
153
214
|
|
|
154
215
|
export async function removeListenerPidRecord(
|
|
155
216
|
sessionId,
|
|
156
217
|
agentId,
|
|
157
|
-
{ targetPath = process.cwd(), onlyForPid = null } = {},
|
|
218
|
+
{ targetPath = process.cwd(), onlyForPid = null, homeDir = os.homedir() } = {},
|
|
158
219
|
) {
|
|
159
220
|
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
await fsp.unlink(pidPath);
|
|
168
|
-
return true;
|
|
169
|
-
} catch {
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
221
|
+
const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
|
|
222
|
+
const localRemoved = await removePidRecordAtPath(pidPath, { onlyForPid });
|
|
223
|
+
const globalRemoved = await removePidRecordAtPath(globalPidPath, { onlyForPid });
|
|
224
|
+
return Boolean(localRemoved || globalRemoved);
|
|
172
225
|
}
|
|
173
226
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
227
|
+
async function listenerStatusFromRecord(
|
|
228
|
+
record,
|
|
229
|
+
{
|
|
230
|
+
sessionId,
|
|
231
|
+
agentId,
|
|
232
|
+
recordScope,
|
|
233
|
+
_readProcessCommandLine = readProcessCommandLine,
|
|
234
|
+
} = {},
|
|
178
235
|
) {
|
|
179
|
-
const record = await readListenerPidRecord(sessionId, agentId, { targetPath });
|
|
180
|
-
if (!record) {
|
|
181
|
-
return {
|
|
182
|
-
running: false,
|
|
183
|
-
pid: null,
|
|
184
|
-
stale: false,
|
|
185
|
-
record: null,
|
|
186
|
-
listenerKey: normalizeListenerProcessKey(agentId),
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
236
|
const alive = isProcessAlive(record.pid);
|
|
190
237
|
if (!alive) {
|
|
191
238
|
return {
|
|
@@ -193,6 +240,7 @@ export async function getListenerProcessStatus(
|
|
|
193
240
|
pid: null,
|
|
194
241
|
stale: true,
|
|
195
242
|
record,
|
|
243
|
+
recordScope,
|
|
196
244
|
listenerKey: normalizeListenerProcessKey(agentId),
|
|
197
245
|
};
|
|
198
246
|
}
|
|
@@ -205,6 +253,7 @@ export async function getListenerProcessStatus(
|
|
|
205
253
|
reused: Boolean(commandLine),
|
|
206
254
|
unverified: !commandLine,
|
|
207
255
|
record,
|
|
256
|
+
recordScope,
|
|
208
257
|
listenerKey: normalizeListenerProcessKey(agentId),
|
|
209
258
|
commandLine,
|
|
210
259
|
};
|
|
@@ -214,11 +263,57 @@ export async function getListenerProcessStatus(
|
|
|
214
263
|
pid: Number(record.pid),
|
|
215
264
|
stale: false,
|
|
216
265
|
record,
|
|
266
|
+
recordScope,
|
|
217
267
|
listenerKey: normalizeListenerProcessKey(agentId),
|
|
218
268
|
commandLine,
|
|
219
269
|
};
|
|
220
270
|
}
|
|
221
271
|
|
|
272
|
+
export async function getListenerProcessStatus(
|
|
273
|
+
sessionId,
|
|
274
|
+
agentId,
|
|
275
|
+
{
|
|
276
|
+
targetPath = process.cwd(),
|
|
277
|
+
homeDir = os.homedir(),
|
|
278
|
+
_readProcessCommandLine = readProcessCommandLine,
|
|
279
|
+
} = {},
|
|
280
|
+
) {
|
|
281
|
+
const records = [
|
|
282
|
+
{
|
|
283
|
+
record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
|
|
284
|
+
recordScope: "global",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
record: await readListenerPidRecord(sessionId, agentId, { targetPath }),
|
|
288
|
+
recordScope: "local",
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
const staleStatuses = [];
|
|
292
|
+
for (const entry of records) {
|
|
293
|
+
if (!entry.record) continue;
|
|
294
|
+
const status = await listenerStatusFromRecord(entry.record, {
|
|
295
|
+
sessionId,
|
|
296
|
+
agentId,
|
|
297
|
+
recordScope: entry.recordScope,
|
|
298
|
+
_readProcessCommandLine,
|
|
299
|
+
});
|
|
300
|
+
if (status.running) {
|
|
301
|
+
return status;
|
|
302
|
+
}
|
|
303
|
+
staleStatuses.push(status);
|
|
304
|
+
}
|
|
305
|
+
if (staleStatuses.length === 0) {
|
|
306
|
+
return {
|
|
307
|
+
running: false,
|
|
308
|
+
pid: null,
|
|
309
|
+
stale: false,
|
|
310
|
+
record: null,
|
|
311
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return staleStatuses[0];
|
|
315
|
+
}
|
|
316
|
+
|
|
222
317
|
export async function requestListenerProcessStop(
|
|
223
318
|
pid,
|
|
224
319
|
{ signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
|
package/src/session/listeners.js
CHANGED
|
@@ -10,6 +10,9 @@ const LISTENER_EVENT_TYPES = new Set([
|
|
|
10
10
|
// listener likely died without a clean stop — show it as stale, not live.
|
|
11
11
|
const DEFAULT_STALE_AFTER_MS = 180_000;
|
|
12
12
|
const MAX_STALE_GRACE_MS = 60_000;
|
|
13
|
+
const DEFAULT_LISTENER_FETCH_LIMIT = 200;
|
|
14
|
+
const DEFAULT_LISTENER_FETCH_MAX_PAGES = 5;
|
|
15
|
+
const MAX_LISTENER_FETCH_MAX_PAGES = 25;
|
|
13
16
|
|
|
14
17
|
function normalizeString(value) {
|
|
15
18
|
return String(value || "").trim();
|
|
@@ -143,24 +146,93 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
|
|
|
143
146
|
|
|
144
147
|
/**
|
|
145
148
|
* Fetch recent session events from the API and summarize the listeners.
|
|
146
|
-
* `limit` controls
|
|
149
|
+
* `limit` controls the raw API page size. Busy rooms can push durable
|
|
150
|
+
* keepalive heartbeats out of a single tail page, so we walk a small bounded
|
|
151
|
+
* number of older pages while the tail contains no listener lifecycle events
|
|
152
|
+
* and the sequence cursor advances.
|
|
147
153
|
*/
|
|
148
154
|
export async function fetchSessionListeners(
|
|
149
155
|
sessionId,
|
|
150
156
|
{
|
|
151
157
|
targetPath = process.cwd(),
|
|
152
|
-
limit =
|
|
158
|
+
limit = DEFAULT_LISTENER_FETCH_LIMIT,
|
|
159
|
+
maxPages = DEFAULT_LISTENER_FETCH_MAX_PAGES,
|
|
153
160
|
nowMs = Date.now,
|
|
154
161
|
forceCircuitProbe = true,
|
|
155
162
|
poll = pollSessionEventsBefore,
|
|
156
163
|
} = {}
|
|
157
164
|
) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
const pageLimit = Math.max(
|
|
166
|
+
1,
|
|
167
|
+
Math.min(DEFAULT_LISTENER_FETCH_LIMIT, positiveInt(limit) || DEFAULT_LISTENER_FETCH_LIMIT),
|
|
168
|
+
);
|
|
169
|
+
const maxPageCount = Math.max(
|
|
170
|
+
1,
|
|
171
|
+
Math.min(MAX_LISTENER_FETCH_MAX_PAGES, positiveInt(maxPages) || DEFAULT_LISTENER_FETCH_MAX_PAGES),
|
|
172
|
+
);
|
|
173
|
+
let beforeSequence = null;
|
|
174
|
+
let latestResult = null;
|
|
175
|
+
let allEvents = [];
|
|
176
|
+
let pageCount = 0;
|
|
177
|
+
let listenerEventCount = 0;
|
|
178
|
+
let partial = false;
|
|
179
|
+
let partialReason = "";
|
|
180
|
+
|
|
181
|
+
for (let page = 0; page < maxPageCount; page += 1) {
|
|
182
|
+
const result = await poll(sessionId, {
|
|
183
|
+
targetPath,
|
|
184
|
+
beforeSequence,
|
|
185
|
+
limit: pageLimit,
|
|
186
|
+
forceCircuitProbe,
|
|
187
|
+
});
|
|
188
|
+
pageCount += 1;
|
|
189
|
+
|
|
190
|
+
if (!result?.ok) {
|
|
191
|
+
if (!latestResult) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: normalizeString(result?.reason) || "fetch_failed",
|
|
195
|
+
listeners: [],
|
|
196
|
+
pageCount,
|
|
197
|
+
scannedEventCount: allEvents.length,
|
|
198
|
+
listenerEventCount: 0,
|
|
199
|
+
partial: false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
partial = true;
|
|
203
|
+
partialReason = normalizeString(result?.reason) || "partial_fetch";
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
latestResult = result;
|
|
208
|
+
const pageEvents = Array.isArray(result.events) ? result.events : [];
|
|
209
|
+
allEvents = [...pageEvents, ...allEvents];
|
|
210
|
+
listenerEventCount += pageEvents.filter((event) =>
|
|
211
|
+
LISTENER_EVENT_TYPES.has(normalizeString(event?.event))
|
|
212
|
+
).length;
|
|
213
|
+
|
|
214
|
+
if (pageEvents.length < pageLimit) break;
|
|
215
|
+
if (listenerEventCount > 0) break;
|
|
216
|
+
|
|
217
|
+
const candidateBeforeSequence = Number(result.beforeSequence || 0);
|
|
218
|
+
if (!Number.isFinite(candidateBeforeSequence) || candidateBeforeSequence <= 0) break;
|
|
219
|
+
const currentBeforeSequence = Number(beforeSequence || 0);
|
|
220
|
+
if (currentBeforeSequence > 0 && candidateBeforeSequence >= currentBeforeSequence) break;
|
|
221
|
+
beforeSequence = Math.floor(candidateBeforeSequence);
|
|
161
222
|
}
|
|
162
|
-
|
|
163
|
-
|
|
223
|
+
|
|
224
|
+
const listeners = summarizeListeners(allEvents, { nowMs: nowMs() });
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
sessionId: normalizeString(sessionId),
|
|
228
|
+
listeners,
|
|
229
|
+
pageCount,
|
|
230
|
+
scannedEventCount: allEvents.length,
|
|
231
|
+
listenerEventCount,
|
|
232
|
+
beforeSequence: latestResult?.beforeSequence || beforeSequence || null,
|
|
233
|
+
partial,
|
|
234
|
+
reason: partial ? partialReason : "",
|
|
235
|
+
};
|
|
164
236
|
}
|
|
165
237
|
|
|
166
238
|
export function formatListenerLine(row) {
|