switchroom 0.18.32 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- 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/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/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/gateway.ts +1347 -6758
- 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-wiring.ts +333 -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/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- 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/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 +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -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 +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- 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-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- 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 +27 -9
- 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/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- 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/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 +28 -18
- 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 +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -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 +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- 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 +10 -4
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* liveness-wiring.ts — the silence-poke timer options (#2996 P8 PR-C3).
|
|
3
|
+
*
|
|
4
|
+
* Extracted VERBATIM from gateway.ts: the `silencePoke.startTimer({...})`
|
|
5
|
+
* options object — `floorState`, the approval-blocked `onMidTurnFloor`
|
|
6
|
+
* re-ping, and the 300 s `onFrameworkFallback` unwedge (turn teardown, stale
|
|
7
|
+
* sibling purge, keyed clear, buffer self-heal). The gateway builds the deps
|
|
8
|
+
* (`gatewayLivenessWiringDeps()`), calls `buildSilencePokeOptions(deps)` and
|
|
9
|
+
* REGISTERS the timer itself (P0c: modules export the body; the gateway owns
|
|
10
|
+
* the timer / boot side effect).
|
|
11
|
+
*
|
|
12
|
+
* The two user-visible sends cross as the injected `sendSilenceText` closure
|
|
13
|
+
* so the `bot.api` touch stays behind the gateway retry-policy allowlist.
|
|
14
|
+
* Volatile reads (`currentTurn`, `inFlightUpdate`, `turnsDb`, `inboundSpool`,
|
|
15
|
+
* `ipcServer`) cross as getters. Module-level collaborators that are already
|
|
16
|
+
* extracted (`silence-poke`, `turn-signal-tracker`, `pending-work-progress`,
|
|
17
|
+
* `turn-state-purge`, registry, metrics) are imported directly.
|
|
18
|
+
*/
|
|
19
|
+
import type { LivenessWiringDeps } from './gateway.js'
|
|
20
|
+
import * as silencePoke from '../silence-poke.js'
|
|
21
|
+
import * as signalTracker from '../turn-signal-tracker.js'
|
|
22
|
+
import * as pendingProgress from '../pending-work-progress.js'
|
|
23
|
+
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
24
|
+
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
25
|
+
import { clearSilentEndState } from '../silent-end.js'
|
|
26
|
+
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
27
|
+
import { removeTurnActiveMarker } from './turn-active-marker.js'
|
|
28
|
+
import { recordTurnEnd } from '../registry/turns-schema.js'
|
|
29
|
+
import { hostdGetStatusOnce } from './hostd-dispatch.js'
|
|
30
|
+
import { formatUpdateStatusLine } from './update-status-line.js'
|
|
31
|
+
import { formatTurnLifecycle } from './status-surface-log.js'
|
|
32
|
+
import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
|
|
33
|
+
|
|
34
|
+
export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<typeof silencePoke.startTimer>[0] {
|
|
35
|
+
const {
|
|
36
|
+
SILENCE_FALLBACK_MS,
|
|
37
|
+
SILENCE_FALLBACK_HARD_MS,
|
|
38
|
+
SILENCE_FLOOR_MS,
|
|
39
|
+
SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
40
|
+
TURN_PREVIEW_MAX,
|
|
41
|
+
STATE_DIR,
|
|
42
|
+
isLegitimatelyWorking,
|
|
43
|
+
getCurrentTurn,
|
|
44
|
+
getInFlightUpdate,
|
|
45
|
+
getTurnsDb,
|
|
46
|
+
getInboundSpool,
|
|
47
|
+
sendToAgent,
|
|
48
|
+
sendSilenceText,
|
|
49
|
+
getMyAgentName,
|
|
50
|
+
statusKey,
|
|
51
|
+
activeTurnStartedAt,
|
|
52
|
+
activeStatusReactions,
|
|
53
|
+
lastPtyPreviewByChat,
|
|
54
|
+
preambleSuppressor,
|
|
55
|
+
purgeReactionTracking,
|
|
56
|
+
currentTurnMap,
|
|
57
|
+
turnLiveForItsTopic,
|
|
58
|
+
endCurrentTurnForKey,
|
|
59
|
+
getPendingInboundBuffer,
|
|
60
|
+
trackRedeliveredInbound,
|
|
61
|
+
closeActivityLane,
|
|
62
|
+
closeProgressLane,
|
|
63
|
+
} = deps
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
|
|
67
|
+
deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
68
|
+
isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
|
|
69
|
+
emitMetric: (event) => {
|
|
70
|
+
// Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
|
|
71
|
+
emitRuntimeMetric(event)
|
|
72
|
+
},
|
|
73
|
+
// #2527 — the gateway-owned half of the mid-turn-floor decision: only the
|
|
74
|
+
// live turn knows its loop role + whether a substantive answer has landed.
|
|
75
|
+
// Keyed on statusKey so a DM (threadId null) and a forum topic are identical.
|
|
76
|
+
floorState: (key) => {
|
|
77
|
+
const turn = getCurrentTurn()
|
|
78
|
+
if (turn == null) return null
|
|
79
|
+
if (statusKey(turn.sessionChatId, turn.sessionThreadId) !== key) return null
|
|
80
|
+
return { role: turn.role, finalAnswerDelivered: turn.finalAnswerDelivered }
|
|
81
|
+
},
|
|
82
|
+
// Phase 3 (reference/rfcs/deterministic-turn-liveness.md): the busy-but-
|
|
83
|
+
// silent TEXT floor is retired. The climbing card (Phase 1 — the 0-label
|
|
84
|
+
// branch of `feedHeartbeatTick` / `runSilentTurnHeartbeatTick` above) IS
|
|
85
|
+
// the mid-turn floor now: it edits the already-open activity card's
|
|
86
|
+
// elapsed clock every `FEED_HEARTBEAT_MIN_STALE_MS`, model-independent, no
|
|
87
|
+
// ping. This handler still decides/fires via `decideMidTurnFloor` (the
|
|
88
|
+
// timing + role + fire-once machinery in `silence-poke.ts` /
|
|
89
|
+
// `turn-liveness-floor.ts` is unchanged and still load-bearing — see
|
|
90
|
+
// below), but its ONLY surviving delivery is the approval-blocked re-ping.
|
|
91
|
+
// Do not re-add a generic "still working…" text send here believing this
|
|
92
|
+
// path covers the silent-tool case; it does not, and re-adding one would
|
|
93
|
+
// reintroduce the exact banned cadence ping `conversational-pacing.md`
|
|
94
|
+
// retired in #2667.
|
|
95
|
+
onMidTurnFloor: async (ctx) => {
|
|
96
|
+
// Late-fire guard, mirroring the fallback: a clean turn-end can race the
|
|
97
|
+
// tick. If the turn is gone, stay silent.
|
|
98
|
+
if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) return
|
|
99
|
+
const blockedOnApproval = activeStatusReactions
|
|
100
|
+
.get(statusKey(ctx.chatId, ctx.threadId))
|
|
101
|
+
?.isAwaiting() ?? false
|
|
102
|
+
// The one surviving text case: the turn is parked on an approval card
|
|
103
|
+
// waiting for YOUR tap. That's not a stall — it names the real blocker —
|
|
104
|
+
// so it keeps this quiet re-ping (`conversational-pacing.md` § Silence-
|
|
105
|
+
// poke fallback names this one of the two surviving honest cases). The
|
|
106
|
+
// busy-but-silent (non-approval) case sends nothing here; the card climb
|
|
107
|
+
// (Phase 1) is its only signal now.
|
|
108
|
+
if (!blockedOnApproval) return
|
|
109
|
+
const text = silencePoke.formatFrameworkFallbackText(
|
|
110
|
+
'working',
|
|
111
|
+
ctx.silenceMs,
|
|
112
|
+
ctx.inFlightTools,
|
|
113
|
+
blockedOnApproval,
|
|
114
|
+
)
|
|
115
|
+
if (text == null) return
|
|
116
|
+
try {
|
|
117
|
+
// The quiet beat: visible in-thread, no device buzz. (The 300s
|
|
118
|
+
// fallback pings; the floor must not train the user to mute.) The send
|
|
119
|
+
// stays in gateway.ts behind the bot-api retry policy (sendSilenceText).
|
|
120
|
+
await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, true)
|
|
121
|
+
// Count it as production so the silence clock resets — the user just
|
|
122
|
+
// saw a real message, so the 300s loud fallback is measured from here.
|
|
123
|
+
silencePoke.noteProduction(ctx.key, Date.now())
|
|
124
|
+
} catch (err) {
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`silence-poke mid-turn floor sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
onFrameworkFallback: async (ctx) => {
|
|
131
|
+
// Late-fire short-circuit (2026-05-23 audit finding). The fallback
|
|
132
|
+
// can race a clean turn-end: the model's actual reply lands inside
|
|
133
|
+
// the silence window's final ~50ms, the canonical turn-end path
|
|
134
|
+
// clears `activeTurnStartedAt` and nulls `currentTurn`, and then
|
|
135
|
+
// this handler fires anyway. Without this check we emit a noisy
|
|
136
|
+
// "still working…" ping to the user (right after they got their
|
|
137
|
+
// real reply) AND a misleading "ended wedged turn ... currentTurn_
|
|
138
|
+
// nulled=false drained_buffered=0/0" log line. The 7-day audit
|
|
139
|
+
// showed this race accounts for ~90% of all framework_fallback log
|
|
140
|
+
// events (124 of 138 `currentTurn_nulled=false` cases). Distinct
|
|
141
|
+
// log line so observability still tracks the fact that the silence
|
|
142
|
+
// crossed threshold; the wedge counter is no longer polluted.
|
|
143
|
+
if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) {
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
`telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
|
|
146
|
+
`turn ended cleanly during silence window ` +
|
|
147
|
+
`chat=${ctx.chatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs}\n`,
|
|
148
|
+
)
|
|
149
|
+
// #2527: structured skip event so the late-fire race is machine-readable.
|
|
150
|
+
logStreamingEvent({
|
|
151
|
+
kind: 'silence_poke_skip',
|
|
152
|
+
chatId: ctx.chatId,
|
|
153
|
+
threadId: ctx.threadId ?? undefined,
|
|
154
|
+
silenceMs: ctx.silenceMs,
|
|
155
|
+
skipReason: 'turn_ended_cleanly_during_window',
|
|
156
|
+
})
|
|
157
|
+
// Tell silence-poke this chat-thread is finished so the next
|
|
158
|
+
// arming doesn't carry stale state.
|
|
159
|
+
silencePoke.endTurn(ctx.key)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Deterministic in-flight update status (klanker incident). If this
|
|
164
|
+
// gateway dispatched an update_apply that's still running, the
|
|
165
|
+
// recurring framework fallback carries hostd's REAL phase + elapsed
|
|
166
|
+
// instead of the content-free "still working…". No model: pure
|
|
167
|
+
// get_status snapshot → pure formatter. Any hostd unavailability
|
|
168
|
+
// degrades silently to the existing generic text (zero regression).
|
|
169
|
+
let text: string | null = null
|
|
170
|
+
// Hoisted out of the generic-fallback branch below because the send site
|
|
171
|
+
// gates `disable_notification` on it: when the turn is parked on an
|
|
172
|
+
// approval card, the fallback TEXT is a user-gating re-ping ("waiting for
|
|
173
|
+
// your approval — tap Approve or Deny …"), and that must stay LOUD so the
|
|
174
|
+
// user knows the ball is in their court. The reaction controller tracks the
|
|
175
|
+
// park via setAwaiting on the permission-request.
|
|
176
|
+
const blockedOnApproval = activeStatusReactions
|
|
177
|
+
.get(statusKey(ctx.chatId, ctx.threadId))
|
|
178
|
+
?.isAwaiting() ?? false
|
|
179
|
+
const upd = getInFlightUpdate()
|
|
180
|
+
if (upd != null) {
|
|
181
|
+
try {
|
|
182
|
+
const st = await hostdGetStatusOnce(getMyAgentName(), upd.requestId)
|
|
183
|
+
if (st !== 'not-configured' && st !== 'unavailable') {
|
|
184
|
+
text = formatUpdateStatusLine(st, upd.startedAt, Date.now())
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
/* degrade to generic fallback below */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (text == null) {
|
|
191
|
+
// Wording is load-bearing — extracted to `formatFrameworkFallbackText`
|
|
192
|
+
// in `silence-poke.ts` so it can be snapshot-tested in isolation
|
|
193
|
+
// (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
|
|
194
|
+
// from `ctx.silenceMs` so the wording stays honest if the 300s
|
|
195
|
+
// threshold is tuned.
|
|
196
|
+
// Honesty: if the turn is parked on an approval card (the dominant
|
|
197
|
+
// benign "wedge" class — claude is alive, waiting on the operator's
|
|
198
|
+
// tap), say so instead of "still working…". The reaction controller
|
|
199
|
+
// already tracks this (setAwaiting on the permission-request park).
|
|
200
|
+
text = silencePoke.formatFrameworkFallbackText(
|
|
201
|
+
ctx.fallbackKind,
|
|
202
|
+
ctx.silenceMs,
|
|
203
|
+
ctx.inFlightTools,
|
|
204
|
+
blockedOnApproval,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
// #2527: log the actual poke fire with structured data before sending,
|
|
208
|
+
// so the event is visible even if the send fails. The stall-notice
|
|
209
|
+
// stop-gap is retired (the user-visible send below is now gated), but the
|
|
210
|
+
// fire event is still logged so observability + the unwedge accounting are
|
|
211
|
+
// unchanged — mirrors PR #2641 (drop the chat badge, keep the log).
|
|
212
|
+
logStreamingEvent({
|
|
213
|
+
kind: 'silence_poke_fire',
|
|
214
|
+
chatId: ctx.chatId,
|
|
215
|
+
threadId: ctx.threadId ?? undefined,
|
|
216
|
+
silenceMs: ctx.silenceMs,
|
|
217
|
+
fallbackKind: ctx.fallbackKind,
|
|
218
|
+
})
|
|
219
|
+
// Send a user-visible message ONLY when there's something honest to say:
|
|
220
|
+
// - a deterministic in-flight update_apply phase (`text` set above), or
|
|
221
|
+
// - the approval-blocked re-ping (`formatFrameworkFallbackText` returns a
|
|
222
|
+
// string only for that case).
|
|
223
|
+
// The generic "still working… (no update from agent in N min)" stall notice
|
|
224
|
+
// is retired — `formatFrameworkFallbackText` returns null for it, so `text`
|
|
225
|
+
// stays null and we skip the send. The turn-teardown below (the unwedge —
|
|
226
|
+
// the one job the live draft can't do, per the conversational-pacing RFC)
|
|
227
|
+
// still runs unconditionally.
|
|
228
|
+
if (text != null) {
|
|
229
|
+
try {
|
|
230
|
+
// Conditional: when the turn is parked on an approval card, this
|
|
231
|
+
// fallback carries a user-gating re-ping ("waiting for your
|
|
232
|
+
// approval — tap Approve or Deny …") — that must PING, because the
|
|
233
|
+
// user is the one being waited on. The deterministic update-status
|
|
234
|
+
// line stays SILENT (a status surface). Gate on `blockedOnApproval`.
|
|
235
|
+
// The send stays in gateway.ts behind the bot-api retry policy.
|
|
236
|
+
await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, blockedOnApproval ? false : true)
|
|
237
|
+
} catch (err) {
|
|
238
|
+
process.stderr.write(
|
|
239
|
+
`silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// #1122 follow-up: end the wedged turn AFTER the fallback fires.
|
|
244
|
+
// Without this, `activeTurnStartedAt` stays set, every subsequent
|
|
245
|
+
// inbound is routed as `queued="true" prior_turn_in_progress="true"`
|
|
246
|
+
// (see handleInbound mid-turn branch ~line 6160), and the user's
|
|
247
|
+
// conversation is permanently stuck behind a dead turn no signal
|
|
248
|
+
// will ever close. Caught by the human-style fuzz on 2026-05-13:
|
|
249
|
+
// 23/23 inbounds queued behind msg 599 that never got a turn_ended.
|
|
250
|
+
//
|
|
251
|
+
// Cleanup mirrors the regular reply / silent-marker turn-end paths
|
|
252
|
+
// (lines 4985 / 5274) so a late `turn_end` from claude — once it
|
|
253
|
+
// finally exits the wedge — finds nulled state and bails at the
|
|
254
|
+
// handler-entry `const turn = currentTurn` check (lines 4441,
|
|
255
|
+
// 4593, etc), avoiding a double-emit / stale-turn-close on the
|
|
256
|
+
// next inbound's fresh turn.
|
|
257
|
+
const fbKey = ctx.key
|
|
258
|
+
const fbChatId = ctx.chatId
|
|
259
|
+
const fbThreadId = ctx.threadId ?? undefined
|
|
260
|
+
const wedgedTurn = getCurrentTurn()
|
|
261
|
+
// Match by sessionChatId + sessionThreadId before mutating
|
|
262
|
+
// `currentTurn` — multi-chat agents shouldn't have their CURRENT
|
|
263
|
+
// (different-chat) turn nulled by this fallback.
|
|
264
|
+
const turnMatchesFallback =
|
|
265
|
+
wedgedTurn != null &&
|
|
266
|
+
wedgedTurn.sessionChatId === fbChatId &&
|
|
267
|
+
wedgedTurn.sessionThreadId === fbThreadId
|
|
268
|
+
const turnStartedAt = activeTurnStartedAt.get(fbKey)
|
|
269
|
+
if (turnStartedAt != null) {
|
|
270
|
+
const turnDurationMs = Date.now() - turnStartedAt
|
|
271
|
+
const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
|
|
272
|
+
emitRuntimeMetric({
|
|
273
|
+
kind: 'turn_ended',
|
|
274
|
+
chat_id: fbChatId,
|
|
275
|
+
thread_id: ctx.threadId,
|
|
276
|
+
duration_ms: turnDurationMs,
|
|
277
|
+
ttfo_ms: outboundMetrics.ttfoMs,
|
|
278
|
+
outbound_count: outboundMetrics.outboundCount,
|
|
279
|
+
longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
|
|
280
|
+
ended_via: 'framework_fallback',
|
|
281
|
+
})
|
|
282
|
+
// #1892-follow-up: do NOT clear signalTracker state here. When the
|
|
283
|
+
// model recovers post-fallback (the framework's user-visible
|
|
284
|
+
// "still working" message is the load-bearing unwedge primitive —
|
|
285
|
+
// see `project_silence_poke_broken_and_cross_turn_fix`), its late
|
|
286
|
+
// reply calls fire `signalTracker.noteOutbound(fbKey, ...)`. If
|
|
287
|
+
// state is already cleared, that's a silent no-op and the late
|
|
288
|
+
// reply is invisible to outbound_count + ttfo metrics — the
|
|
289
|
+
// canonical session-end path (silent-marker line 7407 or normal
|
|
290
|
+
// turn-end line 7502) emits turn_ended a second time, reading
|
|
291
|
+
// the empty state and reporting outbound_count=0 even though
|
|
292
|
+
// replies landed. Defer clear to the canonical paths so
|
|
293
|
+
// post-fallback recoveries are correctly counted.
|
|
294
|
+
}
|
|
295
|
+
// Stamp the turn-DB end row as `timeout` so the wedged turn doesn't
|
|
296
|
+
// stay open until a SIGTERM/restart relabels it (false-negative for
|
|
297
|
+
// clean completion). Mirrors the canonical stop-path at line 5250.
|
|
298
|
+
const fbTurnsDb = getTurnsDb()
|
|
299
|
+
if (fbTurnsDb != null && turnMatchesFallback && wedgedTurn?.registryKey != null) {
|
|
300
|
+
const _turnKey = wedgedTurn.registryKey
|
|
301
|
+
const capturedJoined = wedgedTurn.capturedText.join('')
|
|
302
|
+
const assistantReplyPreview = capturedJoined
|
|
303
|
+
? capturedJoined.slice(0, TURN_PREVIEW_MAX)
|
|
304
|
+
: null
|
|
305
|
+
try {
|
|
306
|
+
recordTurnEnd(fbTurnsDb, {
|
|
307
|
+
turnKey: _turnKey,
|
|
308
|
+
endedVia: 'timeout' as const,
|
|
309
|
+
lastAssistantMsgId: wedgedTurn.lastAssistantMsgId,
|
|
310
|
+
lastAssistantDone: wedgedTurn.lastAssistantDone,
|
|
311
|
+
assistantReplyPreview,
|
|
312
|
+
toolCallCount: wedgedTurn.toolCallCount,
|
|
313
|
+
})
|
|
314
|
+
} catch (err) {
|
|
315
|
+
process.stderr.write(
|
|
316
|
+
`telegram gateway: recordTurnEnd(timeout) failed turnKey=${_turnKey}: ${(err as Error).message}\n`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Bridge-watchdog (#412): clear the on-disk turn-active marker so
|
|
321
|
+
// the watchdog doesn't read a stale "turn active" file after a
|
|
322
|
+
// wedge-recovery. Same defence the canonical paths run at 4971,
|
|
323
|
+
// 5270.
|
|
324
|
+
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
325
|
+
// Stream/lane teardown — a pinned progress card or buffered
|
|
326
|
+
// preamble survives the wedge otherwise and lands on the next
|
|
327
|
+
// turn. Mirror the canonical stop-path at 5228-5229; skip
|
|
328
|
+
// closeProgressLane analogue from the silent-marker path because
|
|
329
|
+
// we have no streams to finalize here.
|
|
330
|
+
closeActivityLane(fbChatId, fbThreadId)
|
|
331
|
+
closeProgressLane(fbChatId, fbThreadId)
|
|
332
|
+
lastPtyPreviewByChat.delete(fbKey)
|
|
333
|
+
preambleSuppressor.dropNow()
|
|
334
|
+
// Drop silence-poke state and clear turn-active so the next inbound
|
|
335
|
+
// for this chat starts a fresh turn instead of queueing forever.
|
|
336
|
+
silencePoke.endTurn(fbKey)
|
|
337
|
+
pendingProgress.noteTurnEnd(fbKey)
|
|
338
|
+
purgeReactionTracking(fbKey)
|
|
339
|
+
// Defense-in-depth: the fallback's purgeReactionTracking above
|
|
340
|
+
// clears the canonical statusKey(chatId, threadId) for fbKey
|
|
341
|
+
// only. activeTurnStartedAt can hold sibling entries for the
|
|
342
|
+
// SAME chat (different threads, or a `null` vs `undefined`-thread
|
|
343
|
+
// variant left over from a normal turn-end path that nulled
|
|
344
|
+
// currentTurn without invoking purgeReactionTracking — the
|
|
345
|
+
// gymbro/klanker held-mid-turn symptom, 2026-05-20); sweep them via
|
|
346
|
+
// the same purger. Multi-chat-safe — only touches keys for fbChatId, so
|
|
347
|
+
// #1546's intentional cross-chat safety guard is preserved.
|
|
348
|
+
//
|
|
349
|
+
// BUT a sibling is NOT "by definition stale": in one-agent-owns-supergroup
|
|
350
|
+
// every forum topic shares fbChatId, so a chatId-only sweep would purge a
|
|
351
|
+
// LIVE sibling topic's reaction controller + typing loop when THIS topic's
|
|
352
|
+
// poke fires. Gate each sibling on its OWN silence clock — purge only those
|
|
353
|
+
// also silent ≥ the fallback threshold (their own poke would fire too),
|
|
354
|
+
// sparing topics that are actively mid-turn. Use silence, not turn-start
|
|
355
|
+
// age, so a long-but-narrating turn isn't mistaken for stale.
|
|
356
|
+
// See turn-state-purge.ts.
|
|
357
|
+
const fbNow = Date.now()
|
|
358
|
+
const fbExtraPurge = purgeStaleTurnsForChat(
|
|
359
|
+
fbChatId,
|
|
360
|
+
activeTurnStartedAt.keys(),
|
|
361
|
+
purgeReactionTracking,
|
|
362
|
+
(siblingKey) => {
|
|
363
|
+
if (siblingKey === fbKey) return true // the firing key is genuinely stale
|
|
364
|
+
const sib = silencePoke.silenceMsForKey(siblingKey, fbNow)
|
|
365
|
+
// No silence-poke state → dangling (turn ended, key not purged) → stale.
|
|
366
|
+
return sib == null || sib >= silencePoke.DEFAULT_THRESHOLDS.fallback
|
|
367
|
+
},
|
|
368
|
+
)
|
|
369
|
+
// PR-4e self-heal backstop: drop any per-topic `currentTurnByKey` entries
|
|
370
|
+
// for fbChatId whose turn is stale-by-the-same-silence-gate the sibling
|
|
371
|
+
// sweep above used — the same precedent as `purgeStaleTurnsForChat`'s
|
|
372
|
+
// activeTurnStartedAt sweep, so a leaked map entry (a turn whose keyed
|
|
373
|
+
// delete somehow never ran) can never outlive its chat. Gated identically:
|
|
374
|
+
// the firing key is always stale; a sibling is stale iff it's silent ≥ the
|
|
375
|
+
// fallback threshold (or has no silence state). No-op under the flag OFF
|
|
376
|
+
// (the map is empty).
|
|
377
|
+
currentTurnMap.purgeChatStale(fbChatId, (siblingKey) => {
|
|
378
|
+
if (siblingKey === fbKey) return true
|
|
379
|
+
const sib = silencePoke.silenceMsForKey(siblingKey, fbNow)
|
|
380
|
+
return sib == null || sib >= silencePoke.DEFAULT_THRESHOLDS.fallback
|
|
381
|
+
})
|
|
382
|
+
// Null `currentTurn` if it's still pointing at the wedged turn —
|
|
383
|
+
// when claude eventually fires a late `turn_end` for this session
|
|
384
|
+
// (or never does), the handler's `const turn = currentTurn` snapshot
|
|
385
|
+
// returns null and the regular teardown short-circuits. Without
|
|
386
|
+
// this, the late event would re-emit `turn_ended` AND clobber
|
|
387
|
+
// whatever fresh turn the next inbound started.
|
|
388
|
+
// PR-4e — keyed liveness for the guard. Flag-OFF: `turnLiveForItsTopic`
|
|
389
|
+
// reduces to `currentTurn === wedgedTurn` (singleton mirror), verbatim.
|
|
390
|
+
// Flag-ON: `byKey.get(fbKey) === wedgedTurn`, so the keyed delete still
|
|
391
|
+
// fires when the LIVE mirror has already flipped to another topic B (a bare
|
|
392
|
+
// `currentTurn === wedgedTurn` would falsely skip and leak A's byKey entry).
|
|
393
|
+
if (turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)) {
|
|
394
|
+
// Status-surface observability: emit the lifecycle CLEAR for the
|
|
395
|
+
// silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
|
|
396
|
+
// line like every other clear path (the framework-fallback line below is
|
|
397
|
+
// its own format — this makes the dark-out greppable in the same shape).
|
|
398
|
+
process.stderr.write(
|
|
399
|
+
`telegram gateway: ${formatTurnLifecycle('clear', 'silence_fallback', wedgedTurn, Date.now())}\n`,
|
|
400
|
+
)
|
|
401
|
+
// PR-4e — keyed delete for the wedged turn's OWN key (fbKey == the
|
|
402
|
+
// statusKey this fallback fired for, == the wedgedTurn's key since
|
|
403
|
+
// turnMatchesFallback gated chat+thread equality). Flag-OFF nulls the
|
|
404
|
+
// singleton, verbatim; flag-ON deletes only this topic's entry and clears
|
|
405
|
+
// the mirror iff it still points here — a live sibling topic is untouched.
|
|
406
|
+
endCurrentTurnForKey(wedgedTurn, fbKey)
|
|
407
|
+
}
|
|
408
|
+
// Best-effort: clear any pending silent-end marker so the Stop hook
|
|
409
|
+
// doesn't double-block when claude eventually exits the wedged turn.
|
|
410
|
+
try {
|
|
411
|
+
clearSilentEndState(fbKey)
|
|
412
|
+
} catch { /* best-effort */ }
|
|
413
|
+
// Self-heal the inbound buffer. pendingInboundBuffer otherwise
|
|
414
|
+
// drains ONLY on bridge re-register (onClientRegistered). After a
|
|
415
|
+
// network storm that settles with the bridge STILL connected, user
|
|
416
|
+
// messages buffered during the flap sit forever — until a manual
|
|
417
|
+
// restart forces a re-register (the fleet-update thundering-herd
|
|
418
|
+
// incident, 2026-05-19: agents "not responding", logs show
|
|
419
|
+
// pending-inbound-buffer depth>0 with no drain). Flushing on
|
|
420
|
+
// wedge-clear makes the agent self-heal. selfAgent-keyed; a miss
|
|
421
|
+
// re-buffers so nothing is lost if the bridge is genuinely offline.
|
|
422
|
+
const fbSelfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
423
|
+
const fbRedeliver = redeliverBufferedInbound(
|
|
424
|
+
getPendingInboundBuffer(),
|
|
425
|
+
fbSelfAgent,
|
|
426
|
+
(m) => sendToAgent(fbSelfAgent, m),
|
|
427
|
+
getInboundSpool(),
|
|
428
|
+
trackRedeliveredInbound,
|
|
429
|
+
)
|
|
430
|
+
process.stderr.write(
|
|
431
|
+
`telegram gateway: silence-poke framework-fallback ended wedged turn ` +
|
|
432
|
+
`chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
|
|
433
|
+
`currentTurn_nulled=${turnMatchesFallback} ` +
|
|
434
|
+
`drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
|
|
435
|
+
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
|
|
436
|
+
`${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
|
|
437
|
+
)
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
}
|