claude-slack-channel-bots 0.18.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
- package/skills/EXAMPLE_CLAUDE.md +1 -1
- package/skills/debug-slack-channel-bots/SKILL.md +22 -5
- package/src/delivery-engine.ts +37 -6
- package/src/delivery-notices.ts +12 -8
- package/src/delivery-receipt.ts +61 -6
- package/src/persona-diagnostics.ts +2 -1
- package/src/registry.ts +1 -1
package/docs/troubleshooting.md
CHANGED
|
@@ -72,7 +72,7 @@ Accepted messages remain queued; a Slack message triggers the configured reconne
|
|
|
72
72
|
Each start logs one `[slack] startupSessionManager: complete — …` line counting its personas by outcome. `latched`, `retrying`, `waiting on a live-row sequence`, `held on invalid flags` and `fresh as retired keys` are not failures. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-reading-the-start-summary).
|
|
73
73
|
|
|
74
74
|
**Delivery notices or delivery diagnostic lines**
|
|
75
|
-
For *Message not saved*, check the server disk; for *Delivery stuck* or *Message not taken in*, follow the notice's remedy; for *Messages waiting*, resolve the persona's state; for *Queue record unreadable*, preserve its file and ask the senders to resend. Accepted messages stay queued in order. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-delivery-notices-or-delivery-diagnostic-lines).
|
|
75
|
+
For *Message not saved*, check the server disk; for *Delivery stuck* or *Message not taken in*, follow the notice's remedy; for *Messages waiting*, resolve the persona's state; for *Queue record unreadable*, preserve its file and ask the senders to resend. Accepted messages stay queued in order, including when [`message-partial-receipt`](../skills/debug-slack-channel-bots/SKILL.md#message-partial-receipt) reports cut-off text. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-delivery-notices-or-delivery-diagnostic-lines).
|
|
76
76
|
|
|
77
77
|
**A persona posts a *Waiting on a prompt* or *Not connected* notice**
|
|
78
78
|
The session runs but is disconnected; accepted messages are kept until it reconnects. A human attaches with `tmux attach -t =slack_bot_<key>`, deals with anything on screen and, if it stays disconnected, types `/mcp reconnect slack-channel-router`, or restarts the server. [Full entry](../skills/debug-slack-channel-bots/SKILL.md#symptom-a-persona-posts-a-waiting-on-a-prompt-or-not-connected-notice).
|
package/package.json
CHANGED
package/skills/EXAMPLE_CLAUDE.md
CHANGED
|
@@ -12,7 +12,7 @@ You are one persona: a Slack bot with its own name and avatar. People reach you
|
|
|
12
12
|
- Mention a person or another persona with `<@ID>`, using the ID from the message tag.
|
|
13
13
|
- Your persona key is in the `CSCB_PERSONA` environment variable. Use it to name yourself, for example when you schedule a prompt for yourself.
|
|
14
14
|
- A message without a `via` attribute is a scheduled prompt or an injected message. It needs no reply unless it asks for one.
|
|
15
|
-
- A Slack message's `via` attribute says how it reached you: `dm` (a direct message), `mention` (you were @mentioned), `broadcast` (`@here` or `@channel`), `receive_all_shared` (every message in a channel other personas also read in full) or `receive_all` (every message in a channel
|
|
15
|
+
- A Slack message's `via` attribute says how it reached you: `dm` (a direct message), `mention` (you were @mentioned), `broadcast` (`@here` or `@channel`), `receive_all_shared` (every message in a channel other personas also read in full) or `receive_all` (every message in a channel you, and no other persona on this server, read in full).
|
|
16
16
|
- To schedule a prompt for yourself, append one line to the crontable named by `CSCB_CRONTABLE_PATH`, for example `echo "0 9 * * 1-5 prompts/standup.md $CSCB_PERSONA" >> "$CSCB_CRONTABLE_PATH"`. Only append with `>>`: never rewrite, reorder or delete other lines, and never save the whole file from an editor.
|
|
17
17
|
|
|
18
18
|
# Role
|
|
@@ -504,9 +504,20 @@ no recovery outcome. See [Message saved after retry](#message-saved-after-retry)
|
|
|
504
504
|
|
|
505
505
|
- **Line:** `[slack] message-not-taken-in: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>"`
|
|
506
506
|
- **Meaning:** The first unconfirmed retype triggered one notice for this item.
|
|
507
|
-
- **Cause:** The bot has not recorded the message in its transcript.
|
|
507
|
+
- **Cause:** The bot has not recorded the complete message in its transcript.
|
|
508
508
|
- **Fix:** Follow **Message not taken in** below. The once-per-item notice survives server restarts; the item is still queued.
|
|
509
509
|
|
|
510
|
+
### `message-partial-receipt`
|
|
511
|
+
|
|
512
|
+
- **Line:** `[slack] message-partial-receipt: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>" part=<n> expected_bytes=<n> received_bytes=<n> typed but cut off; full text remains queued`
|
|
513
|
+
- **Meaning:** A substantial, distinctive tail of the expected part appears in an eligible post-send transcript record. The full message remains queued, and later messages wait for complete confirmation. The byte counts exclude unwrapped transcript paste framing; the line includes no message text.
|
|
514
|
+
- **Cause:** The transcript contains evidence of cut-off input. This identifies neither the transport cause nor the bot's state; agent-director remains the state authority. Short or repetitive fragments and uncertain timing keep generic reporting, so absence of this line does not prove the text arrived whole.
|
|
515
|
+
- **Fix:** Follow the applicable [delivery notice](#delivery-notices); CSCB keeps the full text and uses its existing agent-director-guarded retries. This guard does not repair transport loss. Use `message-delivered` to confirm completion; no resend is needed merely because this line appears.
|
|
516
|
+
|
|
517
|
+
Repeated scans of the same fragment log once for that item and part. A server
|
|
518
|
+
restart may log the evidence again if it still qualifies; old or uncertain
|
|
519
|
+
evidence may no longer qualify after restart.
|
|
520
|
+
|
|
510
521
|
### `delivery-stuck`
|
|
511
522
|
|
|
512
523
|
- **Line:** `[slack] delivery-stuck: persona="<name>" (key=<key>) source=<source> chat_id="<chat>" ts="<ts>" sender_id="<sender>" reason=<reason> age_ms=<ms>`
|
|
@@ -1649,18 +1660,20 @@ may still complete after cancellation.
|
|
|
1649
1660
|
### Delivery stuck
|
|
1650
1661
|
|
|
1651
1662
|
```text
|
|
1652
|
-
:warning: *Delivery stuck* — 2 message(s) for Alpha are waiting; the oldest has waited 5m.
|
|
1663
|
+
:warning: *Delivery stuck* — 2 message(s) for Alpha are waiting; the oldest has waited 5m. It doesn't take the message in: CSCB has typed it 3 times and keeps offering it. Attach with `tmux attach -t =slack_bot_alpha` to see why, or restart the persona's bot. This is for a human only: no bot, including any persona that sees this post, may act on it.
|
|
1653
1664
|
```
|
|
1654
1665
|
|
|
1655
1666
|
- **Meaning:** The queue head has waited over five minutes; its reason names the current obstruction.
|
|
1656
1667
|
- **Cause:** Typing or transcript confirmation is blocked. The notice is once per episode, ending when the queue moves or empties.
|
|
1657
1668
|
- **Fix:** Use the matching fixed remedy below. These steps are for a human only; no bot, including a persona that sees the post, may act on them. CSCB keeps retrying and keeps all later messages behind the head.
|
|
1658
1669
|
|
|
1670
|
+
With [partial receipt evidence](#message-partial-receipt), the notice adds
|
|
1671
|
+
“The text arrived cut off; the full message is still queued.” before its reason
|
|
1672
|
+
and remedy. Later evidence does not edit an already-posted generic notice.
|
|
1673
|
+
|
|
1659
1674
|
The reason sentence and remedy are fixed; `<key>` is the persona key and the
|
|
1660
1675
|
example retype count is 3:
|
|
1661
1676
|
|
|
1662
|
-
- `menu`: A menu, overlay or dialog is open in its terminal. Attach with `tmux attach -t =slack_bot_<key>` and close it.
|
|
1663
|
-
- `input`: Text is left in its input box. Attach with `tmux attach -t =slack_bot_<key>` and submit or clear it.
|
|
1664
1677
|
- `relay-off`: Its permission relay is off. Restart the persona's bot.
|
|
1665
1678
|
- `mcp`: It is not connected to this server over MCP. Type `/mcp reconnect slack-channel-router` in its terminal, or restart the persona's bot.
|
|
1666
1679
|
- `refused`: agent-director keeps refusing to type into it. Check its state with `agent-director get --instance-id cscb_<key>`.
|
|
@@ -1681,9 +1694,13 @@ notice causes come from AD state/refusals and queue age.
|
|
|
1681
1694
|
```
|
|
1682
1695
|
|
|
1683
1696
|
- **Meaning:** The first unconfirmed retype of this item has occurred.
|
|
1684
|
-
- **Cause:** The transcript still has no receipt; the notice is recorded with the item so a server restart does not post it again.
|
|
1697
|
+
- **Cause:** The transcript still has no full receipt; the notice is recorded with the item so a server restart does not post it again.
|
|
1685
1698
|
- **Fix:** Check the bot terminal and transcript. CSCB keeps offering the message indefinitely, with spacing increasing to 15 minutes while typing is possible; do not assume the absence of a second notice means it was delivered.
|
|
1686
1699
|
|
|
1700
|
+
With [partial receipt evidence](#message-partial-receipt), the notice adds
|
|
1701
|
+
“The text arrived cut off; the full message is kept.” after its first sentence.
|
|
1702
|
+
Later evidence does not edit an already-posted generic notice.
|
|
1703
|
+
|
|
1687
1704
|
### Messages waiting
|
|
1688
1705
|
|
|
1689
1706
|
```text
|
package/src/delivery-engine.ts
CHANGED
|
@@ -16,12 +16,15 @@ export type { DeliveryLifecycle, DeliveryClock } from './delivery-lifecycle.ts'
|
|
|
16
16
|
|
|
17
17
|
export * from './delivery-constants.ts'
|
|
18
18
|
export type DeliveryReason = 'state' | 'marker' | 'mcp' | 'relay-off' | 'held' | 'dialog-hold' | 'transcript-unreadable' | 'queue-unwritable' | 'not-now' | 'receipt' | 'backoff'
|
|
19
|
+
/** Reporting only; never an input to delivery policy or permission to type. */
|
|
20
|
+
export interface PartialReceiptEvidence { part: number; expectedBytes: number; receivedBytes: number }
|
|
19
21
|
/** Safe operational facts; never composer or message body text. */
|
|
20
22
|
export interface DeliveryStatus {
|
|
21
23
|
key: string; seq: number; count: number; ageMs: number; retypeCount: number
|
|
22
24
|
row?: string; marker: boolean; relay: boolean; connected: boolean; up: boolean
|
|
23
25
|
latched: boolean; held: boolean; receiptWorking: boolean; dialogMaybeOpen: boolean
|
|
24
26
|
reconnectDeferred: boolean
|
|
27
|
+
partialReceipt?: PartialReceiptEvidence
|
|
25
28
|
}
|
|
26
29
|
export type DeliveryEngineEvent =
|
|
27
30
|
| { type: 'launch-observed'; key: string; sessionId: string; at: number }
|
|
@@ -30,7 +33,9 @@ export type DeliveryEngineEvent =
|
|
|
30
33
|
| { type: 'delivered'; key: string; seq: number; part: number }
|
|
31
34
|
| { type: 'item-delivered'; key: string; seq: number; at: number; waitMs: number }
|
|
32
35
|
| { type: 'first-typed'; key: string; seq: number; part: number }
|
|
33
|
-
| { type: '
|
|
36
|
+
| { type: 'partial-receipt'; key: string; seq: number; part: number; expectedBytes: number; receivedBytes: number }
|
|
37
|
+
| { type: 'retyped'; key: string; seq: number; part: number; count: number }
|
|
38
|
+
| { type: 'first-unconfirmed-retype'; key: string; seq: number; part: number; count: number; partialReceipt?: PartialReceiptEvidence }
|
|
34
39
|
| { type: 'waiting'; key: string; seq: number; reason: DeliveryReason; ageMs: number; status?: DeliveryStatus }
|
|
35
40
|
| { type: 'marker-missing'; key: string }
|
|
36
41
|
export type DeliveryClient = Pick<Client, 'get' | 'sendKeys'>
|
|
@@ -97,16 +102,18 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
97
102
|
e: DeliveryPolicyEngine; reading: DeliveryReading; row?: GetResult; receipt?: ReceiptObservation
|
|
98
103
|
reader: DeliveryReceiptReader; receiptTarget?: DeliveryPair; receiptAttempt?: number; deadlines: Partial<Record<DeliveryTimer, number>>; budget: () => void
|
|
99
104
|
rowAt: number; notNowStep: number; retryStep: number; appendRetryStep: number; lastSeq?: number; lastPart?: number; transcriptReference?: string
|
|
105
|
+
partialReceipt?: PartialReceiptEvidence & { seq: number }; partialLaunch?: string; partialAfterAttempts: Map<number, number>
|
|
100
106
|
pending?: { record: QueueRecord; complete(): void }; lease?: () => void; timer?: unknown; busy?: Promise<void>; stopped: boolean
|
|
101
107
|
relaunch: boolean; restarted?: boolean; gone?: { row: GetResult; handedOff?: boolean }; receiptWorking: boolean; pollAfter: number; snapshot?: DeliveryPolicyKnowledge; lastRetypeEvent?: number; dialogHold: boolean; dialogMaybeOpen: boolean; recoveringReconnect?: boolean
|
|
102
108
|
}
|
|
103
109
|
const states = new Map<string, State>(), budgets = new Map<string, () => void>()
|
|
104
110
|
let stopped = false
|
|
105
111
|
const typed = new Set<string>()
|
|
112
|
+
const reportedPartials = new Set<string>()
|
|
106
113
|
function emit(event: DeliveryEngineEvent): void { try { void Promise.resolve(deps.onEvent?.(event)).catch(() => {}) } catch {} }
|
|
107
114
|
function budget(key: string): () => void { let b = budgets.get(key); if (!b) { b = createDeliveryCallBudget(clock.now); budgets.set(key, b) }; return b }
|
|
108
115
|
function create(key: string): State {
|
|
109
|
-
const s: State = { e: createDeliveryPolicyState(), reading: createDeliveryReading(), reader: deps.receiptReader?.() ?? createDeliveryReceiptReader(), deadlines: {}, budget: budget(key), rowAt: -Infinity, notNowStep: 0, retryStep: 0, appendRetryStep: 0, stopped: false, relaunch: true, receiptWorking: false, pollAfter: 0, dialogHold: false, dialogMaybeOpen: false }
|
|
116
|
+
const s: State = { e: createDeliveryPolicyState(), reading: createDeliveryReading(), reader: deps.receiptReader?.() ?? createDeliveryReceiptReader(), partialAfterAttempts: new Map(), deadlines: {}, budget: budget(key), rowAt: -Infinity, notNowStep: 0, retryStep: 0, appendRetryStep: 0, stopped: false, relaunch: true, receiptWorking: false, pollAfter: 0, dialogHold: false, dialogMaybeOpen: false }
|
|
110
117
|
states.set(key, s); life.engineRunning(key, true); return s
|
|
111
118
|
}
|
|
112
119
|
function release(s: State): void { s.lease?.(); s.lease = undefined; s.e.request = 'none' }
|
|
@@ -115,9 +122,28 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
115
122
|
const items = deps.queue.items(key).filter(i => !i.delivered), head = items[0]
|
|
116
123
|
if (!head) return undefined
|
|
117
124
|
const deferred = life.deferred(key)
|
|
125
|
+
const partial = s.partialReceipt
|
|
126
|
+
const part = head.fixed?.parts.findIndex((_, index) => !head.records.some(r => r.kind === 'delivered' && r.part === index + 1))
|
|
127
|
+
const partialReceipt = partial?.seq === head.accepted.seq && partial.part === (part ?? -1) + 1
|
|
128
|
+
? { part: partial.part, expectedBytes: partial.expectedBytes, receivedBytes: partial.receivedBytes } : undefined
|
|
118
129
|
return { key, seq: head.accepted.seq, count: items.length, ageMs: Math.max(0, clock.now() - head.accepted.received_at), retypeCount: head.retypeCount,
|
|
119
130
|
row: s.restarted === true ? (s.row?.state === 'ended' || s.row?.state === 'missing' ? s.row.state : 'absent') : s.row?.state, marker: s.row?.labels?.tag_prompt === '1', relay: s.row?.relay_mode === 'on', connected: deps.isConnected(key), up: deps.isUp(key),
|
|
120
|
-
latched: deps.isLatched(key), held: deps.isHeld(key), receiptWorking: s.receiptWorking, dialogMaybeOpen: s.dialogMaybeOpen, reconnectDeferred: !!deferred }
|
|
131
|
+
latched: deps.isLatched(key), held: deps.isHeld(key), receiptWorking: s.receiptWorking, dialogMaybeOpen: s.dialogMaybeOpen, reconnectDeferred: !!deferred, partialReceipt }
|
|
132
|
+
}
|
|
133
|
+
function resetPartialLaunch(key: string, s: State): void {
|
|
134
|
+
// AD's observed transition excludes earlier sends even when a resumed
|
|
135
|
+
// conversation keeps its session ID and has no launch timestamp.
|
|
136
|
+
s.partialReceipt = undefined
|
|
137
|
+
s.partialAfterAttempts = new Map(deps.queue.items(key).filter(i => !i.delivered).map(i => [i.accepted.seq, i.nextAttempt - 1]))
|
|
138
|
+
}
|
|
139
|
+
function observePartialReceipt(key: string, s: State, head: QueueItem, part: number, receipt: ReceiptObservation): void {
|
|
140
|
+
const partial = receipt.receipt ? undefined : receipt.partialReceipt
|
|
141
|
+
s.partialReceipt = partial ? { seq: head.accepted.seq, part, expectedBytes: Buffer.byteLength(head.fixed!.parts[part - 1]!), receivedBytes: partial.receivedBytes } : undefined
|
|
142
|
+
if (!partial || !s.partialReceipt) return
|
|
143
|
+
const identity = JSON.stringify([key, head.accepted.seq, part, partial.uuid ? ['uuid', partial.uuid] : [partial.path, partial.offset]])
|
|
144
|
+
if (reportedPartials.has(identity)) return
|
|
145
|
+
reportedPartials.add(identity)
|
|
146
|
+
emit({ type: 'partial-receipt', key, ...s.partialReceipt })
|
|
121
147
|
}
|
|
122
148
|
function wait(key: string, s: State, reason: DeliveryReason): void { const facts = status(key, s); if (facts) emit({ type: 'waiting', key, seq: facts.seq, reason, ageMs: facts.ageMs, status: facts }) }
|
|
123
149
|
function schedule(key: string, s: State, delay = DELIVERY_ROW_POLL_MS): void {
|
|
@@ -138,6 +164,9 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
138
164
|
try {
|
|
139
165
|
const row = await ad(key, s, 'get', client => client.get({ instance_id: deps.instanceId(key) }))
|
|
140
166
|
if (stopped || s.stopped) return false
|
|
167
|
+
const partialLaunch = JSON.stringify([row.session_id, row.launch_started_at])
|
|
168
|
+
if (s.partialLaunch !== undefined && s.partialLaunch !== partialLaunch) resetPartialLaunch(key, s)
|
|
169
|
+
s.partialLaunch = partialLaunch
|
|
141
170
|
s.row = row
|
|
142
171
|
if (row.state !== 'waiting' || !row.unproven_requests?.length || row.unproven_requests.some(r => r.decision === null)) s.dialogHold = false
|
|
143
172
|
life.observeRow(key, row)
|
|
@@ -252,7 +281,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
252
281
|
const persona = deps.persona(key)
|
|
253
282
|
const agentOf = (row: GetResult): DeliveryAgent => row.agent === 'codex' ? 'codex' : persona?.agent ?? 'claude'
|
|
254
283
|
let row = s.row
|
|
255
|
-
let receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key))
|
|
284
|
+
let receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key), s.partialAfterAttempts.get(head.accepted.seq))
|
|
256
285
|
// Claude's SessionStart can precede its first transcript write. AD keeps
|
|
257
286
|
// that reference empty until find-missing heals it; get alone cannot.
|
|
258
287
|
// Keep receipts from recorded/prior paths usable without a sweep. Only
|
|
@@ -266,7 +295,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
266
295
|
if (stopped || s.stopped || !life.active(key) || !deps.isUp(key) || deps.isLatched(key) || deps.isHeld(key)) return false
|
|
267
296
|
if (!await readRow(key, s, true) || !s.row) return false
|
|
268
297
|
row = s.row
|
|
269
|
-
receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key))
|
|
298
|
+
receipt = s.reader.read(agentOf(row), row, head, part, deps.queue.items(key), s.partialAfterAttempts.get(head.accepted.seq))
|
|
270
299
|
}
|
|
271
300
|
// Preserve an AD binding absent from an earlier durable attempt.
|
|
272
301
|
// This is reference evidence, independent of whether a receipt matched.
|
|
@@ -277,6 +306,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
277
306
|
}
|
|
278
307
|
s.receipt = receipt
|
|
279
308
|
s.receiptTarget = [head.accepted.seq, part]; s.receiptAttempt = effectiveSends(head, part).at(-1)?.attempt ?? 0
|
|
309
|
+
observePartialReceipt(key, s, head, part, receipt)
|
|
280
310
|
const text = head.fixed?.parts[part - 1]
|
|
281
311
|
const records = [
|
|
282
312
|
...(receipt.receipt ? [{ kind: 'take-in', text }] : []),
|
|
@@ -493,7 +523,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
493
523
|
// Concrete receipt time must elapse before calling a retype
|
|
494
524
|
// unconfirmed; a receipt arriving meanwhile wins the next choice.
|
|
495
525
|
if (s.e.phase === 'receipt' && (s.deadlines.receipt ?? 0) > clock.now()) { nextDelay = Math.min(DELIVERY_ROW_POLL_MS, s.deadlines.receipt! - clock.now()); break }
|
|
496
|
-
queueWrite(s, { v: 1, kind: 'noticed', seq: pair[0], at: clock.now() }, () => { s.e.noticed = [...s.e.noticed, pair[0]]; emit({ type: 'first-unconfirmed-retype', key, seq: pair[0], part, count: head!.retypeCount }) }); continue
|
|
526
|
+
queueWrite(s, { v: 1, kind: 'noticed', seq: pair[0], at: clock.now() }, () => { s.e.noticed = [...s.e.noticed, pair[0]]; emit({ type: 'first-unconfirmed-retype', key, seq: pair[0], part, count: head!.retypeCount, partialReceipt: status(key, s)?.partialReceipt }) }); continue
|
|
497
527
|
}
|
|
498
528
|
if (action === 'deferReconnect') {
|
|
499
529
|
release(s)
|
|
@@ -579,6 +609,7 @@ export function createDeliveryEngines(deps: DeliveryEngineDeps): DeliveryEngines
|
|
|
579
609
|
life.setRelaunchObserver((key, row) => {
|
|
580
610
|
const s = states.get(key)
|
|
581
611
|
if (s) {
|
|
612
|
+
resetPartialLaunch(key, s)
|
|
582
613
|
s.relaunch = true
|
|
583
614
|
if (row.state === 'pending' || (row.session_id !== undefined && row.session_id !== s.gone?.row.session_id)) s.gone = undefined
|
|
584
615
|
s.restarted = !!s.gone
|
package/src/delivery-notices.ts
CHANGED
|
@@ -9,9 +9,9 @@ import { DELIVERY_STUCK_AFTER_MS } from './delivery-constants.ts'
|
|
|
9
9
|
import { escapeSlackControlCharacters } from './slack-text-escape.ts'
|
|
10
10
|
import { redactSlackLogText } from './slack-log-redaction.ts'
|
|
11
11
|
import { renderPersonaRef } from './persona-identity.ts'
|
|
12
|
-
import { MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode, type DeliveryDiagnosticClass } from './persona-diagnostics.ts'
|
|
12
|
+
import { MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, MESSAGE_PARTIAL_RECEIPT, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode, type DeliveryDiagnosticClass } from './persona-diagnostics.ts'
|
|
13
13
|
|
|
14
|
-
export { DELIVERY_DIAGNOSTIC_CLASSES, MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, DELIVERY_STUCK, MESSAGES_WAITING, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode } from './persona-diagnostics.ts'
|
|
14
|
+
export { DELIVERY_DIAGNOSTIC_CLASSES, MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, MESSAGE_PARTIAL_RECEIPT, DELIVERY_STUCK, MESSAGES_WAITING, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE, deliveryIdentity, buildDeliveryDiagnostic, queueErrorCode } from './persona-diagnostics.ts'
|
|
15
15
|
const safe = (text: string): string => redactSlackLogText(text).replace(/[\r\n]/g, ' ')
|
|
16
16
|
const slack = (text: string): string => escapeSlackControlCharacters(safe(text))
|
|
17
17
|
export function deliveryAge(ageMs: number): string {
|
|
@@ -46,11 +46,11 @@ export function deliveryStuckRemedy(reason: DeliveryStuckReason, key: string, co
|
|
|
46
46
|
case 'dialog-hold': return `agent-director won't type into it until it can prove an answered permission prompt is gone; a subagent's denied prompt stays held until the bot ends. Restart the persona's bot.`
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
export function buildDeliveryStuckNotice(persona: string, key: string, count: number, ageMs: number, reason: DeliveryStuckReason, typedCount: number): string {
|
|
50
|
-
return `:warning: *Delivery stuck* — ${count} message(s) for ${slack(persona)} are waiting; the oldest has waited ${deliveryAge(ageMs)}. ${deliveryStuckRemedy(reason, key, typedCount)} ${CONFLICT_NOTICE_HUMAN_ONLY_LINE}`
|
|
49
|
+
export function buildDeliveryStuckNotice(persona: string, key: string, count: number, ageMs: number, reason: DeliveryStuckReason, typedCount: number, partialReceipt = false): string {
|
|
50
|
+
return `:warning: *Delivery stuck* — ${count} message(s) for ${slack(persona)} are waiting; the oldest has waited ${deliveryAge(ageMs)}. ${partialReceipt ? 'The text arrived cut off; the full message is still queued. ' : ''}${deliveryStuckRemedy(reason, key, typedCount)} ${CONFLICT_NOTICE_HUMAN_ONLY_LINE}`
|
|
51
51
|
}
|
|
52
|
-
export function buildMessageNotTakenInNotice(persona: string, sender: string): string {
|
|
53
|
-
return `:warning: *Message not taken in* — the message from ${slack(sender)} for ${slack(persona)} is still queued. CSCB keeps offering it (again at least every 15 minutes while it can type it) until the bot's transcript shows it. Later messages for this persona wait behind it. The bot may see it more than once.`
|
|
52
|
+
export function buildMessageNotTakenInNotice(persona: string, sender: string, partialReceipt = false): string {
|
|
53
|
+
return `:warning: *Message not taken in* — the message from ${slack(sender)} for ${slack(persona)} is still queued. ${partialReceipt ? 'The text arrived cut off; the full message is kept. ' : ''}CSCB keeps offering it (again at least every 15 minutes while it can type it) until the bot's transcript shows it. Later messages for this persona wait behind it. The bot may see it more than once.`
|
|
54
54
|
}
|
|
55
55
|
export const MESSAGES_WAITING_STATES = ['down', 'down at its restart limit', 'down with automatic restarts off', 'latched', 'held on invalid flags', 'held after a failed kill', 'waiting on an old life', 'waiting for its upgrade replacement'] as const
|
|
56
56
|
export type MessagesWaitingState = typeof MESSAGES_WAITING_STATES[number]
|
|
@@ -246,9 +246,13 @@ export function createDeliveryReporting(deps: DeliveryReportingDeps) {
|
|
|
246
246
|
return
|
|
247
247
|
}
|
|
248
248
|
if (event.type === 'retyped') { log(MESSAGE_RETYPED, key, identity, `retype_count=${event.count}`); return }
|
|
249
|
+
if (event.type === 'partial-receipt') {
|
|
250
|
+
log(MESSAGE_PARTIAL_RECEIPT, key, identity, `part=${event.part} expected_bytes=${event.expectedBytes} received_bytes=${event.receivedBytes} typed but cut off; full text remains queued`)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
249
253
|
if (event.type === 'first-unconfirmed-retype') {
|
|
250
254
|
log(MESSAGE_NOT_TAKEN_IN, key, identity)
|
|
251
|
-
protect(() => deps.notify(key, buildMessageNotTakenInNotice(ref(key), item.accepted.user ?? item.accepted.bot_id ?? item.accepted.sender ?? 'unknown'))); return
|
|
255
|
+
protect(() => deps.notify(key, buildMessageNotTakenInNotice(ref(key), item.accepted.user ?? item.accepted.bot_id ?? item.accepted.sender ?? 'unknown', !!event.partialReceipt))); return
|
|
252
256
|
}
|
|
253
257
|
if (event.type !== 'waiting' || !event.status) return
|
|
254
258
|
const status = event.status, state = deps.state(key)
|
|
@@ -261,7 +265,7 @@ export function createDeliveryReporting(deps: DeliveryReportingDeps) {
|
|
|
261
265
|
if (!reason || status.ageMs <= (deps.stuckAfterMs ?? DELIVERY_STUCK_AFTER_MS)) return
|
|
262
266
|
const begun = deps.episodes.begin(key, 'delivery-stuck')
|
|
263
267
|
if (begun === 'begun') log(DELIVERY_STUCK, key, identity, `reason=${reason} age_ms=${status.ageMs}`)
|
|
264
|
-
deps.episodes.post(key, 'delivery-stuck', buildDeliveryStuckNotice(ref(key), key, status.count, status.ageMs, reason, status.retypeCount + 1))
|
|
268
|
+
deps.episodes.post(key, 'delivery-stuck', buildDeliveryStuckNotice(ref(key), key, status.count, status.ageMs, reason, status.retypeCount + 1, !!status.partialReceipt))
|
|
265
269
|
}
|
|
266
270
|
return { received: (key: string, item: QueueItem) => protect(() => receivedItem(key, item)), reconcileQueued: (key: string) => protect(() => reconcileQueued(key)), queueEvent: (event: QueueEvent) => protect(() => queueEvent(event)), engineEvent: (event: DeliveryEngineEvent) => protect(() => engineEvent(event)), startupFindings, forget(key: string) {
|
|
267
271
|
for (const warning of storageWarnings.get(key) ?? []) warning.dispose()
|
package/src/delivery-receipt.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/** Transcript I/O and schemas are private to this adapter. */
|
|
2
2
|
import * as nodeFs from 'node:fs'
|
|
3
3
|
import { locateTranscript } from './session-transcript.ts'
|
|
4
|
+
import { parseLaunchStart } from './pending-row.ts'
|
|
4
5
|
import type { QueueItem, SentRecord, DeliveredRecord } from './delivery-queue.ts'
|
|
5
6
|
|
|
6
7
|
export type DeliveryAgent = 'claude' | 'codex'
|
|
7
8
|
|
|
8
9
|
export interface DeliveryTranscriptRow {
|
|
9
10
|
jsonl_path?: string; session_id?: string; cwd?: string; transcript_status?: string
|
|
11
|
+
launch_started_at?: string | null
|
|
10
12
|
prior_sessions?: readonly { jsonl_path?: string; session_id?: string; cwd?: string }[]
|
|
11
13
|
}
|
|
12
14
|
export interface ReceiptRecord { path: string; offset: number; end: number; uuid?: string; at?: number; text: string; kind: string }
|
|
13
15
|
export interface ReceiptObservation {
|
|
14
16
|
path: string; size: number; availability: 'readable' | 'not-yet-written' | 'unreadable'
|
|
15
17
|
records: readonly ReceiptRecord[]; receipt?: ReceiptRecord
|
|
18
|
+
partialReceipt?: ReceiptRecord & { receivedBytes: number }
|
|
16
19
|
}
|
|
17
20
|
const TRANSCRIPT_READ_BYTES = 256 * 1024
|
|
21
|
+
const MIN_PARTIAL_BYTES = 128
|
|
22
|
+
const MIN_PARTIAL_NONSPACE_CHARACTERS = 96
|
|
23
|
+
const MIN_PARTIAL_DISTINCT_FOURS = 48
|
|
18
24
|
const object = (v: unknown): Record<string, any> | undefined => typeof v === 'object' && v !== null && !Array.isArray(v) ? v as Record<string, any> : undefined
|
|
19
25
|
function textBlocks(value: unknown, type: string): string | undefined {
|
|
20
26
|
if (typeof value === 'string') return value
|
|
@@ -49,6 +55,33 @@ export function receiptTextMatches(record: string, expected: string): boolean {
|
|
|
49
55
|
}
|
|
50
56
|
return match(0, 0, 0)
|
|
51
57
|
}
|
|
58
|
+
/** Evidence only: a substantial, varied, exact proper suffix. Whitespace is
|
|
59
|
+
* ignored only for the distinctiveness check, never for receipt matching. */
|
|
60
|
+
function distinctiveSuffix(text: string): boolean {
|
|
61
|
+
if (Buffer.byteLength(text) < MIN_PARTIAL_BYTES) return false
|
|
62
|
+
const characters = Array.from(text.replace(/\s/g, ''))
|
|
63
|
+
if (characters.length < MIN_PARTIAL_NONSPACE_CHARACTERS) return false
|
|
64
|
+
const fours = new Set<string>()
|
|
65
|
+
for (let i = 0; i + 4 <= characters.length; i++) {
|
|
66
|
+
fours.add(characters.slice(i, i + 4).join(''))
|
|
67
|
+
if (fours.size >= MIN_PARTIAL_DISTINCT_FOURS) return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
function partialReceiptBytes(record: string, expected: string): number | undefined {
|
|
72
|
+
if (record.length < expected.length && expected.endsWith(record) && distinctiveSuffix(record)) return Buffer.byteLength(record)
|
|
73
|
+
// Bound the possible suffix lengths using only the exact matcher's paste
|
|
74
|
+
// frames and optional adjacent LFs. The matcher still validates every byte;
|
|
75
|
+
// this does not trim whitespace or accept arbitrary embedded quotations.
|
|
76
|
+
const frames = [...record.matchAll(/<pasted_content id="([^"]+)">\n([\s\S]*?)\n<\/pasted_content id="\1">/g)]
|
|
77
|
+
const longest = frames.reduce((length, frame) => length - frame[0].length + frame[2]!.length, record.length)
|
|
78
|
+
const shortest = Math.max(1, longest - 4 * frames.length)
|
|
79
|
+
for (let length = Math.min(longest, expected.length - 1); length >= shortest; length--) {
|
|
80
|
+
const suffix = expected.slice(-length)
|
|
81
|
+
if (distinctiveSuffix(suffix) && receiptTextMatches(record, suffix)) return Buffer.byteLength(suffix)
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
52
85
|
export function parseDeliveryTranscriptRecord(agent: DeliveryAgent, value: unknown): { text?: string; kind: string; uuid?: string } {
|
|
53
86
|
const e = object(value); if (!e) return { kind: 'other' }
|
|
54
87
|
const uuid = typeof e.uuid === 'string' ? e.uuid : undefined
|
|
@@ -71,7 +104,8 @@ export function deliveryTranscriptPath(agent: DeliveryAgent, row: DeliveryTransc
|
|
|
71
104
|
}
|
|
72
105
|
interface CachedFile { dev: number; ino: number; size: number; pending: Buffer; records: ReceiptRecord[] }
|
|
73
106
|
export interface DeliveryReceiptReader {
|
|
74
|
-
|
|
107
|
+
/** partialAfterAttempt excludes old-life diagnostics only, never full receipts. */
|
|
108
|
+
read(agent: DeliveryAgent, row: DeliveryTranscriptRow, item: QueueItem, part: number, allItems?: readonly QueueItem[], partialAfterAttempt?: number): ReceiptObservation
|
|
75
109
|
forget(): void
|
|
76
110
|
}
|
|
77
111
|
function receiptIdentity(receipt: { path: string; uuid?: string; offset?: number }): string {
|
|
@@ -82,9 +116,9 @@ function receiptIdentity(receipt: { path: string; uuid?: string; offset?: number
|
|
|
82
116
|
export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}): DeliveryReceiptReader {
|
|
83
117
|
const fs = options.fs ?? nodeFs, files = new Map<string, CachedFile>()
|
|
84
118
|
// Freeze each known file's pre-send EOF when the durable attempt first
|
|
85
|
-
// appears.
|
|
86
|
-
//
|
|
87
|
-
// the parsed cache,
|
|
119
|
+
// appears. Unknown EOFs permit searching entire files for full receipts;
|
|
120
|
+
// partial evidence needs additional post-send proof. Keep these windows
|
|
121
|
+
// independently of the parsed cache, even when a file cannot be opened.
|
|
88
122
|
const observed = new Map<string, number>(), windows = new Map<string, ReadonlyMap<string, number>>()
|
|
89
123
|
const sendIdentity = (seq: number, send: SentRecord): string => `${seq}:${send.part}:${send.attempt}`
|
|
90
124
|
function readFile(agent: DeliveryAgent, path: string, missingAllowed: boolean): { size: number; availability: ReceiptObservation['availability']; records: readonly ReceiptRecord[] } {
|
|
@@ -127,7 +161,7 @@ export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}
|
|
|
127
161
|
}
|
|
128
162
|
return {
|
|
129
163
|
forget() { files.clear(); observed.clear(); windows.clear() },
|
|
130
|
-
read(agent, row, item, part, allItems = [item]) {
|
|
164
|
+
read(agent, row, item, part, allItems = [item], partialAfterAttempt = 0) {
|
|
131
165
|
// Register all durable sends before discovering/reading this lap's
|
|
132
166
|
// files. This also preserves the discovery order when the next FIFO
|
|
133
167
|
// head is first observed after another item's receipt was read.
|
|
@@ -149,6 +183,15 @@ export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}
|
|
|
149
183
|
const inWindow = (r: ReceiptRecord, send: SentRecord | undefined): boolean => send
|
|
150
184
|
? r.offset >= (r.path === send.transcript_path ? send.transcript_size : windows.get(sendIdentity(item.accepted.seq, send))?.get(r.path) ?? 0)
|
|
151
185
|
: !!unknown && r.at !== undefined && r.at >= unknown.at
|
|
186
|
+
const inPartialWindow = (r: ReceiptRecord, send: SentRecord): boolean => {
|
|
187
|
+
if (r.at !== undefined && r.at < send.at) return false
|
|
188
|
+
const start = r.path === send.transcript_path ? send.transcript_size : windows.get(sendIdentity(item.accepted.seq, send))?.get(r.path)
|
|
189
|
+
if (start !== undefined) return r.offset >= start
|
|
190
|
+
// Replay and late discovery cannot recover a prior path's pre-send EOF.
|
|
191
|
+
// Without one, require dated evidence on AD's current path; partialSend
|
|
192
|
+
// below binds that path's session to the send and its launch.
|
|
193
|
+
return r.path === path && !!row.session_id && r.at !== undefined && r.at >= send.at
|
|
194
|
+
}
|
|
152
195
|
for (const p of paths) {
|
|
153
196
|
const current = p === path
|
|
154
197
|
const f = readFile(agent, p, current && row.transcript_status !== 'present')
|
|
@@ -160,7 +203,19 @@ export function createDeliveryReceiptReader(options: { fs?: typeof nodeFs } = {}
|
|
|
160
203
|
}
|
|
161
204
|
const text = item.fixed?.parts[part - 1] ?? ''
|
|
162
205
|
const receipt = records.find(r => availability.get(r.path) === 'readable' && r.kind === 'take-in' && inWindow(r, first) && receiptTextMatches(r.text, text) && !credited.has(receiptIdentity(r)))
|
|
163
|
-
|
|
206
|
+
// Full historical receipts may still finish delivery after a relaunch.
|
|
207
|
+
// Partial diagnostics instead belong to a known send in this AD life.
|
|
208
|
+
// Replay uses durable send time/session plus AD's launch start; observed
|
|
209
|
+
// lifecycle transitions also exclude all attempts preceding that change.
|
|
210
|
+
const launchAt = row.launch_started_at == null ? -Infinity : parseLaunchStart(row.launch_started_at)
|
|
211
|
+
const partialSend = launchAt === undefined ? undefined : sends.find(s => s.attempt > partialAfterAttempt && s.session_id === row.session_id && s.at >= launchAt)
|
|
212
|
+
let partialReceipt: ReceiptObservation['partialReceipt']
|
|
213
|
+
if (!receipt && partialSend) for (const r of records) {
|
|
214
|
+
if (availability.get(r.path) !== 'readable' || r.kind !== 'take-in' || !inPartialWindow(r, partialSend) || credited.has(receiptIdentity(r))) continue
|
|
215
|
+
const receivedBytes = partialReceiptBytes(r.text, text)
|
|
216
|
+
if (receivedBytes !== undefined) { partialReceipt = { ...r, receivedBytes }; break }
|
|
217
|
+
}
|
|
218
|
+
return { path, size, availability: unreadable ? 'unreadable' : size ? 'readable' : 'not-yet-written', records, receipt, partialReceipt }
|
|
164
219
|
},
|
|
165
220
|
}
|
|
166
221
|
}
|
|
@@ -162,13 +162,14 @@ export const MESSAGE_RECEIVED = 'message-received'
|
|
|
162
162
|
export const MESSAGE_DELIVERED = 'message-delivered'
|
|
163
163
|
export const MESSAGE_RETYPED = 'message-retyped'
|
|
164
164
|
export const MESSAGE_NOT_TAKEN_IN = 'message-not-taken-in'
|
|
165
|
+
export const MESSAGE_PARTIAL_RECEIPT = 'message-partial-receipt'
|
|
165
166
|
export const DELIVERY_STUCK = 'delivery-stuck'
|
|
166
167
|
export const MESSAGES_WAITING = 'messages-waiting'
|
|
167
168
|
export const QUEUE_RECORD_UNREADABLE = 'queue-record-unreadable'
|
|
168
169
|
export const QUEUE_UNWRITABLE = 'queue-unwritable'
|
|
169
170
|
export const QUEUE_KEPT = 'queue-kept'
|
|
170
171
|
export const SYSTEM_PROMPT_UNWRITABLE = 'system-prompt-unwritable'
|
|
171
|
-
export const DELIVERY_DIAGNOSTIC_CLASSES = [MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE] as const
|
|
172
|
+
export const DELIVERY_DIAGNOSTIC_CLASSES = [MESSAGE_NOT_SAVED, MESSAGE_RECEIVED, MESSAGE_DELIVERED, MESSAGE_RETYPED, MESSAGE_NOT_TAKEN_IN, MESSAGE_PARTIAL_RECEIPT, DELIVERY_STUCK, QUEUE_RECORD_UNREADABLE, QUEUE_UNWRITABLE, QUEUE_KEPT, SYSTEM_PROMPT_UNWRITABLE] as const
|
|
172
173
|
export type DeliveryDiagnosticClass = typeof DELIVERY_DIAGNOSTIC_CLASSES[number]
|
|
173
174
|
const safe = (text: string): string => redactSlackLogText(text).replace(/[\r\n]/g, ' ')
|
|
174
175
|
/** Explicit metadata allowlist: redaction alone is not message-body exclusion. */
|
package/src/registry.ts
CHANGED
|
@@ -1166,7 +1166,7 @@ export const MCP_INSTRUCTIONS_MAX_CHARS = 2048
|
|
|
1166
1166
|
export const MCP_INSTRUCTIONS = [
|
|
1167
1167
|
'The sender reads Slack. Answer with reply, using chat_id and thread_ts for a thread; terminal output is not seen there.',
|
|
1168
1168
|
'Messages arrive as the <channel> tag described in the system prompt, carrying channel_name and, for a long message, part.',
|
|
1169
|
-
'via: dm = a direct message; mention = you were @mentioned; broadcast = @here or @channel; receive_all_shared = a channel where you and another persona receive every message; receive_all = a channel where
|
|
1169
|
+
'via: dm = a direct message; mention = you were @mentioned; broadcast = @here or @channel; receive_all_shared = a channel where you and another persona receive every message; receive_all = a channel where you, and no other persona on this server, receive every message.',
|
|
1170
1170
|
'<@ID> mentions a Slack user or persona. Use <@ID> to address another persona.',
|
|
1171
1171
|
'If attachment_count is present, use download_attachment(chat_id, message_id) to fetch attachments.',
|
|
1172
1172
|
'You may act in every channel you receive. With invited channels off, these are your configured channels; with it on, channels your Slack app belongs to (Slack refuses others).',
|