@stratusagent/channel-slack 0.12.5 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -42
- package/dist/index.d.ts +26 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +832 -148
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { lstat, mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
@@ -27,6 +27,17 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
|
|
|
27
27
|
* same message, and the addressee record answers for every message after.
|
|
28
28
|
*/
|
|
29
29
|
const COLD_VERDICT_CAPACITY = 256;
|
|
30
|
+
/**
|
|
31
|
+
* How many of one person's mentions one thread remembers (card 541). Only
|
|
32
|
+
* has to span the lag between two sockets, like `THREAD_HANDOVER_DEPTH`.
|
|
33
|
+
*/
|
|
34
|
+
const SENDER_ADDRESSEE_DEPTH = 8;
|
|
35
|
+
/**
|
|
36
|
+
* How many people one thread remembers an addressee for. Past this the
|
|
37
|
+
* least recently heard is forgotten, and their next untagged reply goes to
|
|
38
|
+
* the thread's owner, as after a restart.
|
|
39
|
+
*/
|
|
40
|
+
const THREAD_SENDER_CAPACITY = 64;
|
|
30
41
|
/**
|
|
31
42
|
* How many claims on one thread it remembers.
|
|
32
43
|
*
|
|
@@ -44,6 +55,17 @@ const COLD_VERDICT_CAPACITY = 256;
|
|
|
44
55
|
* socket stays behind for all of them.
|
|
45
56
|
*/
|
|
46
57
|
const THREAD_HANDOVER_DEPTH = 32;
|
|
58
|
+
/**
|
|
59
|
+
* How far past this host's clock at `start()` claims count as seen live
|
|
60
|
+
* (card 562). Slack stamps messages by its own clock, so a host clock
|
|
61
|
+
* behind Slack's would date a message sent just before the restart as
|
|
62
|
+
* after it, and a read stopping there would look complete. Reads for
|
|
63
|
+
* messages in this first stretch are treated as stopping short, which
|
|
64
|
+
* costs a thread read or two, never a wrong placement.
|
|
65
|
+
*/
|
|
66
|
+
const LIVE_FROM_MARGIN_MS = 60_000;
|
|
67
|
+
/** `liveFrom` while `start()` runs: later than any message. */
|
|
68
|
+
const NOT_LIVE_YET = '9999999999.999999';
|
|
47
69
|
/**
|
|
48
70
|
* How many agent mentions one thread admits in a row before a person has
|
|
49
71
|
* to speak there again (card 513): enough for a question, an answer and a
|
|
@@ -69,17 +91,6 @@ const toolStatus = (toolName) => `is running ${toolName}…`;
|
|
|
69
91
|
* approval wait — is exactly the one whose status must not vanish.
|
|
70
92
|
*/
|
|
71
93
|
const STATUS_REFRESH_MS = 60_000;
|
|
72
|
-
/**
|
|
73
|
-
* How long an agent that judges stays attentive after it last answered a
|
|
74
|
-
* message that addressed it: this many messages, or this many minutes,
|
|
75
|
-
* whichever ends first. Judging costs
|
|
76
|
-
* a model call per message, and two people talking for an hour is a
|
|
77
|
-
* hundred of them for one "no" — so attention decays the way a person's
|
|
78
|
-
* does. Outside the window a message is overheard for free, and a mention
|
|
79
|
-
* re-arms it, which is what a mention has always meant.
|
|
80
|
-
*/
|
|
81
|
-
const ATTENTION_MESSAGES = 8;
|
|
82
|
-
const ATTENTION_MS = 15 * 60 * 1000;
|
|
83
94
|
/**
|
|
84
95
|
* A 5 MB image over a slow link is seconds, not minutes. Past this the
|
|
85
96
|
* downloads are abandoned and the turn runs with the attachments named as
|
|
@@ -114,6 +125,33 @@ const EARLIER_CHANNEL_LIMIT = 20;
|
|
|
114
125
|
const EARLIER_CHANNEL_READ = 100;
|
|
115
126
|
/** The most pages read to find them: 1,000 messages. */
|
|
116
127
|
const EARLIER_CHANNEL_PAGES = 10;
|
|
128
|
+
/** What a person who wrote during a restart is told, when it was kept. */
|
|
129
|
+
export const RESTARTING_REPLY_TEXT = "I'm restarting right now. I'll answer this as soon as I'm back.";
|
|
130
|
+
/** The same, for a daemon that is stopping rather than restarting. */
|
|
131
|
+
export const STOPPING_REPLY_TEXT = "I'm shutting down right now. I'll answer this when I'm started again.";
|
|
132
|
+
/**
|
|
133
|
+
* How old a kept message may be and still be answered at start: a daemon
|
|
134
|
+
* stopped for a week does not wake up to answer last week's questions.
|
|
135
|
+
*/
|
|
136
|
+
const PENDING_INTAKE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
137
|
+
/**
|
|
138
|
+
* How long the start-up replay waits on Slack to read one kept message
|
|
139
|
+
* back. Past it, that message (and any kept after it in its conversation)
|
|
140
|
+
* is left for the next start rather than answered late; see `replayHolds`.
|
|
141
|
+
*/
|
|
142
|
+
const REPLAY_READ_MAX_MS = 10_000;
|
|
143
|
+
/** The most messages kept at once; past it, a new one is refused, not kept. */
|
|
144
|
+
const PENDING_INTAKE_MAX = 200;
|
|
145
|
+
const isPendingIntake = (value) => {
|
|
146
|
+
if (typeof value !== 'object' || value === null) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const entry = value;
|
|
150
|
+
const optionalString = (field) => field === undefined || typeof field === 'string';
|
|
151
|
+
return typeof entry.agentId === 'string' && typeof entry.team === 'string' && typeof entry.channel === 'string'
|
|
152
|
+
&& typeof entry.ts === 'string' && typeof entry.user === 'string' && typeof entry.keptAt === 'number'
|
|
153
|
+
&& optionalString(entry.threadTs) && optionalString(entry.channelType);
|
|
154
|
+
};
|
|
117
155
|
// Lazy (and CJS-interoperable) so tests with injected fakes never load the
|
|
118
156
|
// real SDK stack.
|
|
119
157
|
const requireModule = createRequire(import.meta.url);
|
|
@@ -1046,19 +1084,19 @@ class ReplyRenderer {
|
|
|
1046
1084
|
// that would take the next turn's status down with it.
|
|
1047
1085
|
await this.statusChain;
|
|
1048
1086
|
}
|
|
1049
|
-
return this.outcome(published, landed || this.edited || this.uploaded || stranded);
|
|
1087
|
+
return this.outcome(published, landed || this.edited || this.uploaded || stranded, !published);
|
|
1050
1088
|
}
|
|
1051
1089
|
placeText(ts) {
|
|
1052
1090
|
if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
|
|
1053
1091
|
this.textAt = ts;
|
|
1054
1092
|
}
|
|
1055
1093
|
}
|
|
1056
|
-
outcome(published, spoke) {
|
|
1094
|
+
outcome(published, spoke, refused = false) {
|
|
1057
1095
|
const spokeAt = [this.textAt, this.uploadedAt]
|
|
1058
1096
|
.filter((ts) => ts !== undefined)
|
|
1059
1097
|
.sort()
|
|
1060
1098
|
.at(-1);
|
|
1061
|
-
return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}) };
|
|
1099
|
+
return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}), ...(refused ? { refused: true } : {}) };
|
|
1062
1100
|
}
|
|
1063
1101
|
/**
|
|
1064
1102
|
* Take back the placeholder a turn nobody asked for opened and then had
|
|
@@ -2188,6 +2226,13 @@ const compareSlackTs = (left, right) => {
|
|
|
2188
2226
|
}
|
|
2189
2227
|
return Math.sign(Number(leftMicros.padEnd(6, '0').slice(0, 6)) - Number(rightMicros.padEnd(6, '0').slice(0, 6)));
|
|
2190
2228
|
};
|
|
2229
|
+
/** What a Slack stop aborts a turn with, followed by the stopper's user id. */
|
|
2230
|
+
const SLACK_STOP_REASON = 'Stopped from Slack by ';
|
|
2231
|
+
/**
|
|
2232
|
+
* Who stopped a turn, read back from the error it failed with: the gateway
|
|
2233
|
+
* fails an aborted turn with the abort's reason verbatim.
|
|
2234
|
+
*/
|
|
2235
|
+
const slackStopper = (error) => error?.startsWith(SLACK_STOP_REASON) ? error.slice(SLACK_STOP_REASON.length) || undefined : undefined;
|
|
2191
2236
|
export const createSlackChannelAdapter = (options) => {
|
|
2192
2237
|
const log = options.log ?? (() => { });
|
|
2193
2238
|
const warn = options.warn ?? (() => { });
|
|
@@ -2283,6 +2328,26 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2283
2328
|
* corrects it.
|
|
2284
2329
|
*/
|
|
2285
2330
|
const threadAddressee = new Map();
|
|
2331
|
+
/**
|
|
2332
|
+
* Per thread whose record has been cut in the middle (`trimHandovers`),
|
|
2333
|
+
* the first claim kept after the cut. Who held the thread between its
|
|
2334
|
+
* first claim and that one is no longer known here (card 559): a message
|
|
2335
|
+
* from then is left to the sessions and a thread read, rather than
|
|
2336
|
+
* placed by the first claim, or the latest take, that outlived the cut.
|
|
2337
|
+
* Goes with the thread's record.
|
|
2338
|
+
*/
|
|
2339
|
+
const threadTrimmedUntil = new Map();
|
|
2340
|
+
/**
|
|
2341
|
+
* Per thread the record was warmed from a thread read (#369), the last
|
|
2342
|
+
* message a read covered. A read stops at the message it places, so one
|
|
2343
|
+
* for a message from before this process started (delivered late, or a
|
|
2344
|
+
* replay after a restart) leaves out every take between that message and
|
|
2345
|
+
* the start (card 562). `liveFrom` is when every socket was up, plus a
|
|
2346
|
+
* margin for clock skew: claims since were seen live. Goes with the
|
|
2347
|
+
* thread's record.
|
|
2348
|
+
*/
|
|
2349
|
+
const threadReadUntil = new Map();
|
|
2350
|
+
let liveFrom;
|
|
2286
2351
|
/**
|
|
2287
2352
|
* Threads whose owner this process can name (card 508): `true` for one
|
|
2288
2353
|
* whose first message it saw, so the first claim in `threadAddressee` is
|
|
@@ -2293,6 +2358,67 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2293
2358
|
* the addressee map; a lost entry costs a lookup.
|
|
2294
2359
|
*/
|
|
2295
2360
|
const threadOrigins = new Map();
|
|
2361
|
+
/**
|
|
2362
|
+
* Per thread, the agent each person last addressed by name, and when
|
|
2363
|
+
* (card 541). An untagged reply from that person is that agent's —
|
|
2364
|
+
* `@Bea your turn`, then `and?`, is Bea's — ahead of the thread's owner,
|
|
2365
|
+
* who keeps every reply from someone who has not addressed anyone else.
|
|
2366
|
+
* Recorded by every socket from the message itself, before any refusal,
|
|
2367
|
+
* as a handover is, so the sockets agree. Bounded like the addressee map;
|
|
2368
|
+
* a lost entry, or a restart, falls back to the owner.
|
|
2369
|
+
*/
|
|
2370
|
+
const senderAddressees = new Map();
|
|
2371
|
+
const rememberSenderAddressee = (key, sender, agentId, ts) => {
|
|
2372
|
+
const senders = senderAddressees.get(key) ?? new Map();
|
|
2373
|
+
// Kept in timestamp order, so a socket that is behind can still find the
|
|
2374
|
+
// mention that preceded the reply it is only now reaching. A late
|
|
2375
|
+
// delivery of an older mention slots in where it belongs.
|
|
2376
|
+
const history = (senders.get(sender) ?? []).filter((entry) => entry.ts !== ts);
|
|
2377
|
+
let at = history.length;
|
|
2378
|
+
while (at > 0 && history[at - 1].ts > ts) {
|
|
2379
|
+
at -= 1;
|
|
2380
|
+
}
|
|
2381
|
+
history.splice(at, 0, { agentId, ts });
|
|
2382
|
+
if (history.length > SENDER_ADDRESSEE_DEPTH) {
|
|
2383
|
+
history.splice(0, history.length - SENDER_ADDRESSEE_DEPTH);
|
|
2384
|
+
}
|
|
2385
|
+
// Most recently heard sender last, so the first key is the one to drop.
|
|
2386
|
+
senders.delete(sender);
|
|
2387
|
+
senders.set(sender, history);
|
|
2388
|
+
if (senders.size > THREAD_SENDER_CAPACITY) {
|
|
2389
|
+
const oldest = senders.keys().next();
|
|
2390
|
+
if (!oldest.done) {
|
|
2391
|
+
senders.delete(oldest.value);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
senderAddressees.delete(key);
|
|
2395
|
+
senderAddressees.set(key, senders);
|
|
2396
|
+
if (senderAddressees.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
2397
|
+
const oldest = senderAddressees.keys().next();
|
|
2398
|
+
if (!oldest.done) {
|
|
2399
|
+
senderAddressees.delete(oldest.value);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
/**
|
|
2404
|
+
* The agent `sender` last addressed in thread `key` before `ts`, if this
|
|
2405
|
+
* process saw it. A reply older than every mention still remembered for
|
|
2406
|
+
* that sender has nothing to go on, and falls back to the owner.
|
|
2407
|
+
*/
|
|
2408
|
+
const senderAddresseeAt = (key, sender, ts) => {
|
|
2409
|
+
const history = senderAddressees.get(key)?.get(sender);
|
|
2410
|
+
if (!history) {
|
|
2411
|
+
return undefined;
|
|
2412
|
+
}
|
|
2413
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2414
|
+
if (history[i].ts < ts) {
|
|
2415
|
+
return history[i].agentId;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return undefined;
|
|
2419
|
+
};
|
|
2420
|
+
/** Whose an untagged reply from `sender` at `ts` is: who they last addressed, else the owner. */
|
|
2421
|
+
const replyTargetAt = (key, sender, ts) => senderAddresseeAt(key, sender, ts) ?? ownerAt(key, ts);
|
|
2296
2422
|
/**
|
|
2297
2423
|
* One cold verdict per message — see `followUpWinner`. Bounded, because
|
|
2298
2424
|
* an entry outlives its own resolution on purpose; an entry that has not
|
|
@@ -2361,6 +2487,332 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2361
2487
|
inflight.add(settled);
|
|
2362
2488
|
void settled.finally(() => inflight.delete(settled));
|
|
2363
2489
|
};
|
|
2490
|
+
/**
|
|
2491
|
+
* The kept messages (see `pendingIntakePath`), read and written one change
|
|
2492
|
+
* at a time so two refusals landing together cannot drop each other.
|
|
2493
|
+
*/
|
|
2494
|
+
const pendingIntakePath = options.pendingIntakePath;
|
|
2495
|
+
let pendingChain = Promise.resolve();
|
|
2496
|
+
const readPending = async (file) => {
|
|
2497
|
+
let raw;
|
|
2498
|
+
try {
|
|
2499
|
+
raw = await readFile(file, 'utf8');
|
|
2500
|
+
}
|
|
2501
|
+
catch (error) {
|
|
2502
|
+
if (error.code === 'ENOENT') {
|
|
2503
|
+
return [];
|
|
2504
|
+
}
|
|
2505
|
+
throw error;
|
|
2506
|
+
}
|
|
2507
|
+
try {
|
|
2508
|
+
const parsed = JSON.parse(raw);
|
|
2509
|
+
if (Array.isArray(parsed)) {
|
|
2510
|
+
return parsed.filter(isPendingIntake);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
catch {
|
|
2514
|
+
// Named below, and replaced by the next write.
|
|
2515
|
+
}
|
|
2516
|
+
warn(`slack: ${file} is not a list of kept messages; ignoring it`);
|
|
2517
|
+
return [];
|
|
2518
|
+
};
|
|
2519
|
+
const changePending = (change) => {
|
|
2520
|
+
const file = pendingIntakePath;
|
|
2521
|
+
if (file === undefined) {
|
|
2522
|
+
return Promise.reject(new Error('no pendingIntakePath'));
|
|
2523
|
+
}
|
|
2524
|
+
const run = pendingChain.then(async () => {
|
|
2525
|
+
const before = await readPending(file);
|
|
2526
|
+
const { entries, result } = change(before);
|
|
2527
|
+
if (entries.length === 0) {
|
|
2528
|
+
await rm(file, { force: true });
|
|
2529
|
+
}
|
|
2530
|
+
else {
|
|
2531
|
+
// Whole or not at all: a crash mid-write must not leave half a list
|
|
2532
|
+
// that the next start reads as no list.
|
|
2533
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
2534
|
+
await writeFile(temporary, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 });
|
|
2535
|
+
await rename(temporary, file);
|
|
2536
|
+
}
|
|
2537
|
+
return result;
|
|
2538
|
+
});
|
|
2539
|
+
pendingChain = run.catch(() => undefined);
|
|
2540
|
+
return run;
|
|
2541
|
+
};
|
|
2542
|
+
const pendingKey = (entry) => `${entry.agentId}:${entry.team}:${entry.channel}:${entry.ts}`;
|
|
2543
|
+
/**
|
|
2544
|
+
* Kept messages being replayed, by `pendingKey`. A replayed message comes
|
|
2545
|
+
* off the list only once its answer is confirmed (`answered`): an answer
|
|
2546
|
+
* or failure Slack took, a turn that chose to say nothing, or the
|
|
2547
|
+
* start-up recovery's own report of it reaching the thread. Every other
|
|
2548
|
+
* way a handler can end leaves it kept, so a path that forgets to say
|
|
2549
|
+
* how it went keeps the message rather than losing it. A message kept
|
|
2550
|
+
* again by a restart during the replay (`rekept`) stays kept too. The
|
|
2551
|
+
* next start hands it over again, and the gateway repeats the finished
|
|
2552
|
+
* turn, so the answer is posted from the session rather than run again.
|
|
2553
|
+
* `dispatched` and `refused` only choose the warning.
|
|
2554
|
+
*/
|
|
2555
|
+
const replaying = new Map();
|
|
2556
|
+
/** Notes a reply outcome against the kept message it answers, if that one is being replayed. */
|
|
2557
|
+
const noteReplayOutcome = (connection, channel, ts, outcome) => {
|
|
2558
|
+
const replay = replaying.get(pendingKey({ agentId: connection.config.agentId, team: connection.teamId, channel, ts }));
|
|
2559
|
+
if (replay !== undefined) {
|
|
2560
|
+
if (outcome.refused === true) {
|
|
2561
|
+
replay.refused = true;
|
|
2562
|
+
}
|
|
2563
|
+
else {
|
|
2564
|
+
replay.answered = true;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
return outcome;
|
|
2568
|
+
};
|
|
2569
|
+
/** Confirms that a replayed message was answered by a report posted ahead of its own renderer. */
|
|
2570
|
+
const noteReplayAnsweredAhead = (connection, channel, ts) => {
|
|
2571
|
+
const replay = replaying.get(pendingKey({ agentId: connection.config.agentId, team: connection.teamId, channel, ts }));
|
|
2572
|
+
if (replay !== undefined) {
|
|
2573
|
+
replay.answered = true;
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
/**
|
|
2577
|
+
* Live intake for a conversation (agent and channel) that has kept
|
|
2578
|
+
* messages waits here until each of them has taken its place on its
|
|
2579
|
+
* session's chain or been left for the next start, so a kept message is
|
|
2580
|
+
* never answered after one sent later in the same conversation. Other
|
|
2581
|
+
* conversations are not held. There is no timeout on the hold itself:
|
|
2582
|
+
* the bound is on each read (`REPLAY_READ_MAX_MS`), and a read that
|
|
2583
|
+
* outlasts it gives up its message for this run, which releases the hold.
|
|
2584
|
+
*/
|
|
2585
|
+
const replayHolds = new Map();
|
|
2586
|
+
const holdKey = (agentId, channel) => `${agentId}:${channel}`;
|
|
2587
|
+
/**
|
|
2588
|
+
* Whether `connection` is the one to replay `entry` through: the same
|
|
2589
|
+
* agent in the workspace it was kept from. Channel and user ids belong to
|
|
2590
|
+
* a workspace, so an agent whose credentials moved to another one leaves
|
|
2591
|
+
* the entry kept (and its day-old expiry) rather than reading or answering
|
|
2592
|
+
* whatever has that id there.
|
|
2593
|
+
*/
|
|
2594
|
+
const keptFor = (connection, entry) => connection.config.agentId === entry.agentId && connection.teamId === entry.team;
|
|
2595
|
+
const holdConversation = (key) => {
|
|
2596
|
+
if (replayHolds.has(key)) {
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
let release = () => { };
|
|
2600
|
+
const held = new Promise((resolve) => {
|
|
2601
|
+
release = () => {
|
|
2602
|
+
replayHolds.delete(key);
|
|
2603
|
+
resolve();
|
|
2604
|
+
};
|
|
2605
|
+
});
|
|
2606
|
+
replayHolds.set(key, { held, release });
|
|
2607
|
+
};
|
|
2608
|
+
const releaseConversation = (key) => replayHolds.get(key)?.release();
|
|
2609
|
+
/** Aborted by `stop()`, which ends a replay still reading kept messages back. */
|
|
2610
|
+
let replayStopping;
|
|
2611
|
+
const releaseAllConversations = () => {
|
|
2612
|
+
for (const { release } of [...replayHolds.values()]) {
|
|
2613
|
+
release();
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
/** Keeps a refused message for the next start; resolves with whether it was kept. */
|
|
2617
|
+
const keepForNextStart = async (entry) => {
|
|
2618
|
+
if (pendingIntakePath === undefined) {
|
|
2619
|
+
return false;
|
|
2620
|
+
}
|
|
2621
|
+
try {
|
|
2622
|
+
// Every kept message was told it will be answered, so a full list
|
|
2623
|
+
// never evicts one of them: it makes room only from messages too old
|
|
2624
|
+
// to be answered at start anyway, and otherwise refuses this one, which
|
|
2625
|
+
// then gets the plain refusal and no promise.
|
|
2626
|
+
const { full, expired } = await changePending((entries) => {
|
|
2627
|
+
const key = pendingKey(entry);
|
|
2628
|
+
let others = entries.filter((existing) => pendingKey(existing) !== key);
|
|
2629
|
+
let expired = [];
|
|
2630
|
+
if (others.length >= PENDING_INTAKE_MAX) {
|
|
2631
|
+
const cutoff = now() - PENDING_INTAKE_MAX_AGE_MS;
|
|
2632
|
+
expired = others.filter((existing) => existing.keptAt < cutoff);
|
|
2633
|
+
others = others.filter((existing) => existing.keptAt >= cutoff);
|
|
2634
|
+
}
|
|
2635
|
+
if (others.length >= PENDING_INTAKE_MAX) {
|
|
2636
|
+
return { entries, result: { full: true, expired: [] } };
|
|
2637
|
+
}
|
|
2638
|
+
return { entries: [...others, entry], result: { full: false, expired } };
|
|
2639
|
+
});
|
|
2640
|
+
for (const dropped of expired) {
|
|
2641
|
+
log(`slack: ${dropped.agentId} dropped ${dropped.ts} in ${dropped.channel} kept from a restart: older than a day`);
|
|
2642
|
+
}
|
|
2643
|
+
if (full) {
|
|
2644
|
+
warn(`slack: could not keep ${entry.ts} in ${entry.channel} for after the restart: ${PENDING_INTAKE_MAX} messages are already kept`);
|
|
2645
|
+
return false;
|
|
2646
|
+
}
|
|
2647
|
+
const replay = replaying.get(pendingKey(entry));
|
|
2648
|
+
if (replay !== undefined) {
|
|
2649
|
+
replay.rekept = true;
|
|
2650
|
+
}
|
|
2651
|
+
return true;
|
|
2652
|
+
}
|
|
2653
|
+
catch (error) {
|
|
2654
|
+
warn(`slack: could not keep ${entry.ts} in ${entry.channel} for after the restart: ${error instanceof Error ? error.message : String(error)}`);
|
|
2655
|
+
return false;
|
|
2656
|
+
}
|
|
2657
|
+
};
|
|
2658
|
+
/** The kept message itself, as Slack has it now; `undefined` when it is gone. */
|
|
2659
|
+
const fetchKept = async (connection, entry) => {
|
|
2660
|
+
const { history, replies } = connection.web.conversations;
|
|
2661
|
+
const inThread = entry.threadTs !== undefined && entry.threadTs !== entry.ts;
|
|
2662
|
+
const range = { channel: entry.channel, oldest: entry.ts, latest: entry.ts, inclusive: true };
|
|
2663
|
+
const page = inThread
|
|
2664
|
+
? await replies?.call(connection.web.conversations, { ...range, ts: entry.threadTs, limit: 2 })
|
|
2665
|
+
: await history?.call(connection.web.conversations, { ...range, limit: 1 });
|
|
2666
|
+
if (page === undefined) {
|
|
2667
|
+
throw new Error('this Slack client cannot read conversations');
|
|
2668
|
+
}
|
|
2669
|
+
return page.messages?.find((message) => message.ts === entry.ts);
|
|
2670
|
+
};
|
|
2671
|
+
/** One kept message read back with a bound; `timedOut` when Slack took longer than that. */
|
|
2672
|
+
const fetchKeptWithin = async (connection, entry) => {
|
|
2673
|
+
let timer;
|
|
2674
|
+
const late = new Promise((resolve) => {
|
|
2675
|
+
timer = setTimeout(() => resolve({ timedOut: true }), REPLAY_READ_MAX_MS);
|
|
2676
|
+
});
|
|
2677
|
+
try {
|
|
2678
|
+
return await Promise.race([
|
|
2679
|
+
fetchKept(connection, entry).then((message) => (message !== undefined ? { message } : {})),
|
|
2680
|
+
late,
|
|
2681
|
+
]);
|
|
2682
|
+
}
|
|
2683
|
+
finally {
|
|
2684
|
+
clearTimeout(timer);
|
|
2685
|
+
}
|
|
2686
|
+
};
|
|
2687
|
+
/**
|
|
2688
|
+
* What the last daemon kept, handled as if it had just arrived: through
|
|
2689
|
+
* admission, the thread rules, and a dispatch keyed as the original
|
|
2690
|
+
* delivery would have been, so a message the gateway already ran is a
|
|
2691
|
+
* repeat there and not a second turn. That makes handing one over twice
|
|
2692
|
+
* safe and losing one not, so each comes off the list only once its
|
|
2693
|
+
* handler has reached the gateway and settled: a crash, or a failure
|
|
2694
|
+
* before the dispatch (the reply could not be started), leaves it for the
|
|
2695
|
+
* next start, and one kept again by a restart during the replay stays
|
|
2696
|
+
* kept. A message deleted since is dropped.
|
|
2697
|
+
*
|
|
2698
|
+
* Each conversation is replayed in ts order while its live intake is held
|
|
2699
|
+
* (`replayHolds`), and released once its kept messages are handed over.
|
|
2700
|
+
* A read that fails or outlasts `REPLAY_READ_MAX_MS` leaves that message,
|
|
2701
|
+
* and the ones kept after it in the conversation, for the next start:
|
|
2702
|
+
* handing a later one over first, or the slow one over after live
|
|
2703
|
+
* messages were let in, would answer them out of order.
|
|
2704
|
+
*/
|
|
2705
|
+
const replayKept = async (kept, onEvent, stopping) => {
|
|
2706
|
+
if (pendingIntakePath === undefined || kept.length === 0) {
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
const cutoff = now() - PENDING_INTAKE_MAX_AGE_MS;
|
|
2710
|
+
const expired = kept.filter((entry) => entry.keptAt < cutoff);
|
|
2711
|
+
for (const entry of expired) {
|
|
2712
|
+
log(`slack: ${entry.agentId} dropped ${entry.ts} in ${entry.channel} kept from a restart: older than a day`);
|
|
2713
|
+
}
|
|
2714
|
+
// Not waited on before the reads: the list's writes are serialized, so
|
|
2715
|
+
// this one lands ahead of any the replay makes, and the reads start
|
|
2716
|
+
// inside start() (a stop right after it still sees each conversation's
|
|
2717
|
+
// first one through).
|
|
2718
|
+
const old = new Set(expired.map(pendingKey));
|
|
2719
|
+
const droppingExpired = expired.length > 0
|
|
2720
|
+
? changePending((entries) => ({ entries: entries.filter((entry) => !old.has(pendingKey(entry))), result: undefined }))
|
|
2721
|
+
.catch((error) => warn(`slack: could not drop the day-old messages kept from a restart; the next start drops them: ${error instanceof Error ? error.message : String(error)}`))
|
|
2722
|
+
: Promise.resolve();
|
|
2723
|
+
const conversations = new Map();
|
|
2724
|
+
for (const entry of kept.filter((candidate) => candidate.keptAt >= cutoff)) {
|
|
2725
|
+
const elsewhere = connections.find((connection) => connection.config.agentId === entry.agentId && !keptFor(connection, entry));
|
|
2726
|
+
if (elsewhere !== undefined) {
|
|
2727
|
+
warn(`slack: ${entry.agentId} kept ${entry.ts} in ${entry.channel} from workspace ${entry.team}, but is now connected to ${elsewhere.teamId}; keeping it for a start in that workspace`);
|
|
2728
|
+
continue;
|
|
2729
|
+
}
|
|
2730
|
+
const key = holdKey(entry.agentId, entry.channel);
|
|
2731
|
+
conversations.set(key, [...(conversations.get(key) ?? []), entry]);
|
|
2732
|
+
}
|
|
2733
|
+
const handovers = [];
|
|
2734
|
+
const replayConversation = async (key, entries) => {
|
|
2735
|
+
try {
|
|
2736
|
+
entries.sort((a, b) => compareSlackTs(a.ts, b.ts));
|
|
2737
|
+
const agentId = entries[0].agentId;
|
|
2738
|
+
const connection = connections.find((candidate) => keptFor(candidate, entries[0]));
|
|
2739
|
+
const handle = onEvent.get(agentId);
|
|
2740
|
+
if (connection === undefined || handle === undefined) {
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
for (const [index, entry] of entries.entries()) {
|
|
2744
|
+
// A stop ends the replay at the next message: what it has not read
|
|
2745
|
+
// yet stays kept for the next start, so the stop waits on at most
|
|
2746
|
+
// the one bounded read in progress per conversation, not a chain
|
|
2747
|
+
// of them.
|
|
2748
|
+
if (stopping.aborted) {
|
|
2749
|
+
log(`slack: ${agentId} stopping; leaving ${entries.length - index} kept message(s) in ${entry.channel} for the next start`);
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
let read;
|
|
2753
|
+
try {
|
|
2754
|
+
read = await fetchKeptWithin(connection, entry);
|
|
2755
|
+
}
|
|
2756
|
+
catch (error) {
|
|
2757
|
+
warn(`slack: could not read ${entry.ts} in ${entry.channel} kept from the last restart; keeping it and ${entries.length - index - 1} after it for the next start: ${error instanceof Error ? error.message : String(error)}`);
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
if (read.timedOut === true) {
|
|
2761
|
+
warn(`slack: reading ${entry.ts} in ${entry.channel} kept from the last restart took over ${REPLAY_READ_MAX_MS / 1000}s; keeping it and ${entries.length - index - 1} after it for the next start`);
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
const message = read.message;
|
|
2765
|
+
if (message === undefined) {
|
|
2766
|
+
const gone = pendingKey(entry);
|
|
2767
|
+
await changePending((current) => ({ entries: current.filter((kept) => pendingKey(kept) !== gone), result: undefined }));
|
|
2768
|
+
log(`slack: ${entry.agentId} dropped ${entry.ts} in ${entry.channel} kept from a restart: no longer in Slack`);
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
2771
|
+
log(`slack: ${entry.agentId} replaying ${entry.ts} in ${entry.channel}, sent during the last restart`);
|
|
2772
|
+
const event = {
|
|
2773
|
+
...message,
|
|
2774
|
+
type: 'message',
|
|
2775
|
+
channel: entry.channel,
|
|
2776
|
+
ts: entry.ts,
|
|
2777
|
+
...(entry.threadTs !== undefined ? { thread_ts: entry.threadTs } : {}),
|
|
2778
|
+
...(entry.channelType !== undefined ? { channel_type: entry.channelType } : {}),
|
|
2779
|
+
};
|
|
2780
|
+
const replayKey = pendingKey(entry);
|
|
2781
|
+
const replay = { dispatched: false, rekept: false, refused: false, answered: false };
|
|
2782
|
+
replaying.set(replayKey, replay);
|
|
2783
|
+
// Not waited on: the handler claims its place on its session's
|
|
2784
|
+
// chain synchronously, as a live delivery does, so the next kept
|
|
2785
|
+
// message (and, after the last, the held live ones) queue behind it.
|
|
2786
|
+
const handled = handle({ ack: async () => { }, body: { team_id: entry.team }, event });
|
|
2787
|
+
handovers.push(handled.catch(() => undefined).then(async () => {
|
|
2788
|
+
replaying.delete(replayKey);
|
|
2789
|
+
if (replay.rekept) {
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
if (!replay.dispatched) {
|
|
2793
|
+
warn(`slack: ${entry.agentId} could not hand ${entry.ts} in ${entry.channel} kept from a restart to the gateway; keeping it for the next start`);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (replay.refused) {
|
|
2797
|
+
warn(`slack: ${entry.agentId} could not post the answer to ${entry.ts} in ${entry.channel} kept from a restart; keeping it for the next start`);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
if (!replay.answered) {
|
|
2801
|
+
warn(`slack: ${entry.agentId} has no confirmed answer to ${entry.ts} in ${entry.channel} kept from a restart; keeping it for the next start`);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
await changePending((current) => ({ entries: current.filter((kept) => pendingKey(kept) !== replayKey), result: undefined }));
|
|
2805
|
+
}));
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
finally {
|
|
2809
|
+
releaseConversation(key);
|
|
2810
|
+
}
|
|
2811
|
+
};
|
|
2812
|
+
await Promise.all([...conversations].map(([key, entries]) => replayConversation(key, entries)));
|
|
2813
|
+
await Promise.all(handovers);
|
|
2814
|
+
await droppingExpired;
|
|
2815
|
+
};
|
|
2364
2816
|
const alreadySeen = (key) => {
|
|
2365
2817
|
if (seenEvents.has(key)) {
|
|
2366
2818
|
return true;
|
|
@@ -2813,69 +3265,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2813
3265
|
* nothing, which is every soul written before there was a choice.
|
|
2814
3266
|
*/
|
|
2815
3267
|
const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
|
|
2816
|
-
/**
|
|
2817
|
-
* Whether an agent that judges is still paying attention to a thread:
|
|
2818
|
-
* within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
|
|
2819
|
-
* answered a message that addressed it, both read from the session
|
|
2820
|
-
* rather than remembered here, so a restart forgets nothing. Anchored
|
|
2821
|
-
* to an addressed answer and not to any reply: a reply the agent chose
|
|
2822
|
-
* to give on a turn nobody asked for does not re-arm it, or a talkative
|
|
2823
|
-
* judge would keep itself attentive for good and cost a call per message
|
|
2824
|
-
* — the self-extension the spec declined. An agent never addressed in
|
|
2825
|
-
* the thread is not attentive: it was mentioned and its first turn has
|
|
2826
|
-
* not answered yet, or answered with nothing. Measured against the
|
|
2827
|
-
* message's own timestamp, not the clock, so a delivery that ran late is
|
|
2828
|
-
* judged as of when it was said. Bounded below as well as above: a
|
|
2829
|
-
* message said before the answer the window starts from is outside it
|
|
2830
|
-
* — Slack redelivers events a restarted daemon has no memory of, and a
|
|
2831
|
-
* negative age would otherwise pass the minutes bound however old the
|
|
2832
|
-
* message, on a turn the agent's answer already came after.
|
|
2833
|
-
*/
|
|
2834
|
-
const attentive = (routing, messageTs, pending) => {
|
|
2835
|
-
if (routing.lastAnsweredAt === undefined) {
|
|
2836
|
-
return false;
|
|
2837
|
-
}
|
|
2838
|
-
const answeredAt = Date.parse(routing.lastAnsweredAt);
|
|
2839
|
-
const saidAt = Number(messageTs) * 1000;
|
|
2840
|
-
if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
|
|
2841
|
-
return false;
|
|
2842
|
-
}
|
|
2843
|
-
return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
|
|
2844
|
-
};
|
|
2845
|
-
/**
|
|
2846
|
-
* Messages this adapter has dispatched to a session that the session
|
|
2847
|
-
* does not hold yet. The store counts what it holds, and a burst of
|
|
2848
|
-
* messages typed inside one second would each read the same count and
|
|
2849
|
-
* each be judged — the bound the window exists to give, blown by exactly
|
|
2850
|
-
* the thread that needs it. The renderer queue already knows: a renderer
|
|
2851
|
-
* is queued at dispatch and its turn starts (`turnStarted`) only once
|
|
2852
|
-
* the runner has saved the message — the same moment the store's count
|
|
2853
|
-
* takes it over — so the two never count one message twice.
|
|
2854
|
-
*/
|
|
2855
|
-
const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
|
|
2856
|
-
+ (pendingObserves.get(sessionId) ?? 0);
|
|
2857
|
-
/**
|
|
2858
|
-
* Overhears placed on the gateway's chain and not yet written, per
|
|
2859
|
-
* session: the other kind of message the store does not hold yet. An
|
|
2860
|
-
* observe queued behind a running turn is heard only when the turn ends,
|
|
2861
|
-
* and a burst of messages to a colleague would otherwise count toward
|
|
2862
|
-
* nothing until then. Counted from placement to settlement.
|
|
2863
|
-
*/
|
|
2864
|
-
const pendingObserves = new Map();
|
|
2865
|
-
const placeObserve = (sessionId, observed) => {
|
|
2866
|
-
pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
|
|
2867
|
-
const release = () => {
|
|
2868
|
-
const left = (pendingObserves.get(sessionId) ?? 1) - 1;
|
|
2869
|
-
if (left > 0) {
|
|
2870
|
-
pendingObserves.set(sessionId, left);
|
|
2871
|
-
}
|
|
2872
|
-
else {
|
|
2873
|
-
pendingObserves.delete(sessionId);
|
|
2874
|
-
}
|
|
2875
|
-
};
|
|
2876
|
-
void observed.then(release, release);
|
|
2877
|
-
return observed;
|
|
2878
|
-
};
|
|
2879
3268
|
/**
|
|
2880
3269
|
* Run `work` after everything already queued for this session's intake,
|
|
2881
3270
|
* and hold the next link until it settles. The link is claimed
|
|
@@ -2997,7 +3386,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2997
3386
|
// whoever installed it.
|
|
2998
3387
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2999
3388
|
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
|
|
3000
|
-
return { observed
|
|
3389
|
+
return { observed };
|
|
3001
3390
|
});
|
|
3002
3391
|
await observed;
|
|
3003
3392
|
}
|
|
@@ -3061,7 +3450,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3061
3450
|
}
|
|
3062
3451
|
const author = await displayNameFor(connection, connection.botUserId);
|
|
3063
3452
|
const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
|
|
3064
|
-
return { observed
|
|
3453
|
+
return { observed };
|
|
3065
3454
|
});
|
|
3066
3455
|
// Tracked, so stop() drains it: a restart right after a report must not
|
|
3067
3456
|
// close the stores while the thread's session is still to hear it.
|
|
@@ -3161,6 +3550,55 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3161
3550
|
},
|
|
3162
3551
|
};
|
|
3163
3552
|
};
|
|
3553
|
+
/**
|
|
3554
|
+
* Channel or DM, as this agent's app sees a conversation, for the
|
|
3555
|
+
* operator's standing channel authorization (`approvals.channels`), which
|
|
3556
|
+
* covers channels only. Asked of Slack (`conversations.info`), never read
|
|
3557
|
+
* off the id: a `G…` id is a private channel or a group DM depending on
|
|
3558
|
+
* its age. A definite answer is kept, since a conversation never turns
|
|
3559
|
+
* from one into the other; a failed lookup is not, and reads as unknown,
|
|
3560
|
+
* so the call asks.
|
|
3561
|
+
*/
|
|
3562
|
+
const conversationKinds = new Map();
|
|
3563
|
+
const CONVERSATION_KIND_CACHE_LIMIT = 1000;
|
|
3564
|
+
const conversationKindFor = async (agentId, conversation) => {
|
|
3565
|
+
const id = conversation.trim();
|
|
3566
|
+
const connection = connectionFor(agentId);
|
|
3567
|
+
if (!id || !connection) {
|
|
3568
|
+
return undefined;
|
|
3569
|
+
}
|
|
3570
|
+
const key = `${agentId}:${botIdentities.get(agentId)?.teamId ?? ''}:${id}`;
|
|
3571
|
+
const known = conversationKinds.get(key);
|
|
3572
|
+
if (known !== undefined) {
|
|
3573
|
+
return known;
|
|
3574
|
+
}
|
|
3575
|
+
let info;
|
|
3576
|
+
try {
|
|
3577
|
+
info = (await connection.web.conversations.info({ channel: id })).channel;
|
|
3578
|
+
}
|
|
3579
|
+
catch {
|
|
3580
|
+
return undefined;
|
|
3581
|
+
}
|
|
3582
|
+
if (!info?.id) {
|
|
3583
|
+
return undefined;
|
|
3584
|
+
}
|
|
3585
|
+
// A DM flag wins. Otherwise a channel only when Slack says so, by a
|
|
3586
|
+
// channel flag or by stating it is not a DM: a reply with none of
|
|
3587
|
+
// them is no proof of a channel.
|
|
3588
|
+
const kind = info.is_im === true || info.is_mpim === true
|
|
3589
|
+
? 'direct'
|
|
3590
|
+
: info.is_channel === true || info.is_group === true || (info.is_im === false && info.is_mpim === false) ? 'channel' : undefined;
|
|
3591
|
+
if (kind !== undefined) {
|
|
3592
|
+
if (conversationKinds.size >= CONVERSATION_KIND_CACHE_LIMIT) {
|
|
3593
|
+
const oldest = conversationKinds.keys().next().value;
|
|
3594
|
+
if (oldest !== undefined) {
|
|
3595
|
+
conversationKinds.delete(oldest);
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
conversationKinds.set(key, kind);
|
|
3599
|
+
}
|
|
3600
|
+
return kind;
|
|
3601
|
+
};
|
|
3164
3602
|
/**
|
|
3165
3603
|
* The read side of the same boundary `resolveOutbound` draws: a channel
|
|
3166
3604
|
* the agent's own app is a member of, public or private. DMs and group
|
|
@@ -3334,8 +3772,21 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3334
3772
|
const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
|
|
3335
3773
|
? metadata.slackChannel
|
|
3336
3774
|
: undefined;
|
|
3337
|
-
|
|
3338
|
-
|
|
3775
|
+
// A scheduled firing has no conversation of its own. With no approval
|
|
3776
|
+
// channel configured it used to be denied at its first gated call, seen
|
|
3777
|
+
// only in the daemon log (card 218). Its schedule's destination is a
|
|
3778
|
+
// conversation a person approved as where it speaks, and only approvers
|
|
3779
|
+
// can answer there, so that is where it is asked. A configured approval
|
|
3780
|
+
// channel still wins: the operator chose it for exactly this. Another
|
|
3781
|
+
// channel's destination is not a Slack conversation id.
|
|
3782
|
+
const scheduled = event.scheduleDestination?.channel.trim().toLowerCase() === 'slack'
|
|
3783
|
+
? event.scheduleDestination
|
|
3784
|
+
: undefined;
|
|
3785
|
+
const viaSchedule = !sameChannel && !connection.config.approvalChannel ? scheduled : undefined;
|
|
3786
|
+
const channel = sameChannel ?? connection.config.approvalChannel ?? viaSchedule?.to;
|
|
3787
|
+
const thread = sameChannel
|
|
3788
|
+
? (typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined)
|
|
3789
|
+
: viaSchedule?.thread;
|
|
3339
3790
|
// Undeliverable, not decided: nobody was asked, so filing this next to
|
|
3340
3791
|
// the denials somebody actually made would make the audit record lie
|
|
3341
3792
|
// about which is which.
|
|
@@ -3600,6 +4051,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3600
4051
|
return landed;
|
|
3601
4052
|
};
|
|
3602
4053
|
const reportUnrenderedFailure = async (event, behind, files) => {
|
|
4054
|
+
// A turn no renderer here was showing (card 501) that a Slack stop
|
|
4055
|
+
// aborted fails with the stop's own reason, so who stopped it is read
|
|
4056
|
+
// from the failure itself rather than from a note an outcome may never
|
|
4057
|
+
// come to clear.
|
|
4058
|
+
const stoppedBy = slackStopper(event.error);
|
|
3603
4059
|
const gateway = gatewayRef;
|
|
3604
4060
|
if (!gateway?.sessionRouting) {
|
|
3605
4061
|
return NOTHING_REPORTED;
|
|
@@ -3627,7 +4083,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3627
4083
|
}
|
|
3628
4084
|
const channel = metadata.slackChannel;
|
|
3629
4085
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
3630
|
-
if (routing.unaddressed === true && routing.reply === undefined) {
|
|
4086
|
+
if (stoppedBy === undefined && routing.unaddressed === true && routing.reply === undefined) {
|
|
4087
|
+
// A stop is someone asking, so a stopped turn always says who
|
|
4088
|
+
// stopped it, judged or not.
|
|
4089
|
+
//
|
|
3631
4090
|
// A turn nobody asked for that said nothing before it broke — the
|
|
3632
4091
|
// daemon died inside it, and the next start failed it with no
|
|
3633
4092
|
// renderer here to keep quiet for it. The same rule as the lazy
|
|
@@ -3648,7 +4107,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3648
4107
|
return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
|
|
3649
4108
|
}
|
|
3650
4109
|
let landed = [];
|
|
3651
|
-
const said = await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), async () => {
|
|
4110
|
+
const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy === undefined ? `Something went wrong: ${event.error}` : `Stopped by <@${stoppedBy}>.`), async () => {
|
|
3652
4111
|
landed = await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
3653
4112
|
});
|
|
3654
4113
|
return { said, landed };
|
|
@@ -4077,7 +4536,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4077
4536
|
* Dropping that one would hand the thread back to its owner after enough
|
|
4078
4537
|
* mentions of a third agent, while the judge that took it still answers.
|
|
4079
4538
|
*/
|
|
4080
|
-
const trimHandovers = (handovers) => {
|
|
4539
|
+
const trimHandovers = (key, handovers) => {
|
|
4081
4540
|
if (handovers.length <= THREAD_HANDOVER_DEPTH) {
|
|
4082
4541
|
return handovers;
|
|
4083
4542
|
}
|
|
@@ -4093,6 +4552,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4093
4552
|
// The kept take sits on top of the full recent tail, one past the depth,
|
|
4094
4553
|
// so `holderAt` still sees every recent claim it saw before.
|
|
4095
4554
|
const tail = handovers.slice(tailFrom);
|
|
4555
|
+
// The cut only ever moves later: a claim inserted into the gap since
|
|
4556
|
+
// is dropped again, and the gap it fell in stays unknown.
|
|
4557
|
+
const until = tail[0].ts;
|
|
4558
|
+
const before = threadTrimmedUntil.get(key);
|
|
4559
|
+
if (before === undefined || before < until) {
|
|
4560
|
+
threadTrimmedUntil.set(key, until);
|
|
4561
|
+
}
|
|
4096
4562
|
return latestTake > 0 && latestTake < tailFrom
|
|
4097
4563
|
? [first, handovers[latestTake], ...tail]
|
|
4098
4564
|
: [first, ...tail];
|
|
@@ -4124,7 +4590,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4124
4590
|
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
4125
4591
|
* parts fixed width, so lexicographic order is chronological order.
|
|
4126
4592
|
*/
|
|
4127
|
-
const rememberAddressee = (key, agentId, ts, takes = false) => {
|
|
4593
|
+
const rememberAddressee = (key, agentId, ts, takes = false, cold = false) => {
|
|
4128
4594
|
const handovers = threadAddressee.get(key) ?? [];
|
|
4129
4595
|
let at = handovers.length;
|
|
4130
4596
|
while (at > 0 && handovers[at - 1].ts > ts) {
|
|
@@ -4135,17 +4601,23 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4135
4601
|
if (takes) {
|
|
4136
4602
|
before.takes = true;
|
|
4137
4603
|
}
|
|
4604
|
+
// Seen live as well: no longer only a reconstruction.
|
|
4605
|
+
if (!cold) {
|
|
4606
|
+
delete before.cold;
|
|
4607
|
+
}
|
|
4138
4608
|
return;
|
|
4139
4609
|
}
|
|
4140
|
-
handovers.splice(at, 0,
|
|
4610
|
+
handovers.splice(at, 0, { agentId, ts, ...(takes ? { takes } : {}), ...(cold ? { cold } : {}) });
|
|
4141
4611
|
// Re-inserted rather than mutated in place: a Map iterates in insertion
|
|
4142
4612
|
// order, which is what makes the first key the one to evict.
|
|
4143
4613
|
threadAddressee.delete(key);
|
|
4144
|
-
threadAddressee.set(key, trimHandovers(handovers));
|
|
4614
|
+
threadAddressee.set(key, trimHandovers(key, handovers));
|
|
4145
4615
|
if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4146
4616
|
const oldest = threadAddressee.keys().next();
|
|
4147
4617
|
if (!oldest.done) {
|
|
4148
4618
|
threadAddressee.delete(oldest.value);
|
|
4619
|
+
threadTrimmedUntil.delete(oldest.value);
|
|
4620
|
+
threadReadUntil.delete(oldest.value);
|
|
4149
4621
|
// The owner goes with the record of who took the thread since: an
|
|
4150
4622
|
// owner kept alone would stand in for a judge's takeover it no
|
|
4151
4623
|
// longer knows of, where the thread read would see it.
|
|
@@ -4158,15 +4630,69 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4158
4630
|
* handover at or before it. Undefined when this process has no handover
|
|
4159
4631
|
* that old, which sends the question to the sessions.
|
|
4160
4632
|
*/
|
|
4633
|
+
/**
|
|
4634
|
+
* Whose a stop at `ts` is, from the live record: who was asked last as
|
|
4635
|
+
* of `ts` (`holderAt`). Undefined when the record cannot say, either
|
|
4636
|
+
* because this process has seen nobody asked here by `ts`, or because the
|
|
4637
|
+
* newest entry by then was rebuilt from a thread read (card 508): a
|
|
4638
|
+
* mention since would not be among them. The stop is then left to a
|
|
4639
|
+
* fresh read of the thread, never sent to the owner on a guess.
|
|
4640
|
+
*/
|
|
4641
|
+
const stopHolderAt = (key, ts) => holderAt(key, ts);
|
|
4642
|
+
/** Whether the record of thread `key` lost who held it at `ts` to a cut. */
|
|
4643
|
+
const inTrimmedGap = (key, handovers, ts) => {
|
|
4644
|
+
const until = threadTrimmedUntil.get(key);
|
|
4645
|
+
const first = handovers[0];
|
|
4646
|
+
return until !== undefined && first !== undefined && first.ts < ts && ts < until;
|
|
4647
|
+
};
|
|
4648
|
+
/**
|
|
4649
|
+
* Records that a thread read of `key` covered every message before `ts`.
|
|
4650
|
+
* Bounded on its own, since a read that found nobody's post leaves an
|
|
4651
|
+
* owner and no record (Codex 4321103567). A thread whose mark is evicted
|
|
4652
|
+
* loses its record and owner with it, so it is read afresh instead of
|
|
4653
|
+
* placed from claims the read may have stopped short of.
|
|
4654
|
+
*/
|
|
4655
|
+
const noteReadUntil = (key, ts) => {
|
|
4656
|
+
const until = threadReadUntil.get(key);
|
|
4657
|
+
threadReadUntil.delete(key);
|
|
4658
|
+
threadReadUntil.set(key, until !== undefined && compareSlackTs(ts, until) < 0 ? until : ts);
|
|
4659
|
+
if (threadReadUntil.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4660
|
+
const oldest = threadReadUntil.keys().next();
|
|
4661
|
+
if (!oldest.done) {
|
|
4662
|
+
threadReadUntil.delete(oldest.value);
|
|
4663
|
+
threadAddressee.delete(oldest.value);
|
|
4664
|
+
threadTrimmedUntil.delete(oldest.value);
|
|
4665
|
+
threadOrigins.delete(oldest.value);
|
|
4666
|
+
}
|
|
4667
|
+
}
|
|
4668
|
+
};
|
|
4669
|
+
/**
|
|
4670
|
+
* Where the record of thread `key` stops knowing every take, for a
|
|
4671
|
+
* message at `ts` after what a thread read covered (card 562): a claim
|
|
4672
|
+
* older than the returned point may have been taken from since, unseen.
|
|
4673
|
+
* Undefined when the record is complete as far as `ts`.
|
|
4674
|
+
*/
|
|
4675
|
+
const unreadBefore = (key, ts) => {
|
|
4676
|
+
const until = threadReadUntil.get(key);
|
|
4677
|
+
return until !== undefined && liveFrom !== undefined && compareSlackTs(until, liveFrom) < 0 && compareSlackTs(ts, until) > 0
|
|
4678
|
+
? liveFrom
|
|
4679
|
+
: undefined;
|
|
4680
|
+
};
|
|
4161
4681
|
const holderAt = (key, ts) => {
|
|
4162
4682
|
const handovers = threadAddressee.get(key);
|
|
4163
|
-
if (!handovers) {
|
|
4683
|
+
if (!handovers || inTrimmedGap(key, handovers, ts)) {
|
|
4164
4684
|
return undefined;
|
|
4165
4685
|
}
|
|
4686
|
+
// The newest entry at or before `ts` answers, and only if this process
|
|
4687
|
+
// saw it live. A claim rebuilt from a thread read is a post; who was
|
|
4688
|
+
// asked since may be a mention it cannot see, so it leaves the question
|
|
4689
|
+
// to a fresh read. And it is newer than every live entry before it, so
|
|
4690
|
+
// skipping it for one of those would answer with a claim it superseded.
|
|
4691
|
+
const unread = unreadBefore(key, ts);
|
|
4166
4692
|
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4167
4693
|
const handover = handovers[index];
|
|
4168
4694
|
if (handover && handover.ts <= ts) {
|
|
4169
|
-
return handover.agentId;
|
|
4695
|
+
return handover.cold === true || (unread !== undefined && compareSlackTs(handover.ts, unread) < 0) ? undefined : handover.agentId;
|
|
4170
4696
|
}
|
|
4171
4697
|
}
|
|
4172
4698
|
return undefined;
|
|
@@ -4188,13 +4714,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4188
4714
|
const ownerAt = (key, ts) => {
|
|
4189
4715
|
const origin = threadOrigins.get(key);
|
|
4190
4716
|
const handovers = threadAddressee.get(key) ?? [];
|
|
4717
|
+
if (inTrimmedGap(key, handovers, ts)) {
|
|
4718
|
+
return undefined;
|
|
4719
|
+
}
|
|
4720
|
+
// A take from before the stretch the read left out may have been
|
|
4721
|
+
// superseded there: the question goes to a fresh read (card 562).
|
|
4722
|
+
const unread = unreadBefore(key, ts);
|
|
4191
4723
|
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4192
4724
|
const handover = handovers[index];
|
|
4193
4725
|
if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
|
|
4194
|
-
return handover.agentId;
|
|
4726
|
+
return unread !== undefined && compareSlackTs(handover.ts, unread) < 0 ? undefined : handover.agentId;
|
|
4195
4727
|
}
|
|
4196
4728
|
}
|
|
4197
|
-
return typeof origin === 'string' ? origin : undefined;
|
|
4729
|
+
return typeof origin === 'string' && unread === undefined ? origin : undefined;
|
|
4198
4730
|
};
|
|
4199
4731
|
/**
|
|
4200
4732
|
* The agent the thread `key` belongs to, whoever holds it now: the owner
|
|
@@ -4402,7 +4934,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4402
4934
|
const own = message.user === undefined ? undefined : byBotUser.get(message.user);
|
|
4403
4935
|
if (own !== undefined) {
|
|
4404
4936
|
place(own, at);
|
|
4405
|
-
posts.push({ agentId: own, at, asked: asked.has(own) });
|
|
4937
|
+
posts.push({ agentId: own, at, ts: message.ts, asked: asked.has(own) });
|
|
4406
4938
|
// An agent here naming a colleague that answers its mentions
|
|
4407
4939
|
// asks it, as `admitAgentMention` has it live, so the answer is
|
|
4408
4940
|
// not a judge speaking unasked.
|
|
@@ -4692,13 +5224,38 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4692
5224
|
// takes nothing.
|
|
4693
5225
|
const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
|
|
4694
5226
|
let taken;
|
|
5227
|
+
const takeovers = [];
|
|
4695
5228
|
for (const post of read.posts) {
|
|
4696
5229
|
const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
|
|
4697
|
-
if (takes
|
|
4698
|
-
|
|
5230
|
+
if (takes) {
|
|
5231
|
+
takeovers.push(post);
|
|
5232
|
+
if (!taken || post.at > taken.at) {
|
|
5233
|
+
taken = post;
|
|
5234
|
+
}
|
|
4699
5235
|
}
|
|
4700
5236
|
}
|
|
5237
|
+
// Once the owner is known, the live record learns what the thread read
|
|
5238
|
+
// found, as it would have live: who owns the thread, and every post
|
|
5239
|
+
// that moved it, where it sits. With only the verdict, a judge's reply
|
|
5240
|
+
// to this message became the first claim this process saw and read as
|
|
5241
|
+
// the owner, so the real owner answering a mention could not take the
|
|
5242
|
+
// thread back (Codex 4290051968). Every take rather than the latest, so
|
|
5243
|
+
// a message delivered late is placed where it was typed, and the judge's
|
|
5244
|
+
// take at its own post, so a reply typed before its answer lands still
|
|
5245
|
+
// goes to the judge, as this verdict says.
|
|
5246
|
+
const warm = () => {
|
|
5247
|
+
if (ownerId === undefined) {
|
|
5248
|
+
return;
|
|
5249
|
+
}
|
|
5250
|
+
const key = threadKeyFor(parts);
|
|
5251
|
+
noteOrigin(key, ownerId);
|
|
5252
|
+
for (const post of takeovers) {
|
|
5253
|
+
rememberAddressee(key, post.agentId, post.ts, true, true);
|
|
5254
|
+
}
|
|
5255
|
+
noteReadUntil(key, ts);
|
|
5256
|
+
};
|
|
4701
5257
|
if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
|
|
5258
|
+
warm();
|
|
4702
5259
|
return taken.agentId;
|
|
4703
5260
|
}
|
|
4704
5261
|
if (!owner) {
|
|
@@ -4707,7 +5264,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4707
5264
|
if (ownerId === undefined) {
|
|
4708
5265
|
return undefined;
|
|
4709
5266
|
}
|
|
4710
|
-
|
|
5267
|
+
warm();
|
|
4711
5268
|
return ownerId;
|
|
4712
5269
|
};
|
|
4713
5270
|
/**
|
|
@@ -4823,19 +5380,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4823
5380
|
const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
|
|
4824
5381
|
const running = renderers.get(sessionId) ?? [];
|
|
4825
5382
|
const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
|
|
4826
|
-
|
|
4827
|
-
return false;
|
|
4828
|
-
}
|
|
5383
|
+
const reason = `${SLACK_STOP_REASON}${userId}`;
|
|
4829
5384
|
for (const renderer of running) {
|
|
4830
5385
|
if (renderer.stoppedBy === undefined) {
|
|
4831
5386
|
renderer.stoppedBy = userId;
|
|
4832
|
-
renderer.controller.abort(new RunAbortedError(
|
|
4833
|
-
}
|
|
5387
|
+
renderer.controller.abort(new RunAbortedError(reason));
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
// Also a turn no renderer of this process started (card 501): one the
|
|
5391
|
+
// gateway recovered after a restart, or another caller's on this
|
|
5392
|
+
// session. The gateway counts every turn still on the session, one
|
|
5393
|
+
// already stopping included, so a repeated stop while a stopped turn
|
|
5394
|
+
// winds down is still a stop, and once it has ended, however it ended,
|
|
5395
|
+
// a stop with nothing running is an ordinary message. The renderers'
|
|
5396
|
+
// started turns are among those counted.
|
|
5397
|
+
const onSession = gatewayRef?.abortTurns?.(sessionId, reason) ?? 0;
|
|
5398
|
+
if (running.length === 0 && inIntake === 0 && onSession === 0) {
|
|
5399
|
+
return false;
|
|
4834
5400
|
}
|
|
4835
5401
|
if (inIntake > 0) {
|
|
4836
5402
|
stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
|
|
4837
5403
|
}
|
|
4838
|
-
|
|
5404
|
+
// A renderer's started turn is also one the gateway counts, so this is
|
|
5405
|
+
// the larger of the two, not their sum.
|
|
5406
|
+
log(`slack: ${userId} stopped ${Math.max(running.length, onSession) + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
|
|
4839
5407
|
return true;
|
|
4840
5408
|
};
|
|
4841
5409
|
/**
|
|
@@ -5048,6 +5616,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5048
5616
|
// Ava's transcript; only the map learns who holds the thread.
|
|
5049
5617
|
if (threadKey && handoverTo) {
|
|
5050
5618
|
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
5619
|
+
rememberSenderAddressee(threadKey, sender, handoverTo, event.ts);
|
|
5051
5620
|
}
|
|
5052
5621
|
// A home-channel message hands its new thread to the channel's owner
|
|
5053
5622
|
// the way a mention would, and is recorded by every socket for the same
|
|
@@ -5109,7 +5678,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5109
5678
|
const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
|
|
5110
5679
|
const held = threadKey === undefined
|
|
5111
5680
|
? undefined
|
|
5112
|
-
:
|
|
5681
|
+
: refusedStop ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
|
|
5113
5682
|
if (addressed || held === connection.config.agentId) {
|
|
5114
5683
|
if (!alreadySeen(eventKey)) {
|
|
5115
5684
|
log(refusal);
|
|
@@ -5197,12 +5766,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5197
5766
|
return { ...admission, overhear: true };
|
|
5198
5767
|
}
|
|
5199
5768
|
// An untagged reply. How this agent listens decides what it is: an
|
|
5200
|
-
// agent that answers only mentions hears it and nothing more
|
|
5201
|
-
//
|
|
5202
|
-
//
|
|
5203
|
-
//
|
|
5204
|
-
//
|
|
5205
|
-
//
|
|
5769
|
+
// agent that answers only mentions hears it and nothing more. One that
|
|
5770
|
+
// judges takes a turn nobody asked for on it — one it may stay quiet
|
|
5771
|
+
// on — when the reply is its own by the rule every agent shares (card
|
|
5772
|
+
// 541): the agent this person last addressed, else the thread's owner.
|
|
5773
|
+
// No window: an owner an hour past its last answer still hears an
|
|
5774
|
+
// instruction as its own, and exactly one agent takes each reply.
|
|
5206
5775
|
const listens = listensOf(connection.config.agentId);
|
|
5207
5776
|
if (listens === 'mentions' || listens === 'judge') {
|
|
5208
5777
|
// Except a stop (card 337): the holder rule decides whose it is, as
|
|
@@ -5213,13 +5782,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5213
5782
|
(connection.config.approvers ?? []).includes(sender) &&
|
|
5214
5783
|
isStopRequest(text, connection.botUserId);
|
|
5215
5784
|
const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5785
|
+
if (listens === 'judge' && !approverStop && threadKey !== undefined) {
|
|
5786
|
+
const target = replyTargetAt(threadKey, sender, event.ts);
|
|
5787
|
+
if (target === undefined) {
|
|
5788
|
+
// Nothing seen here says whose thread this is: the sessions and
|
|
5789
|
+
// the thread read decide, as for any agent.
|
|
5790
|
+
return { ...admission, overhear: true, judge: true, settled: false, judgeReason: 'owner, from the thread read' };
|
|
5791
|
+
}
|
|
5792
|
+
if (target !== connection.config.agentId) {
|
|
5793
|
+
return { ...admission, overhear: true, judgeReason: `${target}'s reply` };
|
|
5794
|
+
}
|
|
5795
|
+
const reason = senderAddresseeAt(threadKey, sender, event.ts) === target ? 'addressed it last' : 'owner';
|
|
5796
|
+
return { ...admission, overhear: true, judge: true, judgeReason: reason };
|
|
5797
|
+
}
|
|
5798
|
+
// An approver's stop is never judged (card 541): it stops the turn of
|
|
5799
|
+
// whoever holds the thread, and with nothing to stop it is only heard.
|
|
5219
5800
|
return {
|
|
5220
5801
|
...admission,
|
|
5221
5802
|
overhear: true,
|
|
5222
|
-
...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
|
|
5223
5803
|
...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
|
|
5224
5804
|
...(approverStop && stopHolder === undefined ? { settled: false } : {}),
|
|
5225
5805
|
};
|
|
@@ -5234,7 +5814,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5234
5814
|
isStopRequest(text, connection.botUserId);
|
|
5235
5815
|
const holder = threadKey === undefined
|
|
5236
5816
|
? undefined
|
|
5237
|
-
:
|
|
5817
|
+
: stopRequest ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
|
|
5238
5818
|
if (holder !== undefined && threadKey !== undefined) {
|
|
5239
5819
|
if (holder !== connection.config.agentId) {
|
|
5240
5820
|
// Standing down for the holder, whether or not the holder can still
|
|
@@ -5338,29 +5918,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5338
5918
|
// not to be this agent's leaves an empty link behind, which is all it
|
|
5339
5919
|
// should cost.
|
|
5340
5920
|
let overhear = admitted.overhear;
|
|
5341
|
-
// Judged, not ruled
|
|
5342
|
-
//
|
|
5343
|
-
//
|
|
5344
|
-
// there, and the message is heard rather than judged, which is the
|
|
5345
|
-
// safe side. A host that cannot say leaves the agent hearing.
|
|
5921
|
+
// Judged, not ruled (card 541): the intake already placed this reply
|
|
5922
|
+
// with this agent, so it runs a turn nobody asked for, which may stay
|
|
5923
|
+
// quiet. A reply only the sessions can place waits for them below.
|
|
5346
5924
|
let judged = false;
|
|
5347
|
-
if (admitted.judge &&
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
// other way round, and a window that runs one message short in a
|
|
5351
|
-
// race is a bound kept; one that runs a message long is not.
|
|
5352
|
-
const pending = pendingFor(sessionId);
|
|
5353
|
-
let routing;
|
|
5354
|
-
try {
|
|
5355
|
-
routing = await gateway.sessionRouting(sessionId);
|
|
5356
|
-
}
|
|
5357
|
-
catch (error) {
|
|
5358
|
-
warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5359
|
-
}
|
|
5360
|
-
if (routing && attentive(routing, event.ts, pending)) {
|
|
5361
|
-
judged = true;
|
|
5362
|
-
overhear = false;
|
|
5363
|
-
}
|
|
5925
|
+
if (admitted.judge && admitted.settled) {
|
|
5926
|
+
judged = true;
|
|
5927
|
+
overhear = false;
|
|
5364
5928
|
}
|
|
5365
5929
|
if (!admitted.settled) {
|
|
5366
5930
|
// One verdict for this message, shared with whichever other agents
|
|
@@ -5382,6 +5946,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5382
5946
|
overhear = true;
|
|
5383
5947
|
}
|
|
5384
5948
|
else {
|
|
5949
|
+
if (admitted.judge) {
|
|
5950
|
+
judged = true;
|
|
5951
|
+
overhear = false;
|
|
5952
|
+
}
|
|
5385
5953
|
if (threadKey) {
|
|
5386
5954
|
// Answered from the store rather than from memory: hold it, so
|
|
5387
5955
|
// the rest of the thread is ordered by receipt like any other.
|
|
@@ -5397,6 +5965,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5397
5965
|
}
|
|
5398
5966
|
}
|
|
5399
5967
|
}
|
|
5968
|
+
// One line per decision a judging agent makes on an untagged reply
|
|
5969
|
+
// (card 541): whether it took the turn, and why. Never the text.
|
|
5970
|
+
if (admitted.judgeReason !== undefined) {
|
|
5971
|
+
const reason = admitted.settled || judged ? admitted.judgeReason : 'another agent\'s, from the thread read';
|
|
5972
|
+
log(`slack: judge: ${connection.config.agentId} ${judged ? 'took' : 'heard'} ${event.ts} in ${event.channel} (${reason})`);
|
|
5973
|
+
}
|
|
5400
5974
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
5401
5975
|
// Fetched inside the chain, like the lookups below: it is network
|
|
5402
5976
|
// I/O, and a message whose screenshot is slow to arrive must not lose
|
|
@@ -5500,7 +6074,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5500
6074
|
// an overhear queued behind a long turn would hold the next
|
|
5501
6075
|
// message's place in this chain hostage to that turn.
|
|
5502
6076
|
return {
|
|
5503
|
-
observed:
|
|
6077
|
+
observed: gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata }),
|
|
5504
6078
|
};
|
|
5505
6079
|
}
|
|
5506
6080
|
// A turn nobody asked for opens its placeholder on its first text,
|
|
@@ -5590,7 +6164,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5590
6164
|
leaveIntake();
|
|
5591
6165
|
if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
|
|
5592
6166
|
renderer.stoppedBy = stopped.by;
|
|
5593
|
-
renderer.controller.abort(new RunAbortedError(
|
|
6167
|
+
renderer.controller.abort(new RunAbortedError(`${SLACK_STOP_REASON}${stopped.by}`));
|
|
5594
6168
|
}
|
|
5595
6169
|
// In a DM each message is its own status key, and Slack stops showing
|
|
5596
6170
|
// the running turn's once a newer message sits below it — so a
|
|
@@ -5600,6 +6174,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5600
6174
|
renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
|
|
5601
6175
|
let repeat;
|
|
5602
6176
|
let absorbed = false;
|
|
6177
|
+
const replay = replaying.get(pendingKey({ agentId: connection.config.agentId, team: connection.teamId, channel: event.channel, ts: event.ts }));
|
|
6178
|
+
if (replay !== undefined) {
|
|
6179
|
+
replay.dispatched = true;
|
|
6180
|
+
}
|
|
5603
6181
|
const turn = gateway.dispatch({
|
|
5604
6182
|
sessionId,
|
|
5605
6183
|
agentId: connection.config.agentId,
|
|
@@ -5619,10 +6197,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5619
6197
|
},
|
|
5620
6198
|
// Sent while a turn is running, this message is answered by the
|
|
5621
6199
|
// next turn together with everything else that queued (card 423);
|
|
5622
|
-
// the first of them renders that answer.
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
6200
|
+
// the first of them renders that answer. Not a replayed message:
|
|
6201
|
+
// it leaves the kept list only on its own confirmed answer, and an
|
|
6202
|
+
// answer another message's renderer posts is not one it can see.
|
|
6203
|
+
...(replay === undefined
|
|
6204
|
+
? {
|
|
6205
|
+
onAbsorbed: () => {
|
|
6206
|
+
absorbed = true;
|
|
6207
|
+
},
|
|
6208
|
+
}
|
|
6209
|
+
: {}),
|
|
5626
6210
|
});
|
|
5627
6211
|
return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
|
|
5628
6212
|
});
|
|
@@ -5675,6 +6259,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5675
6259
|
// Likewise a message absorbed into a merged turn: the turn's leading
|
|
5676
6260
|
// message's renderer answers for all of them.
|
|
5677
6261
|
if (repeat() === 'live' || absorbed()) {
|
|
6262
|
+
// A replayed message whose turn the start-up recovery owns is
|
|
6263
|
+
// answered by that recovery's report, and only if it reached the
|
|
6264
|
+
// thread; anything else leaves it kept.
|
|
6265
|
+
const ahead = repeat() === 'live' && renderer.outcomeReportedAhead !== undefined
|
|
6266
|
+
? await renderer.outcomeReportedAhead
|
|
6267
|
+
: undefined;
|
|
6268
|
+
if (ahead?.said === true) {
|
|
6269
|
+
noteReplayAnsweredAhead(connection, event.channel, event.ts);
|
|
6270
|
+
}
|
|
5678
6271
|
await renderer.withdraw();
|
|
5679
6272
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5680
6273
|
return;
|
|
@@ -5712,6 +6305,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5712
6305
|
}));
|
|
5713
6306
|
}
|
|
5714
6307
|
if (reportedAhead?.said === true) {
|
|
6308
|
+
noteReplayAnsweredAhead(connection, event.channel, event.ts);
|
|
5715
6309
|
await renderer.withdraw();
|
|
5716
6310
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5717
6311
|
return;
|
|
@@ -5744,6 +6338,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5744
6338
|
// interim messages already in the thread are heard either way.
|
|
5745
6339
|
let replyLanded = false;
|
|
5746
6340
|
const settled = finalized.then((outcome) => {
|
|
6341
|
+
noteReplayOutcome(connection, event.channel, event.ts, outcome);
|
|
5747
6342
|
replyLanded = outcome.published;
|
|
5748
6343
|
return true;
|
|
5749
6344
|
}, () => true);
|
|
@@ -5782,11 +6377,48 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5782
6377
|
// not a failure of the agent. Matched by name, as this package does
|
|
5783
6378
|
// not import the gateway. A turn nobody asked for stays silent.
|
|
5784
6379
|
const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
|
|
5785
|
-
|
|
6380
|
+
// A daemon on its way down that still heard this (the gateway keeps
|
|
6381
|
+
// channels connected through its drain): the message is kept for the
|
|
6382
|
+
// next start, which handles it as if it had just arrived. Said only
|
|
6383
|
+
// to a message that asked: a judged reply in the agent's thread (a
|
|
6384
|
+
// turn nobody asked for) is kept so the next daemon can still judge
|
|
6385
|
+
// it, but nobody asked, so a notice under it would be noise. An
|
|
6386
|
+
// overheard message never gets here; it was never going to be a turn.
|
|
6387
|
+
// Matched by name, like the pause.
|
|
6388
|
+
const stoppingRefusal = failure instanceof Error && failure.name === 'GatewayStoppingError' && renderer.stoppedBy === undefined;
|
|
6389
|
+
let deferredText;
|
|
6390
|
+
if (stoppingRefusal) {
|
|
6391
|
+
const restarting = failure.restarting !== false;
|
|
6392
|
+
const kept = await keepForNextStart({
|
|
6393
|
+
agentId: connection.config.agentId,
|
|
6394
|
+
team,
|
|
6395
|
+
channel: event.channel,
|
|
6396
|
+
ts: event.ts,
|
|
6397
|
+
...(event.thread_ts !== undefined ? { threadTs: event.thread_ts } : {}),
|
|
6398
|
+
user: userId,
|
|
6399
|
+
...(event.channel_type !== undefined ? { channelType: event.channel_type } : {}),
|
|
6400
|
+
keptAt: now(),
|
|
6401
|
+
});
|
|
6402
|
+
if (kept) {
|
|
6403
|
+
log(`slack: ${connection.config.agentId} kept ${event.ts} in ${event.channel} to answer after the ${restarting ? 'restart' : 'stop'}`);
|
|
6404
|
+
deferredText = restarting ? RESTARTING_REPLY_TEXT : STOPPING_REPLY_TEXT;
|
|
6405
|
+
}
|
|
6406
|
+
else {
|
|
6407
|
+
deferredText = failure.message;
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
// A refusal (paused, or a daemon on its way down) ran no turn, so its
|
|
6411
|
+
// notice is never what answers a replayed message: that one stays
|
|
6412
|
+
// kept, whether or not keeping it again just worked.
|
|
6413
|
+
const refused = pausedReply || stoppingRefusal;
|
|
6414
|
+
const outcome = renderer.stoppedBy !== undefined
|
|
5786
6415
|
? await renderer.stopped(renderer.stoppedBy)
|
|
5787
|
-
:
|
|
5788
|
-
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
6416
|
+
: refused
|
|
6417
|
+
? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
|
|
5789
6418
|
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
6419
|
+
const { spoke, spokeAt } = refused && renderer.stoppedBy === undefined
|
|
6420
|
+
? outcome
|
|
6421
|
+
: noteReplayOutcome(connection, event.channel, event.ts, outcome);
|
|
5790
6422
|
if (spoke && threadKey !== undefined) {
|
|
5791
6423
|
// A file it posted before breaking has still spoken, by the same
|
|
5792
6424
|
// rule as a reply that landed.
|
|
@@ -5803,9 +6435,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5803
6435
|
// A channel id is unique only within its workspace, so a standing
|
|
5804
6436
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
5805
6437
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
6438
|
+
conversationKindFor,
|
|
5806
6439
|
requestCredential,
|
|
5807
6440
|
async start(gateway) {
|
|
5808
6441
|
gatewayRef = gateway;
|
|
6442
|
+
// Nothing counts as seen live until every socket is up (Codex
|
|
6443
|
+
// 4321128637): a message sent while the sockets are still coming up
|
|
6444
|
+
// reaches none of them, so a read stopping before it stops short.
|
|
6445
|
+
liveFrom = NOT_LIVE_YET;
|
|
5809
6446
|
unsubscribe = gateway.bus.subscribe((event) => {
|
|
5810
6447
|
// Approval traffic is not part of a reply: it gets its own message
|
|
5811
6448
|
// with its own lifetime, and routing it through the running turn's
|
|
@@ -5957,21 +6594,55 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5957
6594
|
}
|
|
5958
6595
|
}
|
|
5959
6596
|
settleHomeOwners();
|
|
6597
|
+
const listeners = new Map();
|
|
6598
|
+
// Read before any socket delivers, so each conversation with kept
|
|
6599
|
+
// messages is held from the first live event on.
|
|
6600
|
+
let kept = [];
|
|
6601
|
+
if (pendingIntakePath !== undefined) {
|
|
6602
|
+
try {
|
|
6603
|
+
kept = await readPending(pendingIntakePath);
|
|
6604
|
+
}
|
|
6605
|
+
catch (error) {
|
|
6606
|
+
warn(`slack: could not read the messages kept from the last restart: ${error instanceof Error ? error.message : String(error)}`);
|
|
6607
|
+
}
|
|
6608
|
+
const cutoff = now() - PENDING_INTAKE_MAX_AGE_MS;
|
|
6609
|
+
for (const entry of kept) {
|
|
6610
|
+
if (entry.keptAt >= cutoff && authenticated.some((connection) => keptFor(connection, entry))) {
|
|
6611
|
+
holdConversation(holdKey(entry.agentId, entry.channel));
|
|
6612
|
+
}
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
5960
6615
|
for (const connection of authenticated) {
|
|
5961
6616
|
const { config, socket } = connection;
|
|
5962
6617
|
try {
|
|
5963
6618
|
// Returns the work it started: the SDK ignores it, and a test's fake
|
|
5964
6619
|
// socket waits on it rather than guessing how long it takes.
|
|
5965
|
-
const
|
|
5966
|
-
const handled =
|
|
6620
|
+
const handle = (inbound) => {
|
|
6621
|
+
const handled = inbound.catch((error) => {
|
|
5967
6622
|
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5968
6623
|
});
|
|
5969
6624
|
inflight.add(handled);
|
|
5970
6625
|
void handled.finally(() => inflight.delete(handled));
|
|
5971
6626
|
return handled;
|
|
5972
6627
|
};
|
|
6628
|
+
// The replay's own deliveries never wait on the hold they release.
|
|
6629
|
+
const onReplay = (args) => handle(handleInbound(connection, args));
|
|
6630
|
+
const onEvent = (args) => {
|
|
6631
|
+
const channel = args.event?.channel;
|
|
6632
|
+
const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
|
|
6633
|
+
if (hold === undefined) {
|
|
6634
|
+
return onReplay(args);
|
|
6635
|
+
}
|
|
6636
|
+
// Acked now, as any delivery is, so Slack does not redeliver a
|
|
6637
|
+
// message that is only waiting its turn; admitted after the hold.
|
|
6638
|
+
void Promise.resolve(args.ack()).catch((error) => {
|
|
6639
|
+
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
6640
|
+
});
|
|
6641
|
+
return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
|
|
6642
|
+
};
|
|
5973
6643
|
socket.on('app_mention', onEvent);
|
|
5974
6644
|
socket.on('message', onEvent);
|
|
6645
|
+
listeners.set(config.agentId, onReplay);
|
|
5975
6646
|
socket.on('interactive', (args) => {
|
|
5976
6647
|
const handled = handleInteractive(connection, args).catch((error) => {
|
|
5977
6648
|
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -5989,8 +6660,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5989
6660
|
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5990
6661
|
}
|
|
5991
6662
|
}
|
|
6663
|
+
const liveMs = now() + LIVE_FROM_MARGIN_MS;
|
|
6664
|
+
liveFrom = `${Math.floor(liveMs / 1000)}.${String((liveMs % 1000) * 1000).padStart(6, '0')}`;
|
|
6665
|
+
// Once every app is connected, so a kept message is handled with the
|
|
6666
|
+
// whole roster's identities known, as a live one would be. Only for the
|
|
6667
|
+
// apps that did connect: the rest stay kept for a start where they do.
|
|
6668
|
+
// A kept message is from before the restart, so its thread read stops
|
|
6669
|
+
// short of the start, as `liveFrom` already says (card 562).
|
|
6670
|
+
replayStopping = new AbortController();
|
|
6671
|
+
track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
|
|
5992
6672
|
},
|
|
5993
6673
|
async stop() {
|
|
6674
|
+
replayStopping?.abort();
|
|
6675
|
+
releaseAllConversations();
|
|
5994
6676
|
// Stop intake first, so no new INBOUND event can slip into
|
|
5995
6677
|
// `inflight` while the drain below runs.
|
|
5996
6678
|
await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
|
|
@@ -6018,8 +6700,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6018
6700
|
connections.length = 0;
|
|
6019
6701
|
approvalPosts.clear();
|
|
6020
6702
|
threadAddressee.clear();
|
|
6703
|
+
threadTrimmedUntil.clear();
|
|
6704
|
+
threadReadUntil.clear();
|
|
6705
|
+
senderAddressees.clear();
|
|
6021
6706
|
coldVerdicts.clear();
|
|
6022
|
-
pendingObserves.clear();
|
|
6023
6707
|
botIdentities.clear();
|
|
6024
6708
|
homeOwners.clear();
|
|
6025
6709
|
rendering.clear();
|