sentinelayer-cli 0.40.0 → 0.42.0
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 +22 -4
- package/package.json +4 -4
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +1094 -279
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/pump.js +8 -3
- package/src/session/wake/resolve-target.js +10 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAgentEvent } from "../events/schema.js";
|
|
2
2
|
import { appendToStream } from "./stream.js";
|
|
3
|
+
import { syncSessionEventToApi } from "./sync.js";
|
|
3
4
|
|
|
4
5
|
export const FIRST_MESSAGE_AGENT = Object.freeze({
|
|
5
6
|
id: "senti",
|
|
@@ -72,7 +73,7 @@ export function buildFirstSentiMessage({ sessionId } = {}) {
|
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* Post the first-Senti-message as the opening event of a freshly created
|
|
75
|
-
* session. Best-effort
|
|
76
|
+
* session. Best-effort with bounded remote sync — a failure never fails session
|
|
76
77
|
* creation. Returns { posted, reason }.
|
|
77
78
|
*/
|
|
78
79
|
export async function postFirstSentiMessage({ sessionId, targetPath = process.cwd() } = {}) {
|
|
@@ -91,8 +92,15 @@ export async function postFirstSentiMessage({ sessionId, targetPath = process.cw
|
|
|
91
92
|
},
|
|
92
93
|
});
|
|
93
94
|
try {
|
|
94
|
-
await appendToStream(sid, event, {
|
|
95
|
-
|
|
95
|
+
const persisted = await appendToStream(sid, event, {
|
|
96
|
+
targetPath, syncRemote: false, lockTimeoutMs: 500,
|
|
97
|
+
});
|
|
98
|
+
const remoteSync = await syncSessionEventToApi(sid, persisted, {
|
|
99
|
+
targetPath, timeoutMs: 2_000,
|
|
100
|
+
}).catch(() => ({ synced: false, reason: "sync_failed" }));
|
|
101
|
+
// Keep posted's existing local-durability meaning; remote acknowledgement
|
|
102
|
+
// is explicit and must never be inferred from this compatibility field.
|
|
103
|
+
return { posted: true, reason: "posted", remoteSynced: Boolean(remoteSync.synced), remoteSync };
|
|
96
104
|
} catch (error) {
|
|
97
105
|
return { posted: false, reason: normalizeString(error?.message) || "append_failed" };
|
|
98
106
|
}
|
package/src/session/listener.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
pollSessionEvents,
|
|
5
|
+
pollSessionEventsBefore,
|
|
6
|
+
streamSessionEvents,
|
|
7
|
+
updateSessionReadCursor,
|
|
8
|
+
} from "./sync.js";
|
|
4
9
|
import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
5
10
|
import { isSessionListenerLifecycleEvent } from "./control-events.js";
|
|
11
|
+
import { sessionMessageRoutingEvent } from "./message-edits.js";
|
|
6
12
|
|
|
7
13
|
const BROADCAST_RECIPIENTS = new Set([
|
|
8
14
|
"*",
|
|
@@ -14,8 +20,10 @@ const BROADCAST_RECIPIENTS = new Set([
|
|
|
14
20
|
"all-agents",
|
|
15
21
|
]);
|
|
16
22
|
|
|
17
|
-
const DEFAULT_ACTIVE_INTERVAL_SECONDS =
|
|
23
|
+
const DEFAULT_ACTIVE_INTERVAL_SECONDS = 60;
|
|
18
24
|
const DEFAULT_ACTIVE_WINDOW_SECONDS = 300;
|
|
25
|
+
const DEFAULT_POLL_JITTER_RATIO = 0.2;
|
|
26
|
+
const DEFAULT_MAX_POLL_BACKOFF_SECONDS = 300;
|
|
19
27
|
const MAX_CLOCK_SKEW_MS = 60_000;
|
|
20
28
|
|
|
21
29
|
function normalizeString(value) {
|
|
@@ -38,10 +46,6 @@ function normalizePositiveInteger(value, fallbackValue) {
|
|
|
38
46
|
return Math.floor(normalized);
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
function isListenerStopDirective(event = {}) {
|
|
42
|
-
return normalizeString(event?.event) === "listener_stop";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
49
|
function isPlainObject(value) {
|
|
46
50
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
47
51
|
}
|
|
@@ -65,6 +69,7 @@ function addRecipientValue(values, value) {
|
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export function collectSessionEventRecipients(event = {}) {
|
|
72
|
+
event = sessionMessageRoutingEvent(event);
|
|
68
73
|
const values = [];
|
|
69
74
|
if (!isPlainObject(event)) return values;
|
|
70
75
|
const payload = isPlainObject(event.payload) ? event.payload : {};
|
|
@@ -87,6 +92,7 @@ export function collectSessionEventRecipients(event = {}) {
|
|
|
87
92
|
|
|
88
93
|
export function eventMatchesAgent(event = {}, agentId = "") {
|
|
89
94
|
if (!isPlainObject(event)) return false;
|
|
95
|
+
event = sessionMessageRoutingEvent(event);
|
|
90
96
|
const normalizedAgentId = normalizeComparableId(agentId);
|
|
91
97
|
if (!normalizedAgentId) return false;
|
|
92
98
|
|
|
@@ -128,6 +134,46 @@ function cursorFromEvents(events = [], fallbackCursor = null) {
|
|
|
128
134
|
return cursor;
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
function latestSequenceTarget(events = [], fallbackCursor = null) {
|
|
138
|
+
let targetSequenceId = null;
|
|
139
|
+
let targetCursor = normalizeString(fallbackCursor) || "";
|
|
140
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
141
|
+
const sequence = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
|
|
142
|
+
if (Number.isFinite(sequence) && sequence > 0) {
|
|
143
|
+
if (targetSequenceId === null || sequence > targetSequenceId) {
|
|
144
|
+
targetSequenceId = Math.floor(sequence);
|
|
145
|
+
targetCursor = normalizeString(event?.cursor) || targetCursor;
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const cursor = normalizeString(event?.cursor);
|
|
150
|
+
if (cursor) targetCursor = cursor;
|
|
151
|
+
}
|
|
152
|
+
return { targetSequenceId, targetCursor };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function computeListenerPollDelay({
|
|
156
|
+
baseDelayMs,
|
|
157
|
+
consecutiveFailures = 0,
|
|
158
|
+
retryAfterMs = null,
|
|
159
|
+
jitterRatio = DEFAULT_POLL_JITTER_RATIO,
|
|
160
|
+
maxBackoffMs = DEFAULT_MAX_POLL_BACKOFF_SECONDS * 1000,
|
|
161
|
+
randomValue = Math.random(),
|
|
162
|
+
} = {}) {
|
|
163
|
+
const base = Math.max(1, Math.floor(Number(baseDelayMs) || 1));
|
|
164
|
+
const failures = Math.max(0, Math.floor(Number(consecutiveFailures) || 0));
|
|
165
|
+
const maxBackoff = Math.max(base, Math.floor(Number(maxBackoffMs) || base));
|
|
166
|
+
const exponential = failures > 0
|
|
167
|
+
? Math.min(maxBackoff, base * (2 ** Math.min(failures - 1, 20)))
|
|
168
|
+
: base;
|
|
169
|
+
const retryFloor = Math.max(0, Math.ceil(Number(retryAfterMs) || 0));
|
|
170
|
+
const boundedJitterRatio = Math.max(0, Math.min(0.5, Number(jitterRatio) || 0));
|
|
171
|
+
const boundedRandom = Math.max(0, Math.min(1, Number(randomValue) || 0));
|
|
172
|
+
const jitter = Math.floor(exponential * boundedJitterRatio * boundedRandom);
|
|
173
|
+
const jitteredBackoff = Math.min(maxBackoff, exponential + jitter);
|
|
174
|
+
return Math.max(retryFloor, jitteredBackoff);
|
|
175
|
+
}
|
|
176
|
+
|
|
131
177
|
function eventIdentityKey(event = {}) {
|
|
132
178
|
const cursor = normalizeString(event?.cursor);
|
|
133
179
|
if (cursor) return `cursor:${cursor}`;
|
|
@@ -141,6 +187,41 @@ function eventIdentityKey(event = {}) {
|
|
|
141
187
|
});
|
|
142
188
|
}
|
|
143
189
|
|
|
190
|
+
function listenerControlType(control = {}) {
|
|
191
|
+
return normalizeLower(control?.type || control?.action || control?.event);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function listenerControlIssuedAtMs(control = {}) {
|
|
195
|
+
for (const key of ["issuedAtMs", "issued_at_ms"]) {
|
|
196
|
+
const epoch = Number(control?.[key]);
|
|
197
|
+
if (Number.isFinite(epoch) && epoch > 0) return epoch;
|
|
198
|
+
}
|
|
199
|
+
for (const key of ["issuedAt", "issued_at"]) {
|
|
200
|
+
const epoch = Date.parse(normalizeString(control?.[key]));
|
|
201
|
+
if (Number.isFinite(epoch)) return epoch;
|
|
202
|
+
}
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function listenerControlIdentityKey(control = {}) {
|
|
207
|
+
const explicitId = normalizeString(
|
|
208
|
+
control?.controlId || control?.control_id || control?.id,
|
|
209
|
+
);
|
|
210
|
+
if (explicitId) return `control:${explicitId}`;
|
|
211
|
+
return JSON.stringify({
|
|
212
|
+
type: listenerControlType(control),
|
|
213
|
+
issuedAt: normalizeString(control?.issuedAt || control?.issued_at),
|
|
214
|
+
targetAgentId: normalizeComparableId(
|
|
215
|
+
control?.targetAgentId ||
|
|
216
|
+
control?.target_agent_id ||
|
|
217
|
+
control?.payload?.targetAgentId ||
|
|
218
|
+
control?.payload?.target_agent_id,
|
|
219
|
+
),
|
|
220
|
+
broadcast: Boolean(control?.broadcast || control?.payload?.broadcast),
|
|
221
|
+
reason: normalizeString(control?.reason || control?.payload?.reason),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
144
225
|
function eventTimestampMs(event = {}) {
|
|
145
226
|
for (const key of ["ts", "timestamp", "createdAt", "at"]) {
|
|
146
227
|
const epoch = Date.parse(normalizeString(event?.[key]));
|
|
@@ -241,6 +322,8 @@ export async function listenSessionEvents({
|
|
|
241
322
|
intervalSeconds = 60,
|
|
242
323
|
activeIntervalSeconds = DEFAULT_ACTIVE_INTERVAL_SECONDS,
|
|
243
324
|
activeWindowSeconds = DEFAULT_ACTIVE_WINDOW_SECONDS,
|
|
325
|
+
jitterRatio = DEFAULT_POLL_JITTER_RATIO,
|
|
326
|
+
maxBackoffSeconds = DEFAULT_MAX_POLL_BACKOFF_SECONDS,
|
|
244
327
|
limit = 200,
|
|
245
328
|
since = undefined,
|
|
246
329
|
replay = false,
|
|
@@ -249,6 +332,7 @@ export async function listenSessionEvents({
|
|
|
249
332
|
maxPolls = null,
|
|
250
333
|
signal,
|
|
251
334
|
onEvent = async () => {},
|
|
335
|
+
onControl = async () => {},
|
|
252
336
|
onError = async () => {},
|
|
253
337
|
onCatchup = async () => {},
|
|
254
338
|
onLifecycle = async () => {},
|
|
@@ -256,12 +340,14 @@ export async function listenSessionEvents({
|
|
|
256
340
|
_poll = pollSessionEvents,
|
|
257
341
|
_pollLatest = pollSessionEventsBefore,
|
|
258
342
|
_stream = streamSessionEvents,
|
|
343
|
+
_updateReadCursor = updateSessionReadCursor,
|
|
259
344
|
_readCursor = readSyncCursor,
|
|
260
345
|
_writeCursor = writeSyncCursor,
|
|
261
346
|
_sleep = defaultSleep,
|
|
262
347
|
_setInterval = setInterval,
|
|
263
348
|
_clearInterval = clearInterval,
|
|
264
349
|
_nowMs = Date.now,
|
|
350
|
+
_random = Math.random,
|
|
265
351
|
} = {}) {
|
|
266
352
|
const normalizedSessionId = normalizeString(sessionId);
|
|
267
353
|
const normalizedAgentId = normalizeComparableId(agentId) || "cli-user";
|
|
@@ -293,10 +379,16 @@ export async function listenSessionEvents({
|
|
|
293
379
|
let streamAttempted = false;
|
|
294
380
|
let streamFallbackReason = "";
|
|
295
381
|
let heartbeatCount = 0;
|
|
382
|
+
let consecutivePollFailures = 0;
|
|
383
|
+
let readCursorUpdates = 0;
|
|
384
|
+
let readCursorReason = "";
|
|
385
|
+
let deliveredControls = 0;
|
|
386
|
+
let ignoredControls = 0;
|
|
296
387
|
let catchupNotified = false;
|
|
297
388
|
let catchupEventCount = 0;
|
|
298
389
|
let catchupMatchingEventCount = 0;
|
|
299
390
|
const emittedKeys = new Set();
|
|
391
|
+
const deliveredControlKeys = new Set();
|
|
300
392
|
const maxPollCount = normalizePositiveInteger(maxPolls, 0);
|
|
301
393
|
const pollLimit = normalizePositiveInteger(limit, 200);
|
|
302
394
|
const idleSleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
|
|
@@ -306,6 +398,9 @@ export async function listenSessionEvents({
|
|
|
306
398
|
const activeWindowMs =
|
|
307
399
|
Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
|
|
308
400
|
1000;
|
|
401
|
+
const maxBackoffMs =
|
|
402
|
+
Math.max(1, normalizePositiveInteger(maxBackoffSeconds, DEFAULT_MAX_POLL_BACKOFF_SECONDS)) *
|
|
403
|
+
1000;
|
|
309
404
|
const autoStreamIdleTimeoutMs =
|
|
310
405
|
normalizedTransport === "auto" ? Math.max(5_000, Math.min(idleSleepMs, 30_000)) : 0;
|
|
311
406
|
|
|
@@ -354,6 +449,11 @@ export async function listenSessionEvents({
|
|
|
354
449
|
? new Date(lastHumanActivityMs).toISOString()
|
|
355
450
|
: null,
|
|
356
451
|
lastSleepMs,
|
|
452
|
+
consecutivePollFailures,
|
|
453
|
+
readCursorUpdates,
|
|
454
|
+
readCursorReason,
|
|
455
|
+
deliveredControls,
|
|
456
|
+
ignoredControls,
|
|
357
457
|
reason: lastReason,
|
|
358
458
|
...extra,
|
|
359
459
|
});
|
|
@@ -397,7 +497,7 @@ export async function listenSessionEvents({
|
|
|
397
497
|
cursor: cursor || null,
|
|
398
498
|
candidateCursor: nextCursor,
|
|
399
499
|
});
|
|
400
|
-
return false;
|
|
500
|
+
return { processed: false, retryAfterMs: null };
|
|
401
501
|
}
|
|
402
502
|
|
|
403
503
|
const observedAtMs = Number(_nowMs()) || Date.now();
|
|
@@ -413,7 +513,6 @@ export async function listenSessionEvents({
|
|
|
413
513
|
if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
|
|
414
514
|
lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
|
|
415
515
|
}
|
|
416
|
-
if (timestampMs && timestampMs < startedAtMs && isListenerStopDirective(event)) continue;
|
|
417
516
|
if (isSessionListenerLifecycleEvent(event)) continue;
|
|
418
517
|
if (!eventMatchesAgent(event, normalizedAgentId)) continue;
|
|
419
518
|
visibleEvents.push(event);
|
|
@@ -471,8 +570,71 @@ export async function listenSessionEvents({
|
|
|
471
570
|
persistedCursor = Boolean(writeResult?.written) || persistedCursor;
|
|
472
571
|
cursor = nextCursor;
|
|
473
572
|
}
|
|
573
|
+
// A fetch cursor echoed on an empty page is transport state, not new read
|
|
574
|
+
// progress. Writing it would recreate one operational write per idle poll.
|
|
575
|
+
const readTarget = events.length > 0
|
|
576
|
+
? latestSequenceTarget(events, nextCursor)
|
|
577
|
+
: { targetSequenceId: null, targetCursor: "" };
|
|
578
|
+
let readCursorResult = null;
|
|
579
|
+
if (readTarget.targetSequenceId || readTarget.targetCursor) {
|
|
580
|
+
readCursorResult = await _updateReadCursor(normalizedSessionId, {
|
|
581
|
+
targetPath,
|
|
582
|
+
targetSequenceId: readTarget.targetSequenceId,
|
|
583
|
+
targetCursor: readTarget.targetCursor,
|
|
584
|
+
agentId: normalizedAgentId,
|
|
585
|
+
}).catch((error) => ({
|
|
586
|
+
ok: false,
|
|
587
|
+
reason: normalizeString(error?.message) || "read_cursor_write_failed",
|
|
588
|
+
}));
|
|
589
|
+
if (readCursorResult?.ok) {
|
|
590
|
+
readCursorUpdates += 1;
|
|
591
|
+
readCursorReason = "";
|
|
592
|
+
} else {
|
|
593
|
+
readCursorReason = normalizeString(readCursorResult?.reason) || "read_cursor_write_failed";
|
|
594
|
+
}
|
|
595
|
+
}
|
|
474
596
|
primed = true;
|
|
475
|
-
return
|
|
597
|
+
return {
|
|
598
|
+
processed: true,
|
|
599
|
+
retryAfterMs: Number(readCursorResult?.retryAfterMs) || null,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function processControlBatch(controlsInput = []) {
|
|
604
|
+
const controls = Array.isArray(controlsInput) ? controlsInput : [];
|
|
605
|
+
const observedAtMs = Number(_nowMs()) || Date.now();
|
|
606
|
+
for (const control of controls) {
|
|
607
|
+
if (!isPlainObject(control)) {
|
|
608
|
+
ignoredControls += 1;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const issuedAtMs = listenerControlIssuedAtMs(control);
|
|
612
|
+
const fresh =
|
|
613
|
+
issuedAtMs >= startedAtMs - MAX_CLOCK_SKEW_MS &&
|
|
614
|
+
issuedAtMs <= observedAtMs + MAX_CLOCK_SKEW_MS;
|
|
615
|
+
if (
|
|
616
|
+
!listenerControlType(control) ||
|
|
617
|
+
!fresh ||
|
|
618
|
+
!eventMatchesAgent(control, normalizedAgentId)
|
|
619
|
+
) {
|
|
620
|
+
ignoredControls += 1;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const key = listenerControlIdentityKey(control);
|
|
624
|
+
if (deliveredControlKeys.has(key)) {
|
|
625
|
+
ignoredControls += 1;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
deliveredControlKeys.add(key);
|
|
629
|
+
try {
|
|
630
|
+
await onControl(control);
|
|
631
|
+
deliveredControls += 1;
|
|
632
|
+
} catch (error) {
|
|
633
|
+
deliveredControlKeys.delete(key);
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
if (signal?.aborted) break;
|
|
637
|
+
}
|
|
476
638
|
}
|
|
477
639
|
|
|
478
640
|
async function notifyHeartbeat({ stopping = false, nextPollMs = null } = {}) {
|
|
@@ -492,7 +654,7 @@ export async function listenSessionEvents({
|
|
|
492
654
|
}
|
|
493
655
|
|
|
494
656
|
let streamHeartbeatTimer = null;
|
|
495
|
-
let
|
|
657
|
+
let streamMaintenanceInFlight = null;
|
|
496
658
|
const streamHeartbeatMs = Math.max(1_000, Math.min(idleSleepMs, 60_000));
|
|
497
659
|
|
|
498
660
|
function stopStreamHeartbeatTimer() {
|
|
@@ -503,14 +665,49 @@ export async function listenSessionEvents({
|
|
|
503
665
|
|
|
504
666
|
function startStreamHeartbeatTimer() {
|
|
505
667
|
stopStreamHeartbeatTimer();
|
|
506
|
-
streamHeartbeatTimer = _setInterval(
|
|
507
|
-
if (signal?.aborted ||
|
|
508
|
-
|
|
509
|
-
try {
|
|
510
|
-
await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
|
|
511
|
-
} finally {
|
|
512
|
-
streamHeartbeatInFlight = false;
|
|
668
|
+
streamHeartbeatTimer = _setInterval(() => {
|
|
669
|
+
if (signal?.aborted || streamMaintenanceInFlight) {
|
|
670
|
+
return streamMaintenanceInFlight;
|
|
513
671
|
}
|
|
672
|
+
const maintenance = (async () => {
|
|
673
|
+
const result = await _poll(normalizedSessionId, {
|
|
674
|
+
targetPath,
|
|
675
|
+
since: cursor,
|
|
676
|
+
limit: 1,
|
|
677
|
+
listenerAgentId: normalizedAgentId,
|
|
678
|
+
signal,
|
|
679
|
+
});
|
|
680
|
+
if (signal?.aborted) return;
|
|
681
|
+
if (result?.ok) {
|
|
682
|
+
await processControlBatch(result.listenerControls);
|
|
683
|
+
} else {
|
|
684
|
+
await onError({
|
|
685
|
+
ok: false,
|
|
686
|
+
reason: `control_poll_${normalizeString(result?.reason) || "failed"}`,
|
|
687
|
+
cursor: cursor || null,
|
|
688
|
+
retryAfterMs: Number(result?.retryAfterMs) || null,
|
|
689
|
+
status: Number(result?.status) || null,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (!signal?.aborted) {
|
|
693
|
+
await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
|
|
694
|
+
}
|
|
695
|
+
})().catch(async (error) => {
|
|
696
|
+
if (signal?.aborted || shouldAbort(error, signal)) return;
|
|
697
|
+
await onError({
|
|
698
|
+
ok: false,
|
|
699
|
+
reason: "control_poll_failed",
|
|
700
|
+
cursor: cursor || null,
|
|
701
|
+
detail: normalizeString(error?.message),
|
|
702
|
+
}).catch(() => {});
|
|
703
|
+
});
|
|
704
|
+
streamMaintenanceInFlight = maintenance;
|
|
705
|
+
maintenance.finally(() => {
|
|
706
|
+
if (streamMaintenanceInFlight === maintenance) {
|
|
707
|
+
streamMaintenanceInFlight = null;
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
return maintenance;
|
|
514
711
|
}, streamHeartbeatMs);
|
|
515
712
|
}
|
|
516
713
|
|
|
@@ -534,6 +731,9 @@ export async function listenSessionEvents({
|
|
|
534
731
|
onEvent: async (event) => {
|
|
535
732
|
await processEventBatch([event], normalizeString(event?.cursor) || cursor);
|
|
536
733
|
},
|
|
734
|
+
onCursor: async (streamCursor) => {
|
|
735
|
+
await processEventBatch([], streamCursor);
|
|
736
|
+
},
|
|
537
737
|
onError: async (error) => {
|
|
538
738
|
lastReason = `stream_${normalizeString(error?.reason) || "error"}`;
|
|
539
739
|
await onError({
|
|
@@ -545,8 +745,9 @@ export async function listenSessionEvents({
|
|
|
545
745
|
onHeartbeat: async () => {
|
|
546
746
|
await notifyHeartbeat({ nextPollMs: streamHeartbeatMs });
|
|
547
747
|
},
|
|
548
|
-
}).finally(() => {
|
|
748
|
+
}).finally(async () => {
|
|
549
749
|
stopStreamHeartbeatTimer();
|
|
750
|
+
await streamMaintenanceInFlight;
|
|
550
751
|
});
|
|
551
752
|
if (!streamResult?.ok) {
|
|
552
753
|
streamFallbackReason = normalizeString(streamResult?.reason) || lastReason || "stream_failed";
|
|
@@ -567,24 +768,44 @@ export async function listenSessionEvents({
|
|
|
567
768
|
targetPath,
|
|
568
769
|
since: cursor,
|
|
569
770
|
limit: pollLimit,
|
|
771
|
+
listenerAgentId: normalizedAgentId,
|
|
570
772
|
});
|
|
571
773
|
|
|
774
|
+
let cursorRetryAfterMs = null;
|
|
572
775
|
if (result?.ok) {
|
|
573
776
|
lastReason = "";
|
|
574
|
-
|
|
777
|
+
consecutivePollFailures = 0;
|
|
778
|
+
await processControlBatch(result.listenerControls);
|
|
779
|
+
if (signal?.aborted) break;
|
|
780
|
+
const processed = await processEventBatch(result.events, result.cursor);
|
|
781
|
+
cursorRetryAfterMs = Number(processed?.retryAfterMs) || null;
|
|
575
782
|
} else {
|
|
576
783
|
lastReason = normalizeString(result?.reason) || "poll_failed";
|
|
784
|
+
consecutivePollFailures += 1;
|
|
577
785
|
await onError({
|
|
578
786
|
ok: false,
|
|
579
787
|
reason: lastReason,
|
|
580
788
|
cursor: result?.cursor || cursor || null,
|
|
789
|
+
retryAfterMs: Number(result?.retryAfterMs) || null,
|
|
790
|
+
status: Number(result?.status) || null,
|
|
581
791
|
});
|
|
582
792
|
}
|
|
583
793
|
|
|
584
794
|
const willStop = maxPollCount > 0 && pollCount >= maxPollCount;
|
|
585
795
|
const heartbeatAtMs = Number(_nowMs()) || Date.now();
|
|
586
796
|
const humanActive = isRecentActivity(lastHumanActivityMs, heartbeatAtMs, activeWindowMs);
|
|
587
|
-
const
|
|
797
|
+
const baseSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
|
|
798
|
+
const nextSleepMs = computeListenerPollDelay({
|
|
799
|
+
baseDelayMs: baseSleepMs,
|
|
800
|
+
consecutiveFailures: consecutivePollFailures,
|
|
801
|
+
retryAfterMs: Math.max(
|
|
802
|
+
Number(result?.retryAfterMs) || 0,
|
|
803
|
+
Number(cursorRetryAfterMs) || 0,
|
|
804
|
+
),
|
|
805
|
+
jitterRatio,
|
|
806
|
+
maxBackoffMs,
|
|
807
|
+
randomValue: _random(),
|
|
808
|
+
});
|
|
588
809
|
await notifyHeartbeat({ nextPollMs: willStop ? null : nextSleepMs, stopping: willStop });
|
|
589
810
|
|
|
590
811
|
if (willStop) break;
|
|
@@ -619,6 +840,11 @@ export async function listenSessionEvents({
|
|
|
619
840
|
matched,
|
|
620
841
|
emitted,
|
|
621
842
|
persistedCursor,
|
|
843
|
+
readCursorUpdates,
|
|
844
|
+
readCursorReason,
|
|
845
|
+
deliveredControls,
|
|
846
|
+
ignoredControls,
|
|
847
|
+
consecutivePollFailures,
|
|
622
848
|
catchupNotified,
|
|
623
849
|
catchupEventCount,
|
|
624
850
|
catchupMatchingEventCount,
|
package/src/session/listeners.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchSessionPresence } from "./sync.js";
|
|
2
2
|
|
|
3
3
|
const LISTENER_EVENT_TYPES = new Set([
|
|
4
4
|
"session_listener_started",
|
|
@@ -10,9 +10,6 @@ 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;
|
|
16
13
|
|
|
17
14
|
function normalizeString(value) {
|
|
18
15
|
return String(value || "").trim();
|
|
@@ -144,94 +141,86 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
|
|
|
144
141
|
return rows;
|
|
145
142
|
}
|
|
146
143
|
|
|
144
|
+
function presenceEntryEpochMs(entry = {}) {
|
|
145
|
+
const explicit = Number(entry.lastSeenMs ?? entry.last_seen_ms);
|
|
146
|
+
if (Number.isFinite(explicit) && explicit > 0) {
|
|
147
|
+
return Math.floor(explicit);
|
|
148
|
+
}
|
|
149
|
+
const parsed = Date.parse(
|
|
150
|
+
normalizeString(entry.lastSeen || entry.last_seen || entry.updatedAt || entry.updated_at),
|
|
151
|
+
);
|
|
152
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function presenceRows(entries = [], nowMs = Date.now()) {
|
|
156
|
+
return (Array.isArray(entries) ? entries : [])
|
|
157
|
+
.map((entry) => {
|
|
158
|
+
const agentId = normalizeString(entry?.agentId || entry?.agent_id || entry?.id);
|
|
159
|
+
if (!agentId) return null;
|
|
160
|
+
const seenAtMs = presenceEntryEpochMs(entry);
|
|
161
|
+
const ageMs = seenAtMs === null ? null : Math.max(0, nowMs - seenAtMs);
|
|
162
|
+
return {
|
|
163
|
+
agentId,
|
|
164
|
+
displayName: normalizeString(entry?.displayName || entry?.display_name) || agentId,
|
|
165
|
+
model: normalizeString(entry?.model),
|
|
166
|
+
status: "present",
|
|
167
|
+
active: null,
|
|
168
|
+
cadenceSeconds: null,
|
|
169
|
+
idleIntervalSeconds: null,
|
|
170
|
+
activeIntervalSeconds: null,
|
|
171
|
+
presenceIntervalSeconds: null,
|
|
172
|
+
presenceKeepaliveSeconds: null,
|
|
173
|
+
nextPollSeconds: null,
|
|
174
|
+
staleAfterSeconds: null,
|
|
175
|
+
lastSeenAt: seenAtMs ? new Date(seenAtMs).toISOString() : null,
|
|
176
|
+
lastSeenAgoSeconds: ageMs === null ? null : Math.round(ageMs / 1000),
|
|
177
|
+
lastHumanActivityAt: null,
|
|
178
|
+
};
|
|
179
|
+
})
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.sort((left, right) => (right.lastSeenAt || "").localeCompare(left.lastSeenAt || ""));
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
/**
|
|
148
|
-
* Fetch
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
185
|
+
* Fetch the authoritative ephemeral presence roster.
|
|
186
|
+
*
|
|
187
|
+
* There is intentionally no event-tail compatibility path. A server without
|
|
188
|
+
* the presence endpoint, a disabled rollout flag, or degraded Redis produces
|
|
189
|
+
* an unknown roster instead of silently making every listener a durable
|
|
190
|
+
* heartbeat writer again.
|
|
153
191
|
*/
|
|
154
192
|
export async function fetchSessionListeners(
|
|
155
193
|
sessionId,
|
|
156
194
|
{
|
|
157
195
|
targetPath = process.cwd(),
|
|
158
|
-
limit = DEFAULT_LISTENER_FETCH_LIMIT,
|
|
159
|
-
maxPages = DEFAULT_LISTENER_FETCH_MAX_PAGES,
|
|
160
196
|
nowMs = Date.now,
|
|
161
197
|
forceCircuitProbe = true,
|
|
162
|
-
|
|
198
|
+
fetchPresence = fetchSessionPresence,
|
|
163
199
|
} = {}
|
|
164
200
|
) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const listeners = summarizeListeners(allEvents, { nowMs: nowMs() });
|
|
201
|
+
const result = await fetchPresence(sessionId, {
|
|
202
|
+
targetPath,
|
|
203
|
+
forceCircuitProbe,
|
|
204
|
+
});
|
|
205
|
+
const presenceStatus = normalizeString(result?.status).toLowerCase() || "degraded";
|
|
206
|
+
const authoritative = Boolean(result?.ok && result?.enabled !== false && presenceStatus === "ok");
|
|
207
|
+
const listeners = authoritative
|
|
208
|
+
? presenceRows(result.present, Number(nowMs()) || Date.now())
|
|
209
|
+
: [];
|
|
225
210
|
return {
|
|
226
|
-
ok:
|
|
211
|
+
ok: authoritative,
|
|
212
|
+
authoritative,
|
|
213
|
+
enabled: result?.enabled ?? null,
|
|
214
|
+
presenceStatus,
|
|
215
|
+
reason: authoritative ? "" : normalizeString(result?.reason) || "presence_unknown",
|
|
216
|
+
retryAfterMs: Number(result?.retryAfterMs) || null,
|
|
227
217
|
sessionId: normalizeString(sessionId),
|
|
228
218
|
listeners,
|
|
229
|
-
pageCount,
|
|
230
|
-
scannedEventCount:
|
|
231
|
-
listenerEventCount,
|
|
232
|
-
beforeSequence:
|
|
233
|
-
partial,
|
|
234
|
-
reason: partial ? partialReason : "",
|
|
219
|
+
pageCount: 0,
|
|
220
|
+
scannedEventCount: 0,
|
|
221
|
+
listenerEventCount: 0,
|
|
222
|
+
beforeSequence: null,
|
|
223
|
+
partial: false,
|
|
235
224
|
};
|
|
236
225
|
}
|
|
237
226
|
|
|
@@ -239,7 +228,9 @@ export function formatListenerLine(row) {
|
|
|
239
228
|
const cadence = row.cadenceSeconds ? `${row.cadenceSeconds}s` : "—";
|
|
240
229
|
const seen = row.lastSeenAgoSeconds === null ? "never" : `${row.lastSeenAgoSeconds}s ago`;
|
|
241
230
|
const statusLabel =
|
|
242
|
-
row.status === "
|
|
231
|
+
row.status === "present"
|
|
232
|
+
? "● present"
|
|
233
|
+
: row.status === "active"
|
|
243
234
|
? "● active"
|
|
244
235
|
: row.status === "idle"
|
|
245
236
|
? "○ idle"
|