@cotal-ai/connector-core 0.58.0 → 0.60.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/dist/agent.d.ts +120 -19
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +438 -21
- package/dist/agent.js.map +1 -1
- package/dist/agui-holder.d.ts +6 -6
- package/dist/agui-holder.d.ts.map +1 -1
- package/dist/agui-holder.js +6 -6
- package/dist/agui-holder.js.map +1 -1
- package/dist/agui.d.ts +35 -17
- package/dist/agui.d.ts.map +1 -1
- package/dist/agui.js +68 -19
- package/dist/agui.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/docs-bundle.generated.js +33 -33
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/event-wal.d.ts +2 -2
- package/dist/event-wal.d.ts.map +1 -1
- package/dist/event-wal.js +2 -2
- package/dist/event-wal.js.map +1 -1
- package/dist/runtime.d.ts +13 -13
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +13 -13
- package/dist/runtime.js.map +1 -1
- package/dist/tool-specs.d.ts +10 -11
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +202 -31
- package/dist/tool-specs.js.map +1 -1
- package/package.json +2 -2
package/dist/agent.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { EventEmitter } from "node:events";
|
|
3
4
|
import { hostname } from "node:os";
|
|
4
5
|
import { normalizeMentions, RUN_LAUNCH_DEADLINE_MS, subjectMatches, isConcreteChannel, assertValidChannel, channelInAllow, resolvePeer as resolvePeerInRoster, CotalEndpoint, BASELINE_LIFECYCLE_ENDPOINT, assertLifecycleToken, EpEnvelopeError, isPublishPermissionDenied, unansweredRequest, renderLifecycleBlocked, partsToText, } from "@cotal-ai/core";
|
|
6
|
+
import { attributionSafe } from "./framing.js";
|
|
5
7
|
import { invokeUserManager } from "./manager-call.js";
|
|
6
8
|
/** Client-side floor for a spawn action's submit + follow. The manager acceptance carries the exact
|
|
7
9
|
* connector-selected readiness budget, and core extends the follow through that budget plus
|
|
@@ -80,7 +82,12 @@ const OVERFLOW_EVICTION_CAP = 4 * MAX_INBOX;
|
|
|
80
82
|
const MAX_AHEAD = 256;
|
|
81
83
|
const CLASSIFICATION_CAP = 4096;
|
|
82
84
|
const FOCUS_EXCLUSION_CAP = 4096;
|
|
85
|
+
/** #662: history reads one channel's recall makes while an id-less copy keeps arriving mid-read. */
|
|
86
|
+
const IDLESS_READS = 3;
|
|
83
87
|
const PROTECTED_DISPOSITION_CAP = 4096;
|
|
88
|
+
/** How many unanswered directed messages, and how many asked questions, one session remembers for
|
|
89
|
+
* reply correlation. */
|
|
90
|
+
const MAX_CORRELATIONS = 1024;
|
|
84
91
|
/** Repeated async NATS status errors can arrive once per ordered-consumer retry. They describe one
|
|
85
92
|
* fault, not one hundred useful facts; keep the first visible and summarize at most twice a minute. */
|
|
86
93
|
const ENDPOINT_ERROR_LOG_WINDOW_MS = 30_000;
|
|
@@ -89,7 +96,21 @@ const ENDPOINT_ERROR_LOG_WINDOW_MS = 30_000;
|
|
|
89
96
|
* poll is the intake's whole latency: a fresh turn waits at most one interval plus one wake.
|
|
90
97
|
* Deadlines are minutes-scale; fifteen seconds of intake lag is invisible to a run. */
|
|
91
98
|
const TURN_POLL_MS = 15_000;
|
|
92
|
-
|
|
99
|
+
/** Largest epoch-ms a `Date` can hold; past it `toISOString()` throws. */
|
|
100
|
+
const MAX_DATE_MS = 8.64e15;
|
|
101
|
+
/** A `turn-pending` row as the manager contract declares it, with a deadline a `Date` can format. */
|
|
102
|
+
function isPendingTurn(t) {
|
|
103
|
+
const r = t;
|
|
104
|
+
return typeof r === "object" && r !== null && typeof r.goalId === "string" && typeof r.payload === "string" &&
|
|
105
|
+
Number.isSafeInteger(r.acceptedAt) && r.acceptedAt >= 0 &&
|
|
106
|
+
Number.isSafeInteger(r.deadlineAt) && r.deadlineAt >= 1 && r.deadlineAt <= MAX_DATE_MS;
|
|
107
|
+
}
|
|
108
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
109
|
+
/** #662: what an id-less channel delivery shares with its stream copy. Both are the same published
|
|
110
|
+
* bytes, read through the same authentication, so their content is all that links them. */
|
|
111
|
+
function idlessDigest(m) {
|
|
112
|
+
return createHash("sha256").update(JSON.stringify(m)).digest("base64url");
|
|
113
|
+
}
|
|
93
114
|
function sleep(ms) {
|
|
94
115
|
return new Promise((r) => setTimeout(r, ms));
|
|
95
116
|
}
|
|
@@ -145,6 +166,22 @@ function renderEscalation(p) {
|
|
|
145
166
|
return `\nThis turn is a checkpoint escalated to you at step ${String(p.step)}: ${String(c.prompt)}${by}${wanted}`
|
|
146
167
|
+ `\nAnswer with: cotal run answer ${String(p.run)} ${String(p.step)} --value '<json>'`;
|
|
147
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* A thin, mesh-native agent: a {@link CotalEndpoint} plus a buffered inbox and
|
|
171
|
+
* name-based peer resolution. This is the shared core behind the MCP server
|
|
172
|
+
* (and, later, the lifecycle hooks) — it owns the NATS connection and presence.
|
|
173
|
+
*
|
|
174
|
+
* Connecting is resilient: {@link start} kicks off a background retry loop so the
|
|
175
|
+
* MCP server is responsive immediately even if the mesh isn't up yet.
|
|
176
|
+
*
|
|
177
|
+
* Emits `"incoming"` (InboxItem) when a message is buffered or an unacked durable copy
|
|
178
|
+
* redelivers, so a push layer can apply its normal delivery policy again; `"mention-wake"`
|
|
179
|
+
* (InboxItem) when a `focus`-mode agent is @-mentioned on a channel — the body was
|
|
180
|
+
* acked-and-dropped (not buffered), so this
|
|
181
|
+
* only asks the push layer to *wake* the agent to pull it; `"wake"` (no payload) to ask that
|
|
182
|
+
* layer to wake the session now (the Stop→idle flush of held messages); `"error"` (Error) for
|
|
183
|
+
* endpoint faults.
|
|
184
|
+
*/
|
|
148
185
|
export class MeshAgent extends EventEmitter {
|
|
149
186
|
ep;
|
|
150
187
|
config;
|
|
@@ -225,6 +262,15 @@ export class MeshAgent extends EventEmitter {
|
|
|
225
262
|
pullTrouble;
|
|
226
263
|
turnPollBusy = false;
|
|
227
264
|
_contextId;
|
|
265
|
+
/** The per-call correlation a {@link withCorrelation} scope binds for the sends inside it. */
|
|
266
|
+
correlation = new AsyncLocalStorage();
|
|
267
|
+
/** By message id, oldest first: the live DMs and anycasts peers sent us that no DM back has
|
|
268
|
+
* answered yet (bounded). `pending` marks one a DM in flight is answering; `text` is the start of
|
|
269
|
+
* its body, to name it when a DM must say which one it answers. */
|
|
270
|
+
unanswered = new Map();
|
|
271
|
+
/** By `contextId`, the questions this seat asked inside a {@link withCorrelation} scope, and whom
|
|
272
|
+
* they went to (bounded). See {@link answersQuestion}. */
|
|
273
|
+
asked = new Map();
|
|
228
274
|
/** Chat-stream frontier captured when this agent entered `focus` — recall surfaces ambient
|
|
229
275
|
* published after it ("since you entered focus"). Undefined unless in focus. */
|
|
230
276
|
focusSince;
|
|
@@ -238,6 +284,22 @@ export class MeshAgent extends EventEmitter {
|
|
|
238
284
|
recvKeySecret = randomUUID().replace(/-/g, "");
|
|
239
285
|
recvKeySeq = 0;
|
|
240
286
|
focusExcludedIds = new Map();
|
|
287
|
+
/** #662: id-less channel deliveries settled this focus episode, per content digest. Live ingest has
|
|
288
|
+
* no stream sequence, so content is all an id-less delivery shares with its stream copy, and
|
|
289
|
+
* identical copies may each have had a different disposition. So recall binds each settled copy
|
|
290
|
+
* to a stream sequence and hands back only the copies no settled copy is bound to. Replaced, never
|
|
291
|
+
* cleared, per episode, so a hold from an earlier episode cannot un-count a later one. */
|
|
292
|
+
focusIdless = new Map();
|
|
293
|
+
/** Unbound copies plus bound sequences in {@link focusIdless}, bounded like the exclusion list. */
|
|
294
|
+
focusIdlessEntries = 0;
|
|
295
|
+
/** Bumped as each channel's recall read starts: a copy settled before a read that the read could
|
|
296
|
+
* not bind is behind the focus frontier or out of retention, and no later read will see it. */
|
|
297
|
+
idlessEpoch = 0;
|
|
298
|
+
/** Recall runs one at a time, so an older read can never settle over a newer one (#662). */
|
|
299
|
+
recallLock = Promise.resolve();
|
|
300
|
+
/** Bumped each time the transport goes down, so a settled copy's frontier read can tell whether
|
|
301
|
+
* the connection stayed up from the copy's arrival to the answer (#662). */
|
|
302
|
+
transportDrops = 0;
|
|
241
303
|
focusRecallUnsafeChannels = new Set();
|
|
242
304
|
_stopping = false;
|
|
243
305
|
/** Presence writes go out IN CALL ORDER (#636, #2055). setStatus is two awaited puts and every
|
|
@@ -307,6 +369,8 @@ export class MeshAgent extends EventEmitter {
|
|
|
307
369
|
if (this._transportConnected === e.connected)
|
|
308
370
|
return;
|
|
309
371
|
this._transportConnected = e.connected;
|
|
372
|
+
if (!e.connected)
|
|
373
|
+
this.transportDrops++;
|
|
310
374
|
this.emit("transport", e);
|
|
311
375
|
});
|
|
312
376
|
// The endpoint's (re)binds are the single source of truth for connectedness: this fires on
|
|
@@ -484,6 +548,83 @@ export class MeshAgent extends EventEmitter {
|
|
|
484
548
|
const clean = contextId?.trim();
|
|
485
549
|
this._contextId = clean ? clean : undefined;
|
|
486
550
|
}
|
|
551
|
+
/** Run `fn` with a per-call correlation for the sends it makes. A `replyTo` names the message a
|
|
552
|
+
* DM answers unless the DM names one itself, and `contextId` is then that message's
|
|
553
|
+
* conversation, copied from the message when the scope omits it. Without a `replyTo`,
|
|
554
|
+
* `contextId` is the caller's own conversation: a send, a question DM or an anycast carries it
|
|
555
|
+
* ahead of the agent-wide one from {@link setContextId}, and {@link answersQuestion} recognizes
|
|
556
|
+
* a DM back that copies it. A connector that serves several host sessions from one seat uses it,
|
|
557
|
+
* because one agent-wide context id cannot tell two concurrent sessions apart. */
|
|
558
|
+
withCorrelation(correlation, fn) {
|
|
559
|
+
const contextId = correlation.contextId?.trim() || undefined;
|
|
560
|
+
const replyTo = correlation.replyTo?.trim() || undefined;
|
|
561
|
+
const peerId = correlation.peerId?.trim() || undefined;
|
|
562
|
+
return this.correlation.run({ contextId, replyTo, peerId }, fn);
|
|
563
|
+
}
|
|
564
|
+
/** Whether `item` is a live DM answering a question this seat asked inside a
|
|
565
|
+
* {@link withCorrelation} scope: it copies that question's `contextId`, and its authenticated
|
|
566
|
+
* sender is the peer the question went to, or has the role an anycast question went to. A
|
|
567
|
+
* `contextId` is a string any peer can set, so the copy alone proves nothing. */
|
|
568
|
+
answersQuestion(item) {
|
|
569
|
+
if (item.kind !== "dm" || item.historical || !item.contextId)
|
|
570
|
+
return false;
|
|
571
|
+
const q = this.asked.get(item.contextId);
|
|
572
|
+
return !!q && (q.peerId === item.fromId || (!!q.role && q.role === item.fromRole));
|
|
573
|
+
}
|
|
574
|
+
/** The correlation a send carries, and the question it asks (a `contextId` the caller's scope
|
|
575
|
+
* owns, which {@link recordQuestion} remembers). `answered` is the message a DM answers: a reply
|
|
576
|
+
* copies its `contextId`, which belongs to the asker, and names it in `replyTo` (SPEC §5). */
|
|
577
|
+
stamp(answered) {
|
|
578
|
+
const c = this.correlation.getStore();
|
|
579
|
+
// A DM's own `replyTo` naming another message wins over the scope's, as in answering().
|
|
580
|
+
if (c?.replyTo && (!answered || answered.id === c.replyTo))
|
|
581
|
+
return { stamp: { replyTo: c.replyTo, contextId: c.contextId ?? answered?.contextId ?? this._contextId } };
|
|
582
|
+
if (answered?.contextId)
|
|
583
|
+
return { stamp: { replyTo: answered.id, contextId: answered.contextId } };
|
|
584
|
+
// A scope's `contextId` is the caller's own only when the scope names no message it answers.
|
|
585
|
+
const own = c?.replyTo ? undefined : c?.contextId;
|
|
586
|
+
return { stamp: { replyTo: answered?.id, contextId: own ?? this._contextId }, own };
|
|
587
|
+
}
|
|
588
|
+
/** Remember a question this seat asked, so {@link answersQuestion} can recognize its answer. */
|
|
589
|
+
recordQuestion(contextId, to) {
|
|
590
|
+
if (!contextId)
|
|
591
|
+
return;
|
|
592
|
+
this.asked.delete(contextId);
|
|
593
|
+
this.asked.set(contextId, to);
|
|
594
|
+
if (this.asked.size > MAX_CORRELATIONS)
|
|
595
|
+
this.asked.delete(this.asked.keys().next().value);
|
|
596
|
+
}
|
|
597
|
+
/** The unanswered message a DM to `peerId` answers. `replyTo` names it, and must be a message
|
|
598
|
+
* from that peer that is still waiting for an answer. Otherwise the scope's `replyTo` names it if
|
|
599
|
+
* it is that peer's, else it is the oldest one from that peer no other DM in flight is answering.
|
|
600
|
+
* The oldest is taken only while all of that peer's waiting messages, including those a DM in
|
|
601
|
+
* flight is answering, share one conversation (`contextId`): across several, a guess could put
|
|
602
|
+
* the answer in the wrong conversation, so the DM is refused with the list to choose from. A
|
|
603
|
+
* caller with its own `contextId` answers only in the conversation its scope names. */
|
|
604
|
+
answering(peerId, replyTo) {
|
|
605
|
+
if (replyTo) {
|
|
606
|
+
const e = this.unanswered.get(replyTo);
|
|
607
|
+
if (!e || e.fromId !== peerId)
|
|
608
|
+
throw new Error(`replyTo "${attributionSafe(replyTo)}" is not a message from this peer that is waiting for an answer`);
|
|
609
|
+
return { id: replyTo, contextId: e.contextId };
|
|
610
|
+
}
|
|
611
|
+
const c = this.correlation.getStore();
|
|
612
|
+
if (c?.replyTo) {
|
|
613
|
+
const e = this.unanswered.get(c.replyTo);
|
|
614
|
+
return e && e.fromId === peerId ? { id: c.replyTo, contextId: e.contextId } : undefined;
|
|
615
|
+
}
|
|
616
|
+
if (c?.contextId && c.peerId !== peerId)
|
|
617
|
+
return undefined;
|
|
618
|
+
// One a DM in flight is answering still waits until that DM is published, so it still counts.
|
|
619
|
+
const waiting = [...this.unanswered].filter(([, e]) => e.fromId === peerId);
|
|
620
|
+
if (new Set(waiting.map(([, e]) => e.contextId)).size > 1) {
|
|
621
|
+
const list = waiting.map(([id, e]) => ` • replyTo: ${attributionSafe(id)} — "${attributionSafe(e.text ?? "")}"`);
|
|
622
|
+
throw new Error(`this peer has messages from more than one conversation waiting for an answer. Re-send with ` +
|
|
623
|
+
`"replyTo" set to the id of the one this answers:\n${list.join("\n")}`);
|
|
624
|
+
}
|
|
625
|
+
const first = waiting.find(([, e]) => !e.pending);
|
|
626
|
+
return first && { id: first[0], contextId: first[1].contextId };
|
|
627
|
+
}
|
|
487
628
|
/** Begin connecting with background retry. Resolves after the first completed mesh join. */
|
|
488
629
|
start(retryMs = 3000) {
|
|
489
630
|
return this.connectLoop(retryMs);
|
|
@@ -637,6 +778,12 @@ export class MeshAgent extends EventEmitter {
|
|
|
637
778
|
if (!meta)
|
|
638
779
|
throw new Error(`message ${m.id} delivered without MessageMeta — its class is unauthenticated`);
|
|
639
780
|
const item = this.toInboxItem(m, meta.kind, meta.historical);
|
|
781
|
+
if ((item.kind === "dm" || item.kind === "anycast") && !item.historical && item.id !== "" && !this.answersQuestion(item)) {
|
|
782
|
+
// A DM back to this peer answers it (see dm()). An answer to our own question needs none.
|
|
783
|
+
this.unanswered.set(item.id, { fromId: item.fromId, contextId: item.contextId, text: item.text.slice(0, 80) });
|
|
784
|
+
if (this.unanswered.size > MAX_CORRELATIONS)
|
|
785
|
+
this.unanswered.delete(this.unanswered.keys().next().value);
|
|
786
|
+
}
|
|
640
787
|
// Per-channel override is the FINAL word for a channel message (DMs/anycast are never channel-
|
|
641
788
|
// scoped, so they bypass this entirely and always buffer). Evaluated BEFORE the global mode:
|
|
642
789
|
// - `muted` → hard drop, incl. @mention (a mention rides the channel; you can't keep it if you
|
|
@@ -650,6 +797,14 @@ export class MeshAgent extends EventEmitter {
|
|
|
650
797
|
// tag is payload-forgeable).
|
|
651
798
|
if (item.kind === "channel") {
|
|
652
799
|
const cm = this.channelModes.get(item.channel ?? "");
|
|
800
|
+
// #662: an id-less delivery is counted, not excluded, since recall can only match it by content.
|
|
801
|
+
// Content cannot tell a late copy of one recall handed back from a new identical message, so
|
|
802
|
+
// every copy takes its own disposition, and a late copy surfaces again rather than a new one
|
|
803
|
+
// losing its lane (#624: an empty id asserts no identity).
|
|
804
|
+
const digest = item.id === "" && (this.enteringFocus || this._attention === "focus") ? idlessDigest(m) : undefined;
|
|
805
|
+
const tally = digest === undefined ? undefined : this.reserveIdless(digest, item.channel ?? "");
|
|
806
|
+
const copy = tally ? this.settleIdless(tally) : undefined;
|
|
807
|
+
const idless = copy && { digest: digest, copy };
|
|
653
808
|
// chatFrontier() is asynchronous. Channel traffic retained while entering focus must not also
|
|
654
809
|
// appear in post-watermark recall if it lands after the server captured the frontier.
|
|
655
810
|
if (this.enteringFocus)
|
|
@@ -677,6 +832,10 @@ export class MeshAgent extends EventEmitter {
|
|
|
677
832
|
if (cm !== "quiet" && !snapshottedPullOnly && this._attention === "focus") {
|
|
678
833
|
this.protectDisposition(item.id, "drop");
|
|
679
834
|
delivery.ack();
|
|
835
|
+
// #662: recall cannot pick this copy out from an identical one with another disposition, so
|
|
836
|
+
// its body is held here, pull-only, and recall hands back only copies nothing is bound to.
|
|
837
|
+
if (item.id === "")
|
|
838
|
+
this.buffer(item, () => { }, true, idless);
|
|
680
839
|
if (item.mentionsMe)
|
|
681
840
|
this.emit("mention-wake", item);
|
|
682
841
|
return;
|
|
@@ -686,16 +845,24 @@ export class MeshAgent extends EventEmitter {
|
|
|
686
845
|
// on a real mesh as 119 injected digests / 0 assistant turns and an emergency compaction before
|
|
687
846
|
// the seat's first real order could run. It stays recallable (cotal_inbox, recall), and a
|
|
688
847
|
// historical @mention stays automatic: directed catch-up is the reader's call, not noise.
|
|
689
|
-
|
|
848
|
+
// A reply on a channel is pull-only too when the host answers every turn on the channel
|
|
849
|
+
// (config.channelRepliesPullOnly): that answer replies to the message that started the turn,
|
|
850
|
+
// so a turn on a peer's reply is a turn on its automatic output, and two such seats on one
|
|
851
|
+
// channel would answer each other without end (#2395).
|
|
852
|
+
const automaticReply = this.config.channelRepliesPullOnly === true && !!item.replyTo;
|
|
853
|
+
const pullOnly = snapshottedPullOnly || (!item.mentionsMe && (item.historical || automaticReply || this.classificationUnsafe));
|
|
690
854
|
if (pullOnly)
|
|
691
855
|
this.excludeFromFocus(item);
|
|
692
|
-
this.buffer(item, delivery.ack, pullOnly);
|
|
856
|
+
this.buffer(item, delivery.ack, pullOnly, idless);
|
|
693
857
|
return;
|
|
694
858
|
}
|
|
695
859
|
this.buffer(item, delivery.ack, false);
|
|
696
860
|
}
|
|
697
|
-
buffer(item, ack, pullOnly) {
|
|
698
|
-
|
|
861
|
+
buffer(item, ack, pullOnly, idless) {
|
|
862
|
+
const pending = { item, ack, pullOnly, receivedAt: Date.now() };
|
|
863
|
+
if (idless)
|
|
864
|
+
pending.idless = { tally: this.focusIdless, ...idless };
|
|
865
|
+
this.inbox.push(pending);
|
|
699
866
|
if (this.inbox.length > MAX_INBOX) {
|
|
700
867
|
// Prefer sacrificing pull-only backlog so it cannot crowd out DMs/mentions. Overflow remains
|
|
701
868
|
// bounded local loss: evicted items are acked without being marked handled.
|
|
@@ -722,6 +889,9 @@ export class MeshAgent extends EventEmitter {
|
|
|
722
889
|
const [evicted] = this.inbox.splice(index, 1);
|
|
723
890
|
const sacrificingDirected = evicted.item.kind !== "channel";
|
|
724
891
|
this.rememberEvicted(evicted);
|
|
892
|
+
// #662: a held id-less message is no longer settled here, so recall may hand it back.
|
|
893
|
+
if (evicted.idless?.tally === this.focusIdless)
|
|
894
|
+
this.unsettleIdless(evicted.idless.digest, evicted.idless.copy);
|
|
725
895
|
// ...but NOT an id that is mid-delivery. Overflow prefers the oldest, which is exactly what a
|
|
726
896
|
// surfaced batch is made of, so without this an arrival can ack a message a host is still
|
|
727
897
|
// trying to hand to its runtime. Evicting bounds memory; acking is what makes it
|
|
@@ -791,6 +961,8 @@ export class MeshAgent extends EventEmitter {
|
|
|
791
961
|
excludeFromFocus(item) {
|
|
792
962
|
if ((!this.enteringFocus && this._attention !== "focus") || item.kind !== "channel" || !item.channel)
|
|
793
963
|
return;
|
|
964
|
+
if (item.id === "")
|
|
965
|
+
return; // #662: an id-less delivery is counted in focusIdless instead
|
|
794
966
|
if (!this.focusExcludedIds.has(item.id) && this.focusExcludedIds.size >= FOCUS_EXCLUSION_CAP) {
|
|
795
967
|
const oldest = this.focusExcludedIds.entries().next().value;
|
|
796
968
|
if (oldest) {
|
|
@@ -800,6 +972,125 @@ export class MeshAgent extends EventEmitter {
|
|
|
800
972
|
}
|
|
801
973
|
this.focusExcludedIds.set(item.id, item.channel);
|
|
802
974
|
}
|
|
975
|
+
/** #662: the tally for one more settled or handed-back copy of an id-less message; the caller adds
|
|
976
|
+
* the copy. At the bound, the channel's recall is reported incomplete rather than risk handing
|
|
977
|
+
* back a copy it no longer counts. */
|
|
978
|
+
reserveIdless(digest, channel) {
|
|
979
|
+
if (this.focusIdlessEntries >= FOCUS_EXCLUSION_CAP) {
|
|
980
|
+
this.focusRecallUnsafeChannels.add(channel);
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
let tally = this.focusIdless.get(digest);
|
|
984
|
+
if (!tally)
|
|
985
|
+
this.focusIdless.set(digest, (tally = { channel, unbound: [], bound: new Set() }));
|
|
986
|
+
this.focusIdlessEntries++;
|
|
987
|
+
return tally;
|
|
988
|
+
}
|
|
989
|
+
/** #662: add one settled copy, bounded by the chat frontier read after it arrived. If that read
|
|
990
|
+
* fails, or answers only after the transport dropped, nothing bounds the copy, so the channel's
|
|
991
|
+
* recall is reported incomplete instead. */
|
|
992
|
+
settleIdless(tally) {
|
|
993
|
+
const episode = this.focusIdless;
|
|
994
|
+
const drops = this.transportDrops;
|
|
995
|
+
const unbounded = () => {
|
|
996
|
+
if (episode === this.focusIdless)
|
|
997
|
+
this.focusRecallUnsafeChannels.add(tally.channel);
|
|
998
|
+
};
|
|
999
|
+
const copy = { epoch: this.idlessEpoch, ready: Promise.resolve() };
|
|
1000
|
+
copy.ready = this.ep.chatFrontier().then((frontier) => {
|
|
1001
|
+
if (drops === this.transportDrops)
|
|
1002
|
+
copy.frontier = frontier;
|
|
1003
|
+
else
|
|
1004
|
+
unbounded();
|
|
1005
|
+
}, unbounded);
|
|
1006
|
+
tally.unbound.push(copy);
|
|
1007
|
+
return copy;
|
|
1008
|
+
}
|
|
1009
|
+
/** #662: forget a tally with nothing left in it, so the map is bounded by the entries it counts. */
|
|
1010
|
+
dropEmptyIdless(digest, tally) {
|
|
1011
|
+
if (!tally.unbound.length && !tally.bound.size)
|
|
1012
|
+
this.focusIdless.delete(digest);
|
|
1013
|
+
}
|
|
1014
|
+
/** #662: one buffered copy of an id-less message left the inbox unhandled. Only that copy is
|
|
1015
|
+
* un-counted, or the sequence it was bound to freed: an identical copy may have been muted. A copy
|
|
1016
|
+
* no read has bound yet stays in arrival order, marked, so the read binds it to its own stream
|
|
1017
|
+
* copy rather than letting an earlier identical copy take that one. */
|
|
1018
|
+
unsettleIdless(digest, copy) {
|
|
1019
|
+
const tally = this.focusIdless.get(digest);
|
|
1020
|
+
if (!tally)
|
|
1021
|
+
return;
|
|
1022
|
+
if (tally.unbound.includes(copy)) {
|
|
1023
|
+
copy.evicted = true;
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
if (copy.seq === undefined || !tally.bound.delete(copy.seq))
|
|
1027
|
+
return;
|
|
1028
|
+
this.focusIdlessEntries--;
|
|
1029
|
+
this.dropEmptyIdless(digest, tally);
|
|
1030
|
+
}
|
|
1031
|
+
/** #662: after a complete read of `channel` that started at `epoch`, a copy settled before it that
|
|
1032
|
+
* it could not bind will not be in a later read either, so it is dropped. The read is the
|
|
1033
|
+
* channel's whole retained window, so a bound sequence it lacks aged out. */
|
|
1034
|
+
settleIdlessRead(channel, epoch, retained) {
|
|
1035
|
+
for (const [digest, tally] of this.focusIdless) {
|
|
1036
|
+
if (tally.channel !== channel)
|
|
1037
|
+
continue;
|
|
1038
|
+
const before = tally.unbound.length + tally.bound.size;
|
|
1039
|
+
tally.unbound = tally.unbound.filter((c) => c.epoch >= epoch);
|
|
1040
|
+
for (const seq of tally.bound)
|
|
1041
|
+
if (!retained.has(seq))
|
|
1042
|
+
tally.bound.delete(seq);
|
|
1043
|
+
this.focusIdlessEntries -= before - tally.unbound.length - tally.bound.size;
|
|
1044
|
+
this.dropEmptyIdless(digest, tally);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
/** #662: the frontier reads of every copy of `channel` settled so far have answered. */
|
|
1048
|
+
async idlessReady(tallies, channel) {
|
|
1049
|
+
const reads = [];
|
|
1050
|
+
for (const tally of tallies.values())
|
|
1051
|
+
if (tally.channel === channel)
|
|
1052
|
+
for (const c of tally.unbound)
|
|
1053
|
+
reads.push(c.ready);
|
|
1054
|
+
await Promise.all(reads);
|
|
1055
|
+
}
|
|
1056
|
+
/** #662: bind the copies settled before the read that started at `epoch` to its unbound stream
|
|
1057
|
+
* copies `free` (ascending). Identical copies share a sender subject, so they arrive in stream
|
|
1058
|
+
* order, and a frontier, read after its copy arrived, bounds that copy and every copy before it.
|
|
1059
|
+
* Frontier reads can answer out of order, so their sizes say nothing about arrival order. So the
|
|
1060
|
+
* copies bind in arrival order, latest first, each to the latest free stream copy at or below the
|
|
1061
|
+
* lowest frontier from it on, and a stream copy none of them takes (a reconnect gap) stays free.
|
|
1062
|
+
* An evicted copy takes its own stream copy and leaves it free, so recall hands that one back. */
|
|
1063
|
+
bindIdless(tally, epoch, free) {
|
|
1064
|
+
let bound = Infinity;
|
|
1065
|
+
for (let i = tally.unbound.length - 1; i >= 0; i--) {
|
|
1066
|
+
const copy = tally.unbound[i];
|
|
1067
|
+
if (copy.frontier === undefined)
|
|
1068
|
+
continue;
|
|
1069
|
+
bound = Math.min(bound, copy.frontier);
|
|
1070
|
+
if (copy.epoch >= epoch)
|
|
1071
|
+
continue;
|
|
1072
|
+
let at = free.length - 1;
|
|
1073
|
+
while (at >= 0 && free[at] > bound)
|
|
1074
|
+
at--;
|
|
1075
|
+
if (at < 0)
|
|
1076
|
+
continue;
|
|
1077
|
+
const [seq] = free.splice(at, 1);
|
|
1078
|
+
tally.unbound.splice(i, 1);
|
|
1079
|
+
if (copy.evicted) {
|
|
1080
|
+
this.focusIdlessEntries--;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
copy.seq = seq;
|
|
1084
|
+
tally.bound.add(seq);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/** #662: a copy settled after the read that started at `epoch` began may or may not be in it, so it
|
|
1088
|
+
* binds nothing there, and an unbound stream copy at `seq` it could be is neither free nor bound. */
|
|
1089
|
+
idlessUnsure(tally, epoch, seq) {
|
|
1090
|
+
if (!tally || tally.bound.has(seq))
|
|
1091
|
+
return false;
|
|
1092
|
+
return tally.unbound.some((c) => c.epoch >= epoch && c.frontier !== undefined && c.frontier >= seq);
|
|
1093
|
+
}
|
|
803
1094
|
protectDisposition(id, disposition) {
|
|
804
1095
|
if (id === "")
|
|
805
1096
|
return; // #624: an empty id is never a dedup key, so it is never recorded as one
|
|
@@ -1187,6 +1478,8 @@ export class MeshAgent extends EventEmitter {
|
|
|
1187
1478
|
if (mode === "focus") {
|
|
1188
1479
|
await this.requireConnected();
|
|
1189
1480
|
this.focusExcludedIds.clear();
|
|
1481
|
+
this.focusIdless = new Map();
|
|
1482
|
+
this.focusIdlessEntries = 0;
|
|
1190
1483
|
this.focusRecallUnsafeChannels.clear();
|
|
1191
1484
|
this.enteringFocus = this._attention !== "focus";
|
|
1192
1485
|
try {
|
|
@@ -1195,6 +1488,8 @@ export class MeshAgent extends EventEmitter {
|
|
|
1195
1488
|
catch (error) {
|
|
1196
1489
|
this.enteringFocus = false;
|
|
1197
1490
|
this.focusExcludedIds.clear();
|
|
1491
|
+
this.focusIdless = new Map();
|
|
1492
|
+
this.focusIdlessEntries = 0;
|
|
1198
1493
|
this.focusRecallUnsafeChannels.clear();
|
|
1199
1494
|
throw error;
|
|
1200
1495
|
}
|
|
@@ -1205,6 +1500,8 @@ export class MeshAgent extends EventEmitter {
|
|
|
1205
1500
|
this.enteringFocus = false;
|
|
1206
1501
|
this.focusSince = undefined;
|
|
1207
1502
|
this.focusExcludedIds.clear();
|
|
1503
|
+
this.focusIdless = new Map();
|
|
1504
|
+
this.focusIdlessEntries = 0;
|
|
1208
1505
|
this.focusRecallUnsafeChannels.clear();
|
|
1209
1506
|
this.resetRecallWalk();
|
|
1210
1507
|
}
|
|
@@ -1223,8 +1520,15 @@ export class MeshAgent extends EventEmitter {
|
|
|
1223
1520
|
* the per-channel window — and wildcard subscriptions (`team.>`), which recall cannot read back
|
|
1224
1521
|
* per concrete sub-channel (#977: a wildcard join is not itself a channel ingest can consult a
|
|
1225
1522
|
* replay policy for) and so cannot vouch for either (never-silent throughout). Empty unless in
|
|
1226
|
-
* focus.
|
|
1227
|
-
|
|
1523
|
+
* focus. Calls run one at a time (#662). `underway` names recalled items that already went out in
|
|
1524
|
+
* part: an exclusion that lands after that does not hide one, since recall would then move past
|
|
1525
|
+
* the rest of it (#613). */
|
|
1526
|
+
async recallAmbient(underway = new Set()) {
|
|
1527
|
+
const run = this.recallLock.then(() => this.recallAmbientOnce(underway));
|
|
1528
|
+
this.recallLock = run.catch(() => { });
|
|
1529
|
+
return run;
|
|
1530
|
+
}
|
|
1531
|
+
async recallAmbientOnce(underway) {
|
|
1228
1532
|
if (this._attention !== "focus" || this.focusSince === undefined)
|
|
1229
1533
|
return { items: [], droppedChannels: [] };
|
|
1230
1534
|
const items = [];
|
|
@@ -1238,12 +1542,91 @@ export class MeshAgent extends EventEmitter {
|
|
|
1238
1542
|
droppedChannels.push(channel);
|
|
1239
1543
|
continue;
|
|
1240
1544
|
}
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1545
|
+
const tallies = this.focusIdless;
|
|
1546
|
+
let epoch = 0;
|
|
1547
|
+
let read;
|
|
1548
|
+
// #662: a copy settled while a read runs may or may not be in it, so the read is retried with
|
|
1549
|
+
// that copy settled first, a bounded number of times.
|
|
1550
|
+
for (let attempt = 1; attempt <= IDLESS_READS; attempt++) {
|
|
1551
|
+
epoch = ++this.idlessEpoch;
|
|
1552
|
+
// The read starts after every copy settled before it knows its frontier, so a copy it
|
|
1553
|
+
// cannot bind is behind the focus start or out of retention.
|
|
1554
|
+
await this.idlessReady(tallies, channel);
|
|
1555
|
+
read = await this.ep.recallChannel(channel, this.focusSince);
|
|
1556
|
+
if (read.unanswered)
|
|
1557
|
+
break;
|
|
1558
|
+
await this.idlessReady(tallies, channel); // copies settled during the read
|
|
1559
|
+
const unsure = read.messages.some((m, i) => m.id === "" && this.idlessUnsure(tallies.get(idlessDigest(m)), epoch, read.seqs[i]));
|
|
1560
|
+
if (!unsure)
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1563
|
+
const { messages, seqs, dropped, unanswered } = read;
|
|
1564
|
+
// A read that did not answer says nothing about what the channel retains, so nothing settles.
|
|
1565
|
+
if (unanswered) {
|
|
1566
|
+
droppedChannels.push(channel);
|
|
1567
|
+
continue;
|
|
1245
1568
|
}
|
|
1246
|
-
if (
|
|
1569
|
+
if (this.focusRecallUnsafeChannels.has(channel)) {
|
|
1570
|
+
droppedChannels.push(channel);
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
// #662: each copy settled before the read is bound to one retained stream copy at or below its
|
|
1574
|
+
// frontier. A copy no settled copy is bound to was never received (a reconnect gap) or was
|
|
1575
|
+
// evicted; it is handed back into the inbox, which acks each by receive key, since the recall
|
|
1576
|
+
// mark cannot order identical copies.
|
|
1577
|
+
const retained = new Set();
|
|
1578
|
+
const digests = [];
|
|
1579
|
+
const free = new Map();
|
|
1580
|
+
for (const [i, m] of messages.entries()) {
|
|
1581
|
+
if (m.id !== "" || tallies !== this.focusIdless)
|
|
1582
|
+
continue;
|
|
1583
|
+
retained.add(seqs[i]);
|
|
1584
|
+
const digest = (digests[i] = idlessDigest(m));
|
|
1585
|
+
if (tallies.get(digest)?.bound.has(seqs[i]))
|
|
1586
|
+
continue;
|
|
1587
|
+
const list = free.get(digest);
|
|
1588
|
+
if (list)
|
|
1589
|
+
list.push(seqs[i]);
|
|
1590
|
+
else
|
|
1591
|
+
free.set(digest, [seqs[i]]);
|
|
1592
|
+
}
|
|
1593
|
+
for (const [digest, list] of free) {
|
|
1594
|
+
const tally = tallies.get(digest);
|
|
1595
|
+
if (tally)
|
|
1596
|
+
this.bindIdless(tally, epoch, list);
|
|
1597
|
+
}
|
|
1598
|
+
let incomplete = dropped;
|
|
1599
|
+
for (const [i, m] of messages.entries()) {
|
|
1600
|
+
if (m.id !== "") {
|
|
1601
|
+
if (underway.has(m.id) || !this.focusExcludedIds.has(m.id))
|
|
1602
|
+
items.push(this.toInboxItem(m, "channel", true));
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
if (tallies !== this.focusIdless)
|
|
1606
|
+
continue; // focus was left or re-entered during the read
|
|
1607
|
+
const seq = seqs[i];
|
|
1608
|
+
const digest = digests[i];
|
|
1609
|
+
const tally = tallies.get(digest);
|
|
1610
|
+
if (tally?.bound.has(seq))
|
|
1611
|
+
continue;
|
|
1612
|
+
// Still unsure after the last read: neither claimed nor handed back, and reported.
|
|
1613
|
+
if (this.idlessUnsure(tally, epoch, seq)) {
|
|
1614
|
+
incomplete = true;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
// A full inbox leaves it in the stream for a later call, rather than evict something else.
|
|
1618
|
+
const fresh = this.inbox.length < MAX_INBOX ? this.reserveIdless(digest, channel) : undefined;
|
|
1619
|
+
if (!fresh) {
|
|
1620
|
+
incomplete = true;
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
const copy = { epoch, frontier: seq, ready: Promise.resolve(), seq };
|
|
1624
|
+
fresh.bound.add(seq);
|
|
1625
|
+
this.buffer(this.toInboxItem(m, "channel", true), () => { }, true, { digest, copy });
|
|
1626
|
+
}
|
|
1627
|
+
if (tallies === this.focusIdless)
|
|
1628
|
+
this.settleIdlessRead(channel, epoch, retained);
|
|
1629
|
+
if (incomplete)
|
|
1247
1630
|
droppedChannels.push(channel);
|
|
1248
1631
|
}
|
|
1249
1632
|
items.sort((a, b) => a.ts - b.ts);
|
|
@@ -1255,7 +1638,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
1255
1638
|
const clean = normalizeMentions(mentions);
|
|
1256
1639
|
if (clean)
|
|
1257
1640
|
await this.assertKnownMentions(clean);
|
|
1258
|
-
return this.ep.multicast(text, { channel, mentions: clean,
|
|
1641
|
+
return this.ep.multicast(text, { channel, mentions: clean, ...this.stamp().stamp });
|
|
1259
1642
|
}
|
|
1260
1643
|
/**
|
|
1261
1644
|
* What a caller can TELL about a send target BEFORE the publish: whether the name
|
|
@@ -1315,7 +1698,9 @@ export class MeshAgent extends EventEmitter {
|
|
|
1315
1698
|
}
|
|
1316
1699
|
async anycast(role, text) {
|
|
1317
1700
|
await this.requireConnected();
|
|
1318
|
-
|
|
1701
|
+
const { stamp, own } = this.stamp();
|
|
1702
|
+
this.recordQuestion(own, { role });
|
|
1703
|
+
return this.ep.anycast(role, text, stamp);
|
|
1319
1704
|
}
|
|
1320
1705
|
/** Resolve a peer by instance id (exact) or display name. Deterministic and fail-loud: returns
|
|
1321
1706
|
* one peer, `undefined` if none match, or throws `AmbiguousPeerError` on a same-name collision —
|
|
@@ -1323,7 +1708,7 @@ export class MeshAgent extends EventEmitter {
|
|
|
1323
1708
|
resolvePeer(target) {
|
|
1324
1709
|
return resolvePeerInRoster(this.ep.getRoster(), target, { selfId: this.id });
|
|
1325
1710
|
}
|
|
1326
|
-
async dm(target, text) {
|
|
1711
|
+
async dm(target, text, opts = {}) {
|
|
1327
1712
|
await this.requireConnected();
|
|
1328
1713
|
// #1229: a miss is only a real "no peer" while the view is current. Under `unpopulated`
|
|
1329
1714
|
// the roster may be a reconnect refill in progress, so wait once for the snapshot and
|
|
@@ -1347,10 +1732,25 @@ export class MeshAgent extends EventEmitter {
|
|
|
1347
1732
|
// The only status we can truthfully attribute is the roster snapshot taken right before the
|
|
1348
1733
|
// publish: recipient state can change the instant after, and the ack never tells us either way.
|
|
1349
1734
|
const recipientStatusAtSend = peer.status;
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1735
|
+
// A DM back to a peer answers the message `replyTo` names, or the oldest one it sent us that no
|
|
1736
|
+
// DM has answered yet (see answering()). It counts as answered only once the DM is published,
|
|
1737
|
+
// so a failed send leaves it to the retry.
|
|
1738
|
+
const answered = this.answering(peer.card.id, opts.replyTo?.trim() || undefined);
|
|
1739
|
+
const entry = answered && this.unanswered.get(answered.id);
|
|
1740
|
+
const { stamp, own } = this.stamp(answered);
|
|
1741
|
+
this.recordQuestion(own, { peerId: peer.card.id });
|
|
1742
|
+
if (entry)
|
|
1743
|
+
entry.pending = true;
|
|
1744
|
+
try {
|
|
1745
|
+
const { msg, ack } = await this.ep.unicastAttributed(peer.card.id, text, stamp);
|
|
1746
|
+
if (answered)
|
|
1747
|
+
this.unanswered.delete(answered.id);
|
|
1748
|
+
return { msg, peer, ack, recipientStatusAtSend };
|
|
1749
|
+
}
|
|
1750
|
+
finally {
|
|
1751
|
+
if (entry)
|
|
1752
|
+
entry.pending = false;
|
|
1753
|
+
}
|
|
1354
1754
|
}
|
|
1355
1755
|
// ---- supervision ---------------------------------------------------------
|
|
1356
1756
|
/** Ask the manager to spawn a new teammate into this space (its `spawn` action).
|
|
@@ -1590,8 +1990,20 @@ export class MeshAgent extends EventEmitter {
|
|
|
1590
1990
|
this.notePullTrouble(r.error ?? "refused with no message");
|
|
1591
1991
|
return;
|
|
1592
1992
|
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1993
|
+
const rows = r.data?.turns;
|
|
1994
|
+
// A reply with no turns array is no snapshot: reconciling on it would drop every accepted
|
|
1995
|
+
// turn, including one already shown. Keep what the seat holds until a well-formed pull.
|
|
1996
|
+
if (!Array.isArray(rows)) {
|
|
1997
|
+
this.notePullTrouble("turn-pending returned no turns array");
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
// A responder off the contract must not reach the formatter: one row with no numeric
|
|
2001
|
+
// deadline made peekPendingTurns throw on every frame.
|
|
2002
|
+
const turns = rows.filter(isPendingTurn);
|
|
2003
|
+
if (turns.length < rows.length)
|
|
2004
|
+
this.notePullTrouble(`turn-pending returned ${rows.length - turns.length} malformed turn(s), dropped`);
|
|
2005
|
+
else
|
|
2006
|
+
this.pullTrouble = undefined;
|
|
1595
2007
|
const live = new Set(turns.map((t) => t.goalId));
|
|
1596
2008
|
for (const id of [...this.activeTurns.keys()])
|
|
1597
2009
|
if (!live.has(id))
|
|
@@ -1903,6 +2315,11 @@ export class MeshAgent extends EventEmitter {
|
|
|
1903
2315
|
async setCondition(condition) {
|
|
1904
2316
|
await this.inOrder(() => this.ep.setCondition(condition));
|
|
1905
2317
|
}
|
|
2318
|
+
/** Relay harness-reported work progress (a turn event) as presence `activeAt`. The next heartbeat
|
|
2319
|
+
* carries it, so an observer can tell a turn that stopped advancing from one that is progressing. */
|
|
2320
|
+
noteActivity(at) {
|
|
2321
|
+
this.ep.noteActivity(at);
|
|
2322
|
+
}
|
|
1906
2323
|
/** The working→idle boundary: yield `done` for every SURFACED turn (its payload was in the
|
|
1907
2324
|
* context of the turn that just ended; ending without an explicit yield IS the done signal),
|
|
1908
2325
|
* then re-poll immediately so a queued turn wakes the seat without waiting out the cadence.
|