@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
|
@@ -35,6 +35,7 @@ import { recordOutbound } from "../../lib/comms/receipts.mjs";
|
|
|
35
35
|
// inbound projection stamps into `raw_ref`. Imported rather than re-listed so a
|
|
36
36
|
// new surface cannot be added upstream without this file's switch noticing.
|
|
37
37
|
import { ROOM_SURFACES, SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
|
|
38
|
+
import { isRetiredFirstReply } from "../../lib/assurance/first-reply.mjs";
|
|
38
39
|
|
|
39
40
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
40
41
|
|
|
@@ -741,11 +742,117 @@ export function scaffoldMarkerLeak(text) {
|
|
|
741
742
|
* escalate rather than lose the message to an exception. `permanent`
|
|
742
743
|
* means retrying cannot help (no route, or a NOT_FOUND/BAD_REQUEST).
|
|
743
744
|
*/
|
|
745
|
+
/**
|
|
746
|
+
* Can this inbound be answered with a REACTION rather than a sentence?
|
|
747
|
+
*
|
|
748
|
+
* Only a Cohort message in a real room can: a reaction is attached to a message
|
|
749
|
+
* id in a channel, and that is the one surface where both exist. A board
|
|
750
|
+
* comment, a doc comment, an email and a Slack item all answer `false` here
|
|
751
|
+
* even where the underlying product has reactions, because this daemon has no
|
|
752
|
+
* route to them — and a shape decision that promises a reaction the transport
|
|
753
|
+
* cannot make would turn "acknowledge cheaply" into "say nothing at all".
|
|
754
|
+
*
|
|
755
|
+
* @param {object} item
|
|
756
|
+
* @returns {boolean}
|
|
757
|
+
*/
|
|
758
|
+
export function canReactTo(item) {
|
|
759
|
+
if (!item || item.service !== "cohort") return false;
|
|
760
|
+
const route = cohortReplyRoute(item);
|
|
761
|
+
if (route.transport !== "channel") return false;
|
|
762
|
+
return Boolean(str(item.message_id));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Put a reaction on the message that arrived.
|
|
767
|
+
*
|
|
768
|
+
* The cheapest honest acknowledgement there is: it tells the sender their
|
|
769
|
+
* message reached a person, and it adds no row to the channel. That second
|
|
770
|
+
* property is the whole argument for it — the measured flood
|
|
771
|
+
* (`lib/assurance/tier.mjs`) was 3,069 acknowledgement ROWS, and a reaction is
|
|
772
|
+
* an acknowledgement that is not a row.
|
|
773
|
+
*
|
|
774
|
+
* Fail-open and quiet: a reaction that cannot be made is not an error worth
|
|
775
|
+
* escalating, it is a reason to write the line instead, and the caller treats
|
|
776
|
+
* `{sent:false}` exactly that way.
|
|
777
|
+
*
|
|
778
|
+
* @param {object} item
|
|
779
|
+
* @param {string} emoji
|
|
780
|
+
* @param {object} [o] {cfg, agentRoot, reactImpl, fetchImpl} test seams
|
|
781
|
+
* @returns {Promise<{sent:boolean, via:string|null, error?:string}>}
|
|
782
|
+
*/
|
|
783
|
+
export async function deliverReaction(item, emoji, o = {}) {
|
|
784
|
+
if (!canReactTo(item)) return { sent: false, via: null, error: "no reaction surface" };
|
|
785
|
+
const e = str(emoji);
|
|
786
|
+
if (!e) return { sent: false, via: null, error: "no emoji" };
|
|
787
|
+
try {
|
|
788
|
+
const route = cohortReplyRoute(item);
|
|
789
|
+
const agentRoot = o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
790
|
+
const cfg = o.cfg || (await import("../../lib/org/client.mjs")).loadOrgConfig(agentRoot);
|
|
791
|
+
const reactImpl = o.reactImpl || (await import("../../lib/org/messaging.mjs")).reactMessage;
|
|
792
|
+
const frame = await reactImpl(
|
|
793
|
+
{ channel: route.channelId, messageId: str(item.message_id), emoji: e },
|
|
794
|
+
{ cfg, agentRoot, idempotencyKey: `react-${str(item.message_id)}-${e}`, ...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}) },
|
|
795
|
+
);
|
|
796
|
+
if (frame && frame.ok) {
|
|
797
|
+
recordOutbound({ service: "cohort", channel: route.channelId, kind: "reaction", via: "cohort", chars: 0, agentRoot });
|
|
798
|
+
return { sent: true, via: "cohort" };
|
|
799
|
+
}
|
|
800
|
+
return { sent: false, via: null, error: str(frame && frame.error && frame.error.message) || "react failed" };
|
|
801
|
+
} catch (err) {
|
|
802
|
+
return { sent: false, via: null, error: (err && err.message) || String(err) };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
744
806
|
export async function deliver(item, text, o = {}) {
|
|
745
807
|
const kind = o.kind || "reply";
|
|
746
808
|
if (!item || !text || !String(text).trim()) {
|
|
747
809
|
return { sent: false, via: null, error: "nothing to deliver" };
|
|
748
810
|
}
|
|
811
|
+
// ── THE REGISTER, AT THE CHOKEPOINT ──────────────────────────────────────
|
|
812
|
+
// Every outbound this daemon makes crosses this function — the answer, the
|
|
813
|
+
// acknowledgement and the assurance sweep alike — which is why the scaffold
|
|
814
|
+
// check below lives here rather than at three call sites. The retired
|
|
815
|
+
// placeholder replies get the same treatment, and for a reason the composer
|
|
816
|
+
// guards cannot cover:
|
|
817
|
+
//
|
|
818
|
+
// On 2026-09-25 two seats (Ravi Patel, Daniel Connors) were still emitting
|
|
819
|
+
// "On it." / "Looking now." / "On it — digging in now." — 251 of them in six
|
|
820
|
+
// days, the last at 11:28Z that morning — with `clientMsgId` ending `-ack`,
|
|
821
|
+
// i.e. through this very path, while `AgentStatus.machine.daemon` reported
|
|
822
|
+
// sdkVersion 2.18.13 and `versionsAgree: true`. The published package at that
|
|
823
|
+
// version contains none of those strings and `sanitiseAckText` returns null
|
|
824
|
+
// for all three. The explanation is that the daemon runs
|
|
825
|
+
// `$AGENT_ROOT/scripts/daemon/maestro-daemon.mjs` — the seat's PRESERVED
|
|
826
|
+
// LOCAL COPY — and `versionsAgree` compares two npm version numbers, which
|
|
827
|
+
// says nothing whatever about that tree.
|
|
828
|
+
//
|
|
829
|
+
// WHAT THIS GUARD DOES AND DOES NOT REACH — stated exactly, because the first
|
|
830
|
+
// version of this comment overclaimed it and a reader would have taken the
|
|
831
|
+
// stale-seat vector for closed.
|
|
832
|
+
//
|
|
833
|
+
// IT DOES protect every outbound of an UPDATED daemon, at the last point
|
|
834
|
+
// before bytes leave the machine, whatever composed the text:
|
|
835
|
+
// the answer, the acknowledgement, the assurance sweep, and any
|
|
836
|
+
// composer added later that nobody remembers to guard. That is
|
|
837
|
+
// the case for putting it here rather than at three call sites.
|
|
838
|
+
// IT DOES NOT reach the two seats that were actually emitting these
|
|
839
|
+
// sentences. This file is `scripts/daemon/deliver.mjs`, and it
|
|
840
|
+
// lives in the very tree those seats have preserved a stale copy
|
|
841
|
+
// of — a stale tree ships a stale deliver.mjs alongside its
|
|
842
|
+
// stale composer. THE REMEDY FOR THOSE SEATS IS THE ROLLOUT
|
|
843
|
+
// (publish, then `scripts/local-triggers/autoupdate.sh` on each
|
|
844
|
+
// box, verified by a server-recorded presence.beat rather than
|
|
845
|
+
// by `versionsAgree`). Nothing in this file can shorten that.
|
|
846
|
+
//
|
|
847
|
+
// The other plane is covered separately: `lib/comms/send-gate.screenOutbound`
|
|
848
|
+
// and `screenHookPayload` apply the same register to `messaging_send`,
|
|
849
|
+
// `email_send`, `email_draft_send` and `org_call_share_step`, which is how a
|
|
850
|
+
// SPAWNED SESSION typing "On it." itself gets refused — that path never
|
|
851
|
+
// crosses this function.
|
|
852
|
+
if (isRetiredFirstReply(text)) {
|
|
853
|
+
try { console.error(`[deliver] refused: retired placeholder reply in outbound body ${JSON.stringify(String(text).slice(0, 120))} — this is a stale composer, not a message (permanent, not retrying)`); } catch { /* */ }
|
|
854
|
+
return { sent: false, via: null, permanent: true, channel: item && item.channel_id, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: retired placeholder reply — the first line must be generated from the actual thread, or nothing" };
|
|
855
|
+
}
|
|
749
856
|
const leak = scaffoldMarkerLeak(text);
|
|
750
857
|
if (leak) {
|
|
751
858
|
const surface = (item && item.channel_id) ? "room" : "unknown";
|
|
@@ -827,6 +934,8 @@ export async function deliverWithRetry(item, text, o = {}) {
|
|
|
827
934
|
export default {
|
|
828
935
|
deliver,
|
|
829
936
|
deliverWithRetry,
|
|
937
|
+
canReactTo,
|
|
938
|
+
deliverReaction,
|
|
830
939
|
resolveSlackChannel,
|
|
831
940
|
replyTargetOf,
|
|
832
941
|
canDeliverTo,
|
|
@@ -6,7 +6,7 @@ import { spawn } from "child_process";
|
|
|
6
6
|
import { appendFileSync, mkdirSync, writeFileSync, readFileSync, renameSync, existsSync, readdirSync, unlinkSync } from "fs";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import { join, dirname } from "path";
|
|
9
|
-
import { releaseLock, releaseThreadLock, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
|
|
9
|
+
import { releaseLock, releaseThreadLock, threadLockKey, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
|
|
10
10
|
import { promoteDeferred } from "./inbox-deferral.mjs";
|
|
11
11
|
import { recordSession } from "./health.mjs";
|
|
12
12
|
import { startTyping, stopTyping } from "./typing-registry.mjs";
|
|
@@ -2020,7 +2020,16 @@ function spawnSession(entry) {
|
|
|
2020
2020
|
{
|
|
2021
2021
|
const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
|
|
2022
2022
|
if (channel) {
|
|
2023
|
-
|
|
2023
|
+
// THE SAME KEY THE DAEMON TOOK. `item.thread_id` alone is not that
|
|
2024
|
+
// key: an untreaded post locks `channel-main` and a Cohort DM locks
|
|
2025
|
+
// `dm-channel`, and neither is derivable from an empty thread id
|
|
2026
|
+
// without the item's own DM verdict. One derivation, both sides.
|
|
2027
|
+
releaseThreadLock(channel, threadLockKey({
|
|
2028
|
+
channel,
|
|
2029
|
+
threadId: item.thread_id,
|
|
2030
|
+
isDm: item.is_dm === true,
|
|
2031
|
+
channelMainLock: String(process.env.MAESTRO_CHANNEL_MAIN_LOCK ?? "1") !== "0",
|
|
2032
|
+
}));
|
|
2024
2033
|
// Now that the lock is gone, promote any messages that were
|
|
2025
2034
|
// deferred behind it. Latest-wins: a burst of N messages
|
|
2026
2035
|
// collapses into ONE re-dispatch carrying the most recent
|
|
@@ -2157,7 +2166,16 @@ function spawnSession(entry) {
|
|
|
2157
2166
|
{
|
|
2158
2167
|
const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
|
|
2159
2168
|
if (channel) {
|
|
2160
|
-
|
|
2169
|
+
// THE SAME KEY THE DAEMON TOOK. `item.thread_id` alone is not that
|
|
2170
|
+
// key: an untreaded post locks `channel-main` and a Cohort DM locks
|
|
2171
|
+
// `dm-channel`, and neither is derivable from an empty thread id
|
|
2172
|
+
// without the item's own DM verdict. One derivation, both sides.
|
|
2173
|
+
releaseThreadLock(channel, threadLockKey({
|
|
2174
|
+
channel,
|
|
2175
|
+
threadId: item.thread_id,
|
|
2176
|
+
isDm: item.is_dm === true,
|
|
2177
|
+
channelMainLock: String(process.env.MAESTRO_CHANNEL_MAIN_LOCK ?? "1") !== "0",
|
|
2178
|
+
}));
|
|
2161
2179
|
const promo = promoteDeferred(channel, AGENT_REPO_DIR);
|
|
2162
2180
|
if (promo.promoted > 0) {
|
|
2163
2181
|
logSession({
|
|
@@ -18,11 +18,26 @@
|
|
|
18
18
|
* every `.deferred` file in any service inbox dir whose body
|
|
19
19
|
* references `channel`. If exactly one is found, renames it back
|
|
20
20
|
* to its original name (next poll picks it up). If N>1 are found,
|
|
21
|
-
* keeps only the LATEST (by timestamp)
|
|
22
|
-
* marks the others `.processed-bundled` for
|
|
23
|
-
* the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* keeps only the LATEST (by timestamp) of each BUNDLING GROUP,
|
|
22
|
+
* promotes those, and marks the others `.processed-bundled` for
|
|
23
|
+
* the audit trail.
|
|
24
|
+
*
|
|
25
|
+
* WHAT A BUNDLING GROUP IS, AND WHY IT IS NOT JUST "THE SURFACE".
|
|
26
|
+
* The collapse is only sound when the promoted item genuinely
|
|
27
|
+
* carries what is being folded away. That used to be asserted
|
|
28
|
+
* unconditionally — "the latest item's `thread_context` already
|
|
29
|
+
* contains the prior messages" — and it is true of a Slack
|
|
30
|
+
* channel or DM item and FALSE of a Cohort space post, which
|
|
31
|
+
* `lib/org/inbound` never hydrates a thread context for. Fold a
|
|
32
|
+
* second person's question into one of those and it is not
|
|
33
|
+
* bundled, it is deleted: marked `.processed-bundled` and never
|
|
34
|
+
* answered by anyone.
|
|
35
|
+
*
|
|
36
|
+
* So a group is (surface, sender) — a person's own flurry always
|
|
37
|
+
* collapses to their latest word — UNLESS the surface's newest
|
|
38
|
+
* item carries `thread_context`, in which case the whole surface
|
|
39
|
+
* collapses as before, because the session really will see every
|
|
40
|
+
* message it stands for.
|
|
26
41
|
*
|
|
27
42
|
* The file format is the YAML emitted by the slack/gmail/calendar
|
|
28
43
|
* pollers (a flat top-level object with quoted scalar fields, plus an
|
|
@@ -155,6 +170,42 @@ function readScalar(body, field) {
|
|
|
155
170
|
return null;
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Does this file carry a TOP-LEVEL key at all, block scalar or not?
|
|
175
|
+
*
|
|
176
|
+
* `readScalar` answers "what is this field's single-line value", which is the
|
|
177
|
+
* wrong question for `thread_context`: the pollers write it as a block scalar
|
|
178
|
+
* (`thread_context: |`), so its value never lives on the key's own line. All
|
|
179
|
+
* the bundling decision needs to know is whether the field is PRESENT — the
|
|
180
|
+
* pollers emit it only when there is history to emit (`if (item.thread_context)`
|
|
181
|
+
* in scripts/poller/utils.mjs), so presence is exactly "this item carries the
|
|
182
|
+
* conversation around it".
|
|
183
|
+
*
|
|
184
|
+
* @param {string} body
|
|
185
|
+
* @param {string} field
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
function hasTopLevelKey(body, field) {
|
|
189
|
+
if (typeof body !== "string") return false;
|
|
190
|
+
// TWO MECHANISMS, ONE PROPERTY, and the redundancy is stated rather than
|
|
191
|
+
// pretended away: the pattern is anchored at column 0, so an indented line
|
|
192
|
+
// cannot match it and the `continue` below is a BELT, not the mechanism.
|
|
193
|
+
// Deleting the `continue` alone changes no behaviour — a mutation of it
|
|
194
|
+
// leaves the suite green, and that is honest rather than a hole. What the
|
|
195
|
+
// suite does refuse is the realistic future edit: loosening this anchor to
|
|
196
|
+
// `^\s*` (the shape `readScalar` had before audit L7) with nothing else in
|
|
197
|
+
// the way. That is pinned as a BEHAVIOUR, in inbox-deferral.test.mjs — "L7:
|
|
198
|
+
// body line `thread_context:` in a block scalar does not fake carrying
|
|
199
|
+
// history" — because the property worth pinning is that a colleague's
|
|
200
|
+
// question survives, not which of two lines delivered it.
|
|
201
|
+
const re = new RegExp(`^${escapeRegExp(field)}\\s*:`);
|
|
202
|
+
for (const line of body.split("\n")) {
|
|
203
|
+
if (/^\s/.test(line)) continue; // indented — belongs to a block, not top level
|
|
204
|
+
if (re.test(line)) return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
/**
|
|
159
210
|
* Promote every `.deferred` item targeting `channel` back into the
|
|
160
211
|
* live inbox. Bundles bursts: if multiple deferred items exist for
|
|
@@ -219,6 +270,26 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
219
270
|
readScalar(body, "thread_id") ||
|
|
220
271
|
readScalar(body, "scope_id") ||
|
|
221
272
|
"",
|
|
273
|
+
// WHO SENT IT, and WHETHER THE ITEM CARRIES ITS CONVERSATION.
|
|
274
|
+
//
|
|
275
|
+
// These two decide whether bundling is a bundle or a deletion. The
|
|
276
|
+
// latest-wins collapse is justified by one sentence in this module's
|
|
277
|
+
// header — "the latest item's thread_context already contains the
|
|
278
|
+
// prior messages as conversation history, so Claude sees everything" —
|
|
279
|
+
// and that sentence is TRUE OF SOME ITEMS AND FALSE OF OTHERS. Slack
|
|
280
|
+
// channel and DM items carry `thread_context` (slack-poller.mjs:434,
|
|
281
|
+
// :558); a Cohort space post does NOT, because `lib/org/inbound`
|
|
282
|
+
// never hydrates one for an untreaded space message. Bundling a
|
|
283
|
+
// second person's ask into a Cohort item that carries no history is
|
|
284
|
+
// not folding a duplicate away, it is marking their question
|
|
285
|
+
// `.processed-bundled` and never answering it.
|
|
286
|
+
//
|
|
287
|
+
// So: the collapse now requires evidence. Same sender is always safe
|
|
288
|
+
// (a person's own flurry — their newest message is their latest word
|
|
289
|
+
// either way). A DIFFERENT sender may only be folded into an item that
|
|
290
|
+
// actually carries the room's history.
|
|
291
|
+
sender: (readScalar(body, "sender") || "").trim().toLowerCase(),
|
|
292
|
+
carriesHistory: hasTopLevelKey(body, "thread_context"),
|
|
222
293
|
});
|
|
223
294
|
}
|
|
224
295
|
if (matches.length === 0) continue;
|
|
@@ -226,13 +297,35 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
226
297
|
// Group by surface within the channel, then bundle latest-wins WITHIN each
|
|
227
298
|
// group. Distinct surfaces each promote their own latest; none is dropped
|
|
228
299
|
// just because a newer message landed on a different surface in the room.
|
|
229
|
-
const
|
|
300
|
+
const surfaces = new Map();
|
|
230
301
|
for (const m of matches) {
|
|
231
|
-
if (!
|
|
232
|
-
|
|
302
|
+
if (!surfaces.has(m.surface)) surfaces.set(m.surface, []);
|
|
303
|
+
surfaces.get(m.surface).push(m);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// SPLIT A SURFACE BY SENDER UNLESS ITS WINNER CARRIES THE HISTORY.
|
|
307
|
+
// With history present this is byte-for-byte the old behaviour: one group
|
|
308
|
+
// per surface, latest wins, everything else bundled. Without it, each
|
|
309
|
+
// sender keeps their own latest and promotes it — so two people's asks
|
|
310
|
+
// serialise into two sessions rather than one of them disappearing.
|
|
311
|
+
const groups = [];
|
|
312
|
+
for (const group of surfaces.values()) {
|
|
313
|
+
group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
314
|
+
const winner = group[group.length - 1];
|
|
315
|
+
const senders = new Set(group.map((m) => m.sender));
|
|
316
|
+
if (senders.size <= 1 || winner.carriesHistory) {
|
|
317
|
+
groups.push(group);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const bySender = new Map();
|
|
321
|
+
for (const m of group) {
|
|
322
|
+
if (!bySender.has(m.sender)) bySender.set(m.sender, []);
|
|
323
|
+
bySender.get(m.sender).push(m);
|
|
324
|
+
}
|
|
325
|
+
for (const g of bySender.values()) groups.push(g);
|
|
233
326
|
}
|
|
234
327
|
|
|
235
|
-
for (const group of groups
|
|
328
|
+
for (const group of groups) {
|
|
236
329
|
// Latest-wins: lex-sort ISO timestamps, take the most recent.
|
|
237
330
|
group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
238
331
|
const latest = group[group.length - 1];
|
|
@@ -311,6 +311,37 @@ export function checkRecentlySent(channel, threadTs, type) {
|
|
|
311
311
|
return { allowed: true };
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
/**
|
|
315
|
+
* PURE. The ONE definition of which conversation a thread lock covers.
|
|
316
|
+
*
|
|
317
|
+
* WHY THIS EXISTS AS A FUNCTION. The normalisation used to be written twice —
|
|
318
|
+
* once where the lock is taken (agent-daemon, as an inline ternary) and once
|
|
319
|
+
* where it is released (`releaseThreadLock`, as a different inline rule) — and
|
|
320
|
+
* the two did not agree. The acquiring side turned a DM into `dm-channel` from
|
|
321
|
+
* `item.is_dm`; the releasing side only recognised a DM by a channel id
|
|
322
|
+
* starting with "D", which is a SLACK id shape. A Cohort DM therefore took a
|
|
323
|
+
* `dm-channel` lock and released nothing, and the room stayed locked for the
|
|
324
|
+
* full sixty-minute TTL with every later message deferred behind it. Two
|
|
325
|
+
* copies of a key derivation is one copy too many; this is now the only one.
|
|
326
|
+
*
|
|
327
|
+
* @param {object} o
|
|
328
|
+
* @param {string} o.channel
|
|
329
|
+
* @param {string} [o.threadId] the item's own thread id, if it has one
|
|
330
|
+
* @param {boolean} [o.isDm] the daemon's own DM verdict (item.is_dm)
|
|
331
|
+
* @param {boolean} [o.channelMainLock=true] false restores the pre-2026-09-25
|
|
332
|
+
* behaviour where an untreaded channel post took no lock at all
|
|
333
|
+
* @returns {string|null} the lock key, or null when this item takes no lock
|
|
334
|
+
*/
|
|
335
|
+
export function threadLockKey(o = {}) {
|
|
336
|
+
const channel = o.channel;
|
|
337
|
+
if (!channel) return null;
|
|
338
|
+
// A Slack DM id ("D…") is a DM whatever the caller believed.
|
|
339
|
+
if (String(channel).startsWith("D")) return "dm-channel";
|
|
340
|
+
if (o.threadId) return String(o.threadId);
|
|
341
|
+
if (o.isDm === true) return "dm-channel";
|
|
342
|
+
return o.channelMainLock === false ? null : "channel-main";
|
|
343
|
+
}
|
|
344
|
+
|
|
314
345
|
/**
|
|
315
346
|
* Check if a session was already dispatched for the same thread recently.
|
|
316
347
|
* Prevents multiple sessions from responding to the same thread when
|
|
@@ -516,7 +547,16 @@ export function releaseThreadLock(channel, threadTs) {
|
|
|
516
547
|
// the first was skipped with "thread_dedup: DM channel already
|
|
517
548
|
// dispatched 1200s ago").
|
|
518
549
|
if (channel.startsWith("D")) threadTs = "dm-channel";
|
|
519
|
-
|
|
550
|
+
// A caller that hands us nothing is asking us to guess, and the only guess
|
|
551
|
+
// that mirrors the acquiring side is `threadLockKey`'s: an untreaded, non-DM
|
|
552
|
+
// channel post locks the room's main feed. Unlinking a lock that was never
|
|
553
|
+
// taken (the off-switch case, or a genuinely lockless item) is a no-op under
|
|
554
|
+
// the catch below, so guessing here can only ever free a lock, never wedge
|
|
555
|
+
// one. The reverse — returning early, as this did — left every
|
|
556
|
+
// `channel-main` lock to expire on its sixty-minute TTL, which is the whole
|
|
557
|
+
// channel gagged for an hour after one message.
|
|
558
|
+
if (!threadTs) threadTs = threadLockKey({ channel, threadId: null, isDm: false });
|
|
559
|
+
if (!threadTs) return;
|
|
520
560
|
const safeKey = sanitiseItemId(`thread-${channel}-${threadTs}`);
|
|
521
561
|
const lockPath = join(LOCKS_DIR, `${safeKey}.lock`);
|
|
522
562
|
try {
|