tokenmaxxing 1.3.0 → 1.5.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/DESIGN.md +4 -2
- package/package.json +1 -1
- package/src/cli/serve.ts +458 -67
- package/src/lib/install.ts +3 -2
- package/src/lib/slackbridge.ts +334 -22
- package/src/lib/slackstate.ts +9 -0
package/src/cli/serve.ts
CHANGED
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
// serve links list links
|
|
16
16
|
// serve run the daemon
|
|
17
17
|
|
|
18
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
19
20
|
import { delay, omit, uniq } from "es-toolkit";
|
|
20
21
|
import { z } from "zod";
|
|
21
22
|
import { Chat, ConsoleLogger, StreamingPlan, ThreadImpl, type StreamChunk } from "chat";
|
|
22
23
|
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
23
|
-
import {
|
|
24
|
+
import { MemoryStateAdapter } from "@chat-adapter/state-memory";
|
|
24
25
|
import {
|
|
25
26
|
bareChannelId,
|
|
26
27
|
isChannelId,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
stripLeadingMention,
|
|
37
38
|
upsertLink,
|
|
38
39
|
SlackLinkSchema,
|
|
40
|
+
SlackThreadSchema,
|
|
39
41
|
type ActiveTurn,
|
|
40
42
|
type SlackConfig,
|
|
41
43
|
type SlackLink,
|
|
@@ -332,6 +334,10 @@ export function buildServeRuntime(seam: {
|
|
|
332
334
|
* relayThread). */
|
|
333
335
|
onSpawn?: (pid: number) => void;
|
|
334
336
|
drainSignal?: AbortSignal;
|
|
337
|
+
/** steering seam (see relayThread): a live attempt's steer function, or
|
|
338
|
+
* null when the attempt ends. steer runs its onAccept callback
|
|
339
|
+
* synchronously inside acceptance, before the child sees the text. */
|
|
340
|
+
onSteer?: (steer: ((text: string, onAccept?: () => void) => boolean) | null) => void;
|
|
335
341
|
}) => Promise<TurnOutcome>;
|
|
336
342
|
cleanup: (input: { threadId: string }) => CleanupOutcome;
|
|
337
343
|
/** add/remove a status reaction on a message (production: the Slack
|
|
@@ -369,6 +375,35 @@ export function buildServeRuntime(seam: {
|
|
|
369
375
|
let draining = false;
|
|
370
376
|
// channels already diagnosed as unlinked this run (see handleTurn).
|
|
371
377
|
const unlinkedLogged = new Set<string>();
|
|
378
|
+
// corrupt thread records already logged as skipped this run (see nudgeSweep).
|
|
379
|
+
const sweepSkipLogged = new Set<string>();
|
|
380
|
+
// Steering registry: one acceptor per thread with a LIVE query attempt
|
|
381
|
+
// (registered by runTurn via the relay's onSteer hook, removed when the
|
|
382
|
+
// attempt ends). onMessage tries the acceptor before the inbox - steering
|
|
383
|
+
// is the default for a mid-turn reply (owner decision 2026-07-27); a
|
|
384
|
+
// refusal (attempt ending, mention-only text, out-of-order arrival) falls
|
|
385
|
+
// back to the inbox path. A thread only ever appears here after handleTurn
|
|
386
|
+
// ran under this daemon's cfg, so the channel is known linked.
|
|
387
|
+
const liveSteers = new Map<string, (m: { relayed: { text: string; authorId: string; id: string }[] }) => Promise<boolean>>();
|
|
388
|
+
// TURN-PRODUCING serialized work per thread (running plus waiting). Only
|
|
389
|
+
// turn producers count (adversarial-review catch: reaction notes and nudge
|
|
390
|
+
// bookkeeping ride the same serialized chain, and counting them silently
|
|
391
|
+
// disabled steering for the rest of any turn a user reacted to). A steer
|
|
392
|
+
// is only ordered when nothing waits behind the live turn.
|
|
393
|
+
const turnDepth = new Map<string, number>();
|
|
394
|
+
// Un-steered messages waiting for the next turn, folded and drained as ONE
|
|
395
|
+
// turn (sorted by Slack ts, which restores order for any upstream arrival
|
|
396
|
+
// race). This is the daemon-owned replacement for the chat queue's
|
|
397
|
+
// skipped-message folding. Each entry carries its delivery's mention flag,
|
|
398
|
+
// so the drained turn derives mention-ness from the RETAINED messages - a
|
|
399
|
+
// mention dropped by the overflow cap must not leave a phantom flag behind
|
|
400
|
+
// (cubic review catch on PR #50).
|
|
401
|
+
const pendingInbox = new Map<string, { text: string; authorId: string; id: string; isMention: boolean }[]>();
|
|
402
|
+
// Per-thread arrival ordering: the steer attempt and the inbox push for
|
|
403
|
+
// one message run to completion before the next message's do, so two
|
|
404
|
+
// near-simultaneous replies can never interleave at the acceptor's await
|
|
405
|
+
// points and land on the child's stdin out of order.
|
|
406
|
+
const arrivalChains = new Map<string, Promise<void>>();
|
|
372
407
|
|
|
373
408
|
/** Best-effort status reaction: reaction state is decoration, so every
|
|
374
409
|
* failure (missing reactions:write until the app is reinstalled,
|
|
@@ -387,7 +422,9 @@ export function buildServeRuntime(seam: {
|
|
|
387
422
|
* before the spawn, cleared when the turn returns, so a marker surviving
|
|
388
423
|
* into the next daemon start identifies a turn a restart killed mid-run.
|
|
389
424
|
* The session id persists the moment init assigns it - a first-turn kill
|
|
390
|
-
* must stay resumable.
|
|
425
|
+
* must stay resumable. Returns the steered message ids alongside the
|
|
426
|
+
* outcome: they joined the turn mid-run, so the caller's settle must
|
|
427
|
+
* close their reaction lifecycle too. */
|
|
391
428
|
const runTurn = async (input: {
|
|
392
429
|
thread: { id: string; post: (m: StreamingPlan) => Promise<unknown> };
|
|
393
430
|
record: SlackThread;
|
|
@@ -396,14 +433,17 @@ export function buildServeRuntime(seam: {
|
|
|
396
433
|
sessionId: string | null;
|
|
397
434
|
marker: ActiveTurn;
|
|
398
435
|
link: SlackLink;
|
|
399
|
-
}): Promise<TurnOutcome> => {
|
|
436
|
+
}): Promise<{ outcome: TurnOutcome; steeredMessageIds: string[] }> => {
|
|
400
437
|
let record: SlackThread = { ...input.record, activeTurn: input.marker };
|
|
401
438
|
saveSlackThread(record);
|
|
402
|
-
// the whole turn (parks and retries included) reads as "being processed"
|
|
403
|
-
|
|
404
|
-
|
|
439
|
+
// the whole turn (parks and retries included) reads as "being processed";
|
|
440
|
+
// a resumed marker's steered messages re-arm their hourglass too
|
|
441
|
+
// (setStatus swallows already_reacted).
|
|
442
|
+
for (const id of [input.marker.messageId, ...(input.marker.steeredMessageIds ?? [])]) {
|
|
443
|
+
if (id) await setStatus({ threadId: input.thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "add" });
|
|
405
444
|
}
|
|
406
445
|
let outcome: TurnOutcome | null = null;
|
|
446
|
+
let steeredMessageIds = input.marker.steeredMessageIds ?? [];
|
|
407
447
|
try {
|
|
408
448
|
outcome = await seam.relay({
|
|
409
449
|
cwd: record.cwd,
|
|
@@ -420,9 +460,14 @@ export function buildServeRuntime(seam: {
|
|
|
420
460
|
onSpawn: (pid) => {
|
|
421
461
|
// the lstart token makes the pid a verifiable identity for the
|
|
422
462
|
// orphan reaper; a child dead before ps sees it persists without
|
|
423
|
-
// one, and an identity-less pid is never signaled.
|
|
463
|
+
// one, and an identity-less pid is never signaled. Built on the
|
|
464
|
+
// CURRENT marker (a steer may have grown it), with the previous
|
|
465
|
+
// spawn's identity dropped: a retry child must never inherit the
|
|
466
|
+
// dead child's lstart, or the reaper would skip (or mis-verify)
|
|
467
|
+
// the live group.
|
|
424
468
|
const startedAt = pidStartTime(pid);
|
|
425
|
-
|
|
469
|
+
const marker = omit(record.activeTurn ?? input.marker, ["pid", "pidStartedAt"]);
|
|
470
|
+
record = { ...record, activeTurn: { ...marker, pid, ...(startedAt === null ? {} : { pidStartedAt: startedAt }) } };
|
|
426
471
|
saveSlackThread(record);
|
|
427
472
|
},
|
|
428
473
|
onSessionId: (sessionId) => {
|
|
@@ -430,9 +475,115 @@ export function buildServeRuntime(seam: {
|
|
|
430
475
|
saveSlackThread(record);
|
|
431
476
|
},
|
|
432
477
|
drainSignal: drainAbort.signal,
|
|
478
|
+
// While an attempt is steerable, a relayable mid-turn message folds
|
|
479
|
+
// into the RUNNING turn (owner decision 2026-07-27: steering is the
|
|
480
|
+
// default; the queued next turn is only the fallback). The acceptor
|
|
481
|
+
// runs its marker mutation synchronously after a successful steer:
|
|
482
|
+
// the steered text becomes part of the durable prompt (replays and
|
|
483
|
+
// retries must include it) before any await can interleave with the
|
|
484
|
+
// turn's own marker writes.
|
|
485
|
+
onSteer: (steerText) => {
|
|
486
|
+
if (steerText === null) {
|
|
487
|
+
liveSteers.delete(input.thread.id);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
liveSteers.set(input.thread.id, async (m) => {
|
|
491
|
+
const stripped = m.relayed
|
|
492
|
+
.map((r) => ({ text: stripLeadingMention({ text: r.text, botUserId: seam.botUserId() }), authorId: r.authorId, id: r.id }))
|
|
493
|
+
.filter((r) => r.text !== "");
|
|
494
|
+
if (stripped.length === 0) return false;
|
|
495
|
+
// out-of-order insurance: Slack ids are timestamps, so a message
|
|
496
|
+
// OLDER than anything this turn already carries arrived late
|
|
497
|
+
// through an upstream race - refuse it into the inbox, whose
|
|
498
|
+
// drain sorts by ts, instead of folding it after its successor.
|
|
499
|
+
const seen = record.activeTurn ?? input.marker;
|
|
500
|
+
const newestSeen = Math.max(Number(seen.messageId ?? 0) || 0, ...(seen.steeredMessageIds ?? []).map((id) => Number(id) || 0));
|
|
501
|
+
if (stripped.some((r) => (Number(r.id) || 0) < newestSeen)) return false;
|
|
502
|
+
// per-turn steer budget (cursor security review, round 2 on
|
|
503
|
+
// PR #50): accepted steers grow the durable prompt and the
|
|
504
|
+
// child's queue outside the inbox's 100-entry cap, so a flood
|
|
505
|
+
// could balloon one turn without bound. Far past any human
|
|
506
|
+
// steering cadence, a reply takes the capped inbox path instead.
|
|
507
|
+
if ((seen.steeredMessageIds?.length ?? 0) + stripped.length > 25) return false;
|
|
508
|
+
// hourglass BEFORE the steer so a settle racing this acceptor
|
|
509
|
+
// can never leave an unremovable reaction; a refusal takes it
|
|
510
|
+
// back off.
|
|
511
|
+
for (const r of stripped) {
|
|
512
|
+
await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "add" });
|
|
513
|
+
}
|
|
514
|
+
// authors the turn does not know yet get named inline: the
|
|
515
|
+
// UserPromptSubmit context does not re-fire for folded mid-turn
|
|
516
|
+
// messages, so attribution rides the message itself.
|
|
517
|
+
const text = stripped
|
|
518
|
+
.map((r) => (input.requesterIds.includes(r.authorId) ? r.text : `Message from <@${r.authorId}>:\n${r.text}`))
|
|
519
|
+
.join("\n\n");
|
|
520
|
+
// The durable commit runs INSIDE steer's acceptance, in the same
|
|
521
|
+
// JS tick as its liveness check (adversarial-review catch, round
|
|
522
|
+
// 3: this acceptor runs on the arrival chain and can resume from
|
|
523
|
+
// its hourglass awaits AFTER the turn ended - a write-ahead save
|
|
524
|
+
// here resurrected the finished turn's marker on disk, and its
|
|
525
|
+
// refusal rollback clobbered post-turn state with a stale
|
|
526
|
+
// snapshot). Acceptance proves the turn is live, so the closure
|
|
527
|
+
// record is disk-faithful; the marker still grows durably BEFORE
|
|
528
|
+
// the text reaches the child's stdin (a crash in between replays
|
|
529
|
+
// a steer the child may never have seen - duplication over
|
|
530
|
+
// loss); and a refusal commits nothing, so a stale invocation is
|
|
531
|
+
// a harmless fall-through to the inbox.
|
|
532
|
+
let asked: SlackThread["attention"];
|
|
533
|
+
const commit = () => {
|
|
534
|
+
const mergedRequesters = uniq([...input.requesterIds, ...stripped.map((r) => r.authorId)]);
|
|
535
|
+
const marker = record.activeTurn ?? input.marker;
|
|
536
|
+
const grownIds = [...(marker.steeredMessageIds ?? []), ...stripped.map((r) => r.id)];
|
|
537
|
+
let next = { ...record, activeTurn: { ...marker, prompt: `${marker.prompt}\n\n${text}`, requesterIds: mergedRequesters, steeredMessageIds: grownIds } };
|
|
538
|
+
// the user responded: a pending ask is answered by the steer
|
|
539
|
+
// just like by a queued turn (adversarial-review catch:
|
|
540
|
+
// leaving it would strand the question mark and fire a
|
|
541
|
+
// spurious nudge about an ask this very message answered).
|
|
542
|
+
const pendingAsk = next.attention;
|
|
543
|
+
if (pendingAsk) next = omit(next, ["attention"]);
|
|
544
|
+
// all-or-nothing (cubic review catch, round 4): the durable
|
|
545
|
+
// save runs before ANY outer mutation, so a failed write
|
|
546
|
+
// leaves the turn's view untouched and the refusal fallback
|
|
547
|
+
// below starts from clean state - without this ordering the
|
|
548
|
+
// settle would stamp the never-delivered message with the
|
|
549
|
+
// turn's outcome emoji.
|
|
550
|
+
saveSlackThread(next);
|
|
551
|
+
steeredMessageIds = grownIds;
|
|
552
|
+
record = next;
|
|
553
|
+
asked = pendingAsk;
|
|
554
|
+
for (const r of stripped) {
|
|
555
|
+
if (!input.requesterIds.includes(r.authorId)) input.requesterIds.push(r.authorId);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
// a throwing commit (the durable save failing) escapes steer()
|
|
559
|
+
// BEFORE the text reaches the child or the relay records it
|
|
560
|
+
// (cubic review catch, round 4): treat it as a refusal, so the
|
|
561
|
+
// message keeps its inbox fallback instead of vanishing into
|
|
562
|
+
// the generic task_crashed log with its hourglass stranded. If
|
|
563
|
+
// the disk stays broken, the inbox turn's own marker write
|
|
564
|
+
// surfaces it loudly through the crash-notice path.
|
|
565
|
+
let accepted = false;
|
|
566
|
+
try {
|
|
567
|
+
accepted = steerText(text, commit);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
log("serve.steer_commit_failed", { thread: input.thread.id, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
570
|
+
}
|
|
571
|
+
if (!accepted) {
|
|
572
|
+
for (const r of stripped) {
|
|
573
|
+
await setStatus({ threadId: input.thread.id, messageId: r.id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
log("serve.steered", { thread: input.thread.id, texts: stripped.length });
|
|
578
|
+
if (asked?.messageId) {
|
|
579
|
+
await setStatus({ threadId: input.thread.id, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "remove" });
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
});
|
|
583
|
+
},
|
|
433
584
|
});
|
|
434
585
|
record = { ...record, sessionId: outcome.sessionId };
|
|
435
|
-
return outcome;
|
|
586
|
+
return { outcome, steeredMessageIds };
|
|
436
587
|
} finally {
|
|
437
588
|
// a failure DURING a drain is presumed to be the shutdown signal killing
|
|
438
589
|
// the claude child (terminal Ctrl-C and group signals hit the whole
|
|
@@ -458,6 +609,9 @@ export function buildServeRuntime(seam: {
|
|
|
458
609
|
if (!draining && outcome !== null && outcome.failed && outcome.rateLimited && !outcome.announcedDrop && deferUntil === null) {
|
|
459
610
|
log("serve.drop_unannounced", { thread: input.thread.id });
|
|
460
611
|
}
|
|
612
|
+
// belt-and-braces: the relay clears its steer hook per attempt, but the
|
|
613
|
+
// registry entry must never outlive the turn that owns it.
|
|
614
|
+
liveSteers.delete(input.thread.id);
|
|
461
615
|
if (deferUntil !== null && record.activeTurn) {
|
|
462
616
|
record = { ...record, activeTurn: { ...record.activeTurn, resumeAt: deferUntil } };
|
|
463
617
|
saveSlackThread(record);
|
|
@@ -470,7 +624,7 @@ export function buildServeRuntime(seam: {
|
|
|
470
624
|
|
|
471
625
|
const handleTurn = async (input: {
|
|
472
626
|
thread: ServeThread;
|
|
473
|
-
/** every relayed message this turn (
|
|
627
|
+
/** every relayed message this turn (the inbox batch, ts-sorted), text
|
|
474
628
|
* paired with its author id: a decision may be owed to an earlier
|
|
475
629
|
* folded sender, and a sender whose whole message was the bot mention
|
|
476
630
|
* contributes no prompt text, so text and author filter together
|
|
@@ -525,11 +679,10 @@ export function buildServeRuntime(seam: {
|
|
|
525
679
|
return;
|
|
526
680
|
}
|
|
527
681
|
log("serve.message", { thread: thread.id, isMention, texts: input.relayed.length });
|
|
528
|
-
// relayed carries
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
// prompt text nor a requester id (cursor review catch 2026-07-18).
|
|
682
|
+
// relayed carries every message the inbox batched for this turn, folded
|
|
683
|
+
// into one prompt here. A message that is empty once its bot mention is
|
|
684
|
+
// stripped contributes neither prompt text nor a requester id (cursor
|
|
685
|
+
// review catch 2026-07-18).
|
|
533
686
|
const stripped = input.relayed
|
|
534
687
|
.map((m) => ({ text: stripLeadingMention({ text: m.text, botUserId: seam.botUserId() }), authorId: m.authorId }))
|
|
535
688
|
.filter((m) => m.text !== "");
|
|
@@ -571,6 +724,12 @@ export function buildServeRuntime(seam: {
|
|
|
571
724
|
// prompt re-appears alongside the session transcript that already holds
|
|
572
725
|
// its partial work, and the newer message steers.
|
|
573
726
|
const deferred = record.activeTurn?.resumeAt !== undefined ? record.activeTurn : null;
|
|
727
|
+
// a taken-over deferral's messages still wear their processing hourglass
|
|
728
|
+
// (a deferral settles nothing); this turn serves their held prompt, so
|
|
729
|
+
// it adopts their ids and settles them with its own outcome - without
|
|
730
|
+
// the adoption the old trigger's hourglass would read "processing"
|
|
731
|
+
// forever once the fold replaced its marker.
|
|
732
|
+
const adoptedIds = deferred ? [...(deferred.messageId ? [deferred.messageId] : []), ...(deferred.steeredMessageIds ?? [])] : [];
|
|
574
733
|
if (deferred) {
|
|
575
734
|
const timer = deferredTimers.get(thread.id);
|
|
576
735
|
if (timer !== undefined) clearTimeout(timer);
|
|
@@ -608,16 +767,16 @@ export function buildServeRuntime(seam: {
|
|
|
608
767
|
// agent feature + assistant:write (the adapter warns instead of throwing).
|
|
609
768
|
await thread.startTyping();
|
|
610
769
|
const startedAt = Date.now();
|
|
611
|
-
const outcome = await runTurn({
|
|
770
|
+
const { outcome, steeredMessageIds } = await runTurn({
|
|
612
771
|
thread,
|
|
613
772
|
record,
|
|
614
773
|
prompt,
|
|
615
774
|
requesterIds,
|
|
616
775
|
sessionId: record.sessionId,
|
|
617
|
-
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds },
|
|
776
|
+
marker: { prompt, startedAt: new Date().toISOString(), resumeCount: 0, ...(messageId ? { messageId } : {}), requesterIds, ...(adoptedIds.length > 0 ? { steeredMessageIds: adoptedIds } : {}) },
|
|
618
777
|
link,
|
|
619
778
|
});
|
|
620
|
-
await settleTurn({ thread, outcome, startedAt, messageId, requesterIds });
|
|
779
|
+
await settleTurn({ thread, outcome, startedAt, messageId, steeredMessageIds, requesterIds });
|
|
621
780
|
};
|
|
622
781
|
|
|
623
782
|
/** Post-turn bookkeeping shared by inbound and resumed turns: the outcome
|
|
@@ -630,6 +789,9 @@ export function buildServeRuntime(seam: {
|
|
|
630
789
|
startedAt: number;
|
|
631
790
|
/** the triggering message carrying the status reactions; absent = skip. */
|
|
632
791
|
messageId?: string;
|
|
792
|
+
/** messages steered into the turn mid-run: they carry the same reaction
|
|
793
|
+
* lifecycle as the trigger and settle with the same emoji. */
|
|
794
|
+
steeredMessageIds?: string[];
|
|
633
795
|
/** the turn's asked users, persisted when the model flagged attention. */
|
|
634
796
|
requesterIds?: string[];
|
|
635
797
|
}) => {
|
|
@@ -653,10 +815,19 @@ export function buildServeRuntime(seam: {
|
|
|
653
815
|
// below makes the question mark unremovable forever.
|
|
654
816
|
const killedByDrain = draining && outcome.failed && !outcome.announcedDrop && !outcome.resultReceived;
|
|
655
817
|
const deferredForResume = outcome.deferUntil !== null;
|
|
656
|
-
if (
|
|
818
|
+
if (!killedByDrain && !deferredForResume) {
|
|
657
819
|
const emoji = outcome.failed ? STATUS_EMOJI.failed : outcome.attention && !outcome.finish ? STATUS_EMOJI.attention : STATUS_EMOJI.done;
|
|
658
|
-
|
|
659
|
-
|
|
820
|
+
// steerLost: a steered follow-up's own drained turn failed after the
|
|
821
|
+
// primary turn succeeded, so the steered messages settle as failed -
|
|
822
|
+
// matching the in-thread re-send notice; a lost instruction must never
|
|
823
|
+
// read green (see TurnOutcomeSchema.steerLost for the per-turn
|
|
824
|
+
// attribution tradeoff).
|
|
825
|
+
const steeredEmoji = outcome.steerLost ? STATUS_EMOJI.failed : emoji;
|
|
826
|
+
for (const id of [input.messageId, ...(input.steeredMessageIds ?? [])]) {
|
|
827
|
+
if (!id) continue;
|
|
828
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: id === input.messageId ? emoji : steeredEmoji, op: "add" });
|
|
829
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
830
|
+
}
|
|
660
831
|
}
|
|
661
832
|
// the model asked the user for a decision: mark the thread waiting so the
|
|
662
833
|
// nudge sweep and the reaction-answer path can see it. Persisted even on
|
|
@@ -730,21 +901,33 @@ export function buildServeRuntime(seam: {
|
|
|
730
901
|
return true;
|
|
731
902
|
};
|
|
732
903
|
|
|
904
|
+
/** The ownership funnel for every task the daemon spawns: registration in
|
|
905
|
+
* activeTurns so a shutdown drains it, and the daemon's terminal error
|
|
906
|
+
* boundary. Bun kills the WHOLE process on any unhandled rejection
|
|
907
|
+
* (default-mode exit verified 2026-07-27), so a `void tracked(...)`
|
|
908
|
+
* fire-and-forget whose task threw would otherwise take every concurrent
|
|
909
|
+
* session's turn down with it - one thread's bad state file must never
|
|
910
|
+
* end another thread's half-streamed answer. Site-specific handling (the
|
|
911
|
+
* in-thread crash notice in onMessage) stays at the site that has the
|
|
912
|
+
* context; whatever escapes lands here, logged, and the daemon keeps
|
|
913
|
+
* serving. */
|
|
733
914
|
const tracked = async (turn: Promise<void>) => {
|
|
734
915
|
activeTurns.add(turn);
|
|
735
916
|
try {
|
|
736
917
|
await turn;
|
|
918
|
+
} catch (e) {
|
|
919
|
+
log("serve.task_crashed", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
737
920
|
} finally {
|
|
738
921
|
activeTurns.delete(turn);
|
|
739
922
|
}
|
|
740
923
|
};
|
|
741
924
|
|
|
742
|
-
// Per-thread serialization owned HERE
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
925
|
+
// Per-thread serialization owned HERE: with concurrent dispatch (see
|
|
926
|
+
// runDaemon's Chat config) chat provides no per-thread locking at all, so
|
|
927
|
+
// this chain is the ONLY thing keeping two claude turns off one thread's
|
|
928
|
+
// cwd and session. It predates the concurrent switch for the same reason
|
|
929
|
+
// in weaker form: chat's old queue lock expired 30s into any claude turn
|
|
930
|
+
// and let a second handler start anyway (review catch, PR #18).
|
|
748
931
|
const threadTurns = new Map<string, Promise<void>>();
|
|
749
932
|
const serialized = (threadId: string, run: () => Promise<void>) => {
|
|
750
933
|
const prev = threadTurns.get(threadId) ?? Promise.resolve();
|
|
@@ -766,16 +949,140 @@ export function buildServeRuntime(seam: {
|
|
|
766
949
|
})();
|
|
767
950
|
return next;
|
|
768
951
|
};
|
|
952
|
+
/** serialized + the turnDepth count, for callers that produce a claude
|
|
953
|
+
* TURN (inbox drains, reaction answers, recoveries). Bookkeeping riders
|
|
954
|
+
* on the chain (reaction notes, nudges) use bare `serialized` so they
|
|
955
|
+
* never gate steering. */
|
|
956
|
+
const serializedTurn = (threadId: string, run: () => Promise<void>) => {
|
|
957
|
+
turnDepth.set(threadId, (turnDepth.get(threadId) ?? 0) + 1);
|
|
958
|
+
const next = serialized(threadId, run);
|
|
959
|
+
void next.catch(() => {}).finally(() => {
|
|
960
|
+
const depth = (turnDepth.get(threadId) ?? 1) - 1;
|
|
961
|
+
if (depth <= 0) turnDepth.delete(threadId);
|
|
962
|
+
else turnDepth.set(threadId, depth);
|
|
963
|
+
});
|
|
964
|
+
return next;
|
|
965
|
+
};
|
|
769
966
|
|
|
770
967
|
// both Chat SDK callbacks funnel here. Filter EVERY message, trigger
|
|
771
968
|
// included: an outsider (or our own post) arriving last must not discard
|
|
772
|
-
// relayable home-workspace messages
|
|
773
|
-
//
|
|
774
|
-
//
|
|
969
|
+
// relayable home-workspace messages delivered alongside it (review catch,
|
|
970
|
+
// PR #18). context.skipped is empty under concurrent dispatch but stays
|
|
971
|
+
// merged defensively - a chat release reintroducing batching must not
|
|
972
|
+
// silently drop messages.
|
|
775
973
|
const onMessage = async (input: { thread: ServeThread; message: ServeMessage; skipped: ServeMessage[]; isMention: boolean }) => {
|
|
776
974
|
const relayed = [...input.skipped, input.message].filter(relayable).map((m) => ({ text: m.text, authorId: m.author.userId, id: m.id }));
|
|
777
975
|
if (relayed.length === 0) return; // outsider mentions never open a session
|
|
778
|
-
|
|
976
|
+
// arrivals for one thread DECIDE strictly one at a time, in delivery
|
|
977
|
+
// order: the steer-or-inbox decision for this message completes before
|
|
978
|
+
// the next message's begins, so two near-simultaneous replies can never
|
|
979
|
+
// interleave at the acceptor's await points. The chain holds only the
|
|
980
|
+
// DECISION - a scheduled turn's completion is awaited outside it, or a
|
|
981
|
+
// long turn would hold every later arrival hostage and steering could
|
|
982
|
+
// never engage.
|
|
983
|
+
const prev = arrivalChains.get(input.thread.id) ?? Promise.resolve();
|
|
984
|
+
const job = (async () => {
|
|
985
|
+
try {
|
|
986
|
+
await prev;
|
|
987
|
+
} catch { /* the previous arrival's failure was surfaced to its own caller */ }
|
|
988
|
+
return dispatchArrival({ thread: input.thread, relayed, isMention: input.isMention });
|
|
989
|
+
})();
|
|
990
|
+
const link = job.then(
|
|
991
|
+
() => {},
|
|
992
|
+
() => {},
|
|
993
|
+
);
|
|
994
|
+
arrivalChains.set(input.thread.id, link);
|
|
995
|
+
void link.then(() => {
|
|
996
|
+
if (arrivalChains.get(input.thread.id) === link) arrivalChains.delete(input.thread.id);
|
|
997
|
+
});
|
|
998
|
+
// tracked from the DECISION on (codex review catch on PR #50): the drain
|
|
999
|
+
// waits on activeTurns, and an arrival still deciding at shutdown lived
|
|
1000
|
+
// nowhere else - the daemon could exit before the message reached the
|
|
1001
|
+
// inbox, a marker, or the drop notice.
|
|
1002
|
+
await tracked((async () => {
|
|
1003
|
+
const scheduled = await job;
|
|
1004
|
+
if (scheduled) await scheduled.turn;
|
|
1005
|
+
})());
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/** One message's steer-or-inbox decision. Steering first (owner decision
|
|
1009
|
+
* 2026-07-27): a reply landing while the thread's turn is running folds
|
|
1010
|
+
* into that turn instead of waiting behind it. Guarded to an empty inbox
|
|
1011
|
+
* and no waiting turn, or the fold would reorder this message ahead of
|
|
1012
|
+
* one already waiting; a drain keeps its loud-drop contract. Every
|
|
1013
|
+
* refusal falls through to the inbox, whose drain runs ALL waiting
|
|
1014
|
+
* messages as one folded turn - the daemon-owned replacement for the
|
|
1015
|
+
* chat queue's skipped-message folding, sorted by Slack ts so an
|
|
1016
|
+
* upstream arrival race cannot reorder the prompt. Returns the tracked
|
|
1017
|
+
* drain-turn promise when this arrival scheduled one, so the caller can
|
|
1018
|
+
* await the turn without holding the arrival chain. */
|
|
1019
|
+
const dispatchArrival = async (input: { thread: ServeThread; relayed: { text: string; authorId: string; id: string }[]; isMention: boolean }): Promise<{ turn: Promise<void> } | null> => {
|
|
1020
|
+
const threadId = input.thread.id;
|
|
1021
|
+
if (!draining && (pendingInbox.get(threadId)?.length ?? 0) === 0 && (turnDepth.get(threadId) ?? 0) <= 1) {
|
|
1022
|
+
const accept = liveSteers.get(threadId);
|
|
1023
|
+
if (accept && (await accept({ relayed: input.relayed }))) return null;
|
|
1024
|
+
}
|
|
1025
|
+
const inbox = pendingInbox.get(threadId) ?? [];
|
|
1026
|
+
const hadPending = inbox.length > 0;
|
|
1027
|
+
inbox.push(...input.relayed.map((r) => ({ ...r, isMention: input.isMention })));
|
|
1028
|
+
// hard cap, replacing the retired chat queue's maxQueueSize bound
|
|
1029
|
+
// (cursor security review on PR #50): without it a flood during one
|
|
1030
|
+
// long turn grows memory and the folded prompt without limit. Newest
|
|
1031
|
+
// dropped, LOUDLY - the old queue's silent drop-oldest ate the earliest
|
|
1032
|
+
// instructions, the worse failure.
|
|
1033
|
+
if (inbox.length > 100) {
|
|
1034
|
+
log("serve.inbox_dropped", { thread: threadId, dropped: inbox.length - 100 });
|
|
1035
|
+
inbox.length = 100;
|
|
1036
|
+
}
|
|
1037
|
+
pendingInbox.set(threadId, inbox);
|
|
1038
|
+
// one drain per non-empty inbox: later arrivals fold into the batch the
|
|
1039
|
+
// already-scheduled drain snapshots when it finally runs.
|
|
1040
|
+
if (hadPending) return null;
|
|
1041
|
+
const turn = tracked(serializedTurn(threadId, async () => {
|
|
1042
|
+
const batch = (pendingInbox.get(threadId) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
|
|
1043
|
+
pendingInbox.delete(threadId);
|
|
1044
|
+
if (batch.length === 0) return;
|
|
1045
|
+
try {
|
|
1046
|
+
await handleTurn({ thread: input.thread, relayed: batch, isMention: batch.some((m) => m.isMention) });
|
|
1047
|
+
} catch (e) {
|
|
1048
|
+
// an escaped handleTurn throw (a state-file parse, a Slack API
|
|
1049
|
+
// rejection outside relayThread's never-throws boundary) previously
|
|
1050
|
+
// died in the chat SDK's catch-and-log: the user's message vanished
|
|
1051
|
+
// with no reply and no log line of ours (2026-07-27 report). Tell
|
|
1052
|
+
// the thread and keep the daemon serving.
|
|
1053
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
1054
|
+
log("serve.turn_crashed", { thread: input.thread.id, err: detail });
|
|
1055
|
+
// a surviving activeTurn marker means the turn is PRESERVED (a drain
|
|
1056
|
+
// kill kept it for the next generation's auto-resume): a "re-send it"
|
|
1057
|
+
// notice would invite a duplicate run and a failed x would misread a
|
|
1058
|
+
// guaranteed retry (cubic review catch, round 3) - log only, the
|
|
1059
|
+
// resume machinery owns the messaging. The read is best-effort: an
|
|
1060
|
+
// unreadable record (possibly the crash itself) takes the visible
|
|
1061
|
+
// crash path.
|
|
1062
|
+
let preserved = false;
|
|
1063
|
+
try {
|
|
1064
|
+
preserved = loadSlackThread(input.thread.id)?.activeTurn !== undefined;
|
|
1065
|
+
} catch { /* unreadable record: treat as not preserved */ }
|
|
1066
|
+
if (preserved) return;
|
|
1067
|
+
try {
|
|
1068
|
+
await input.thread.post(
|
|
1069
|
+
(async function* () {
|
|
1070
|
+
yield `tokenmaxxing: this message's handling crashed: ${detail}. If no reply landed above, re-send it.`;
|
|
1071
|
+
})(),
|
|
1072
|
+
);
|
|
1073
|
+
} catch (postErr) {
|
|
1074
|
+
log("serve.turn_crash_notice_failed", { thread: input.thread.id, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
1075
|
+
}
|
|
1076
|
+
// a crash after runTurn added the hourglass would otherwise read as
|
|
1077
|
+
// "processing" forever (codex review catch); setStatus never throws.
|
|
1078
|
+
const messageId = batch.at(-1)?.id;
|
|
1079
|
+
if (messageId) {
|
|
1080
|
+
await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1081
|
+
await setStatus({ threadId: input.thread.id, messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}));
|
|
1085
|
+
return { turn };
|
|
779
1086
|
};
|
|
780
1087
|
|
|
781
1088
|
/** A user reaction in a tracked thread. While the thread waits on an asked
|
|
@@ -816,6 +1123,14 @@ export function buildServeRuntime(seam: {
|
|
|
816
1123
|
log("serve.reaction_dropped", { thread: input.threadId, reason: "unlinked-channel" });
|
|
817
1124
|
return;
|
|
818
1125
|
}
|
|
1126
|
+
// bare `serialized` on purpose, so a reaction can never gate steering
|
|
1127
|
+
// (adversarial-review catch: counting these bookkeeping riders in
|
|
1128
|
+
// turnDepth silently disabled steering for the rest of any turn a user
|
|
1129
|
+
// reacted to). Accepted tradeoff (documented, WONTFIX): in the rare
|
|
1130
|
+
// shape where an ANSWER turn is queued here behind a live turn, a
|
|
1131
|
+
// fresh reply may steer the live turn ahead of the queued answer - both
|
|
1132
|
+
// still reach the model, and an answer coexisting with a live turn only
|
|
1133
|
+
// occurs in multi-user threads.
|
|
819
1134
|
await tracked(serialized(input.threadId, async () => {
|
|
820
1135
|
const fresh = loadSlackThread(input.threadId);
|
|
821
1136
|
if (!fresh) return; // finished while queued
|
|
@@ -829,16 +1144,47 @@ export function buildServeRuntime(seam: {
|
|
|
829
1144
|
// folds into the next turn.
|
|
830
1145
|
if (!draining && asked && asked.requesterIds.includes(input.userId) && afterAsk && onAskMessage) {
|
|
831
1146
|
log("serve.reaction_answer", { thread: input.threadId, emoji: input.emoji });
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1147
|
+
try {
|
|
1148
|
+
const { thread } = await streamable(input.threadId);
|
|
1149
|
+
await handleTurn({
|
|
1150
|
+
thread,
|
|
1151
|
+
relayed: [{
|
|
1152
|
+
text: `<@${input.userId}> answered your pending question with the Slack reaction :${input.emoji}:. Interpret the reaction as their reply and continue.`,
|
|
1153
|
+
authorId: input.userId,
|
|
1154
|
+
id: input.messageId,
|
|
1155
|
+
}],
|
|
1156
|
+
isMention: false,
|
|
1157
|
+
});
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
// a crashed answer turn must not read as an accepted answer (codex
|
|
1160
|
+
// review catch): tell the thread and settle the reacted-to
|
|
1161
|
+
// message's status so it never reads as processing forever.
|
|
1162
|
+
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
1163
|
+
log("serve.reaction_crashed", { thread: input.threadId, err: detail });
|
|
1164
|
+
try {
|
|
1165
|
+
await seam.postToThread({ threadId: input.threadId, text: `tokenmaxxing: handling your reaction answer crashed: ${detail}. Reply in the thread to answer instead.` });
|
|
1166
|
+
} catch (postErr) {
|
|
1167
|
+
log("serve.reaction_crash_notice_failed", { thread: input.threadId, err: (postErr instanceof Error ? postErr.message : String(postErr)).slice(0, 300) });
|
|
1168
|
+
}
|
|
1169
|
+
await setStatus({ threadId: input.threadId, messageId: input.messageId, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1170
|
+
await setStatus({ threadId: input.threadId, messageId: input.messageId, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1171
|
+
// handleTurn consumed the attention state (and its question mark)
|
|
1172
|
+
// before the turn ran; a crashed answer must not eat the ask (cubic
|
|
1173
|
+
// review catch, round 2). Restore both so the nudge sweep and the
|
|
1174
|
+
// reaction-answer gates keep working; the restore itself is
|
|
1175
|
+
// best-effort (the crash may BE an unreadable record).
|
|
1176
|
+
try {
|
|
1177
|
+
const cur = loadSlackThread(input.threadId);
|
|
1178
|
+
if (cur && !cur.attention) {
|
|
1179
|
+
saveSlackThread({ ...cur, attention: asked });
|
|
1180
|
+
if (asked.messageId) {
|
|
1181
|
+
await setStatus({ threadId: input.threadId, messageId: asked.messageId, emoji: STATUS_EMOJI.attention, op: "add" });
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
} catch (restoreErr) {
|
|
1185
|
+
log("serve.attention_restore_failed", { thread: input.threadId, err: (restoreErr instanceof Error ? restoreErr.message : String(restoreErr)).slice(0, 300) });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
842
1188
|
return;
|
|
843
1189
|
}
|
|
844
1190
|
const home = await seam.isHomeUser({ userId: input.userId });
|
|
@@ -868,7 +1214,32 @@ export function buildServeRuntime(seam: {
|
|
|
868
1214
|
const nudgeSweep = async (input?: { now?: number }) => {
|
|
869
1215
|
if (draining) return;
|
|
870
1216
|
const now = input?.now ?? Date.now();
|
|
871
|
-
|
|
1217
|
+
// per-record parsing, not listSlackThreads: state files that fail to
|
|
1218
|
+
// parse THROW by contract, but here one corrupt record aborting the
|
|
1219
|
+
// whole sweep would silence every OTHER thread's overdue nudge on every
|
|
1220
|
+
// tick (codex review catch) - and before the daemon's rejection backstop
|
|
1221
|
+
// existed, this bare-interval throw was a whole-daemon crash killing
|
|
1222
|
+
// every in-flight turn (2026-07-27 report shape). The skip is logged
|
|
1223
|
+
// once per file per daemon run; a 60s tick would repeat it forever.
|
|
1224
|
+
let files: string[] = [];
|
|
1225
|
+
try {
|
|
1226
|
+
files = existsSync(paths.slackThreadsDir) ? readdirSync(paths.slackThreadsDir) : [];
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
log("serve.nudge_sweep_error", { err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
for (const f of files) {
|
|
1232
|
+
if (!f.endsWith(".json")) continue;
|
|
1233
|
+
let record: SlackThread;
|
|
1234
|
+
try {
|
|
1235
|
+
record = SlackThreadSchema.parse(JSON.parse(readFileSync(join(paths.slackThreadsDir, f), "utf8")));
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
if (!sweepSkipLogged.has(f)) {
|
|
1238
|
+
sweepSkipLogged.add(f);
|
|
1239
|
+
log("serve.nudge_record_skipped", { file: f, err: (e instanceof Error ? e.message : String(e)).slice(0, 300) });
|
|
1240
|
+
}
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
872
1243
|
const asked = record.attention;
|
|
873
1244
|
if (!asked || asked.nudgedAt !== undefined || now - Date.parse(asked.askedAt) < ATTENTION_NUDGE_MS) continue;
|
|
874
1245
|
// unlinked channels are contractually silent in Slack (review catch:
|
|
@@ -940,7 +1311,9 @@ export function buildServeRuntime(seam: {
|
|
|
940
1311
|
try {
|
|
941
1312
|
const { thread, requesterIds } = await seam.streamable(record.threadId);
|
|
942
1313
|
const link = linkForChannel(cfg, bareChannelId(thread.channelId));
|
|
943
|
-
|
|
1314
|
+
// serializedTurn: a recovery runs a real claude turn, so it must gate
|
|
1315
|
+
// steering-order like any other queued turn.
|
|
1316
|
+
await serializedTurn(record.threadId, async () => {
|
|
944
1317
|
// a drain signal can land between the scan and this turn; leave the
|
|
945
1318
|
// marker at its previous count so the next start retries.
|
|
946
1319
|
if (draining) return;
|
|
@@ -974,10 +1347,11 @@ export function buildServeRuntime(seam: {
|
|
|
974
1347
|
// with the user never told the daemon gave up.
|
|
975
1348
|
await thread.post(decision.notice);
|
|
976
1349
|
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
977
|
-
// the abandoned turn's
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
await setStatus({ threadId: thread.id, messageId:
|
|
1350
|
+
// the abandoned turn's messages must not keep reading as processing.
|
|
1351
|
+
for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
|
|
1352
|
+
if (!id) continue;
|
|
1353
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1354
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
981
1355
|
}
|
|
982
1356
|
return;
|
|
983
1357
|
}
|
|
@@ -1015,9 +1389,10 @@ export function buildServeRuntime(seam: {
|
|
|
1015
1389
|
// or its hourglass reads "processing" forever (cubic review
|
|
1016
1390
|
// catch on PR #43). The unlinked abandon above stays reactionless
|
|
1017
1391
|
// on purpose: unlinked channels are contractually untouchable.
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
await setStatus({ threadId: thread.id, messageId:
|
|
1392
|
+
for (const id of [turn.messageId, ...(turn.steeredMessageIds ?? [])]) {
|
|
1393
|
+
if (!id) continue;
|
|
1394
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.failed, op: "add" });
|
|
1395
|
+
await setStatus({ threadId: thread.id, messageId: id, emoji: STATUS_EMOJI.processing, op: "remove" });
|
|
1021
1396
|
}
|
|
1022
1397
|
saveSlackThread(omit(fresh, ["activeTurn"]));
|
|
1023
1398
|
return;
|
|
@@ -1041,8 +1416,8 @@ export function buildServeRuntime(seam: {
|
|
|
1041
1416
|
// nudge and answer-gate the users who were actually asked (vercel
|
|
1042
1417
|
// review catch on PR #43); older markers without the field fall back.
|
|
1043
1418
|
const resumedRequesterIds = decision.marker.requesterIds ?? requesterIds;
|
|
1044
|
-
const outcome = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
1045
|
-
await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, requesterIds: resumedRequesterIds });
|
|
1419
|
+
const { outcome, steeredMessageIds } = await runTurn({ thread, record: fresh, prompt: decision.prompt, requesterIds: resumedRequesterIds, sessionId: decision.sessionId, marker: decision.marker, link });
|
|
1420
|
+
await settleTurn({ thread, outcome, startedAt, messageId: decision.marker.messageId, steeredMessageIds, requesterIds: resumedRequesterIds });
|
|
1046
1421
|
});
|
|
1047
1422
|
} catch (e) {
|
|
1048
1423
|
const detail = (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
@@ -1164,24 +1539,25 @@ async function runDaemon(): Promise<number> {
|
|
|
1164
1539
|
// held directly (not only via Chat) so startup can re-subscribe recorded
|
|
1165
1540
|
// threads: subscriptions live in this in-memory state and die with the
|
|
1166
1541
|
// process, and only a fresh mention would otherwise revive a thread.
|
|
1167
|
-
const state =
|
|
1542
|
+
const state = new MemoryStateAdapter();
|
|
1168
1543
|
const bot = new Chat({
|
|
1169
1544
|
userName: "tokenmaxxing",
|
|
1170
1545
|
adapters: { slack },
|
|
1171
1546
|
state,
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
|
|
1547
|
+
// CONCURRENT dispatch (steering redesign, superseding the queue strategy
|
|
1548
|
+
// and its 1h-TTL/size-100 tuning): every message reaches onMessage the
|
|
1549
|
+
// moment Slack delivers it, in arrival order. The queue strategy's
|
|
1550
|
+
// 30s dispatch-lock lease made mid-turn messages invisible AND could
|
|
1551
|
+
// reorder them - a reply enqueued behind a live lease was only released
|
|
1552
|
+
// when a LATER message took the expired lock, dispatching newest-first
|
|
1553
|
+
// (adversarial-review catch; chat 4.34.0 handleQueueOrDebounce). The
|
|
1554
|
+
// daemon owns everything the queue used to provide: per-thread turn
|
|
1555
|
+
// ordering (the serialized chain), mid-turn folding (the steering path),
|
|
1556
|
+
// and batching of waiting messages (the per-thread inbox in
|
|
1557
|
+
// buildServeRuntime, drained sorted as ONE folded turn). Chat's
|
|
1558
|
+
// message-id dedupe runs before the concurrency branch, so the
|
|
1559
|
+
// app_mention + message.channels double-delivery stays deduped.
|
|
1560
|
+
concurrency: { strategy: "concurrent" },
|
|
1185
1561
|
// without this a cards-only segment in post-and-edit fallback would
|
|
1186
1562
|
// strand a bare "..." placeholder message.
|
|
1187
1563
|
fallbackStreamingPlaceholderText: null,
|
|
@@ -1338,6 +1714,21 @@ async function runDaemon(): Promise<number> {
|
|
|
1338
1714
|
// 2s, zero API calls).
|
|
1339
1715
|
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
1340
1716
|
|
|
1717
|
+
// LAST-RESORT backstop, not the error strategy: `tracked` is the daemon's
|
|
1718
|
+
// own boundary, so this should stay idle - it exists for rejections minted
|
|
1719
|
+
// outside our funnels (the chat SDK's socket client, adapter internals),
|
|
1720
|
+
// where Bun's default is to kill the whole process (verified 2026-07-27)
|
|
1721
|
+
// and with it every concurrent session's in-flight turn, leaving
|
|
1722
|
+
// half-streamed Slack messages and no daemon to resume the markers.
|
|
1723
|
+
// Sync throws keep the default crash: an uncaughtException means state is
|
|
1724
|
+
// undefined and the durable markers make a restart the honest recovery.
|
|
1725
|
+
// message-only, like every other logged error here: a rejection minted by
|
|
1726
|
+
// an auth-carrying HTTP client must not persist its request into the log
|
|
1727
|
+
// (cursor review catch).
|
|
1728
|
+
process.on("unhandledRejection", (reason) => {
|
|
1729
|
+
log("serve.unhandled_rejection", { err: (reason instanceof Error ? reason.message : String(reason)).slice(0, 300) });
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1341
1732
|
// initialize() starts the PERSISTENT Socket Mode client (auto-reconnecting)
|
|
1342
1733
|
// wired straight into event routing; the daemon only has to stay alive.
|
|
1343
1734
|
// Never call startSocketModeListener here: that is the serverless leased
|