switchroom 0.18.31 → 0.18.33
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/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `checklist_tasks_done` / `checklist_tasks_added` service-message forwarding
|
|
3
|
+
* (switchroom#2996 P6 cluster F).
|
|
4
|
+
*
|
|
5
|
+
* Telegram emits these service messages when users tick or add tasks in a
|
|
6
|
+
* native checklist. They arrive as part of the `message` update type (no extra
|
|
7
|
+
* `allowed_updates` config needed). We route them to the agent as a new channel
|
|
8
|
+
* event with kind="checklist_task_changed" so the agent can react to user
|
|
9
|
+
* actions on a checklist it sent.
|
|
10
|
+
*
|
|
11
|
+
* Extracted verbatim from gateway.ts (Amendment 9 — no module-global reads).
|
|
12
|
+
* The two `bot.on('message:checklist_tasks_*')` registration lines stay in
|
|
13
|
+
* gateway.ts (order is a load-bearing invariant) and delegate here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Context } from 'grammy'
|
|
17
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
18
|
+
|
|
19
|
+
type ChecklistTaskUpdate = {
|
|
20
|
+
message_checklist?: {
|
|
21
|
+
title?: string
|
|
22
|
+
tasks?: Array<{ id?: number; text?: string; is_completed?: boolean }>
|
|
23
|
+
}
|
|
24
|
+
checklist_tasks_done?: Array<{ id?: number; user?: { id?: number; username?: string }; done?: boolean }>
|
|
25
|
+
checklist_tasks_added?: Array<{ id?: number; text?: string; user?: { id?: number; username?: string } }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChecklistHandlerDeps {
|
|
29
|
+
/** Access config — only `allowFrom` is consulted (same gate as inbound). */
|
|
30
|
+
loadAccess: () => { allowFrom: string[] }
|
|
31
|
+
/** Broadcast a channel event to connected bridges. */
|
|
32
|
+
broadcast: (msg: InboundMessage) => void
|
|
33
|
+
/** stderr log sink. */
|
|
34
|
+
log: (line: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleChecklistUpdate(
|
|
38
|
+
ctx: Context,
|
|
39
|
+
kind: 'checklist_tasks_done' | 'checklist_tasks_added',
|
|
40
|
+
deps: ChecklistHandlerDeps,
|
|
41
|
+
): void {
|
|
42
|
+
try {
|
|
43
|
+
const msg = ctx.message as (typeof ctx.message & ChecklistTaskUpdate) | undefined
|
|
44
|
+
if (!msg) return
|
|
45
|
+
|
|
46
|
+
const chat = ctx.chat
|
|
47
|
+
if (!chat) return
|
|
48
|
+
|
|
49
|
+
const chat_id = String(chat.id)
|
|
50
|
+
const access = deps.loadAccess()
|
|
51
|
+
|
|
52
|
+
// Only notify if this chat is allowlisted — same guard as inbound user messages.
|
|
53
|
+
// Closes #472 finding #13. Pre-fix the `&& access.allowFrom.length > 0`
|
|
54
|
+
// tail made this fail-OPEN when the allowlist was empty: every chat's
|
|
55
|
+
// checklist tasks would forward to the agent. Sibling guards (the
|
|
56
|
+
// inbound-message gate at line ~588 and the operator-event broadcast
|
|
57
|
+
// at line ~1221) are both fail-closed for empty allowlists. The
|
|
58
|
+
// empty-allowlist case is the most likely state for a misconfiguration
|
|
59
|
+
// (e.g. /unpair just ran), so fail-OPEN is the worst default.
|
|
60
|
+
if (!access.allowFrom.includes(chat_id)) return
|
|
61
|
+
|
|
62
|
+
const message_id = String(msg.message_id)
|
|
63
|
+
const ts = msg.date ?? Math.floor(Date.now() / 1000)
|
|
64
|
+
|
|
65
|
+
// Extract task updates depending on service message type
|
|
66
|
+
const tasksDone = msg.checklist_tasks_done ?? []
|
|
67
|
+
const tasksAdded = msg.checklist_tasks_added ?? []
|
|
68
|
+
const allTasks = kind === 'checklist_tasks_done' ? tasksDone : tasksAdded
|
|
69
|
+
|
|
70
|
+
// Build per-task channel events and broadcast each to connected bridges.
|
|
71
|
+
for (const task of allTasks) {
|
|
72
|
+
const taskId = task.id != null ? String(task.id) : '?'
|
|
73
|
+
const user = (task.user as { username?: string; id?: number } | undefined)
|
|
74
|
+
const userName = user?.username ?? (user?.id != null ? String(user.id) : 'unknown')
|
|
75
|
+
const state = kind === 'checklist_tasks_done'
|
|
76
|
+
? ((task as { done?: boolean }).done === false ? 'undone' : 'done')
|
|
77
|
+
: 'added'
|
|
78
|
+
|
|
79
|
+
const inboundMsg: InboundMessage = {
|
|
80
|
+
type: 'inbound',
|
|
81
|
+
chatId: chat_id,
|
|
82
|
+
messageId: Number(message_id),
|
|
83
|
+
user: userName,
|
|
84
|
+
userId: user?.id ?? 0,
|
|
85
|
+
ts,
|
|
86
|
+
text: `(checklist task ${state}: id=${taskId})`,
|
|
87
|
+
meta: {
|
|
88
|
+
chat_id,
|
|
89
|
+
message_id,
|
|
90
|
+
kind: 'checklist_task_changed',
|
|
91
|
+
task_id: taskId,
|
|
92
|
+
state,
|
|
93
|
+
user: userName,
|
|
94
|
+
user_id: user?.id != null ? String(user.id) : '0',
|
|
95
|
+
ts: new Date(ts * 1000).toISOString(),
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
// allow-broadcast: informational checklist task notification, not turn-driving
|
|
99
|
+
deps.broadcast(inboundMsg)
|
|
100
|
+
deps.log(
|
|
101
|
+
`telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state} user=${userName}\n`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
deps.log(`telegram gateway: checklist handler error (${kind}): ${err}\n`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* delivery-confirm-wiring.ts — the deliver-until-acked SWEEP wiring
|
|
3
|
+
* (#2996 P8 PR-C1).
|
|
4
|
+
*
|
|
5
|
+
* Extracted VERBATIM from gateway.ts: the 5 s confirm-sweep tick body
|
|
6
|
+
* (`runDeliveryConfirmSweep`), the strand re-delivery (`redeliverStrandedInbound`)
|
|
7
|
+
* and the #2787 Mechanism A card-suspension scoper (`sweepSuspendedTargets`).
|
|
8
|
+
* The gateway keeps thin same-name wrappers and OWNS the `setInterval`
|
|
9
|
+
* (the P0c rule — a test import must stay side-effect-free).
|
|
10
|
+
*
|
|
11
|
+
* DI contract: state stays in gateway.ts (`deliveryQueue`, the pending-card
|
|
12
|
+
* maps, `pendingInboundBuffer`); volatile handles cross as getters. The idle
|
|
13
|
+
* gate deliberately reads `getCurrentTurnNull()` — the turn ATOM, not the
|
|
14
|
+
* machine's eager in-turn state (the documented trap: `turnInFlightForGate`
|
|
15
|
+
* stays set through a strand until the TTL tick, so it is NOT a usable idle
|
|
16
|
+
* signal here). Design §3 PR-C1 risk row; harness case 4 pins mid-turn
|
|
17
|
+
* suppression.
|
|
18
|
+
*
|
|
19
|
+
* `trackDelivery` enrolment callsites stay in gateway.ts unchanged — the two
|
|
20
|
+
* twin-body sites live inside the P7-owned handleInbound window and are P7's
|
|
21
|
+
* to delete (design §2 PR-C1 note).
|
|
22
|
+
*/
|
|
23
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
24
|
+
import type { DeliveryConfirmWiringDeps } from './gateway.js'
|
|
25
|
+
import {
|
|
26
|
+
trackDelivery,
|
|
27
|
+
sweep as sweepDeliveryQueue,
|
|
28
|
+
forgetDelivery,
|
|
29
|
+
isRedeliverySuspended,
|
|
30
|
+
type PendingDelivery,
|
|
31
|
+
} from './inbound-delivery-confirm.js'
|
|
32
|
+
import { chatKey } from './chat-key.js'
|
|
33
|
+
|
|
34
|
+
export function createDeliveryConfirmWiring(deps: DeliveryConfirmWiringDeps) {
|
|
35
|
+
const {
|
|
36
|
+
DELIVERY_CONFIRM_ENABLED,
|
|
37
|
+
DELIVERY_CONFIRM_TIMEOUT_MS,
|
|
38
|
+
getCurrentTurnNull,
|
|
39
|
+
sendToAgent,
|
|
40
|
+
deliveryQueue,
|
|
41
|
+
pendingInboundBuffer,
|
|
42
|
+
pendingPermissions,
|
|
43
|
+
pendingAskUser,
|
|
44
|
+
} = deps
|
|
45
|
+
|
|
46
|
+
// Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
|
|
47
|
+
// Every few seconds, re-send any inbound that was handed to claude but never
|
|
48
|
+
// acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
|
|
49
|
+
// the composer so the re-sent notification lands on a clean line, then
|
|
50
|
+
// re-send. Reuses the same delivery primitives; the message is never dropped.
|
|
51
|
+
// (Refs ipcServer / pendingInboundBuffer declared below — resolved at fire
|
|
52
|
+
// time, after module init.) unref so the interval never holds the process.
|
|
53
|
+
async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Promise<void> {
|
|
54
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
`telegram gateway: inbound strand (no enqueue ack) key=${p.key} — re-clearing composer + re-delivering\n`,
|
|
57
|
+
)
|
|
58
|
+
try {
|
|
59
|
+
const { clearAgentComposer } = await import('../../src/agents/tmux.js')
|
|
60
|
+
if (selfAgent) clearAgentComposer({ agentName: selfAgent })
|
|
61
|
+
} catch { /* best-effort; re-deliver regardless */ }
|
|
62
|
+
const ok = sendToAgent(selfAgent, p.inbound)
|
|
63
|
+
if (ok) {
|
|
64
|
+
// Survive an ack that raced the `await import` above: only `enqueue`
|
|
65
|
+
// clears tracking, so if a concurrent ack removed the entry, re-affirm
|
|
66
|
+
// it — never drop. Idempotent.
|
|
67
|
+
if (!deliveryQueue.pending.has(p.key)) {
|
|
68
|
+
trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// Bridge offline between attempts — hand off to the offline buffer
|
|
72
|
+
// (bridgeUp drains it) and stop tracking here; the spool owns it now.
|
|
73
|
+
pendingInboundBuffer.push(selfAgent, p.inbound)
|
|
74
|
+
forgetDelivery(deliveryQueue, p.key)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// #2787 Mechanism A — which chats/topics currently hold a live permission or
|
|
78
|
+
// ask_user card. A pending card is a live interaction for ITS OWN chat, so the
|
|
79
|
+
// confirm sweep must not re-clear the composer + re-send there. But the OLD guard
|
|
80
|
+
// (`pendingPermissions.size > 0 || pendingAskUser.size > 0 → return`) suspended
|
|
81
|
+
// the sweep GLOBALLY: one card parked in a single topic (or in the operator DM,
|
|
82
|
+
// a different chatId entirely) froze re-delivery of every stranded inbound across
|
|
83
|
+
// EVERY topic until that one card resolved. This scopes the suspension to the
|
|
84
|
+
// card's own target. Returns per-topic chatKeys where the topic is known and bare
|
|
85
|
+
// chatIds where it isn't (a card fanned to operator DMs records chatId only, and
|
|
86
|
+
// a whole-chat suspension is the safe conservative fallback there); the callsite
|
|
87
|
+
// tests a delivery entry against both renderings.
|
|
88
|
+
function sweepSuspendedTargets(): { keys: Set<string>; chats: Set<string> } {
|
|
89
|
+
const keys = new Set<string>()
|
|
90
|
+
const chats = new Set<string>()
|
|
91
|
+
for (const p of pendingPermissions.values()) {
|
|
92
|
+
for (const c of p.cards) {
|
|
93
|
+
if (c.threadId != null) keys.add(chatKey(c.chatId, c.threadId))
|
|
94
|
+
else chats.add(c.chatId)
|
|
95
|
+
}
|
|
96
|
+
// A permission whose card send hasn't resolved yet (cards still empty) has
|
|
97
|
+
// no recorded target — suspend nothing for it; the next sweep sees it once
|
|
98
|
+
// the send resolves, and the currentTurn guard already covers the in-turn
|
|
99
|
+
// window a fresh permission request lives in.
|
|
100
|
+
}
|
|
101
|
+
for (const a of pendingAskUser.values()) {
|
|
102
|
+
if (a.threadId != null) keys.add(chatKey(a.chatId, a.threadId))
|
|
103
|
+
else chats.add(a.chatId)
|
|
104
|
+
}
|
|
105
|
+
return { keys, chats }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// #2996 P0c/P8 PR-C1: the sweep TICK body (named in PR-A so the harness can
|
|
109
|
+
// drive it) — the GATEWAY still owns the `setInterval` (the P0c gate rule:
|
|
110
|
+
// modules export the tick body; the gateway registers the timer).
|
|
111
|
+
function runDeliveryConfirmSweep(): void {
|
|
112
|
+
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
113
|
+
// Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
|
|
114
|
+
// by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
|
|
115
|
+
// means a real turn is in flight — re-clearing the composer + re-sending now
|
|
116
|
+
// would clobber it (the exact mid-turn wedge this queue exists to prevent).
|
|
117
|
+
// NB: the machine's in-turn state (turnInFlightForGate) is advanced EAGERLY
|
|
118
|
+
// at delivery and stays set through a strand until the TTL tick, so it is
|
|
119
|
+
// NOT a usable "idle" signal here.
|
|
120
|
+
if (!getCurrentTurnNull()) return
|
|
121
|
+
// #2787 Mechanism A: a pending permission / ask_user card suspends re-delivery
|
|
122
|
+
// ONLY for its own chat/topic — never globally. Skip just the stranded entries
|
|
123
|
+
// whose target holds a live card; sweep the rest so unrelated topics keep
|
|
124
|
+
// getting re-delivered.
|
|
125
|
+
const suspended = sweepSuspendedTargets()
|
|
126
|
+
for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
127
|
+
if (isRedeliverySuspended(p.key, suspended)) continue
|
|
128
|
+
void redeliverStrandedInbound(p)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { runDeliveryConfirmSweep, redeliverStrandedInbound, sweepSuspendedTargets }
|
|
133
|
+
}
|
|
@@ -37,17 +37,6 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
|
|
|
37
37
|
activeReactionMsgIds: Map<string, { chatId: string; messageId: number }>
|
|
38
38
|
/** Mirror map: same keys → turn-start timestamps. */
|
|
39
39
|
activeTurnStartedAt: Map<string, number>
|
|
40
|
-
/** PR3b: keys claude has actually been handed (delivered, not just
|
|
41
|
-
* received). Cleared on disconnect for the same reason as
|
|
42
|
-
* activeTurnStartedAt — the bridge just died, every turn it
|
|
43
|
-
* was handed is dead by definition. */
|
|
44
|
-
claudeBusyKeys: Set<string>
|
|
45
|
-
/** #2787: insertion-timestamp map for `claudeBusyKeys`, backing the orphan
|
|
46
|
-
* reaper. MUST stay in lockstep with `claudeBusyKeys` at every clear site —
|
|
47
|
-
* a stale timestamp surviving a clear makes a later re-marked key look
|
|
48
|
-
* >TTL-old and get reaped against a live delivery. Deleted alongside every
|
|
49
|
-
* `claudeBusyKeys.delete`/`.clear` below. */
|
|
50
|
-
claudeBusyKeySince: Map<string, number>
|
|
51
40
|
|
|
52
41
|
/** Open draft-stream handles keyed by chat:thread:replyId. */
|
|
53
42
|
activeDraftStreams: Map<string, Stream>
|
|
@@ -96,8 +85,6 @@ export function flushOnAgentDisconnect<
|
|
|
96
85
|
activeStatusReactions,
|
|
97
86
|
activeReactionMsgIds,
|
|
98
87
|
activeTurnStartedAt,
|
|
99
|
-
claudeBusyKeys,
|
|
100
|
-
claudeBusyKeySince,
|
|
101
88
|
activeDraftStreams,
|
|
102
89
|
clearActiveReactions,
|
|
103
90
|
disposeProgressDriver,
|
|
@@ -125,8 +112,6 @@ export function flushOnAgentDisconnect<
|
|
|
125
112
|
activeStatusReactions.delete(key)
|
|
126
113
|
activeReactionMsgIds.delete(key)
|
|
127
114
|
activeTurnStartedAt.delete(key)
|
|
128
|
-
claudeBusyKeys.delete(key)
|
|
129
|
-
claudeBusyKeySince.delete(key) // #2787: keep orphan-TTL map in lockstep
|
|
130
115
|
}
|
|
131
116
|
clearActiveReactions()
|
|
132
117
|
|
|
@@ -146,8 +131,6 @@ export function flushOnAgentDisconnect<
|
|
|
146
131
|
for (const k of danglingKeys) {
|
|
147
132
|
activeTurnStartedAt.delete(k)
|
|
148
133
|
activeReactionMsgIds.delete(k)
|
|
149
|
-
claudeBusyKeys.delete(k)
|
|
150
|
-
claudeBusyKeySince.delete(k) // #2787: keep orphan-TTL map in lockstep
|
|
151
134
|
}
|
|
152
135
|
log(
|
|
153
136
|
`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
|
|
@@ -156,33 +139,12 @@ export function flushOnAgentDisconnect<
|
|
|
156
139
|
onDanglingTurnsSwept?.(danglingKeys)
|
|
157
140
|
}
|
|
158
141
|
|
|
159
|
-
// PR3b orphan-sweep
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// so the sweep loop above wouldn't notice them. Pre-PR3b this was
|
|
166
|
-
// invisible because the fleet gate read activeTurnStartedAt.size —
|
|
167
|
-
// synthetic-only turns never registered. PR3b's claudeBusyKeys.add
|
|
168
|
-
// is the more-accurate "claude is busy on this" gate, which means
|
|
169
|
-
// a synthetic-delivered turn that dies WITHOUT turn_end leaves an
|
|
170
|
-
// orphan that the activeTurnStartedAt-keyed sweep can't see.
|
|
171
|
-
// Cure: clear any leftover busy keys here. Bridge died → every
|
|
172
|
-
// busy key is dead by definition. Same justification as the
|
|
173
|
-
// dangling-sweep above for activeTurnStartedAt.
|
|
174
|
-
if (claudeBusyKeys.size > 0) {
|
|
175
|
-
const orphanCount = claudeBusyKeys.size
|
|
176
|
-
const orphanKeys = [...claudeBusyKeys]
|
|
177
|
-
claudeBusyKeys.clear()
|
|
178
|
-
// #2787: keep the orphan-TTL map in lockstep with the set it shadows.
|
|
179
|
-
for (const k of orphanKeys) claudeBusyKeySince.delete(k)
|
|
180
|
-
log(
|
|
181
|
-
`telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
|
|
182
|
-
`entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
|
|
183
|
-
` keys=${orphanKeys.join(',')}`,
|
|
184
|
-
)
|
|
185
|
-
}
|
|
142
|
+
// (#2996 P1) The PR3b `claudeBusyKeys` orphan-sweep that lived here was
|
|
143
|
+
// deleted with the set itself: the delivery machine's `bridgeDown` event
|
|
144
|
+
// — emitted by the gateway's onClientDisconnected alongside this flush —
|
|
145
|
+
// resets the machine to bridge_dead, which structurally clears the
|
|
146
|
+
// turn-in-flight gate for synthetic-delivered turns that died without a
|
|
147
|
+
// turn_end (the case the sweep existed for).
|
|
186
148
|
|
|
187
149
|
// Stop coalesce timers that could emit into a finalized draft stream, but
|
|
188
150
|
// preserve chats with pendingCompletion=true — those have background
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #2996 P0e — gateway.ts import-cleanliness.
|
|
8
|
+
*
|
|
9
|
+
* Prior phases (P0a/P0b/P0c) could only pin import-cleanliness with AST/source
|
|
10
|
+
* checks because importing gateway.ts under vitest was *impossible*: the
|
|
11
|
+
* module-scope `createIpcServer(...)` calls `Bun.listen` at construction, and
|
|
12
|
+
* `Bun` is undefined under Node/vitest, so a bare `import('./gateway.js')`
|
|
13
|
+
* threw `ReferenceError: Bun is not defined` before any assertion could run.
|
|
14
|
+
*
|
|
15
|
+
* P0e removes the last import-time side effects. This test therefore uses the
|
|
16
|
+
* thing prior phases could not: an ACTUAL runtime import of gateway.ts as the
|
|
17
|
+
* oracle for the import-clean half, and deterministic source-pins for the
|
|
18
|
+
* boot-fires-under-`isGatewayMain` half (which cannot be runtime-exercised
|
|
19
|
+
* under vitest — the prod boot path needs Bun, a real Bot token, and the
|
|
20
|
+
* vault, none of which exist here).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const GATEWAY_SRC = readFileSync(join(here, 'gateway.ts'), 'utf8')
|
|
25
|
+
|
|
26
|
+
// The process-signal / lifecycle events gateway.ts wires on the prod boot path.
|
|
27
|
+
// Under a vitest import (isGatewayMain === false) it must register NONE of them.
|
|
28
|
+
const GATEWAY_SIGNALS = ['SIGTERM', 'SIGINT', 'beforeExit'] as const
|
|
29
|
+
|
|
30
|
+
function signalListenerCounts(): Record<string, number> {
|
|
31
|
+
const out: Record<string, number> = {}
|
|
32
|
+
for (const sig of GATEWAY_SIGNALS) out[sig] = process.listenerCount(sig)
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('gateway.ts import-cleanliness (#2996 P0e)', () => {
|
|
37
|
+
it('runtime-imports with zero side effects: resolves, no process.exit, no gateway signal handlers', async () => {
|
|
38
|
+
const before = signalListenerCounts()
|
|
39
|
+
|
|
40
|
+
// Any reachable process.exit during import is a hard failure ("no exit
|
|
41
|
+
// reachable on import"). Throw instead of exiting so the harness survives
|
|
42
|
+
// and the assertion is legible.
|
|
43
|
+
const exitSpy = vi
|
|
44
|
+
.spyOn(process, 'exit')
|
|
45
|
+
.mockImplementation(((code?: number) => {
|
|
46
|
+
throw new Error(`process.exit(${code}) was reached during import()`)
|
|
47
|
+
}) as never)
|
|
48
|
+
|
|
49
|
+
// Positive oracle that no UDS server is bound at import: if the gate
|
|
50
|
+
// regressed and `createIpcServer` ran, it would call this Bun.listen fake.
|
|
51
|
+
// With the gate intact it is never invoked. (Also proves the import no
|
|
52
|
+
// longer depends on a real Bun global — it resolves whether or not one
|
|
53
|
+
// exists.)
|
|
54
|
+
//
|
|
55
|
+
// Dual-runner note: this file runs under BOTH vitest (Node) and `bun test`
|
|
56
|
+
// (the plugin bun suite globs every *.test.ts). Under Node `globalThis.Bun`
|
|
57
|
+
// is undefined and the fake installs cleanly — that run holds the no-bind
|
|
58
|
+
// oracle. Under the Bun runtime the `Bun` global is READONLY (assignment
|
|
59
|
+
// throws "Attempted to assign to readonly property"), so the swap is
|
|
60
|
+
// best-effort there; the bind oracle is then carried by the vitest run and
|
|
61
|
+
// the source pins below, while the remaining outcome assertions (resolve /
|
|
62
|
+
// no exit / no listeners) still run in both runtimes.
|
|
63
|
+
const listenSpy = vi.fn(() => {
|
|
64
|
+
throw new Error('Bun.listen was called during import() — an IPC server was bound')
|
|
65
|
+
})
|
|
66
|
+
const priorBun = (globalThis as { Bun?: unknown }).Bun
|
|
67
|
+
let bunSwapped = false
|
|
68
|
+
try {
|
|
69
|
+
;(globalThis as { Bun?: unknown }).Bun = { listen: listenSpy }
|
|
70
|
+
bunSwapped = true
|
|
71
|
+
} catch {
|
|
72
|
+
// Bun runtime — readonly global; see dual-runner note above.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let mod: Record<string, unknown>
|
|
76
|
+
try {
|
|
77
|
+
mod = (await import('./gateway.js')) as Record<string, unknown>
|
|
78
|
+
} finally {
|
|
79
|
+
if (bunSwapped) {
|
|
80
|
+
if (priorBun === undefined) delete (globalThis as { Bun?: unknown }).Bun
|
|
81
|
+
else (globalThis as { Bun?: unknown }).Bun = priorBun
|
|
82
|
+
}
|
|
83
|
+
exitSpy.mockRestore()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Import resolved (did not throw). Under P0c-and-earlier this line was
|
|
87
|
+
// unreachable — the import rejected with `Bun is not defined`.
|
|
88
|
+
expect(mod).toBeTruthy()
|
|
89
|
+
|
|
90
|
+
// No IPC server bound (Node run; under Bun the swap is best-effort and the
|
|
91
|
+
// spy simply stays uninstalled + uncalled).
|
|
92
|
+
expect(listenSpy).not.toHaveBeenCalled()
|
|
93
|
+
|
|
94
|
+
// No exit reachable on import.
|
|
95
|
+
expect(exitSpy).not.toHaveBeenCalled()
|
|
96
|
+
|
|
97
|
+
// No gateway-owned SIGTERM / SIGINT / beforeExit handlers registered.
|
|
98
|
+
// (vitest owns a baseline uncaughtException/unhandledRejection listener of
|
|
99
|
+
// its own, so those two are asserted via the delta only.)
|
|
100
|
+
const after = signalListenerCounts()
|
|
101
|
+
for (const sig of GATEWAY_SIGNALS) {
|
|
102
|
+
// Runner-agnostic single-arg expect (bun's expect has no message arg):
|
|
103
|
+
// encode the signal name in the compared value so a failure is legible.
|
|
104
|
+
expect(`${sig}:+${after[sig] - before[sig]}`).toBe(`${sig}:+0`)
|
|
105
|
+
}
|
|
106
|
+
}, 60_000)
|
|
107
|
+
|
|
108
|
+
it('does not add uncaughtException / unhandledRejection handlers on import', async () => {
|
|
109
|
+
// Distinct from the signal test: gateway.ts registers TWO families of these
|
|
110
|
+
// (installGlobalErrorHandlers @ ~910 and its own shutdown-routing handlers
|
|
111
|
+
// @ ~29926/29935). Both call process.exit, both are gated on P0e. Assert the
|
|
112
|
+
// delta the import contributed is zero (vitest's own baseline is subtracted).
|
|
113
|
+
const before = {
|
|
114
|
+
uncaughtException: process.listenerCount('uncaughtException'),
|
|
115
|
+
unhandledRejection: process.listenerCount('unhandledRejection'),
|
|
116
|
+
}
|
|
117
|
+
const mod = await import('./gateway.js')
|
|
118
|
+
expect(mod).toBeTruthy()
|
|
119
|
+
const after = {
|
|
120
|
+
uncaughtException: process.listenerCount('uncaughtException'),
|
|
121
|
+
unhandledRejection: process.listenerCount('unhandledRejection'),
|
|
122
|
+
}
|
|
123
|
+
expect(after.uncaughtException - before.uncaughtException).toBe(0)
|
|
124
|
+
expect(after.unhandledRejection - before.unhandledRejection).toBe(0)
|
|
125
|
+
}, 60_000)
|
|
126
|
+
|
|
127
|
+
describe('boot wiring is gated on isGatewayMain (source pins — the prod path still fires)', () => {
|
|
128
|
+
// These pin, deterministically, that each side-effecting construct remains
|
|
129
|
+
// (a) gated so the import stays clean, and (b) present so the prod boot path
|
|
130
|
+
// (isGatewayMain === true) still constructs it. They guard against a future
|
|
131
|
+
// edit silently ungating one of them (re-inflating the import surface) OR
|
|
132
|
+
// deleting the construction (breaking boot).
|
|
133
|
+
|
|
134
|
+
it('IPC server construction is deferred behind isGatewayMain', () => {
|
|
135
|
+
expect(GATEWAY_SRC).toMatch(/let ipcServer!: IpcServer/)
|
|
136
|
+
expect(GATEWAY_SRC).toMatch(/if \(isGatewayMain\) ipcServer = createIpcServer\(\{/)
|
|
137
|
+
// No ungated `const ipcServer = createIpcServer` remains.
|
|
138
|
+
expect(GATEWAY_SRC).not.toMatch(/const ipcServer[^\n]*= createIpcServer/)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('inbound spool construction is deferred behind isGatewayMain', () => {
|
|
142
|
+
expect(GATEWAY_SRC).toMatch(
|
|
143
|
+
/let inboundSpool: ReturnType<typeof createInboundSpool> \| undefined/,
|
|
144
|
+
)
|
|
145
|
+
expect(GATEWAY_SRC).toMatch(/if \(isGatewayMain\) inboundSpool = STATIC/)
|
|
146
|
+
expect(GATEWAY_SRC).not.toMatch(/const inboundSpool = STATIC/)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('global error handlers + beforeExit are gated on isGatewayMain', () => {
|
|
150
|
+
// installPosthogErrorHandlers must not sit ungated at module scope.
|
|
151
|
+
expect(GATEWAY_SRC).not.toMatch(/^installPosthogErrorHandlers\(\)/m)
|
|
152
|
+
expect(GATEWAY_SRC).toMatch(
|
|
153
|
+
/if \(isGatewayMain\) \{\n {2}installPosthogErrorHandlers\(\)/,
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('signal + crash handlers are gated on isGatewayMain', () => {
|
|
158
|
+
// No ungated (column-0) process.on wiring survives.
|
|
159
|
+
expect(GATEWAY_SRC).not.toMatch(/^process\.on\('SIGTERM'/m)
|
|
160
|
+
expect(GATEWAY_SRC).not.toMatch(/^process\.on\('SIGINT'/m)
|
|
161
|
+
expect(GATEWAY_SRC).not.toMatch(/^process\.on\('unhandledRejection'/m)
|
|
162
|
+
expect(GATEWAY_SRC).not.toMatch(/^process\.on\('uncaughtException'/m)
|
|
163
|
+
// The gateway's beforeExit analytics flush is likewise not at column 0.
|
|
164
|
+
expect(GATEWAY_SRC).not.toMatch(/^process\.on\('beforeExit'/m)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('startup reaction sweep (module-load IO) is gated on isGatewayMain', () => {
|
|
168
|
+
expect(GATEWAY_SRC).toMatch(
|
|
169
|
+
/if \(isGatewayMain\) \{\n {2}const startupAgentDir = resolveAgentDirFromEnv\(\)/,
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('preserves prod construction order: inboundSpool before pendingInboundBuffer before ipcServer', () => {
|
|
174
|
+
const iSpool = GATEWAY_SRC.indexOf('if (isGatewayMain) inboundSpool = STATIC')
|
|
175
|
+
const iPending = GATEWAY_SRC.indexOf('const pendingInboundBuffer = createPendingInboundBuffer(')
|
|
176
|
+
const iIpc = GATEWAY_SRC.indexOf('if (isGatewayMain) ipcServer = createIpcServer({')
|
|
177
|
+
expect(iSpool).toBeGreaterThan(0)
|
|
178
|
+
expect(iPending).toBeGreaterThan(0)
|
|
179
|
+
expect(iIpc).toBeGreaterThan(0)
|
|
180
|
+
expect(iSpool).toBeLessThan(iPending)
|
|
181
|
+
expect(iPending).toBeLessThan(iIpc)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('startGateway() remains the single guarded boot invocation', () => {
|
|
185
|
+
expect(GATEWAY_SRC).toMatch(/if \(isGatewayMain\) void startGateway\(\)/)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|