@cohortapp/agent-sdk 2.18.14 → 2.18.16
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/docs/runbooks/fleet-rollout.md +45 -1
- 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/comms/send-gate.mjs +59 -0
- package/lib/identity/persona.mjs +31 -2
- package/lib/org/inbound/hydrate.mjs +35 -1
- package/lib/org/inbound/project.mjs +3 -0
- package/lib/session/frontdoor.mjs +87 -8
- package/lib/session/handoffs.mjs +57 -0
- package/lib/session/inbox-claims.mjs +47 -2
- package/lib/session/revive.mjs +302 -5
- package/lib/telemetry/alerts.mjs +94 -0
- package/lib/telemetry/collect.mjs +250 -2
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +317 -14
- package/scripts/daemon/assurance.mjs +765 -45
- 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/fleet/rollout.mjs +292 -9
- package/scripts/hooks/pre-write-yaml-validate.mjs +63 -2
- package/scripts/local-triggers/autoupdate.sh +338 -20
|
@@ -79,12 +79,56 @@ summary}` — whose `reason` separates the cases the exit code merges:
|
|
|
79
79
|
|
|
80
80
|
| `reason` | meaning |
|
|
81
81
|
| --- | --- |
|
|
82
|
-
| `verified` | every seat reports the target version (`code` 0) |
|
|
82
|
+
| `verified` | every seat reports the target version **and** no seat is stuck (`code` 0) |
|
|
83
|
+
| `stuck` | at least one seat has attempted `@latest` repeatedly and failed — waiting will not fix it |
|
|
83
84
|
| `behind` | at least one seat is **provably** on an older version |
|
|
84
85
|
| `unverifiable` | no seat is known to be behind; nothing available can prove any seat is current |
|
|
85
86
|
| `empty-fleet` | no beating seats were found — never treated as done |
|
|
86
87
|
| `no-fleet-read` | the org read never succeeded; says nothing about the fleet |
|
|
87
88
|
|
|
89
|
+
### `behind` is waited out; `stuck` is visited
|
|
90
|
+
|
|
91
|
+
Every seat is `behind` for twenty minutes after a publish. Three seats were
|
|
92
|
+
behind for three weeks (2.17.0 while the fleet ran 2.18.13) and **nothing was in
|
|
93
|
+
an error state**: the hourly job fired, npm answered, the install ran, the health
|
|
94
|
+
gate failed honestly, the rollback worked, `last.json` recorded it and the beat
|
|
95
|
+
carried it. Every one of those records was correct. Only the REPETITION says the
|
|
96
|
+
seat will never arrive unaided, and no single record can hold it — so the org
|
|
97
|
+
learned it the way it always had, from a person noticing an old number in a
|
|
98
|
+
table.
|
|
99
|
+
|
|
100
|
+
`stuck` is that repetition, made sayable. A seat earns it two ways:
|
|
101
|
+
|
|
102
|
+
- **streak basis** — the seat counted its own consecutive failures
|
|
103
|
+
(`machine.upgrade.failStreak`, SDK 2.18.15+) and reached the threshold.
|
|
104
|
+
- **attempt basis** — an older beat carries one outcome and no count. A failed
|
|
105
|
+
attempt with the seat *still reporting the version it failed from* proves the
|
|
106
|
+
hop did not take; older than two failed-target holds (48 h) it also proves the
|
|
107
|
+
retry it was owed came and went. Below that it reads `failing`, not `stuck`:
|
|
108
|
+
a seat that has not been ASKED again is not a stuck seat.
|
|
109
|
+
|
|
110
|
+
Two things the table will not tell you, so they are said here:
|
|
111
|
+
|
|
112
|
+
- **`stuck` is not a subset of `behind`.** A seat's verdict is about *this run's*
|
|
113
|
+
target; its streak is about whatever `@latest` was when it last tried. A seat
|
|
114
|
+
can be current on the version you are verifying and unable to move off it.
|
|
115
|
+
- **The seat-side alert cannot cover the seats this is for.** `upgrade_stuck`
|
|
116
|
+
(`lib/telemetry/alerts.mjs`) is derived *on* the seat, so a seat too stale to
|
|
117
|
+
install the SDK never emits it — by construction the alert is blind to exactly
|
|
118
|
+
the machines it was written for. This tool is their cover, and it is automatic
|
|
119
|
+
because publishing is: stage 3 runs on every publish, and a stuck seat makes
|
|
120
|
+
the release command exit 3 with `reason: "stuck"` and the machine's name.
|
|
121
|
+
**Between publishes nothing watches them** — if a release is weeks away, run
|
|
122
|
+
`--verify-only` deliberately.
|
|
123
|
+
|
|
124
|
+
**What to do with a stuck seat:** it needs a person at the machine. `maestro
|
|
125
|
+
doctor` there, read `state/autoupdate/last.json` for the `reason`
|
|
126
|
+
(`install-failed` is a network problem; `unhealthy-rolled-back` means the gate
|
|
127
|
+
rejected the new version and the rollback worked; `rollback-unhealthy` means the
|
|
128
|
+
gate rejects the OLD version too, so the seat is sick independently of the
|
|
129
|
+
upgrade). Deleting `state/autoupdate/last.json` clears the 24 h hold and the
|
|
130
|
+
streak, and the next hourly run retries immediately.
|
|
131
|
+
|
|
88
132
|
`unverifiable` becomes `verified` the moment the one-line hq change below lands,
|
|
89
133
|
with no change to this script. `propagationOutcome()` is pure and exported, so
|
|
90
134
|
the mapping is pinned by test rather than by this table.
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/assurance/batch.mjs — the unit of acknowledgement is the CONVERSATION,
|
|
3
|
+
* not the message.
|
|
4
|
+
*
|
|
5
|
+
* THE COMPLAINT THIS EXISTS TO FIX
|
|
6
|
+
*
|
|
7
|
+
* "If there's a flurry of 4 messages in one go by others, instead of having a
|
|
8
|
+
* generic reply per message, it would be responding to the batch of messages."
|
|
9
|
+
*
|
|
10
|
+
* The acknowledgement path already counts per ROOM (`room-budget.mjs`: one
|
|
11
|
+
* interim per (seat, service, channel) per fifteen minutes) and per DEBT
|
|
12
|
+
* (`interimSaid`, latched and inherited across retries). Neither of those is
|
|
13
|
+
* the thing being asked for here, and it is worth being exact about why,
|
|
14
|
+
* because "the room budget already caps it at one" is the wrong answer:
|
|
15
|
+
*
|
|
16
|
+
* The room budget caps HOW MANY. It says nothing about WHICH, or WHEN.
|
|
17
|
+
*
|
|
18
|
+
* Under the budget alone, four messages arriving together open four debts; the
|
|
19
|
+
* sweep reaches whichever one `readdir` yielded first, composes a holding line
|
|
20
|
+
* from THAT ONE MESSAGE, sends it, and spends the room's fifteen minutes. The
|
|
21
|
+
* other three are latched silent. So the room does get exactly one line — and
|
|
22
|
+
* that line answers a quarter of what was said, chosen arbitrarily, while the
|
|
23
|
+
* sender watches three of their four messages go unmentioned. One arbitrary
|
|
24
|
+
* reply is not the same thing as one reply to the batch.
|
|
25
|
+
*
|
|
26
|
+
* Worse, the budget cannot wait. The sweep fires on the first debt to cross
|
|
27
|
+
* ACK_AFTER_MS, which is the FIRST message of the flurry. A person mid-flurry
|
|
28
|
+
* gets acknowledged for message one and then sends messages two, three and
|
|
29
|
+
* four into a room that has already spent its budget.
|
|
30
|
+
*
|
|
31
|
+
* THE MODEL
|
|
32
|
+
*
|
|
33
|
+
* A conversation is (service, channel) — deliberately the same key as
|
|
34
|
+
* `roomKey`, so this gate and the budget can never disagree about what a room
|
|
35
|
+
* is. Within a conversation, the open debts that may still speak form a BATCH.
|
|
36
|
+
* A batch has three times on it:
|
|
37
|
+
*
|
|
38
|
+
* firstAt the earliest arrival. The human has been waiting since here, so
|
|
39
|
+
* ACK_AFTER_MS is measured from it — unchanged from today.
|
|
40
|
+
* lastAt the latest arrival. The batch is not finished until it has been
|
|
41
|
+
* QUIET for `quietMs` after this, which is the whole point: an ack
|
|
42
|
+
* that fires mid-flurry cannot possibly address the flurry.
|
|
43
|
+
* span lastAt - firstAt, capped by `maxSpanMs` so a slow-drip
|
|
44
|
+
* conversation can never defer its acknowledgement forever.
|
|
45
|
+
*
|
|
46
|
+
* READY = aged past ACK_AFTER_MS, and (quiet, or spanned out, or urgent).
|
|
47
|
+
*
|
|
48
|
+
* WHY TWENTY SECONDS OF QUIET
|
|
49
|
+
*
|
|
50
|
+
* The quiet window is not a display-grouping heuristic (chat clients group at
|
|
51
|
+
* 30-60 s for layout; that is a rendering question, not an obligation one). It
|
|
52
|
+
* is the answer to "how long after someone's last message may I still expect
|
|
53
|
+
* another in the same breath". Twenty seconds is chosen because it is BELOW
|
|
54
|
+
* the floor at which a human expects any response at all — nothing is said
|
|
55
|
+
* before ACK_AFTER_MS (90 s) regardless, so in the common case where a flurry
|
|
56
|
+
* lands inside five seconds the quiet window costs exactly nothing and the ack
|
|
57
|
+
* still fires at 90 s — and ABOVE the inter-message gap of a real flurry,
|
|
58
|
+
* where a follow-up thought arrives while the previous message is still on
|
|
59
|
+
* screen. Raising it buys later acks for no additional coverage; lowering it
|
|
60
|
+
* starts splitting one person's two-part thought into two batches.
|
|
61
|
+
*
|
|
62
|
+
* WHAT HAPPENS TO A GENUINELY URGENT MESSAGE MID-WINDOW — the question this
|
|
63
|
+
* design has to answer honestly, because a quiet window is a delay and a delay
|
|
64
|
+
* is exactly the wrong thing to add to an emergency. Three answers, in order:
|
|
65
|
+
*
|
|
66
|
+
* 1. It was never delayed by this module in the first place. The quiet
|
|
67
|
+
* window only ever moves an acknowledgement — a courtesy — and only
|
|
68
|
+
* within the span between 90 s and `maxSpanMs`. The ANSWER is dispatched
|
|
69
|
+
* by the daemon the moment the item is classified; nothing here touches
|
|
70
|
+
* the session, the work, or the reply.
|
|
71
|
+
* 2. `isUrgent` (critical priority, or the classifier's urgent flags) takes
|
|
72
|
+
* the record OUT of the batch into a singleton of its own, which waives
|
|
73
|
+
* the quiet wait entirely: it acks at ACK_AFTER_MS on its own terms and
|
|
74
|
+
* does not wait behind its noisier neighbours. It still passes the room
|
|
75
|
+
* budget, so it cannot double up with a batch ack.
|
|
76
|
+
* 3. `maxSpanMs` (10 min) is the backstop for the case urgency flags miss: a
|
|
77
|
+
* conversation that never falls quiet is acknowledged anyway at ten
|
|
78
|
+
* minutes, because at that point the flurry IS the conversation and
|
|
79
|
+
* waiting for it to end is waiting forever.
|
|
80
|
+
*
|
|
81
|
+
* PURE. Everything arrives on the argument — the records, the clock, the
|
|
82
|
+
* windows. Nothing is read from disk, the environment or the network, so the
|
|
83
|
+
* whole matrix below is pinned by a test rather than sampled from a daemon.
|
|
84
|
+
* The environment is resolved by FUNCTIONS called at the edge, never folded
|
|
85
|
+
* into a constant at import time (the lesson `room-budget.mjs` records: a
|
|
86
|
+
* constant captured at module load means an operator who exports the variable
|
|
87
|
+
* afterwards gets the stale default with no error).
|
|
88
|
+
*
|
|
89
|
+
* @module lib/assurance/batch
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
"use strict";
|
|
93
|
+
|
|
94
|
+
const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How long a conversation must be QUIET before its batch may be acknowledged.
|
|
98
|
+
* See the header for why twenty seconds and not five or sixty.
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_BATCH_QUIET_MS = 20_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The hard cap on how long a batch may stay open waiting for quiet. Past this,
|
|
104
|
+
* the batch is acknowledged whether or not the flurry has stopped — the
|
|
105
|
+
* starvation guard, and the honest answer to a conversation that never stops.
|
|
106
|
+
*
|
|
107
|
+
* Ten minutes is sized against the measured session distribution (p50 14.7
|
|
108
|
+
* min): an acknowledgement that arrives later than this is competing with the
|
|
109
|
+
* answer, at which point silence is better.
|
|
110
|
+
*/
|
|
111
|
+
export const DEFAULT_BATCH_MAX_SPAN_MS = 10 * 60_000;
|
|
112
|
+
|
|
113
|
+
/** The configured quiet window. A FUNCTION, read at the edge — see the header. */
|
|
114
|
+
export function batchQuietMs(env = process.env) {
|
|
115
|
+
return num(env && env.ASSURANCE_BATCH_QUIET_MS, DEFAULT_BATCH_QUIET_MS);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The configured maximum batch span. Same reasoning. */
|
|
119
|
+
export function batchMaxSpanMs(env = process.env) {
|
|
120
|
+
return num(env && env.ASSURANCE_BATCH_MAX_SPAN_MS, DEFAULT_BATCH_MAX_SPAN_MS);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* PURE. The conversation's identity.
|
|
125
|
+
*
|
|
126
|
+
* (service, channel), normalised — BYTE-FOR-BYTE the same construction as
|
|
127
|
+
* `room-budget.roomKey`, and that is a requirement rather than a coincidence.
|
|
128
|
+
* If this module batched by thread while the budget counted by channel, a
|
|
129
|
+
* four-thread room would assemble four batches and the budget would silently
|
|
130
|
+
* refuse three of them: three batches latched silent having said nothing,
|
|
131
|
+
* which is the failure mode this whole file exists to end. One definition of a
|
|
132
|
+
* room, or the two gates disagree.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} [o] {service, channel}
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function conversationKey(o) {
|
|
138
|
+
const a = o && typeof o === "object" ? o : {};
|
|
139
|
+
const norm = (v) => String(v == null ? "" : v).trim().toLowerCase() || "unknown";
|
|
140
|
+
return `${norm(a.service)}|${norm(a.channel)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* PURE. Does this debt refuse to wait for its neighbours?
|
|
145
|
+
*
|
|
146
|
+
* Deliberately narrow. "Urgent" here does not mean "important" — it means "a
|
|
147
|
+
* twenty-second wait for the room to fall quiet is itself a defect". Only two
|
|
148
|
+
* signals qualify: the classifier's own `critical` priority, and an explicit
|
|
149
|
+
* urgent flag from the poller's priority signals. Anything looser (a keyword
|
|
150
|
+
* scan over the body, a CEO sender) would take most of a busy room out of
|
|
151
|
+
* batching and hand the flood back.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} rec an obligation record
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function isUrgent(rec) {
|
|
157
|
+
if (!rec || typeof rec !== "object") return false;
|
|
158
|
+
const p = String(rec.priority == null ? "" : rec.priority).trim().toLowerCase();
|
|
159
|
+
if (p === "critical") return true;
|
|
160
|
+
const sig = (rec.item && rec.item.priority_signals) || rec.priority_signals;
|
|
161
|
+
return !!(sig && typeof sig === "object" && sig.tagged_urgent === true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* PURE. When did this debt arrive?
|
|
166
|
+
*
|
|
167
|
+
* `openedAt`, and NOT `lastAttemptAt`: a retry restarts the work but it does
|
|
168
|
+
* not restart the conversation, and measuring a batch from a retry clock would
|
|
169
|
+
* let a single retrying debt drag its whole room's batch forward repeatedly.
|
|
170
|
+
* A record with no usable clock is treated as arriving `now`, which makes it
|
|
171
|
+
* the batch's newest member and therefore delays the ack rather than firing
|
|
172
|
+
* one early — the safe direction, because the cost is a later courtesy and the
|
|
173
|
+
* alternative cost is acknowledging a flurry that is still arriving.
|
|
174
|
+
*/
|
|
175
|
+
export function arrivalAt(rec, now) {
|
|
176
|
+
const t = rec && rec.openedAt;
|
|
177
|
+
return Number.isFinite(t) ? t : (Number.isFinite(now) ? now : 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* PURE. Split interim-eligible debts into the batches that will each earn at
|
|
182
|
+
* most ONE first response.
|
|
183
|
+
*
|
|
184
|
+
* Urgent records are extracted into singleton batches BEFORE grouping, so an
|
|
185
|
+
* urgent ask is never held behind a chatty neighbour and never drags a quiet
|
|
186
|
+
* conversation's batch forward.
|
|
187
|
+
*
|
|
188
|
+
* Returned batches carry their conversation key and their members sorted
|
|
189
|
+
* OLDEST FIRST, because that is the order the acknowledgement has to read them
|
|
190
|
+
* in and the order the carrier selection assumes.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} o
|
|
193
|
+
* @param {object[]} o.records interim-eligible obligation records
|
|
194
|
+
* @param {number} o.now
|
|
195
|
+
* @returns {{conversation:string, urgent:boolean, records:object[]}[]}
|
|
196
|
+
*/
|
|
197
|
+
export function groupIntoBatches(o = {}) {
|
|
198
|
+
const records = Array.isArray(o.records) ? o.records.filter((r) => r && typeof r === "object") : [];
|
|
199
|
+
const now = Number.isFinite(o.now) ? o.now : 0;
|
|
200
|
+
const batches = [];
|
|
201
|
+
const grouped = new Map();
|
|
202
|
+
|
|
203
|
+
for (const rec of records) {
|
|
204
|
+
const conv = conversationKey(rec);
|
|
205
|
+
if (isUrgent(rec)) {
|
|
206
|
+
batches.push({ conversation: conv, urgent: true, records: [rec] });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!grouped.has(conv)) grouped.set(conv, []);
|
|
210
|
+
grouped.get(conv).push(rec);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const [conversation, group] of grouped) {
|
|
214
|
+
// Oldest first. Ties broken on `key` so two records stamped in the same
|
|
215
|
+
// millisecond order identically in every process — a sweep that picked a
|
|
216
|
+
// different carrier per tick would be a different message each time.
|
|
217
|
+
group.sort((a, b) => {
|
|
218
|
+
const d = arrivalAt(a, now) - arrivalAt(b, now);
|
|
219
|
+
return d !== 0 ? d : String(a.key || "").localeCompare(String(b.key || ""));
|
|
220
|
+
});
|
|
221
|
+
batches.push({ conversation, urgent: false, records: group });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return batches;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* PURE. Is this batch ready to be acknowledged, and if so, by whom?
|
|
229
|
+
*
|
|
230
|
+
* THE CARRIER IS THE NEWEST RECORD, not the oldest. The acknowledgement is
|
|
231
|
+
* about to be read directly underneath the most recent thing the person said,
|
|
232
|
+
* and a line that answers the oldest message of four reads as an agent that
|
|
233
|
+
* stopped listening three messages ago. The older members are `speaksFor`:
|
|
234
|
+
* they are latched silent by the caller and their content is handed to the
|
|
235
|
+
* composer, so the one line that does go out was written with all four
|
|
236
|
+
* messages in front of it.
|
|
237
|
+
*
|
|
238
|
+
* `ready:false` is NOT a refusal and the caller must not latch anything on it.
|
|
239
|
+
* It means "not yet" — the batch is still assembling, and the next sweep tick
|
|
240
|
+
* will ask again. That distinction is the first of the two boundary pins:
|
|
241
|
+
* a flurry's LAST message must be able to join the batch it belongs to, which
|
|
242
|
+
* it can only do if an unready batch leaves every member untouched.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} o
|
|
245
|
+
* @param {{records:object[], urgent?:boolean}} o.batch
|
|
246
|
+
* @param {number} o.now
|
|
247
|
+
* @param {number} o.ackAfterMs nothing is said before this, measured from firstAt
|
|
248
|
+
* @param {number} [o.quietMs]
|
|
249
|
+
* @param {number} [o.maxSpanMs]
|
|
250
|
+
* @returns {{ready:boolean, reason:string, carrier:object|null, speaksFor:object[],
|
|
251
|
+
* firstAt:number, lastAt:number, size:number, waitMs:number}}
|
|
252
|
+
*/
|
|
253
|
+
export function batchVerdict(o = {}) {
|
|
254
|
+
const batch = o.batch && typeof o.batch === "object" ? o.batch : {};
|
|
255
|
+
const records = Array.isArray(batch.records) ? batch.records.filter(Boolean) : [];
|
|
256
|
+
const now = Number.isFinite(o.now) ? o.now : 0;
|
|
257
|
+
const ackAfterMs = Number.isFinite(o.ackAfterMs) ? o.ackAfterMs : 0;
|
|
258
|
+
const quietMs = Number.isFinite(o.quietMs) ? o.quietMs : DEFAULT_BATCH_QUIET_MS;
|
|
259
|
+
const maxSpanMs = Number.isFinite(o.maxSpanMs) ? o.maxSpanMs : DEFAULT_BATCH_MAX_SPAN_MS;
|
|
260
|
+
|
|
261
|
+
const empty = { ready: false, reason: "empty-batch", carrier: null, speaksFor: [], firstAt: 0, lastAt: 0, size: 0, waitMs: 0 };
|
|
262
|
+
if (!records.length) return empty;
|
|
263
|
+
|
|
264
|
+
const stamped = records.map((r) => ({ rec: r, at: arrivalAt(r, now) }));
|
|
265
|
+
const firstAt = Math.min(...stamped.map((s) => s.at));
|
|
266
|
+
const lastAt = Math.max(...stamped.map((s) => s.at));
|
|
267
|
+
const size = records.length;
|
|
268
|
+
|
|
269
|
+
// Newest wins the carrier; `key` breaks a same-millisecond tie the same way
|
|
270
|
+
// in every process.
|
|
271
|
+
let carrierEntry = stamped[0];
|
|
272
|
+
for (const s of stamped) {
|
|
273
|
+
if (s.at > carrierEntry.at) { carrierEntry = s; continue; }
|
|
274
|
+
if (s.at === carrierEntry.at
|
|
275
|
+
&& String(s.rec.key || "").localeCompare(String(carrierEntry.rec.key || "")) > 0) carrierEntry = s;
|
|
276
|
+
}
|
|
277
|
+
const carrier = carrierEntry.rec;
|
|
278
|
+
const speaksFor = records.filter((r) => r !== carrier);
|
|
279
|
+
|
|
280
|
+
const age = now - firstAt;
|
|
281
|
+
const quiet = now - lastAt;
|
|
282
|
+
const span = lastAt - firstAt;
|
|
283
|
+
|
|
284
|
+
// TIME first, and measured from firstAt, exactly as branch (e) does today.
|
|
285
|
+
if (age < ackAfterMs) {
|
|
286
|
+
return { ready: false, reason: "not-yet-aged", carrier, speaksFor, firstAt, lastAt, size, waitMs: ackAfterMs - age };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// URGENT waives the quiet wait — see the header's answer 2.
|
|
290
|
+
if (batch.urgent === true) {
|
|
291
|
+
return { ready: true, reason: "urgent", carrier, speaksFor, firstAt, lastAt, size, waitMs: 0 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// SPANNED OUT — the starvation guard. Checked BEFORE quiet, because a
|
|
295
|
+
// conversation that has run past the cap is ready whether or not the current
|
|
296
|
+
// instant happens to be quiet, and ordering it the other way would make the
|
|
297
|
+
// reported reason depend on the arrival jitter rather than the rule.
|
|
298
|
+
if (span >= maxSpanMs) {
|
|
299
|
+
return { ready: true, reason: "max-span", carrier, speaksFor, firstAt, lastAt, size, waitMs: 0 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// QUIET — the flurry has stopped.
|
|
303
|
+
if (quiet < quietMs) {
|
|
304
|
+
return { ready: false, reason: "flurry-open", carrier, speaksFor, firstAt, lastAt, size, waitMs: quietMs - quiet };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { ready: true, reason: size > 1 ? "batch-settled" : "settled", carrier, speaksFor, firstAt, lastAt, size, waitMs: 0 };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* PURE. Render a batch's messages as the context the acknowledgement is
|
|
312
|
+
* composed from — OLDEST FIRST, so the line reads as a reply to a
|
|
313
|
+
* conversation rather than to a list.
|
|
314
|
+
*
|
|
315
|
+
* WHAT THIS DELIBERATELY DOES NOT DO: it does not count. "I have four
|
|
316
|
+
* messages" is a report about the inbox, not a reply to a person, and it is
|
|
317
|
+
* the exact register the owner rejected. The composer is handed the messages
|
|
318
|
+
* themselves and nothing else; how the line reads is
|
|
319
|
+
* `assurance.buildAckUserPrompt`'s to decide.
|
|
320
|
+
*
|
|
321
|
+
* @param {object[]} records
|
|
322
|
+
* @param {object} [o] {perMessageChars, maxMessages}
|
|
323
|
+
* @returns {{sender:string|null, text:string}[]}
|
|
324
|
+
*/
|
|
325
|
+
export function batchMessages(records, o = {}) {
|
|
326
|
+
const perMessageChars = Number.isFinite(o.perMessageChars) ? o.perMessageChars : 400;
|
|
327
|
+
// Six is the most a short acknowledgement can meaningfully address; past
|
|
328
|
+
// that the oldest are already stale and the line would be a summary.
|
|
329
|
+
const maxMessages = Number.isFinite(o.maxMessages) ? o.maxMessages : 6;
|
|
330
|
+
const list = Array.isArray(records) ? records.filter(Boolean) : [];
|
|
331
|
+
const tail = list.slice(-maxMessages);
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const rec of tail) {
|
|
334
|
+
const item = (rec && rec.item) || {};
|
|
335
|
+
const text = String(item.content || item.subject || rec.summary || "").trim();
|
|
336
|
+
if (!text) continue;
|
|
337
|
+
out.push({ sender: item.sender || rec.sender || null, text: text.slice(0, perMessageChars) });
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export default {
|
|
343
|
+
DEFAULT_BATCH_QUIET_MS,
|
|
344
|
+
DEFAULT_BATCH_MAX_SPAN_MS,
|
|
345
|
+
batchQuietMs,
|
|
346
|
+
batchMaxSpanMs,
|
|
347
|
+
conversationKey,
|
|
348
|
+
isUrgent,
|
|
349
|
+
arrivalAt,
|
|
350
|
+
groupIntoBatches,
|
|
351
|
+
batchVerdict,
|
|
352
|
+
batchMessages,
|
|
353
|
+
};
|