@cohortapp/agent-sdk 2.18.13 → 2.18.15
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/bin/maestro.mjs +38 -1
- package/docs/runbooks/fleet-rollout.md +58 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- package/lib/assurance/batch.mjs +353 -0
- package/lib/assurance/first-reply.mjs +423 -0
- package/lib/assurance/notice-voice.mjs +357 -0
- package/lib/assurance/plan-note.mjs +43 -0
- package/lib/assurance/room-budget.mjs +55 -6
- package/lib/cadence-failure-class.mjs +245 -0
- package/lib/claude-bin.mjs +26 -7
- package/lib/cli/doctor-checks.mjs +149 -1
- package/lib/comms/send-gate.mjs +59 -0
- package/lib/diagnostics/alerts.mjs +33 -0
- package/lib/engine/agents/usage.mjs +45 -0
- package/lib/engine/budget.mjs +293 -29
- package/lib/engine/cli.mjs +54 -5
- package/lib/engine/loop.mjs +30 -0
- package/lib/engine/output/json.mjs +26 -0
- package/lib/engine/wire/errors.mjs +179 -0
- package/lib/engine/wire/search.mjs +44 -8
- package/lib/identity/persona.mjs +31 -2
- package/lib/org/quota.mjs +27 -0
- package/lib/session/config.mjs +4 -0
- package/lib/session/identity.mjs +71 -7
- package/lib/session/launch-failure.mjs +251 -0
- package/lib/session/resume-target.mjs +86 -0
- package/lib/telemetry/alerts.mjs +94 -0
- package/lib/telemetry/collect.mjs +155 -2
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- package/scaffold/config/alerts.yaml +7 -0
- package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/daemon/agent-daemon.mjs +75 -5
- package/scripts/daemon/assurance.mjs +709 -44
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/deliver.mjs +109 -0
- package/scripts/daemon/dispatcher.mjs +21 -3
- package/scripts/daemon/inbox-deferral.mjs +102 -9
- package/scripts/daemon/session-lock.mjs +41 -1
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +256 -10
- package/scripts/healthcheck.sh +131 -33
- package/scripts/local-triggers/autoupdate.sh +144 -11
- package/scripts/resume-operations.sh +101 -6
- package/scripts/session/supervisor.mjs +198 -5
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/assurance/first-reply.mjs — what an agent says FIRST, and whether it says
|
|
3
|
+
* anything at all.
|
|
4
|
+
*
|
|
5
|
+
* THE DIRECTIVE THIS IMPLEMENTS (owner, 2026-09-25, with four screenshots of
|
|
6
|
+
* the org's own channels attached)
|
|
7
|
+
*
|
|
8
|
+
* "Get rid of the use of these kind of generic placeholder replies. Instead,
|
|
9
|
+
* the first reply should come as part of the actual LLM's initial
|
|
10
|
+
* consideration of the question, and then replying with an appropriate
|
|
11
|
+
* initial short reply that uses a natural tone of voice that is contextually
|
|
12
|
+
* relevant… So for example, if there's a flurry of 4 messages in one go by
|
|
13
|
+
* others, instead of having a generic reply per message, it would be
|
|
14
|
+
* responding to the batch of messages. And then also, sometimes a reply isn't
|
|
15
|
+
* needed (only do this when it truly doesn't need any reply), and then also
|
|
16
|
+
* sometimes the initial response can be an emoji response."
|
|
17
|
+
*
|
|
18
|
+
* Three decisions fall out of that, and all three are made HERE, as pure
|
|
19
|
+
* functions, rather than as sentences in a system prompt. A rule that lives
|
|
20
|
+
* only in a prompt is a rule nothing checks: the ack prompt has said "never a
|
|
21
|
+
* generic 'on it'" since it was written, and 3,069 generic acks shipped anyway
|
|
22
|
+
* (see `tier.mjs`). Prompts state intent; functions are the enforcement.
|
|
23
|
+
*
|
|
24
|
+
* SHAPE — none / react / line. `firstReplyShape` below.
|
|
25
|
+
* BATCH — a flurry in one room is ONE first reply, not one per message.
|
|
26
|
+
* `groupForFirstReply` below.
|
|
27
|
+
* VOICE — the line itself is generated from the actual thread
|
|
28
|
+
* (`scripts/daemon/assurance.generateAck`); this module never
|
|
29
|
+
* composes prose, it only decides whether prose is owed.
|
|
30
|
+
*
|
|
31
|
+
* THE REGISTER, AND WHY IT IS A LIST OF EXACT SENTENCES
|
|
32
|
+
*
|
|
33
|
+
* `RETIRED_FIRST_REPLIES` holds the four sentences the owner screenshotted, in
|
|
34
|
+
* the exact bytes they shipped in. Two separate defects put them on screen and
|
|
35
|
+
* the register answers both:
|
|
36
|
+
*
|
|
37
|
+
* (1) Two seats were still running a preserved local copy of the daemon that
|
|
38
|
+
* predates the 2026-08-30 removal of the canned rotation, and went on
|
|
39
|
+
* emitting "On it." / "Looking now." / "On it — digging in now." while
|
|
40
|
+
* reporting a current SDK version. `isRetiredFirstReply` is the check at
|
|
41
|
+
* the SEND chokepoint that makes a stale composer upstream unable to put
|
|
42
|
+
* one on screen anyway.
|
|
43
|
+
* (2) The failure notices were deterministic strings by design, so every
|
|
44
|
+
* dead session in the fleet said the same two sentences. Those two are
|
|
45
|
+
* in the register too, which is what makes it impossible to reintroduce
|
|
46
|
+
* them by editing the composer back.
|
|
47
|
+
*
|
|
48
|
+
* PURE. Nothing here reads the clock, the filesystem, the environment or the
|
|
49
|
+
* network; every input arrives on the argument. That is what lets the whole
|
|
50
|
+
* matrix be pinned by a test rather than sampled off a live daemon.
|
|
51
|
+
*
|
|
52
|
+
* @module lib/assurance/first-reply
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
"use strict";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The exact sentences the owner screenshotted on 2026-09-25, byte for byte.
|
|
59
|
+
*
|
|
60
|
+
* FROZEN LITERALS, deliberately — the opposite convention to
|
|
61
|
+
* `assurance.narratorPatterns()`, which derives its templates from the live
|
|
62
|
+
* composers so the list cannot drift from the emitter. Here drift is the POINT:
|
|
63
|
+
* these must stay recognisable after the composers that produced them are gone,
|
|
64
|
+
* and a derived list would delete them at exactly the moment they stop being
|
|
65
|
+
* produced locally and start arriving from a seat that has not updated yet.
|
|
66
|
+
*
|
|
67
|
+
* @type {readonly string[]}
|
|
68
|
+
*/
|
|
69
|
+
export const RETIRED_FIRST_REPLIES = Object.freeze([
|
|
70
|
+
"On it.",
|
|
71
|
+
"Looking now.",
|
|
72
|
+
"On it — digging in now.",
|
|
73
|
+
"Hit a problem — the working session ended with an error. Retrying now; if it fails again I'll come straight back rather than leave you waiting.",
|
|
74
|
+
"Couldn't finish this — the work never came back with a result and has now outrun its time limit. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The same five with their variable middle replaced by a wildcard, so the
|
|
79
|
+
* register catches the whole FAMILY and not only the one cause that happened
|
|
80
|
+
* to be screenshotted. `composeFailure`'s cause is interpolated; "the working
|
|
81
|
+
* session ended with an error" and "the work never came back with a result and
|
|
82
|
+
* has now outrun its time limit" are two of roughly a dozen `classifyFailure`
|
|
83
|
+
* can produce, and pinning only those two would leave ten siblings live.
|
|
84
|
+
*/
|
|
85
|
+
const RETIRED_PATTERNS = Object.freeze([
|
|
86
|
+
/^on it[.!]?$/i,
|
|
87
|
+
/^looking now[.!]?$/i,
|
|
88
|
+
/^on it\s*[—–-]\s*digging in now[.!]?$/i,
|
|
89
|
+
/^hit a problem\s*[—–-]\s*.+\.\s*retrying now;\s*if it fails again i'?ll come straight back rather than leave you waiting\.?$/i,
|
|
90
|
+
/^couldn'?t finish this\s*[—–-]\s*.+\.\s*i'?ve stopped retrying and flagged it so it isn'?t lost\.\s*want me to try a narrower version,\s*hand it off,\s*or leave it with you\?$/i,
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One line, in the shape the register compares. Collapses whitespace, unifies
|
|
95
|
+
* the three dashes and the two apostrophes, and trims — so a copy that survived
|
|
96
|
+
* a round trip through a chat client's smart-quote substitution still matches.
|
|
97
|
+
*
|
|
98
|
+
* @param {unknown} text
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function normaliseLine(text) {
|
|
102
|
+
return String(text == null ? "" : text)
|
|
103
|
+
.replace(/[‘’ʼ]/g, "'")
|
|
104
|
+
.replace(/[“”]/g, '"')
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Split a body into the units the register judges: paragraphs, then lines.
|
|
111
|
+
*
|
|
112
|
+
* WHY PARAGRAPHS AND NOT THE WHOLE BODY. The owner's 15:04 screenshot showed
|
|
113
|
+
* the same failure sentence twice under one timestamp. Whatever produced that —
|
|
114
|
+
* two notices concatenated, or a client rendering two sends as one block — a
|
|
115
|
+
* check that only tests the WHOLE string against the register would pass it,
|
|
116
|
+
* because "<sentence>\n\n<sentence>" is not equal to "<sentence>". Judging each
|
|
117
|
+
* paragraph closes that.
|
|
118
|
+
*
|
|
119
|
+
* @param {unknown} body
|
|
120
|
+
* @returns {string[]}
|
|
121
|
+
*/
|
|
122
|
+
export function bodySegments(body) {
|
|
123
|
+
const t = String(body == null ? "" : body).trim();
|
|
124
|
+
if (!t) return [];
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const para of t.split(/\n\s*\n/)) {
|
|
127
|
+
const p = normaliseLine(para);
|
|
128
|
+
if (p) out.push(p);
|
|
129
|
+
for (const line of para.split("\n")) {
|
|
130
|
+
const l = normaliseLine(line);
|
|
131
|
+
if (l && l !== p) out.push(l);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Is this text — or any paragraph of it — one of the retired placeholder
|
|
139
|
+
* replies?
|
|
140
|
+
*
|
|
141
|
+
* Used as the last gate before a send, so it answers the only question that
|
|
142
|
+
* matters there: would a reader see one of these sentences on screen. It does
|
|
143
|
+
* NOT try to judge whether a line is "generic" in general; `GENERIC_OPENER` in
|
|
144
|
+
* `plan-note.mjs` owns that, and this is the narrower, exact, un-arguable half.
|
|
145
|
+
*
|
|
146
|
+
* @param {unknown} text
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
export function isRetiredFirstReply(text) {
|
|
150
|
+
const segs = bodySegments(text);
|
|
151
|
+
if (!segs.length) return false;
|
|
152
|
+
return segs.some((s) => RETIRED_PATTERNS.some((re) => re.test(s)));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Does a body repeat one of its own paragraphs verbatim?
|
|
157
|
+
*
|
|
158
|
+
* The 15:04 defect, made checkable independently of WHICH sentence repeated.
|
|
159
|
+
* A body that says the same paragraph twice is telling the reader nothing the
|
|
160
|
+
* second time; whatever composed it has double-counted.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} body
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
export function repeatsItself(body) {
|
|
166
|
+
const paras = String(body == null ? "" : body)
|
|
167
|
+
.trim()
|
|
168
|
+
.split(/\n\s*\n/)
|
|
169
|
+
.map(normaliseLine)
|
|
170
|
+
.filter((p) => p.length >= 12); // a bare "Yes." or a bullet marker may legitimately recur
|
|
171
|
+
if (paras.length < 2) return false;
|
|
172
|
+
return new Set(paras).size !== paras.length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// THE SHAPE DECISION
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** The three things a first response can be. */
|
|
180
|
+
export const SHAPES = Object.freeze(["none", "react", "line"]);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Reactions, by what the message was. Deliberately tiny and deliberately dull:
|
|
184
|
+
* a reaction is an acknowledgement, not a performance, and a vocabulary big
|
|
185
|
+
* enough to be expressive is a vocabulary big enough to be wrong.
|
|
186
|
+
*
|
|
187
|
+
* `seen` is the default because it is the only one that is always honest — it
|
|
188
|
+
* claims nothing beyond "this reached me".
|
|
189
|
+
*/
|
|
190
|
+
export const REACTIONS = Object.freeze({
|
|
191
|
+
seen: "👀",
|
|
192
|
+
agreed: "👍",
|
|
193
|
+
thanks: "🙏",
|
|
194
|
+
done: "✅",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classifier actions that are, by their own verdict, nothing to answer.
|
|
199
|
+
* `archive` and `ignore` are the classifier saying so in as many words.
|
|
200
|
+
*/
|
|
201
|
+
const NO_REPLY_ACTIONS = Object.freeze(["archive", "ignore"]);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Does this inbound ASK something of the agent?
|
|
205
|
+
*
|
|
206
|
+
* A question mark is the strong signal but not the only one: "let me know what
|
|
207
|
+
* you think" and "can you take this" are questions without one. Kept narrow on
|
|
208
|
+
* purpose — the cost of a false negative here is an emoji where a sentence was
|
|
209
|
+
* owed, which is the failure the owner explicitly warned about ("an emoji is
|
|
210
|
+
* wrong for a question").
|
|
211
|
+
*
|
|
212
|
+
* @param {unknown} text
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
export function asksSomething(text) {
|
|
216
|
+
const t = normaliseLine(text);
|
|
217
|
+
if (!t) return false;
|
|
218
|
+
if (t.includes("?")) return true;
|
|
219
|
+
if (/\b(let me know|tell me|send me|can you|could you|would you|please|pls|need you to|thoughts\?*$|any update|update on|when can|how soon)\b/i.test(t)) return true;
|
|
220
|
+
// An interrogative opener is a question whatever the punctuation. Chat drops
|
|
221
|
+
// the question mark constantly ("what's the status on the raise", "when does
|
|
222
|
+
// the window close"), and a dropped mark must not downgrade a question to an
|
|
223
|
+
// emoji — that is the one outcome the owner named as wrong. Erring toward
|
|
224
|
+
// `true` costs a sentence where a reaction would have done; erring toward
|
|
225
|
+
// `false` leaves a question answered with a thumb.
|
|
226
|
+
if (/^(what|when|where|who|whose|which|why|how|is|are|was|were|do|does|did|can|could|would|should|will|have|has|any)\b/i.test(t)) return true;
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* THE DECISION: what is the first thing said about this inbound, if anything?
|
|
232
|
+
*
|
|
233
|
+
* The rules, in the order they are applied, because the order IS the policy:
|
|
234
|
+
*
|
|
235
|
+
* 1. Already covered by a batch ⇒ `none`. One flurry, one first reply. This
|
|
236
|
+
* is first because it outranks every other reason to speak: the batch's
|
|
237
|
+
* OWN first reply has already said whatever was owed.
|
|
238
|
+
* 2. Tier `answer` ⇒ `none`. Unchanged and load-bearing — the reply arrives
|
|
239
|
+
* in this turn, so anything in front of it is the content-free traffic
|
|
240
|
+
* `tier.mjs` was written to delete.
|
|
241
|
+
* 3. The classifier says archive/ignore ⇒ `none`.
|
|
242
|
+
* 4. Not addressed to this agent and asking nothing ⇒ `none`. This is the
|
|
243
|
+
* owner's "sometimes a reply isn't needed", scoped as narrowly as he
|
|
244
|
+
* scoped it ("only do this when it truly doesn't need any reply"): the
|
|
245
|
+
* agent must be neither the addressee NOR asked anything.
|
|
246
|
+
* 5. Addressed, but asking nothing, on a surface that has reactions ⇒
|
|
247
|
+
* `react`. The FYI case. An acknowledgement is owed — someone deliberately
|
|
248
|
+
* put this in front of this agent — but a sentence would be noise.
|
|
249
|
+
* 6. Otherwise ⇒ `line`, and the line is generated, never canned.
|
|
250
|
+
*
|
|
251
|
+
* Note rule 5's guard: `asksSomething` must be FALSE. A question never gets an
|
|
252
|
+
* emoji, whatever else is true of it. That is the distinction the owner drew
|
|
253
|
+
* and it is the one this function exists to make mechanical.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} [o]
|
|
256
|
+
* @param {"answer"|"work"|"plan"} [o.tier] from `replyTier`
|
|
257
|
+
* @param {string} [o.action] classifier action
|
|
258
|
+
* @param {boolean} [o.directed] was this agent addressed (mention, DM, assignment)
|
|
259
|
+
* @param {string} [o.text] the inbound body
|
|
260
|
+
* @param {boolean} [o.coveredByBatch] an earlier message in this flurry already owns the first reply
|
|
261
|
+
* @param {boolean} [o.reactionsAvailable] does this surface support a reaction
|
|
262
|
+
* @param {string} [o.reaction] which REACTIONS key, when one is right
|
|
263
|
+
* @returns {{shape:"none"|"react"|"line", emoji:string|null, reason:string}}
|
|
264
|
+
*/
|
|
265
|
+
export function firstReplyShape(o = {}) {
|
|
266
|
+
const a = o && typeof o === "object" ? o : {};
|
|
267
|
+
const none = (reason) => ({ shape: "none", emoji: null, reason });
|
|
268
|
+
|
|
269
|
+
if (a.coveredByBatch === true) return none("covered-by-batch");
|
|
270
|
+
if (a.tier === "answer") return none("tier-answer");
|
|
271
|
+
if (NO_REPLY_ACTIONS.includes(String(a.action || ""))) return none(`action-${a.action}`);
|
|
272
|
+
|
|
273
|
+
const asks = asksSomething(a.text);
|
|
274
|
+
if (a.directed !== true && !asks) return none("not-directed-nothing-asked");
|
|
275
|
+
|
|
276
|
+
if (!asks && a.reactionsAvailable === true) {
|
|
277
|
+
const key = Object.prototype.hasOwnProperty.call(REACTIONS, String(a.reaction || "")) ? String(a.reaction) : "seen";
|
|
278
|
+
return { shape: "react", emoji: REACTIONS[key], reason: "acknowledged-not-answered" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { shape: "line", emoji: null, reason: asks ? "question-owed-an-answer" : "no-reaction-surface" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// THE BATCH
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** How close together messages must be to count as one flurry. */
|
|
289
|
+
export const DEFAULT_BATCH_WINDOW_MS = 3 * 60_000;
|
|
290
|
+
|
|
291
|
+
/** The room a first reply would land in. Thread-aware: two threads in one
|
|
292
|
+
* channel are two conversations and each is owed its own first reply. */
|
|
293
|
+
function conversationKey(item) {
|
|
294
|
+
const i = item && typeof item === "object" ? item : {};
|
|
295
|
+
const svc = String(i.service || "").trim().toLowerCase() || "unknown";
|
|
296
|
+
const ch = String(i.channel_id || "").trim().toLowerCase() || "unknown";
|
|
297
|
+
const th = String(i.thread_id || "").trim().toLowerCase();
|
|
298
|
+
return th ? `${svc}|${ch}|t:${th}` : `${svc}|${ch}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const atOf = (item) => {
|
|
302
|
+
const v = item && (item.received_at ?? item.ts ?? item.createdAt ?? item.at);
|
|
303
|
+
const n = typeof v === "number" ? v : Date.parse(String(v || ""));
|
|
304
|
+
return Number.isFinite(n) ? n : null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Group a poll's worth of inbound into the flurries a human would recognise.
|
|
309
|
+
*
|
|
310
|
+
* "If there's a flurry of 4 messages in one go by others, instead of having a
|
|
311
|
+
* generic reply per message, it would be responding to the batch." So: same
|
|
312
|
+
* conversation, within `windowMs` of the previous message in that conversation,
|
|
313
|
+
* is one batch. The batch's FIRST message carries the reply; every later member
|
|
314
|
+
* is returned with `coveredByBatch`, which rule 1 of `firstReplyShape` turns
|
|
315
|
+
* into silence.
|
|
316
|
+
*
|
|
317
|
+
* The leader is the first message, not the last, because the first is the one
|
|
318
|
+
* the generated line is composed against — and every other member of the batch
|
|
319
|
+
* is handed to that composer as context, so the one line answers all four.
|
|
320
|
+
*
|
|
321
|
+
* PURE: the clock arrives as a field on each item, never from `Date.now()`.
|
|
322
|
+
* An item with no readable timestamp starts its own batch rather than being
|
|
323
|
+
* folded into whatever came before it — guessing a time is how four unrelated
|
|
324
|
+
* asks become one reply.
|
|
325
|
+
*
|
|
326
|
+
* @param {Array<object>} items
|
|
327
|
+
* @param {object} [o] {windowMs}
|
|
328
|
+
* @returns {Array<{key:string, leader:object, members:object[]}>}
|
|
329
|
+
*/
|
|
330
|
+
export function groupForFirstReply(items, o = {}) {
|
|
331
|
+
const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_BATCH_WINDOW_MS;
|
|
332
|
+
const list = Array.isArray(items) ? items.filter((i) => i && typeof i === "object") : [];
|
|
333
|
+
const ordered = list
|
|
334
|
+
.map((item, idx) => ({ item, idx, at: atOf(item) }))
|
|
335
|
+
.sort((x, y) => (x.at ?? Number.POSITIVE_INFINITY) - (y.at ?? Number.POSITIVE_INFINITY) || x.idx - y.idx);
|
|
336
|
+
|
|
337
|
+
const open = new Map(); // conversationKey → {batch, lastAt}
|
|
338
|
+
const batches = [];
|
|
339
|
+
for (const { item, at } of ordered) {
|
|
340
|
+
const key = conversationKey(item);
|
|
341
|
+
const cur = open.get(key);
|
|
342
|
+
const joinable = cur && at != null && cur.lastAt != null && at - cur.lastAt <= windowMs;
|
|
343
|
+
if (joinable) {
|
|
344
|
+
cur.batch.members.push(item);
|
|
345
|
+
cur.lastAt = at;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const batch = { key, leader: item, members: [item] };
|
|
349
|
+
batches.push(batch);
|
|
350
|
+
open.set(key, { batch, lastAt: at });
|
|
351
|
+
}
|
|
352
|
+
return batches;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The per-item verdict for a whole poll: every item, with the batch it belongs
|
|
357
|
+
* to and whether it is the one that speaks.
|
|
358
|
+
*
|
|
359
|
+
* @param {Array<object>} items
|
|
360
|
+
* @param {object} [o] {windowMs}
|
|
361
|
+
* @returns {Array<{item:object, key:string, leader:boolean, batchSize:number, coveredByBatch:boolean}>}
|
|
362
|
+
*/
|
|
363
|
+
export function firstReplyPlan(items, o = {}) {
|
|
364
|
+
const out = [];
|
|
365
|
+
for (const b of groupForFirstReply(items, o)) {
|
|
366
|
+
b.members.forEach((item, i) => {
|
|
367
|
+
out.push({
|
|
368
|
+
item,
|
|
369
|
+
key: b.key,
|
|
370
|
+
leader: i === 0,
|
|
371
|
+
batchSize: b.members.length,
|
|
372
|
+
coveredByBatch: i !== 0,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Stamp a poll's worth of inbound with its flurry verdict, in place.
|
|
381
|
+
*
|
|
382
|
+
* WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE DAEMON. It used to be four
|
|
383
|
+
* lines inside `pollService`, and that made the wiring untestable: the only
|
|
384
|
+
* call site was inside a module-internal function, so deleting it left every
|
|
385
|
+
* test — including one named "wired, not just available" — green. A defect that
|
|
386
|
+
* no test can detect is the exact shape that let the previous generation of
|
|
387
|
+
* this fix rot unnoticed, so the loop moved here, where a test can call it, and
|
|
388
|
+
* `scripts/daemon/agent-daemon.pollService` calls this.
|
|
389
|
+
*
|
|
390
|
+
* MUTATES ITS INPUT, deliberately. `processItem` is called per item further
|
|
391
|
+
* down the same function and reads the stamp off the item it is handed; a
|
|
392
|
+
* returned copy would be thrown away. The mutation is confined to two fields
|
|
393
|
+
* this module owns.
|
|
394
|
+
*
|
|
395
|
+
* @param {Array<object>} items the poll's surviving items, in arrival order
|
|
396
|
+
* @param {object} [o] {windowMs}
|
|
397
|
+
* @returns {Array<{item:object, key:string, leader:boolean, batchSize:number, coveredByBatch:boolean}>}
|
|
398
|
+
* the plan, for a caller that wants to log or assert on it
|
|
399
|
+
*/
|
|
400
|
+
export function stampFirstReplyPlan(items, o = {}) {
|
|
401
|
+
const plan = firstReplyPlan(items, o);
|
|
402
|
+
for (const p of plan) {
|
|
403
|
+
p.item.covered_by_batch = p.coveredByBatch;
|
|
404
|
+
p.item.batch_size = p.batchSize;
|
|
405
|
+
}
|
|
406
|
+
return plan;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export default {
|
|
410
|
+
RETIRED_FIRST_REPLIES,
|
|
411
|
+
SHAPES,
|
|
412
|
+
REACTIONS,
|
|
413
|
+
DEFAULT_BATCH_WINDOW_MS,
|
|
414
|
+
normaliseLine,
|
|
415
|
+
bodySegments,
|
|
416
|
+
isRetiredFirstReply,
|
|
417
|
+
repeatsItself,
|
|
418
|
+
asksSomething,
|
|
419
|
+
firstReplyShape,
|
|
420
|
+
groupForFirstReply,
|
|
421
|
+
firstReplyPlan,
|
|
422
|
+
stampFirstReplyPlan,
|
|
423
|
+
};
|