switchroom 0.12.20 → 0.12.22
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/cli/switchroom.js +2 -2
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +27 -14
- package/telegram-plugin/gateway/chat-key.ts +67 -0
- package/telegram-plugin/gateway/gateway.ts +69 -10
- package/telegram-plugin/pty-partial-handler.ts +4 -1
- package/telegram-plugin/stream-reply-handler.ts +6 -1
- package/telegram-plugin/tests/e2e.test.ts +5 -1
- package/telegram-plugin/tests/races.test.ts +5 -1
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +157 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -47247,8 +47247,8 @@ var {
|
|
|
47247
47247
|
} = import__.default;
|
|
47248
47248
|
|
|
47249
47249
|
// src/build-info.ts
|
|
47250
|
-
var VERSION = "0.12.
|
|
47251
|
-
var COMMIT_SHA = "
|
|
47250
|
+
var VERSION = "0.12.22";
|
|
47251
|
+
var COMMIT_SHA = "332e23e";
|
|
47252
47252
|
|
|
47253
47253
|
// src/cli/agent.ts
|
|
47254
47254
|
init_source();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.22",
|
|
4
4
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,12 +26,13 @@
|
|
|
26
26
|
"test:vitest": "vitest run",
|
|
27
27
|
"test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
|
|
28
28
|
"test:watch": "vitest",
|
|
29
|
-
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs",
|
|
29
|
+
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
|
|
30
30
|
"lint:tsc": "tsc --noEmit",
|
|
31
31
|
"lint:plugin-references": "node scripts/check-plugin-references.mjs",
|
|
32
32
|
"lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
|
|
33
33
|
"lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
|
|
34
34
|
"lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
|
|
35
|
+
"lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
|
|
35
36
|
"prepublishOnly": "npm run build && npm run lint && npm test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
@@ -31461,7 +31461,8 @@ function createStreamController(cfg) {
|
|
|
31461
31461
|
|
|
31462
31462
|
// pty-partial-handler.ts
|
|
31463
31463
|
function streamKey(chatId, threadId) {
|
|
31464
|
-
|
|
31464
|
+
const t = threadId == null || threadId === 0 ? "_" : String(threadId);
|
|
31465
|
+
return `${chatId}:${t}`;
|
|
31465
31466
|
}
|
|
31466
31467
|
function handlePtyPartialPure(text, state, deps) {
|
|
31467
31468
|
if (state.currentSessionChatId == null) {
|
|
@@ -31547,7 +31548,8 @@ function buildAccentHeader(accent) {
|
|
|
31547
31548
|
}
|
|
31548
31549
|
}
|
|
31549
31550
|
function streamKey2(chatId, threadId, lane, turnKey) {
|
|
31550
|
-
const
|
|
31551
|
+
const t = threadId == null || threadId === 0 ? "_" : String(threadId);
|
|
31552
|
+
const base = `${chatId}:${t}`;
|
|
31551
31553
|
const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base;
|
|
31552
31554
|
return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane;
|
|
31553
31555
|
}
|
|
@@ -43803,6 +43805,15 @@ function createPendingPermissionBuffer(opts = {}) {
|
|
|
43803
43805
|
};
|
|
43804
43806
|
}
|
|
43805
43807
|
|
|
43808
|
+
// gateway/chat-key.ts
|
|
43809
|
+
function chatKey(chatId, threadId) {
|
|
43810
|
+
const t = threadId == null || threadId === 0 ? "_" : String(threadId);
|
|
43811
|
+
return `${chatId}:${t}`;
|
|
43812
|
+
}
|
|
43813
|
+
function chatKeyWithSuffix(chatId, threadId, suffix) {
|
|
43814
|
+
return `${chatKey(chatId, threadId)}:${suffix}`;
|
|
43815
|
+
}
|
|
43816
|
+
|
|
43806
43817
|
// gateway/vault-grant-inbound-builders.ts
|
|
43807
43818
|
function buildVaultGrantApprovedInbound(opts) {
|
|
43808
43819
|
const ts = opts.nowMs ?? Date.now();
|
|
@@ -47115,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47115
47126
|
}
|
|
47116
47127
|
|
|
47117
47128
|
// ../src/build-info.ts
|
|
47118
|
-
var VERSION = "0.12.
|
|
47119
|
-
var COMMIT_SHA = "
|
|
47120
|
-
var COMMIT_DATE = "2026-05-
|
|
47121
|
-
var LATEST_PR =
|
|
47122
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
47129
|
+
var VERSION = "0.12.22";
|
|
47130
|
+
var COMMIT_SHA = "332e23e";
|
|
47131
|
+
var COMMIT_DATE = "2026-05-20T02:55:00Z";
|
|
47132
|
+
var LATEST_PR = 1574;
|
|
47133
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
47123
47134
|
|
|
47124
47135
|
// gateway/boot-version.ts
|
|
47125
47136
|
function formatRelativeAgo(iso) {
|
|
@@ -48062,10 +48073,10 @@ var CONTEXT_EXHAUSTION_COOLDOWN_MS = 600000;
|
|
|
48062
48073
|
var lastContextExhaustionWarningAt = 0;
|
|
48063
48074
|
var pendingPtyPartial = null;
|
|
48064
48075
|
function statusKey(chatId, threadId) {
|
|
48065
|
-
return
|
|
48076
|
+
return chatKey(chatId, threadId);
|
|
48066
48077
|
}
|
|
48067
48078
|
function streamKey3(chatId, threadId) {
|
|
48068
|
-
return
|
|
48079
|
+
return chatKey(chatId, threadId);
|
|
48069
48080
|
}
|
|
48070
48081
|
function purgeReactionTracking(key) {
|
|
48071
48082
|
const msgInfo = activeReactionMsgIds.get(key);
|
|
@@ -50526,7 +50537,7 @@ function resetOrphanedReplyTimeout() {
|
|
|
50526
50537
|
}
|
|
50527
50538
|
}
|
|
50528
50539
|
function closeActivityLane(chatId, threadId) {
|
|
50529
|
-
const key =
|
|
50540
|
+
const key = chatKeyWithSuffix(chatId, threadId, "activity");
|
|
50530
50541
|
const stream = activeDraftStreams.get(key);
|
|
50531
50542
|
if (stream == null)
|
|
50532
50543
|
return;
|
|
@@ -50535,7 +50546,7 @@ function closeActivityLane(chatId, threadId) {
|
|
|
50535
50546
|
stream.finalize().catch(() => {});
|
|
50536
50547
|
}
|
|
50537
50548
|
function closeProgressLane(chatId, threadId) {
|
|
50538
|
-
const prefix =
|
|
50549
|
+
const prefix = chatKeyWithSuffix(chatId, threadId, "progress");
|
|
50539
50550
|
for (const [key, stream] of activeDraftStreams) {
|
|
50540
50551
|
if (key.startsWith(prefix)) {
|
|
50541
50552
|
activeDraftStreams.delete(key);
|
|
@@ -50575,7 +50586,8 @@ function handleSessionEvent(ev) {
|
|
|
50575
50586
|
currentTurn = next;
|
|
50576
50587
|
preambleSuppressor.reset();
|
|
50577
50588
|
if (turnsDb != null) {
|
|
50578
|
-
const
|
|
50589
|
+
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null;
|
|
50590
|
+
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt));
|
|
50579
50591
|
next.registryKey = turnKey;
|
|
50580
50592
|
const userPromptPreview = extractUserPromptPreview(ev.rawContent);
|
|
50581
50593
|
try {
|
|
@@ -50808,7 +50820,7 @@ function handleSessionEvent(ev) {
|
|
|
50808
50820
|
if (flushDecision.kind === "skip" && flushDecision.reason === "silent-marker") {
|
|
50809
50821
|
process.stderr.write(`telegram gateway: silent-turn-suppression: chat=${chatId} turnKey=${turn.startedAt} reason=silent-marker
|
|
50810
50822
|
`);
|
|
50811
|
-
const suppressPrefix =
|
|
50823
|
+
const suppressPrefix = chatKeyWithSuffix(chatId, threadId, "progress");
|
|
50812
50824
|
for (const [key] of activeDraftStreams) {
|
|
50813
50825
|
if (key.startsWith(suppressPrefix)) {
|
|
50814
50826
|
activeDraftStreams.delete(key);
|
|
@@ -51263,6 +51275,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
|
|
|
51263
51275
|
return;
|
|
51264
51276
|
}
|
|
51265
51277
|
const inboundReceivedAt = Date.now();
|
|
51278
|
+
const turnInFlightAtReceipt = activeTurnStartedAt.size > 0;
|
|
51266
51279
|
const access = result.access;
|
|
51267
51280
|
const from = ctx.from;
|
|
51268
51281
|
const chat_id = String(ctx.chat.id);
|
|
@@ -51822,7 +51835,7 @@ ${preBlock(write.output)}`;
|
|
|
51822
51835
|
};
|
|
51823
51836
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
51824
51837
|
if (decideInboundDelivery({
|
|
51825
|
-
turnInFlight:
|
|
51838
|
+
turnInFlight: turnInFlightAtReceipt,
|
|
51826
51839
|
isSteering
|
|
51827
51840
|
}) === "buffer-until-idle") {
|
|
51828
51841
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical chat-key construction with branded type.
|
|
3
|
+
*
|
|
4
|
+
* The gateway tracks per-chat state (active turn, draft stream, status
|
|
5
|
+
* reactions, progress throttle, etc.) in `Map<string, ...>` instances
|
|
6
|
+
* keyed by a string derived from `(chatId, threadId | null)`. Multiple
|
|
7
|
+
* callsites historically constructed those keys inline as
|
|
8
|
+
* `` `${chatId}:${threadId ?? '_'}` ``. That worked until PR #1564 hit
|
|
9
|
+
* the silence-poke wedge: an `activeTurnStartedAt` entry survived a
|
|
10
|
+
* turn-end because the entry was set under one rendering of the key
|
|
11
|
+
* (`null` thread → `_`) and the cleanup ran against another
|
|
12
|
+
* (`undefined` thread → `_`, but also `0` thread → `0`, which doesn't
|
|
13
|
+
* coalesce). The fallback's `purgeReactionTracking` cleared one
|
|
14
|
+
* statusKey at a time; sibling keys for the same chat persisted, so
|
|
15
|
+
* `activeTurnStartedAt.size > 0` stayed true forever and every
|
|
16
|
+
* subsequent inbound was held mid-turn.
|
|
17
|
+
*
|
|
18
|
+
* This module makes the bug class hard to reintroduce:
|
|
19
|
+
*
|
|
20
|
+
* 1. `chatKey()` is the canonical constructor for "this chat+thread"
|
|
21
|
+
* keys. It collapses `null`, `undefined`, and `0` thread IDs
|
|
22
|
+
* into the same token (`_`). All sites in `gateway.ts`,
|
|
23
|
+
* `stream-reply-handler.ts`, and `pty-partial-handler.ts` route
|
|
24
|
+
* through it (or mirror its expression inline).
|
|
25
|
+
* 2. The returned `ChatKey` is a branded string — a documentation
|
|
26
|
+
* marker for human readers. `telegram-plugin/` is bun-bundled
|
|
27
|
+
* and NOT in `tsconfig.json`'s `include`; `lint:plugin-references`
|
|
28
|
+
* filters tsc output to reference-class errors only (TS2304/2552/
|
|
29
|
+
* 2722/2561). A future opt-in `Map<ChatKey, T>` migration could
|
|
30
|
+
* provide enforcement teeth, but that requires either bringing
|
|
31
|
+
* `telegram-plugin/` into the strict tsc check or rewriting the
|
|
32
|
+
* lint to fail on TS2345 type-mismatch. Today the brand is a
|
|
33
|
+
* nudge, not a fence.
|
|
34
|
+
*
|
|
35
|
+
* `chatKeyWithSuffix()` extends a ChatKey with a lane suffix
|
|
36
|
+
* (`activity`, `progress`, etc.) — same documentation brand.
|
|
37
|
+
*
|
|
38
|
+
* Non-ChatKey keyspaces (chat:message, chat:user, chat:sender:reason)
|
|
39
|
+
* are deliberately NOT branded — they have different shape and
|
|
40
|
+
* different invariants, see `deferredKey` / `inboundCoalesceKey` /
|
|
41
|
+
* the gate-dedup helper in `gateway.ts`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export type ChatKey = string & { readonly __brand: 'ChatKey' }
|
|
45
|
+
|
|
46
|
+
export function chatKey(chatId: string, threadId?: number | null): ChatKey {
|
|
47
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
48
|
+
return `${chatId}:${t}` as ChatKey
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function chatKeyWithSuffix(
|
|
52
|
+
chatId: string,
|
|
53
|
+
threadId: number | null | undefined,
|
|
54
|
+
suffix: string,
|
|
55
|
+
): ChatKey {
|
|
56
|
+
return `${chatKey(chatId, threadId)}:${suffix}` as ChatKey
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the chatId portion of a ChatKey. Used by sibling-key sweeps
|
|
61
|
+
* (PR #1564's `purgeStaleTurnsForChat`) that need to enumerate every
|
|
62
|
+
* thread under a single chat.
|
|
63
|
+
*/
|
|
64
|
+
export function chatIdOfChatKey(key: ChatKey): string {
|
|
65
|
+
const idx = key.indexOf(':')
|
|
66
|
+
return idx === -1 ? key : key.slice(0, idx)
|
|
67
|
+
}
|
|
@@ -253,6 +253,7 @@ import { createInboundSpool } from './inbound-spool.js'
|
|
|
253
253
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
254
254
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
255
255
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
256
|
+
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
256
257
|
import {
|
|
257
258
|
buildVaultGrantApprovedInbound,
|
|
258
259
|
buildVaultGrantDeniedInbound,
|
|
@@ -1026,6 +1027,7 @@ function checkApprovals(): void {
|
|
|
1026
1027
|
try { files = readdirSync(APPROVED_DIR) } catch { return }
|
|
1027
1028
|
for (const senderId of files) {
|
|
1028
1029
|
const file = join(APPROVED_DIR, senderId)
|
|
1030
|
+
// allow-raw-bot-api: Paired! sendMessage to DM senderId; no thread_id, cannot trigger THREAD_NOT_FOUND
|
|
1029
1031
|
void bot.api.sendMessage(senderId, "Paired! Say hi to Claude.").then(
|
|
1030
1032
|
() => rmSync(file, { force: true }),
|
|
1031
1033
|
err => {
|
|
@@ -1248,12 +1250,18 @@ let lastContextExhaustionWarningAt = 0
|
|
|
1248
1250
|
|
|
1249
1251
|
let pendingPtyPartial: string | null = null
|
|
1250
1252
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
+
// statusKey + streamKey are thin re-exports of the canonical chatKey()
|
|
1254
|
+
// constructor (see telegram-plugin/gateway/chat-key.ts). chatKey()
|
|
1255
|
+
// collapses 0/null/undefined thread IDs to the same token (`_`),
|
|
1256
|
+
// closing the sibling-key class behind #1564 (where one site set the
|
|
1257
|
+
// entry under `null → _` and another cleared under `0 → 0`, leaving
|
|
1258
|
+
// activeTurnStartedAt non-empty forever).
|
|
1259
|
+
function statusKey(chatId: string, threadId?: number | null): string {
|
|
1260
|
+
return chatKey(chatId, threadId)
|
|
1253
1261
|
}
|
|
1254
1262
|
|
|
1255
|
-
function streamKey(chatId: string, threadId?: number): string {
|
|
1256
|
-
return
|
|
1263
|
+
function streamKey(chatId: string, threadId?: number | null): string {
|
|
1264
|
+
return chatKey(chatId, threadId)
|
|
1257
1265
|
}
|
|
1258
1266
|
|
|
1259
1267
|
function purgeReactionTracking(key: string): void {
|
|
@@ -2675,6 +2683,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
2675
2683
|
parse_mode: 'HTML' as const,
|
|
2676
2684
|
...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
|
|
2677
2685
|
}
|
|
2686
|
+
// allow-raw-bot-api: operator-event broadcast loop; opts has no message_thread_id
|
|
2678
2687
|
void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
|
|
2679
2688
|
process.stderr.write(
|
|
2680
2689
|
`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
|
|
@@ -3453,6 +3462,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3453
3462
|
.text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
|
|
3454
3463
|
}
|
|
3455
3464
|
for (const chat_id of access.allowFrom) {
|
|
3465
|
+
// allow-raw-bot-api: permission-request keyboard fan-out; reply_markup-only opts, no thread_id
|
|
3456
3466
|
void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => {
|
|
3457
3467
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
3458
3468
|
})
|
|
@@ -4069,6 +4079,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4069
4079
|
stripped.length > 0
|
|
4070
4080
|
? stripped
|
|
4071
4081
|
: '⚠️ (a formatted fragment could not be rendered for Telegram)'
|
|
4082
|
+
// allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse-mode policy that just rejected the payload
|
|
4072
4083
|
const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
|
|
4073
4084
|
sentIds.push(sent.message_id)
|
|
4074
4085
|
logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
|
|
@@ -4090,6 +4101,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4090
4101
|
const retryOpts = { ...sendOpts }
|
|
4091
4102
|
delete (retryOpts as any).message_thread_id
|
|
4092
4103
|
try {
|
|
4104
|
+
// allow-raw-bot-api: chunk-loop THREAD_NOT_FOUND fallback; thread already dropped, wrapping would re-enter the throw
|
|
4093
4105
|
const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
|
|
4094
4106
|
sentIds.push(sent.message_id)
|
|
4095
4107
|
} catch (retryErr) {
|
|
@@ -5392,7 +5404,7 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
5392
5404
|
}
|
|
5393
5405
|
|
|
5394
5406
|
function closeActivityLane(chatId: string, threadId: number | undefined): void {
|
|
5395
|
-
const key =
|
|
5407
|
+
const key = chatKeyWithSuffix(chatId, threadId, 'activity')
|
|
5396
5408
|
const stream = activeDraftStreams.get(key)
|
|
5397
5409
|
if (stream == null) return
|
|
5398
5410
|
activeDraftStreams.delete(key)
|
|
@@ -5404,7 +5416,7 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
|
|
|
5404
5416
|
// Progress-card streams include a turnKey suffix in their key
|
|
5405
5417
|
// (e.g. "chatId:_:progress:chatId:1"). Iterate and match by prefix
|
|
5406
5418
|
// so the backstop actually finds the stream.
|
|
5407
|
-
const prefix =
|
|
5419
|
+
const prefix = chatKeyWithSuffix(chatId, threadId, 'progress')
|
|
5408
5420
|
for (const [key, stream] of activeDraftStreams) {
|
|
5409
5421
|
if (key.startsWith(prefix)) {
|
|
5410
5422
|
activeDraftStreams.delete(key)
|
|
@@ -5460,7 +5472,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5460
5472
|
// progress-card-driver's per-chat sequence number (these are two
|
|
5461
5473
|
// independent identifier schemes and don't need to align).
|
|
5462
5474
|
if (turnsDb != null) {
|
|
5463
|
-
|
|
5475
|
+
// ev.threadId is `string | null` (Telegram emits as string); convert
|
|
5476
|
+
// to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
|
|
5477
|
+
// to '_' — same as the explicit `null` branch below.
|
|
5478
|
+
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
|
|
5479
|
+
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
|
|
5464
5480
|
next.registryKey = turnKey
|
|
5465
5481
|
// Phase 1 of #332: capture first ~200 chars of the user's message.
|
|
5466
5482
|
const userPromptPreview = extractUserPromptPreview(ev.rawContent)
|
|
@@ -5857,7 +5873,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5857
5873
|
// Drop progress-card streams without finalising — the normal
|
|
5858
5874
|
// closeProgressLane call below would call stream.finalize() which
|
|
5859
5875
|
// sends a final "Done" edit to Telegram. Skip that for silent turns.
|
|
5860
|
-
const suppressPrefix =
|
|
5876
|
+
const suppressPrefix = chatKeyWithSuffix(chatId, threadId, 'progress')
|
|
5861
5877
|
for (const [key] of activeDraftStreams) {
|
|
5862
5878
|
if (key.startsWith(suppressPrefix)) {
|
|
5863
5879
|
activeDraftStreams.delete(key)
|
|
@@ -6621,6 +6637,39 @@ async function handleInbound(
|
|
|
6621
6637
|
// network RTT) but not a user-perceived end-to-end measurement.
|
|
6622
6638
|
const inboundReceivedAt = Date.now()
|
|
6623
6639
|
|
|
6640
|
+
// #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
|
|
6641
|
+
// BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
|
|
6642
|
+
// for THIS inbound. The #1556 delivery gate further down asks "is a
|
|
6643
|
+
// turn ALREADY in flight" — but if we read activeTurnStartedAt.size
|
|
6644
|
+
// at the gate, we see the entry this handler just wrote, and buffer
|
|
6645
|
+
// the very message that just started the turn. Symptom: every first
|
|
6646
|
+
// post-restart message in each thread was held 5 minutes until the
|
|
6647
|
+
// silence-poke fallback drained the buffer; the "5-min blank after
|
|
6648
|
+
// restart" wedge documented in
|
|
6649
|
+
// feedback_5min_restart_wedge_violates_vision.md.
|
|
6650
|
+
//
|
|
6651
|
+
// Why ONLY first-after-restart, not every steady-state message: the
|
|
6652
|
+
// fresh-turn branch fires only when `priorActive = activeStatusReactions.get(key)`
|
|
6653
|
+
// returns null (no controller currently running for this chat+thread).
|
|
6654
|
+
// In a live conversation, the controller from the previous turn
|
|
6655
|
+
// typically hasn't been cleared yet when the user's follow-up
|
|
6656
|
+
// arrives (or follow-ups arrive mid-turn, taking the queued path) —
|
|
6657
|
+
// so the else branch at ~7313 is skipped and the .set never fires.
|
|
6658
|
+
// A fresh container after restart has EMPTY `activeStatusReactions`,
|
|
6659
|
+
// so the very first message in each thread is guaranteed to enter
|
|
6660
|
+
// the fresh-turn branch and trigger the self-block.
|
|
6661
|
+
//
|
|
6662
|
+
// Why snapshot, not move-the-set(): the .set() at ~7357 is embedded
|
|
6663
|
+
// in a coupled init bundle (controller, msgIds, signalTracker.reset,
|
|
6664
|
+
// silencePoke.startTurn, the 👀 reaction emit). Moving only the .set
|
|
6665
|
+
// splits the bundle in ways future maintainers will drift; moving
|
|
6666
|
+
// the WHOLE bundle past the gate changes user-visible ack timing
|
|
6667
|
+
// (👀 wouldn't land until after the gate decides to deliver, hiding
|
|
6668
|
+
// an ack on the buffered path). The snapshot is the minimal precise
|
|
6669
|
+
// fix. Phase 2b's state-machine extraction will revisit this
|
|
6670
|
+
// structurally.
|
|
6671
|
+
const turnInFlightAtReceipt = activeTurnStartedAt.size > 0
|
|
6672
|
+
|
|
6624
6673
|
const access = result.access
|
|
6625
6674
|
const from = ctx.from!
|
|
6626
6675
|
const chat_id = String(ctx.chat!.id)
|
|
@@ -7530,9 +7579,15 @@ async function handleInbound(
|
|
|
7530
7579
|
// idle-drain flush it the instant claude goes idle, where the channel
|
|
7531
7580
|
// notification submits cleanly as a fresh turn. Steering messages are
|
|
7532
7581
|
// exempt — reaching claude mid-turn is the whole point of /steer.
|
|
7582
|
+
//
|
|
7583
|
+
// CRITICAL: turnInFlight reads the snapshot taken at receipt above,
|
|
7584
|
+
// not `activeTurnStartedAt.size > 0` live. The fresh-turn branch at
|
|
7585
|
+
// line ~7357 already populated the Map for THIS inbound's turn;
|
|
7586
|
+
// reading the live size here would self-block (see the comment on
|
|
7587
|
+
// turnInFlightAtReceipt for the wedge symptom this fixes).
|
|
7533
7588
|
if (
|
|
7534
7589
|
decideInboundDelivery({
|
|
7535
|
-
turnInFlight:
|
|
7590
|
+
turnInFlight: turnInFlightAtReceipt,
|
|
7536
7591
|
isSteering,
|
|
7537
7592
|
}) === 'buffer-until-idle'
|
|
7538
7593
|
) {
|
|
@@ -10978,6 +11033,7 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
|
|
|
10978
11033
|
const kb = buildGrantKeysKeyboard(keys, selected)
|
|
10979
11034
|
const text = `<b>Grant capability token — Step 2/3</b>\n\nWhich keys for <code>${escapeHtmlForTg(agent)}</code>?\n<i>Tap to toggle; tap Continue when done.</i>`
|
|
10980
11035
|
if (wizardMsgId != null) {
|
|
11036
|
+
// allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
|
|
10981
11037
|
await ctx.api.editMessageText(chatId, wizardMsgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
|
|
10982
11038
|
} else {
|
|
10983
11039
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
@@ -11001,6 +11057,7 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
|
|
|
11001
11057
|
const text = `<b>Grant capability token — Step 3/3</b>\n\nKeys for <code>${escapeHtmlForTg(state.agent!)}</code>:\n${keyList}\n\nHow long should this grant be valid?`
|
|
11002
11058
|
const msgId = state.wizardMsgId
|
|
11003
11059
|
if (msgId != null) {
|
|
11060
|
+
// allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
|
|
11004
11061
|
await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
|
|
11005
11062
|
} else {
|
|
11006
11063
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
@@ -11025,6 +11082,7 @@ async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<P
|
|
|
11025
11082
|
].join('\n')
|
|
11026
11083
|
const msgId = state.wizardMsgId
|
|
11027
11084
|
if (msgId != null) {
|
|
11085
|
+
// allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
|
|
11028
11086
|
await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
|
|
11029
11087
|
} else {
|
|
11030
11088
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
@@ -13462,6 +13520,7 @@ function handleChecklistUpdate(
|
|
|
13462
13520
|
ts: new Date(ts * 1000).toISOString(),
|
|
13463
13521
|
},
|
|
13464
13522
|
}
|
|
13523
|
+
// allow-broadcast: informational checklist task notification, not turn-driving
|
|
13465
13524
|
ipcServer.broadcast(inboundMsg)
|
|
13466
13525
|
process.stderr.write(
|
|
13467
13526
|
`telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state} user=${userName}\n`,
|
|
@@ -13913,7 +13972,7 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
13913
13972
|
// gateway no longer pre-allocates drafts on inbound, so there is
|
|
13914
13973
|
// nothing to clear at SIGTERM time.
|
|
13915
13974
|
|
|
13916
|
-
//
|
|
13975
|
+
// allow-broadcast: informational shutdown notify — bridges mark themselves disconnected
|
|
13917
13976
|
ipcServer.broadcast({ type: 'status', status: 'gateway_shutting_down' })
|
|
13918
13977
|
|
|
13919
13978
|
// Hard force-exit safety net at budget + 5s. systemd's TimeoutStopSec
|
|
@@ -94,7 +94,10 @@ export interface PtyHandlerDeps {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function streamKey(chatId: string, threadId?: number): string {
|
|
97
|
-
|
|
97
|
+
// Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
|
|
98
|
+
// expression in lockstep (treats 0/null/undefined the same). See #1564.
|
|
99
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
100
|
+
return `${chatId}:${t}`
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/**
|
|
@@ -313,7 +313,12 @@ function streamKey(
|
|
|
313
313
|
lane?: string,
|
|
314
314
|
turnKey?: string,
|
|
315
315
|
): string {
|
|
316
|
-
|
|
316
|
+
// Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
|
|
317
|
+
// expression in lockstep with that helper (treats 0/null/undefined the
|
|
318
|
+
// same), but inline here so this file doesn't introduce a cross-package
|
|
319
|
+
// import for one expression. See #1564 for the sibling-key bug class.
|
|
320
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
321
|
+
const base = `${chatId}:${t}`
|
|
317
322
|
const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base
|
|
318
323
|
return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane
|
|
319
324
|
}
|
|
@@ -50,7 +50,11 @@ function statusKey(chatId: string, threadId?: number): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function streamKey(chatId: string, threadId?: number): string {
|
|
53
|
-
|
|
53
|
+
// Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
|
|
54
|
+
// 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
|
|
55
|
+
// here would let the harness pass with a key shape production rejects.
|
|
56
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
57
|
+
return `${chatId}:${t}`
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
interface PluginState {
|
|
@@ -29,7 +29,11 @@ function statusKey(chatId: string, threadId?: number): string {
|
|
|
29
29
|
return `${chatId}:${threadId ?? '_'}`
|
|
30
30
|
}
|
|
31
31
|
function streamKey(chatId: string, threadId?: number): string {
|
|
32
|
-
|
|
32
|
+
// Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
|
|
33
|
+
// 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
|
|
34
|
+
// here would let the harness pass with a key shape production rejects.
|
|
35
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
36
|
+
return `${chatId}:${t}`
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
interface PluginState {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — always-on vision: first message after restart replies
|
|
3
|
+
* quickly, NOT 5 minutes later via silence-poke fallback.
|
|
4
|
+
*
|
|
5
|
+
* Vision (`reference/vision.md`, see [[project_vision_reanchor_human_assistants]]):
|
|
6
|
+
* agents are always-on specialist exec-assistants. A 5-min blank
|
|
7
|
+
* window on the first message after restart is what BROKEN feels
|
|
8
|
+
* like to a user trying to use their assistant.
|
|
9
|
+
*
|
|
10
|
+
* ## The wedge this guards against
|
|
11
|
+
*
|
|
12
|
+
* Pre-v0.12.22, every agent restart produced ~5 min blank on the
|
|
13
|
+
* first user message in each thread:
|
|
14
|
+
*
|
|
15
|
+
* 1. User sends msg → handleInbound runs the fresh-turn branch
|
|
16
|
+
* → activeTurnStartedAt.set(key, now) at gateway.ts:7357
|
|
17
|
+
* 2. The #1556 delivery gate further down (~7551) checks
|
|
18
|
+
* activeTurnStartedAt.size > 0 to decide if a turn is "already
|
|
19
|
+
* in flight" — sees the entry it just wrote → buffer-until-idle
|
|
20
|
+
* 3. Inbound stuck in pendingInboundBuffer. Bridge never sees it.
|
|
21
|
+
* Claude never replies. activeTurnStartedAt[key] stays set.
|
|
22
|
+
* 4. ~300s later silence-poke framework-fallback fires, drains
|
|
23
|
+
* the buffer, the reply finally lands — five minutes late.
|
|
24
|
+
*
|
|
25
|
+
* Documented in `feedback_5min_restart_wedge_violates_vision.md`.
|
|
26
|
+
* Fix is a one-line snapshot of the live size at receipt-time before
|
|
27
|
+
* the fresh-turn branch mutates the Map.
|
|
28
|
+
*
|
|
29
|
+
* ## What this UAT asserts
|
|
30
|
+
*
|
|
31
|
+
* After a deliberate restart, the FIRST message in a fresh thread
|
|
32
|
+
* gets a reply within a budget that excludes the silence-poke
|
|
33
|
+
* fallback floor (300s). Concretely we assert < 120s, which is
|
|
34
|
+
* generous for slow LLM replies but well below the wedge symptom.
|
|
35
|
+
*
|
|
36
|
+
* The test also makes a stricter observation log so a future
|
|
37
|
+
* regression that lands BETWEEN "wedge fixed" (~LLM latency) and
|
|
38
|
+
* "wedge present" (~5 min) is visible — e.g. some slow startup
|
|
39
|
+
* path that takes 60-90s would pass the contract but bear
|
|
40
|
+
* investigation.
|
|
41
|
+
*
|
|
42
|
+
* ## Why this scenario specifically
|
|
43
|
+
*
|
|
44
|
+
* - `smoke-dm-reply.test.ts` covers steady-state inbound→reply but
|
|
45
|
+
* does NOT restart the agent first, so the wedge surfaces as a
|
|
46
|
+
* "first message is slow" pattern that the smoke test would
|
|
47
|
+
* silently absorb on warm runs.
|
|
48
|
+
* - `silent-end-recovery-dm.test.ts` covers mid-turn silent-end →
|
|
49
|
+
* no-reply, but at 6 min budget — too generous to catch the
|
|
50
|
+
* 5-min wedge as a regression.
|
|
51
|
+
* - This is the FIRST UAT to explicitly tie the assertion to the
|
|
52
|
+
* "always-on" vision and measure first-after-restart TTFO.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
56
|
+
import { execSync } from "node:child_process";
|
|
57
|
+
import { spinUp } from "../harness.js";
|
|
58
|
+
|
|
59
|
+
const AGENT = "test-harness";
|
|
60
|
+
|
|
61
|
+
// Budget for the marker-safe restart itself (per
|
|
62
|
+
// feedback_agent_restart_needs_sudo_when_running.md, restart blocks
|
|
63
|
+
// ~30s as the gateway's bridge reattaches).
|
|
64
|
+
const RESTART_BUDGET_MS = 90_000;
|
|
65
|
+
|
|
66
|
+
// Hard contract: first-after-restart reply must land in under 2 min.
|
|
67
|
+
// This is generous for slow LLM replies but well below the 5-min
|
|
68
|
+
// silence-poke fallback floor — a regression of the #1556 wedge
|
|
69
|
+
// would trip on TTFO ≥ 300s and fail the test.
|
|
70
|
+
const HARD_REPLY_BUDGET_MS = 120_000;
|
|
71
|
+
|
|
72
|
+
// Vision-aligned target: real expectation is well under 30s on a
|
|
73
|
+
// healthy fleet. A pass between 30-120s is yellow — covered by the
|
|
74
|
+
// contract but worth logging for forensic visibility.
|
|
75
|
+
const VISION_REPLY_BUDGET_MS = 30_000;
|
|
76
|
+
|
|
77
|
+
function canShellSudo(): boolean {
|
|
78
|
+
try {
|
|
79
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function restartAgent(name: string): void {
|
|
87
|
+
// Marker-safe restart per memory feedback_compose_rollout.md +
|
|
88
|
+
// feedback_agent_restart_needs_sudo_when_running.md. Apply step
|
|
89
|
+
// self-elevates internally; restart needs the wrapper. We don't
|
|
90
|
+
// call apply here — the agent scaffolds are already current; only
|
|
91
|
+
// the in-memory state needs to reset.
|
|
92
|
+
execSync(
|
|
93
|
+
`sudo -n env PATH=$PATH HOME=$HOME switchroom agent restart ${name} --force`,
|
|
94
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: RESTART_BUDGET_MS },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// This scenario requires NOPASSWD sudo + the switchroom CLI on PATH on
|
|
99
|
+
// the harness host. Skip on CI runners that don't expose those.
|
|
100
|
+
const sudoOk = canShellSudo();
|
|
101
|
+
|
|
102
|
+
(sudoOk ? describe : describe.skip)(
|
|
103
|
+
"uat: always-on after restart",
|
|
104
|
+
() => {
|
|
105
|
+
beforeAll(() => {
|
|
106
|
+
restartAgent(AGENT);
|
|
107
|
+
// Brief settle so the bridge sidecar finishes its reattach
|
|
108
|
+
// before we send the first inbound. The bridge-register log
|
|
109
|
+
// line is the earliest the agent can accept inbound.
|
|
110
|
+
return new Promise((r) => setTimeout(r, 5_000));
|
|
111
|
+
}, RESTART_BUDGET_MS + 10_000);
|
|
112
|
+
|
|
113
|
+
it(
|
|
114
|
+
"first message after fresh restart → reply within 2 min (NOT the 5-min wedge)",
|
|
115
|
+
async () => {
|
|
116
|
+
const sc = await spinUp({ agent: AGENT });
|
|
117
|
+
try {
|
|
118
|
+
const sendStart = Date.now();
|
|
119
|
+
await sc.sendDM("ping — JTBD always-on UAT");
|
|
120
|
+
|
|
121
|
+
const firstReply = await sc.expectMessage(/\S/, {
|
|
122
|
+
from: "bot",
|
|
123
|
+
timeout: HARD_REPLY_BUDGET_MS,
|
|
124
|
+
});
|
|
125
|
+
const ttfo = Date.now() - sendStart;
|
|
126
|
+
|
|
127
|
+
expect(firstReply.text.length).toBeGreaterThan(0);
|
|
128
|
+
|
|
129
|
+
// HARD CONTRACT: the wedge symptom is 300s+ TTFO. Anything
|
|
130
|
+
// ≥ HARD_REPLY_BUDGET_MS (120s) flags a regression of the
|
|
131
|
+
// #1556 self-blocking gate.
|
|
132
|
+
if (ttfo >= HARD_REPLY_BUDGET_MS) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`[always-on] first-post-restart reply took ${ttfo}ms — ` +
|
|
135
|
+
`matches the #1556 wedge symptom (5-min silence-poke fallback). ` +
|
|
136
|
+
`Vision broken; see feedback_5min_restart_wedge_violates_vision.md`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
expect(ttfo).toBeLessThan(HARD_REPLY_BUDGET_MS);
|
|
140
|
+
|
|
141
|
+
// Yellow-band log: passes the contract but degraded from the
|
|
142
|
+
// vision target. Worth investigating if this fires regularly.
|
|
143
|
+
if (ttfo >= VISION_REPLY_BUDGET_MS) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[always-on] first-post-restart TTFO=${ttfo}ms — passed ` +
|
|
146
|
+
`contract (${HARD_REPLY_BUDGET_MS}ms) but slower than the ` +
|
|
147
|
+
`vision target (${VISION_REPLY_BUDGET_MS}ms). Forensic flag.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
await sc.tearDown();
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
HARD_REPLY_BUDGET_MS + 10_000,
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
);
|