@stratusagent/channel-slack 0.12.6 → 0.13.1
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 +25 -18
- package/dist/index.d.ts +22 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +603 -28
- 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';
|
|
@@ -55,6 +55,17 @@ const THREAD_SENDER_CAPACITY = 64;
|
|
|
55
55
|
* socket stays behind for all of them.
|
|
56
56
|
*/
|
|
57
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';
|
|
58
69
|
/**
|
|
59
70
|
* How many agent mentions one thread admits in a row before a person has
|
|
60
71
|
* to speak there again (card 513): enough for a question, an answer and a
|
|
@@ -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
|
|
@@ -2422,6 +2487,332 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2422
2487
|
inflight.add(settled);
|
|
2423
2488
|
void settled.finally(() => inflight.delete(settled));
|
|
2424
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
|
+
};
|
|
2425
2816
|
const alreadySeen = (key) => {
|
|
2426
2817
|
if (seenEvents.has(key)) {
|
|
2427
2818
|
return true;
|
|
@@ -3660,6 +4051,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3660
4051
|
return landed;
|
|
3661
4052
|
};
|
|
3662
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);
|
|
3663
4059
|
const gateway = gatewayRef;
|
|
3664
4060
|
if (!gateway?.sessionRouting) {
|
|
3665
4061
|
return NOTHING_REPORTED;
|
|
@@ -3687,7 +4083,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3687
4083
|
}
|
|
3688
4084
|
const channel = metadata.slackChannel;
|
|
3689
4085
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
3690
|
-
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
|
+
//
|
|
3691
4090
|
// A turn nobody asked for that said nothing before it broke — the
|
|
3692
4091
|
// daemon died inside it, and the next start failed it with no
|
|
3693
4092
|
// renderer here to keep quiet for it. The same rule as the lazy
|
|
@@ -3708,7 +4107,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3708
4107
|
return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
|
|
3709
4108
|
}
|
|
3710
4109
|
let landed = [];
|
|
3711
|
-
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 () => {
|
|
3712
4111
|
landed = await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
3713
4112
|
});
|
|
3714
4113
|
return { said, landed };
|
|
@@ -4137,7 +4536,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4137
4536
|
* Dropping that one would hand the thread back to its owner after enough
|
|
4138
4537
|
* mentions of a third agent, while the judge that took it still answers.
|
|
4139
4538
|
*/
|
|
4140
|
-
const trimHandovers = (handovers) => {
|
|
4539
|
+
const trimHandovers = (key, handovers) => {
|
|
4141
4540
|
if (handovers.length <= THREAD_HANDOVER_DEPTH) {
|
|
4142
4541
|
return handovers;
|
|
4143
4542
|
}
|
|
@@ -4153,6 +4552,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4153
4552
|
// The kept take sits on top of the full recent tail, one past the depth,
|
|
4154
4553
|
// so `holderAt` still sees every recent claim it saw before.
|
|
4155
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
|
+
}
|
|
4156
4562
|
return latestTake > 0 && latestTake < tailFrom
|
|
4157
4563
|
? [first, handovers[latestTake], ...tail]
|
|
4158
4564
|
: [first, ...tail];
|
|
@@ -4205,11 +4611,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4205
4611
|
// Re-inserted rather than mutated in place: a Map iterates in insertion
|
|
4206
4612
|
// order, which is what makes the first key the one to evict.
|
|
4207
4613
|
threadAddressee.delete(key);
|
|
4208
|
-
threadAddressee.set(key, trimHandovers(handovers));
|
|
4614
|
+
threadAddressee.set(key, trimHandovers(key, handovers));
|
|
4209
4615
|
if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4210
4616
|
const oldest = threadAddressee.keys().next();
|
|
4211
4617
|
if (!oldest.done) {
|
|
4212
4618
|
threadAddressee.delete(oldest.value);
|
|
4619
|
+
threadTrimmedUntil.delete(oldest.value);
|
|
4620
|
+
threadReadUntil.delete(oldest.value);
|
|
4213
4621
|
// The owner goes with the record of who took the thread since: an
|
|
4214
4622
|
// owner kept alone would stand in for a judge's takeover it no
|
|
4215
4623
|
// longer knows of, where the thread read would see it.
|
|
@@ -4231,9 +4639,48 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4231
4639
|
* fresh read of the thread, never sent to the owner on a guess.
|
|
4232
4640
|
*/
|
|
4233
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
|
+
};
|
|
4234
4681
|
const holderAt = (key, ts) => {
|
|
4235
4682
|
const handovers = threadAddressee.get(key);
|
|
4236
|
-
if (!handovers) {
|
|
4683
|
+
if (!handovers || inTrimmedGap(key, handovers, ts)) {
|
|
4237
4684
|
return undefined;
|
|
4238
4685
|
}
|
|
4239
4686
|
// The newest entry at or before `ts` answers, and only if this process
|
|
@@ -4241,10 +4688,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4241
4688
|
// asked since may be a mention it cannot see, so it leaves the question
|
|
4242
4689
|
// to a fresh read. And it is newer than every live entry before it, so
|
|
4243
4690
|
// skipping it for one of those would answer with a claim it superseded.
|
|
4691
|
+
const unread = unreadBefore(key, ts);
|
|
4244
4692
|
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4245
4693
|
const handover = handovers[index];
|
|
4246
4694
|
if (handover && handover.ts <= ts) {
|
|
4247
|
-
return handover.cold === true ? undefined : handover.agentId;
|
|
4695
|
+
return handover.cold === true || (unread !== undefined && compareSlackTs(handover.ts, unread) < 0) ? undefined : handover.agentId;
|
|
4248
4696
|
}
|
|
4249
4697
|
}
|
|
4250
4698
|
return undefined;
|
|
@@ -4266,13 +4714,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4266
4714
|
const ownerAt = (key, ts) => {
|
|
4267
4715
|
const origin = threadOrigins.get(key);
|
|
4268
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);
|
|
4269
4723
|
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4270
4724
|
const handover = handovers[index];
|
|
4271
4725
|
if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
|
|
4272
|
-
return handover.agentId;
|
|
4726
|
+
return unread !== undefined && compareSlackTs(handover.ts, unread) < 0 ? undefined : handover.agentId;
|
|
4273
4727
|
}
|
|
4274
4728
|
}
|
|
4275
|
-
return typeof origin === 'string' ? origin : undefined;
|
|
4729
|
+
return typeof origin === 'string' && unread === undefined ? origin : undefined;
|
|
4276
4730
|
};
|
|
4277
4731
|
/**
|
|
4278
4732
|
* The agent the thread `key` belongs to, whoever holds it now: the owner
|
|
@@ -4798,6 +5252,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4798
5252
|
for (const post of takeovers) {
|
|
4799
5253
|
rememberAddressee(key, post.agentId, post.ts, true, true);
|
|
4800
5254
|
}
|
|
5255
|
+
noteReadUntil(key, ts);
|
|
4801
5256
|
};
|
|
4802
5257
|
if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
|
|
4803
5258
|
warm();
|
|
@@ -4925,19 +5380,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4925
5380
|
const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
|
|
4926
5381
|
const running = renderers.get(sessionId) ?? [];
|
|
4927
5382
|
const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
|
|
4928
|
-
|
|
4929
|
-
return false;
|
|
4930
|
-
}
|
|
5383
|
+
const reason = `${SLACK_STOP_REASON}${userId}`;
|
|
4931
5384
|
for (const renderer of running) {
|
|
4932
5385
|
if (renderer.stoppedBy === undefined) {
|
|
4933
5386
|
renderer.stoppedBy = userId;
|
|
4934
|
-
renderer.controller.abort(new RunAbortedError(
|
|
4935
|
-
}
|
|
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;
|
|
4936
5400
|
}
|
|
4937
5401
|
if (inIntake > 0) {
|
|
4938
5402
|
stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
|
|
4939
5403
|
}
|
|
4940
|
-
|
|
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}`);
|
|
4941
5407
|
return true;
|
|
4942
5408
|
};
|
|
4943
5409
|
/**
|
|
@@ -5698,7 +6164,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5698
6164
|
leaveIntake();
|
|
5699
6165
|
if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
|
|
5700
6166
|
renderer.stoppedBy = stopped.by;
|
|
5701
|
-
renderer.controller.abort(new RunAbortedError(
|
|
6167
|
+
renderer.controller.abort(new RunAbortedError(`${SLACK_STOP_REASON}${stopped.by}`));
|
|
5702
6168
|
}
|
|
5703
6169
|
// In a DM each message is its own status key, and Slack stops showing
|
|
5704
6170
|
// the running turn's once a newer message sits below it — so a
|
|
@@ -5708,6 +6174,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5708
6174
|
renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
|
|
5709
6175
|
let repeat;
|
|
5710
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
|
+
}
|
|
5711
6181
|
const turn = gateway.dispatch({
|
|
5712
6182
|
sessionId,
|
|
5713
6183
|
agentId: connection.config.agentId,
|
|
@@ -5727,10 +6197,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5727
6197
|
},
|
|
5728
6198
|
// Sent while a turn is running, this message is answered by the
|
|
5729
6199
|
// next turn together with everything else that queued (card 423);
|
|
5730
|
-
// the first of them renders that answer.
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
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
|
+
: {}),
|
|
5734
6210
|
});
|
|
5735
6211
|
return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
|
|
5736
6212
|
});
|
|
@@ -5783,6 +6259,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5783
6259
|
// Likewise a message absorbed into a merged turn: the turn's leading
|
|
5784
6260
|
// message's renderer answers for all of them.
|
|
5785
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
|
+
}
|
|
5786
6271
|
await renderer.withdraw();
|
|
5787
6272
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5788
6273
|
return;
|
|
@@ -5820,6 +6305,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5820
6305
|
}));
|
|
5821
6306
|
}
|
|
5822
6307
|
if (reportedAhead?.said === true) {
|
|
6308
|
+
noteReplayAnsweredAhead(connection, event.channel, event.ts);
|
|
5823
6309
|
await renderer.withdraw();
|
|
5824
6310
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5825
6311
|
return;
|
|
@@ -5852,6 +6338,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5852
6338
|
// interim messages already in the thread are heard either way.
|
|
5853
6339
|
let replyLanded = false;
|
|
5854
6340
|
const settled = finalized.then((outcome) => {
|
|
6341
|
+
noteReplayOutcome(connection, event.channel, event.ts, outcome);
|
|
5855
6342
|
replyLanded = outcome.published;
|
|
5856
6343
|
return true;
|
|
5857
6344
|
}, () => true);
|
|
@@ -5890,11 +6377,48 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5890
6377
|
// not a failure of the agent. Matched by name, as this package does
|
|
5891
6378
|
// not import the gateway. A turn nobody asked for stays silent.
|
|
5892
6379
|
const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
|
|
5893
|
-
|
|
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
|
|
5894
6415
|
? await renderer.stopped(renderer.stoppedBy)
|
|
5895
|
-
:
|
|
5896
|
-
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
6416
|
+
: refused
|
|
6417
|
+
? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
|
|
5897
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);
|
|
5898
6422
|
if (spoke && threadKey !== undefined) {
|
|
5899
6423
|
// A file it posted before breaking has still spoken, by the same
|
|
5900
6424
|
// rule as a reply that landed.
|
|
@@ -5915,6 +6439,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5915
6439
|
requestCredential,
|
|
5916
6440
|
async start(gateway) {
|
|
5917
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;
|
|
5918
6446
|
unsubscribe = gateway.bus.subscribe((event) => {
|
|
5919
6447
|
// Approval traffic is not part of a reply: it gets its own message
|
|
5920
6448
|
// with its own lifetime, and routing it through the running turn's
|
|
@@ -6066,21 +6594,55 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6066
6594
|
}
|
|
6067
6595
|
}
|
|
6068
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
|
+
}
|
|
6069
6615
|
for (const connection of authenticated) {
|
|
6070
6616
|
const { config, socket } = connection;
|
|
6071
6617
|
try {
|
|
6072
6618
|
// Returns the work it started: the SDK ignores it, and a test's fake
|
|
6073
6619
|
// socket waits on it rather than guessing how long it takes.
|
|
6074
|
-
const
|
|
6075
|
-
const handled =
|
|
6620
|
+
const handle = (inbound) => {
|
|
6621
|
+
const handled = inbound.catch((error) => {
|
|
6076
6622
|
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6077
6623
|
});
|
|
6078
6624
|
inflight.add(handled);
|
|
6079
6625
|
void handled.finally(() => inflight.delete(handled));
|
|
6080
6626
|
return handled;
|
|
6081
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
|
+
};
|
|
6082
6643
|
socket.on('app_mention', onEvent);
|
|
6083
6644
|
socket.on('message', onEvent);
|
|
6645
|
+
listeners.set(config.agentId, onReplay);
|
|
6084
6646
|
socket.on('interactive', (args) => {
|
|
6085
6647
|
const handled = handleInteractive(connection, args).catch((error) => {
|
|
6086
6648
|
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -6098,8 +6660,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6098
6660
|
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6099
6661
|
}
|
|
6100
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));
|
|
6101
6672
|
},
|
|
6102
6673
|
async stop() {
|
|
6674
|
+
replayStopping?.abort();
|
|
6675
|
+
releaseAllConversations();
|
|
6103
6676
|
// Stop intake first, so no new INBOUND event can slip into
|
|
6104
6677
|
// `inflight` while the drain below runs.
|
|
6105
6678
|
await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
|
|
@@ -6127,6 +6700,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6127
6700
|
connections.length = 0;
|
|
6128
6701
|
approvalPosts.clear();
|
|
6129
6702
|
threadAddressee.clear();
|
|
6703
|
+
threadTrimmedUntil.clear();
|
|
6704
|
+
threadReadUntil.clear();
|
|
6130
6705
|
senderAddressees.clear();
|
|
6131
6706
|
coldVerdicts.clear();
|
|
6132
6707
|
botIdentities.clear();
|