switchroom 0.19.4 → 0.19.6
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 +7 -3
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +585 -302
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +128 -259
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -40,6 +40,145 @@ export function handleWorkerResume(
|
|
|
40
40
|
log(`telegram gateway: worker ${agentId} card RE-SURFACED — resumed via SendMessage after a genuine terminal (issue #3373)`)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Worker-feed origin-race defer decision (issue: DM-misrouted worker card).
|
|
45
|
+
*
|
|
46
|
+
* The gateway picks a worker card's destination on the FIRST progress tick of
|
|
47
|
+
* a new sub-agent. That tick can beat the async `jsonl_agent_id` backfill that
|
|
48
|
+
* links the sub-agent's registry row to its origin turn (retried ~every 3s).
|
|
49
|
+
* Until the link lands, origin resolution returns null and the gateway's
|
|
50
|
+
* `resolveWorkerFeedChat` hard-falls back to the owner DM — CREATING the card
|
|
51
|
+
* there. The origin (supergroup + forum topic) resolves ~3s later, but the DM
|
|
52
|
+
* card already exists and stays the visible one.
|
|
53
|
+
*
|
|
54
|
+
* Decision: DEFER card creation while the agent is not yet linked to its
|
|
55
|
+
* origin AND no card exists yet for it. The subagent-watcher re-fires within
|
|
56
|
+
* seconds; once the backfill completes the origin resolves and the card is
|
|
57
|
+
* created in the correct chat+topic. Only CARD CREATION is deferred — if a
|
|
58
|
+
* card already exists, updates always proceed (`defer:false`). A bounded
|
|
59
|
+
* counter caps the wait: after `maxDeferrals` unlinked ticks (pathological
|
|
60
|
+
* backfill failure) it stops deferring so active work always gets a card.
|
|
61
|
+
*
|
|
62
|
+
* Pure so the seam is unit-testable — see worker-feed-dispatch.test.ts. The
|
|
63
|
+
* gateway must never inline this decision again.
|
|
64
|
+
*/
|
|
65
|
+
export function decideWorkerFeedOriginDefer(input: {
|
|
66
|
+
/** True once `resolveSubagentOriginChat` returns a chat (row linked). */
|
|
67
|
+
originResolved: boolean
|
|
68
|
+
/** True if the feed already has a posted message for this worker. */
|
|
69
|
+
cardExists: boolean
|
|
70
|
+
/** Consecutive prior deferrals for this agent (0 on the first tick). */
|
|
71
|
+
priorDeferrals: number
|
|
72
|
+
/** Max consecutive deferrals before painting anyway. */
|
|
73
|
+
maxDeferrals: number
|
|
74
|
+
}): { defer: boolean; deferrals: number } {
|
|
75
|
+
const { originResolved, cardExists, priorDeferrals, maxDeferrals } = input
|
|
76
|
+
// A card already exists, or the origin has resolved: never defer.
|
|
77
|
+
if (originResolved || cardExists) return { defer: false, deferrals: 0 }
|
|
78
|
+
const deferrals = priorDeferrals + 1
|
|
79
|
+
// Bounded: stop deferring once we've waited long enough for the backfill.
|
|
80
|
+
if (deferrals >= maxDeferrals) return { defer: false, deferrals }
|
|
81
|
+
return { defer: true, deferrals }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The FULL worker-feed destination decision for a single onProgress tick,
|
|
86
|
+
* extracted verbatim from the gateway's inline `onProgress` block (issue
|
|
87
|
+
* #3460). It folds two concerns that the gateway used to run inline:
|
|
88
|
+
*
|
|
89
|
+
* 1. the origin-race defer choice (`decideWorkerFeedOriginDefer`), and
|
|
90
|
+
* 2. the chat/thread RESOLUTION the gateway's `resolveWorkerFeedChat`
|
|
91
|
+
* performed (origin chat → fleet chat / stamp-turn fallback → owner DM),
|
|
92
|
+
* including the exhausted-defer stamp-turn forum-thread carry (#3458).
|
|
93
|
+
*
|
|
94
|
+
* Returning a plain decision object lets the gateway keep only a thin
|
|
95
|
+
* delegation (defer-map bookkeeping + the two audit logs + the feed.update)
|
|
96
|
+
* and gives this whole path REAL regression coverage — the test drives THIS
|
|
97
|
+
* function, the same code the gateway runs, instead of a hand-rolled replica.
|
|
98
|
+
*
|
|
99
|
+
* Pure and side-effect-free: the two audit-log side effects the gateway used
|
|
100
|
+
* to emit inline (`exhausted`, `ownerDmFallback`) are returned as flags so the
|
|
101
|
+
* caller performs them against its module-level state. Behavior — routing — is
|
|
102
|
+
* identical to the prior inline path; only the seam moved.
|
|
103
|
+
*
|
|
104
|
+
* Precedence for a PAINT (mirrors `resolveWorkerFeedChat`):
|
|
105
|
+
* origin chat (when resolved, non-empty) → fleet chat, else stamp-turn chat
|
|
106
|
+
* when no fleet chat is configured (carrying the stamp-turn forum topic) →
|
|
107
|
+
* owner DM (durable floor). Never returns an empty chat for a paint unless
|
|
108
|
+
* every source is empty.
|
|
109
|
+
*/
|
|
110
|
+
export type WorkerFeedDestination =
|
|
111
|
+
| { action: 'defer'; deferrals: number }
|
|
112
|
+
| {
|
|
113
|
+
action: 'paint'
|
|
114
|
+
chatId: string
|
|
115
|
+
threadId: number | undefined
|
|
116
|
+
/** New consecutive-deferral count to persist (0 once painting resumes). */
|
|
117
|
+
deferrals: number
|
|
118
|
+
/** True when painting only because the bounded defer cap was hit and the
|
|
119
|
+
* origin never linked — the gateway logs the "never linked" audit line. */
|
|
120
|
+
exhausted: boolean
|
|
121
|
+
/** True when the paint fell all the way to the owner DM (origin unresolved
|
|
122
|
+
* AND no fleet/stamp chat) — the gateway logs the once-per-agent misroute. */
|
|
123
|
+
ownerDmFallback: boolean
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function decideWorkerFeedDestination(input: {
|
|
127
|
+
/** Resolved origin chat/topic (`resolveSubagentOriginChat`), or null if the
|
|
128
|
+
* `jsonl_agent_id` backfill hasn't linked the row to its origin turn yet. */
|
|
129
|
+
origin: { chatId: string; threadId?: number } | null
|
|
130
|
+
/** True if the feed already has a posted message for this worker. */
|
|
131
|
+
cardExists: boolean
|
|
132
|
+
/** Consecutive prior deferrals for this agent (0 on the first tick). */
|
|
133
|
+
priorDeferrals: number
|
|
134
|
+
/** Max consecutive deferrals before painting anyway. */
|
|
135
|
+
maxDeferrals: number
|
|
136
|
+
/** The gateway's outer `fleetChatId` (may be empty). */
|
|
137
|
+
fleetChatId: string
|
|
138
|
+
/** Live turn's chat (`stampTurn.sessionChatId`) — the stamp-turn fallback
|
|
139
|
+
* used only when no fleet chat is configured. */
|
|
140
|
+
stampChatId?: string
|
|
141
|
+
/** Live turn's forum topic (`stampTurn.sessionThreadId`), carried on the
|
|
142
|
+
* stamp-turn fallback so an exhausted-defer paint lands in the origin
|
|
143
|
+
* topic, not General (#3458). */
|
|
144
|
+
stampThreadId?: number
|
|
145
|
+
/** Owner DM chat id (`loadAccess().allowFrom[0]`) — the durable floor. */
|
|
146
|
+
ownerDm: string
|
|
147
|
+
}): WorkerFeedDestination {
|
|
148
|
+
const { origin, cardExists, priorDeferrals, maxDeferrals, fleetChatId, stampChatId, stampThreadId, ownerDm } = input
|
|
149
|
+
const originResolved = origin != null
|
|
150
|
+
const { defer, deferrals } = decideWorkerFeedOriginDefer({
|
|
151
|
+
originResolved,
|
|
152
|
+
cardExists,
|
|
153
|
+
priorDeferrals,
|
|
154
|
+
maxDeferrals,
|
|
155
|
+
})
|
|
156
|
+
if (defer) return { action: 'defer', deferrals }
|
|
157
|
+
// Painting: exhausted-defer iff we're painting despite no origin and no card.
|
|
158
|
+
const exhausted = !originResolved && !cardExists
|
|
159
|
+
// Prefer the live turn's chat/topic over the owner DM when we must fall back
|
|
160
|
+
// (a misroute at least lands near the work) — only when NO fleet chat is set.
|
|
161
|
+
const usingStampFallback = fleetChatId.length === 0
|
|
162
|
+
const workerFleetChatId = usingStampFallback ? stampChatId ?? fleetChatId : fleetChatId
|
|
163
|
+
const fallbackThreadId = usingStampFallback ? stampThreadId : undefined
|
|
164
|
+
// resolveWorkerFeedChat precedence:
|
|
165
|
+
if (origin != null && origin.chatId.length > 0) {
|
|
166
|
+
return { action: 'paint', chatId: origin.chatId, threadId: origin.threadId, deferrals, exhausted, ownerDmFallback: false }
|
|
167
|
+
}
|
|
168
|
+
if (workerFleetChatId.length > 0) {
|
|
169
|
+
return { action: 'paint', chatId: workerFleetChatId, threadId: fallbackThreadId, deferrals, exhausted, ownerDmFallback: false }
|
|
170
|
+
}
|
|
171
|
+
const ownerDmFallback = origin == null && workerFleetChatId.length === 0 && ownerDm.length > 0
|
|
172
|
+
return {
|
|
173
|
+
action: 'paint',
|
|
174
|
+
chatId: ownerDm,
|
|
175
|
+
threadId: origin?.threadId ?? fallbackThreadId,
|
|
176
|
+
deferrals,
|
|
177
|
+
exhausted,
|
|
178
|
+
ownerDmFallback,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
43
182
|
export interface WorkerFeedDispatch {
|
|
44
183
|
/** True when the sub-agent was dispatched with `run_in_background: true`. */
|
|
45
184
|
isBackground: boolean
|
|
@@ -58,7 +58,13 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
|
58
58
|
import { join } from 'node:path'
|
|
59
59
|
import { homedir } from 'node:os'
|
|
60
60
|
|
|
61
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
scanTurnForFinalReply,
|
|
63
|
+
decideStopHookDisposition,
|
|
64
|
+
isTurnFlushSafetyEnabledEnv,
|
|
65
|
+
isCapturedProseDeliveryEnabledEnv,
|
|
66
|
+
isGatewayHeartbeatFresh,
|
|
67
|
+
} from './silent-end-scan.mjs'
|
|
62
68
|
|
|
63
69
|
// MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
|
|
64
70
|
// (this hook is a standalone .mjs and can't import the TS module).
|
|
@@ -77,6 +83,54 @@ function getStateDir() {
|
|
|
77
83
|
return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Build the state-file payload the gateway reads back: carries `turnKey` /
|
|
88
|
+
* `chatId` / `threadId` / per-turn `turnId` nonce and the Option-A
|
|
89
|
+
* `pendingText` bridge, with the given `retryCount`. Stale carryover values
|
|
90
|
+
* from the prior on-disk state (`base`) are explicitly dropped when THIS turn
|
|
91
|
+
* has no derivable nonce / no deliverable prose.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} base Prior on-disk state (spread as a starting point).
|
|
94
|
+
* @param {ReturnType<import('./silent-end-scan.mjs').scanTurnForFinalReply>} decision
|
|
95
|
+
* @param {number} retryCount
|
|
96
|
+
*/
|
|
97
|
+
function buildNextState(base, decision, retryCount) {
|
|
98
|
+
const next = { ...base, retryCount, timestamp: Date.now() }
|
|
99
|
+
if (decision.turnKey) {
|
|
100
|
+
next.turnKey = decision.turnKey
|
|
101
|
+
next.chatId = decision.chatId
|
|
102
|
+
if (decision.threadId != null) next.threadId = decision.threadId
|
|
103
|
+
if (decision.turnId) next.turnId = decision.turnId
|
|
104
|
+
else delete next.turnId
|
|
105
|
+
} else {
|
|
106
|
+
delete next.turnId
|
|
107
|
+
}
|
|
108
|
+
if (typeof decision.pendingText === 'string' && decision.pendingText.length > 0) {
|
|
109
|
+
next.pendingText = decision.pendingText
|
|
110
|
+
} else {
|
|
111
|
+
delete next.pendingText
|
|
112
|
+
}
|
|
113
|
+
return next
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Persist the elected state file (single-writer election allow path).
|
|
118
|
+
* retryCount is left UNCHANGED (this is a hand-off to the gateway's delivery
|
|
119
|
+
* machine, not a re-prompt). Fail-open on write error — an allow never loops.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} statePath
|
|
122
|
+
* @param {object} base
|
|
123
|
+
* @param {ReturnType<import('./silent-end-scan.mjs').scanTurnForFinalReply>} decision
|
|
124
|
+
*/
|
|
125
|
+
function writeElectedState(statePath, base, decision) {
|
|
126
|
+
const retryCount = typeof base.retryCount === 'number' ? base.retryCount : 0
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(statePath, JSON.stringify(buildNextState(base, decision, retryCount)), 'utf8')
|
|
129
|
+
} catch (err) {
|
|
130
|
+
process.stderr.write(`[silent-end-interrupt] failed to write elected state file: ${err.message}\n`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
80
134
|
function main() {
|
|
81
135
|
const raw = readStdin().trim()
|
|
82
136
|
if (!raw) process.exit(0)
|
|
@@ -136,6 +190,34 @@ function main() {
|
|
|
136
190
|
|
|
137
191
|
const retryCount = typeof state.retryCount === 'number' ? state.retryCount : 0
|
|
138
192
|
|
|
193
|
+
// ── Single-writer election (duplicate-message fix) ────────────────
|
|
194
|
+
// On a would-BLOCK scan, ALLOW the stop (while still writing the state
|
|
195
|
+
// file so the gateway's delivery machines have their input) IFF a
|
|
196
|
+
// gateway delivery machine is PROVABLY going to deliver the trailing
|
|
197
|
+
// prose. Otherwise BLOCK exactly as today. See
|
|
198
|
+
// `decideStopHookDisposition` in silent-end-scan.mjs for the four
|
|
199
|
+
// never-drop gates. This eliminates the double-send: the gateway flush
|
|
200
|
+
// / captured-prose bridge is the single writer; the hook no longer
|
|
201
|
+
// re-prompts a reworded reply that defeats the exact-match dedup.
|
|
202
|
+
const disposition = decideStopHookDisposition({
|
|
203
|
+
scan: decision,
|
|
204
|
+
retryCount,
|
|
205
|
+
turnFlushSafetyEnabled: isTurnFlushSafetyEnabledEnv(process.env),
|
|
206
|
+
capturedProseDeliveryEnabled: isCapturedProseDeliveryEnabledEnv(process.env),
|
|
207
|
+
gatewayLive: isGatewayHeartbeatFresh(stateDir),
|
|
208
|
+
})
|
|
209
|
+
if (disposition.action === 'allow-elected') {
|
|
210
|
+
// Persist the state file (turnKey / turnId / pendingText) so the
|
|
211
|
+
// gateway's turn-end path delivers the answer — retryCount stays at 0
|
|
212
|
+
// (this is NOT a re-prompt, it's a hand-off to the single writer).
|
|
213
|
+
writeElectedState(statePath, state, decision)
|
|
214
|
+
process.stderr.write(
|
|
215
|
+
`[silent-end-interrupt] single-writer election ALLOWED stop ` +
|
|
216
|
+
`(scan=${decision.reason} elect=${disposition.reason}) — gateway will deliver\n`,
|
|
217
|
+
)
|
|
218
|
+
process.exit(0)
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
if (retryCount >= MAX_RETRIES) {
|
|
140
222
|
// Budget spent. Let the session end so the gateway's
|
|
141
223
|
// `silent-end.ts:recordUndeliveredTurnEnd` path delivers the
|
|
@@ -161,41 +243,10 @@ function main() {
|
|
|
161
243
|
// doubles the effective re-prompt budget vs. the design. With turnKey
|
|
162
244
|
// present (same chatKey shape the gateway uses), the match succeeds
|
|
163
245
|
// and the budget is honored.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
if (decision.turnKey) {
|
|
170
|
-
nextState.turnKey = decision.turnKey
|
|
171
|
-
nextState.chatId = decision.chatId
|
|
172
|
-
if (decision.threadId != null) {
|
|
173
|
-
nextState.threadId = decision.threadId
|
|
174
|
-
}
|
|
175
|
-
// Per-turn nonce (Finding 3, #3228). The gateway requires this to match
|
|
176
|
-
// the live turn's `turnId` before delivering `pendingText`, so a stale
|
|
177
|
-
// record left over from a prior turn on the same chat/thread can never
|
|
178
|
-
// deliver a previous turn's answer on a later one. Explicitly drop a
|
|
179
|
-
// carried-over `turnId` from the spread `...state` when THIS turn has no
|
|
180
|
-
// derivable nonce, so an old value never lingers.
|
|
181
|
-
if (decision.turnId) nextState.turnId = decision.turnId
|
|
182
|
-
else delete nextState.turnId
|
|
183
|
-
} else {
|
|
184
|
-
delete nextState.turnId
|
|
185
|
-
}
|
|
186
|
-
// Option A transcript-prose bridge: when the scan isolated a substantive
|
|
187
|
-
// final answer the model wrote as plain text but never sent through the
|
|
188
|
-
// reply tool, persist it so the gateway's turn-end path can deliver it
|
|
189
|
-
// directly on the first silent-end (instead of relying on this hook's
|
|
190
|
-
// re-prompt / the obligation represent to eventually recover it). The
|
|
191
|
-
// gateway reads this field back out of the same state file. Explicitly
|
|
192
|
-
// clear a stale carryover value from a prior turn's spread `...state` when
|
|
193
|
-
// THIS turn has no deliverable prose, so an old answer is never re-sent.
|
|
194
|
-
if (typeof decision.pendingText === 'string' && decision.pendingText.length > 0) {
|
|
195
|
-
nextState.pendingText = decision.pendingText
|
|
196
|
-
} else {
|
|
197
|
-
delete nextState.pendingText
|
|
198
|
-
}
|
|
246
|
+
//
|
|
247
|
+
// Per-turn nonce (Finding 3, #3228) and the Option-A `pendingText` bridge
|
|
248
|
+
// are plumbed by `buildNextState`.
|
|
249
|
+
const nextState = buildNextState(state, decision, retryCount + 1)
|
|
199
250
|
try {
|
|
200
251
|
writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
|
|
201
252
|
} catch (err) {
|
|
@@ -55,6 +55,9 @@
|
|
|
55
55
|
// (`done:true`) call as the qualifying one regardless of how many
|
|
56
56
|
// intermediate non-final `stream_reply` calls preceded it. No gap found;
|
|
57
57
|
// re-verify only if a new outbound-delivery tool is added to bridge.ts.
|
|
58
|
+
import { statSync } from 'node:fs'
|
|
59
|
+
import { join } from 'node:path'
|
|
60
|
+
|
|
58
61
|
const REPLY_TOOLS = new Set([
|
|
59
62
|
'mcp__switchroom-telegram__reply',
|
|
60
63
|
'mcp__switchroom-telegram__stream_reply',
|
|
@@ -97,6 +100,68 @@ export function endsWithSilentMarker(text) {
|
|
|
97
100
|
return SILENT_MARKER_RE.test(lines[lines.length - 1])
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
// ── Narration heuristics — ported from `turn-flush-safety.ts:198-274` ──
|
|
104
|
+
//
|
|
105
|
+
// Kept byte-parallel with `selectFlushDeliveryText` / `isNarrationBlock` so
|
|
106
|
+
// the JOINED multi-block prose the scan persists as `pendingText` (for the
|
|
107
|
+
// capture-divergence corner) matches what the gateway flush would itself have
|
|
108
|
+
// delivered. The scan has no structural `followedByToolUse` provenance, so it
|
|
109
|
+
// uses the opener/trailer heuristic fallback (the same branch the TS side
|
|
110
|
+
// takes when the flag is absent). MUST stay in sync with the TS source; a
|
|
111
|
+
// drift only affects the rare capture-empty corner, never the primary flush.
|
|
112
|
+
const NARRATION_OPENER =
|
|
113
|
+
/^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
|
|
114
|
+
const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
|
|
115
|
+
|
|
116
|
+
function isTrailingNarrationLine(block) {
|
|
117
|
+
const t = block.trim()
|
|
118
|
+
if (t.length === 0 || t.length >= FINAL_ANSWER_MIN_CHARS) return false
|
|
119
|
+
if (t.includes('\n')) return false
|
|
120
|
+
return NARRATION_TRAILER.test(t)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isNarrationBlock(block) {
|
|
124
|
+
return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Choose the prose the captured-prose bridge should deliver from the trailing
|
|
129
|
+
* text blocks in the ZERO-reply case, mirroring `selectFlushDeliveryText`
|
|
130
|
+
* (`turn-flush-safety.ts:198-232`). `texts` are already trimmed non-empty.
|
|
131
|
+
*
|
|
132
|
+
* - 0 blocks → undefined (nothing to deliver).
|
|
133
|
+
* - 1 block → that block (the real single-block short-answer shape;
|
|
134
|
+
* persisted even below the substance floor so the lowered-
|
|
135
|
+
* floor capture-divergence corner can deliver it).
|
|
136
|
+
* - ≥2 blocks → strip leading narration exactly as the flush does: if EVERY
|
|
137
|
+
* preceding block is narration, deliver only the terminal
|
|
138
|
+
* block; otherwise JOIN all blocks with a paragraph break (a
|
|
139
|
+
* genuine multi-paragraph answer split across blocks). When
|
|
140
|
+
* the result is narration-only (terminal block is itself
|
|
141
|
+
* narration AND all preceding are narration) → undefined, so a
|
|
142
|
+
* pure narration run never masquerades as an answer (#3228
|
|
143
|
+
* Finding 2 parity).
|
|
144
|
+
*
|
|
145
|
+
* @param {string[]} texts
|
|
146
|
+
* @returns {string | undefined}
|
|
147
|
+
*/
|
|
148
|
+
export function selectBridgePendingText(texts) {
|
|
149
|
+
const candidates = texts.map((t) => t.trim()).filter((t) => t.length > 0)
|
|
150
|
+
if (candidates.length === 0) return undefined
|
|
151
|
+
if (candidates.length === 1) return candidates[0]
|
|
152
|
+
const answer = candidates[candidates.length - 1]
|
|
153
|
+
const preceding = candidates.slice(0, -1)
|
|
154
|
+
const allPrecedingNarration = preceding.every((b) => isNarrationBlock(b))
|
|
155
|
+
if (allPrecedingNarration) {
|
|
156
|
+
// Deliver only the terminal block — unless it too is pure narration, in
|
|
157
|
+
// which case the whole run is narration and there is no answer to bridge.
|
|
158
|
+
return isNarrationBlock(answer) ? undefined : answer
|
|
159
|
+
}
|
|
160
|
+
// A preceding block carries real content → keep the whole answer joined so a
|
|
161
|
+
// multi-block answer is never truncated to its last paragraph.
|
|
162
|
+
return candidates.join('\n\n')
|
|
163
|
+
}
|
|
164
|
+
|
|
100
165
|
/**
|
|
101
166
|
* Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
|
|
102
167
|
* Kept in this .mjs so the hook is fully self-contained (no TS import).
|
|
@@ -193,8 +258,16 @@ function buildTurnId(chatId, threadId, messageId) {
|
|
|
193
258
|
* clears the substance floor, so the gateway never re-delivers a short
|
|
194
259
|
* trailing pleasantry. Omitted entirely otherwise.
|
|
195
260
|
*/
|
|
196
|
-
function buildBlockResult(envelope, reason, pendingText) {
|
|
261
|
+
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse) {
|
|
197
262
|
const block = { decided: 'block', reason }
|
|
263
|
+
// Single-writer election input (#duplicate-message fix): does ANY
|
|
264
|
+
// non-empty, non-silent trailing text block exist after the last
|
|
265
|
+
// delivery event? The zero-reply election allows only when this is
|
|
266
|
+
// true — `decideTurnFlush` has no length floor, so any non-empty
|
|
267
|
+
// non-silent captured text WILL flush; but a turn with NO trailing
|
|
268
|
+
// prose at all (tool calls only) has nothing for any delivery
|
|
269
|
+
// machine to send and must keep blocking.
|
|
270
|
+
if (hasTrailingProse === true) block.hasTrailingProse = true
|
|
198
271
|
if (envelope.chatId) {
|
|
199
272
|
block.chatId = envelope.chatId
|
|
200
273
|
block.threadId = envelope.threadId
|
|
@@ -432,6 +505,32 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
432
505
|
substantiveBlocks.length > 0
|
|
433
506
|
? substantiveBlocks[substantiveBlocks.length - 1].text
|
|
434
507
|
: undefined
|
|
508
|
+
const trailingTextBlocks = undeliveredSlice.filter(
|
|
509
|
+
(b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0,
|
|
510
|
+
)
|
|
511
|
+
const hasTrailingProse = trailingTextBlocks.length > 0
|
|
512
|
+
// Capture-divergence bridge (#duplicate-message fix): in the ZERO-reply
|
|
513
|
+
// case, persist the last trailing block even when no single block clears
|
|
514
|
+
// the 200-char substance floor. The gateway's flush normally delivers any
|
|
515
|
+
// non-empty captured text, but when the gateway's own capture diverged
|
|
516
|
+
// (captured empty) the captured-prose bridge is the only delivery machine
|
|
517
|
+
// left, and it reads `pendingText` — the gateway lowers `minChars` for
|
|
518
|
+
// exactly this corner (`capturedProseMinCharsFor`, silent-end.ts). The
|
|
519
|
+
// interim-ack case (`trailing-text-after-reply`) keeps the substantive
|
|
520
|
+
// floor unchanged — a short closer after a real reply is not a dropped
|
|
521
|
+
// answer.
|
|
522
|
+
//
|
|
523
|
+
// Multi-block corner (review item 3): a real answer split across ≥2
|
|
524
|
+
// individually-sub-200 blocks (e.g. two ~150-char paragraphs) would
|
|
525
|
+
// otherwise yield NO pendingText — and in the capture-divergence-empty
|
|
526
|
+
// corner (gateway `capturedText` empty → flush skips 'empty-text') the
|
|
527
|
+
// bridge would then have nothing to deliver and the hook already allowed the
|
|
528
|
+
// stop: a DROPPED ANSWER. `selectBridgePendingText` mirrors the flush's own
|
|
529
|
+
// `selectFlushDeliveryText` narration-strip/join, so the bridge delivers the
|
|
530
|
+
// joined prose (with the lowered `minChars`) instead of dropping. The #3228
|
|
531
|
+
// Finding 2 guard is preserved: a pure narration run still yields undefined.
|
|
532
|
+
const zeroReplyPendingText =
|
|
533
|
+
pendingText ?? selectBridgePendingText(trailingTextBlocks.map((b) => b.text))
|
|
435
534
|
|
|
436
535
|
if (lastAllowBlockIdx === -1) {
|
|
437
536
|
// No qualifying delivery/silence event anywhere in the turn.
|
|
@@ -444,7 +543,7 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
444
543
|
if (envelope.source === 'cron') {
|
|
445
544
|
return { decided: 'allow', reason: 'cron-source' }
|
|
446
545
|
}
|
|
447
|
-
return buildBlockResult(envelope, 'no-final-reply',
|
|
546
|
+
return buildBlockResult(envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse)
|
|
448
547
|
}
|
|
449
548
|
|
|
450
549
|
if (sawUndeliveredTextAfterAllow) {
|
|
@@ -453,8 +552,169 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
453
552
|
// sent through a delivery tool. This is the "at least once" bug:
|
|
454
553
|
// an early ack (or any qualifying reply) must not amnesty
|
|
455
554
|
// everything written afterward.
|
|
456
|
-
return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText)
|
|
555
|
+
return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse)
|
|
457
556
|
}
|
|
458
557
|
|
|
459
558
|
return { decided: 'allow', reason: lastAllowReason }
|
|
460
559
|
}
|
|
560
|
+
|
|
561
|
+
// ── Single-writer election (duplicate-message fix) ───────────────────
|
|
562
|
+
//
|
|
563
|
+
// RCA: when a turn ends with its final answer as plain transcript text,
|
|
564
|
+
// TWO uncoordinated recovery paths both fire — (A) the gateway's
|
|
565
|
+
// deterministic turn-end flush (`decideTurnFlush` /
|
|
566
|
+
// `answer-ready-flush.ts`) delivers the captured transcript prose, AND
|
|
567
|
+
// (B) this Stop hook blocks and re-prompts the model, which regenerates
|
|
568
|
+
// a REWORDED reply that defeats the exact-match dedup
|
|
569
|
+
// (`flushed-turn-supersede.ts` `flushedAnswerMatchesReply`). The user
|
|
570
|
+
// gets two near-identical messages.
|
|
571
|
+
//
|
|
572
|
+
// Fix: the Stop hook is the single elector. On a would-BLOCK scan it
|
|
573
|
+
// ALLOWS the stop (still persisting the state file so the gateway's
|
|
574
|
+
// delivery machines have their input) IFF it can PROVE a gateway
|
|
575
|
+
// delivery machine will handle the trailing prose. Every gate below
|
|
576
|
+
// exists to prevent a DROP (worse than a duplicate):
|
|
577
|
+
//
|
|
578
|
+
// 1. deliverable trailing prose exists AND the right machine covers it
|
|
579
|
+
// - zero-reply (`no-final-reply`): ANY non-empty non-silent
|
|
580
|
+
// trailing prose — `decideTurnFlush` has no length floor and
|
|
581
|
+
// flushes any non-empty non-silent captured text. (No 200-char
|
|
582
|
+
// floor here.)
|
|
583
|
+
// - interim-ack (`trailing-text-after-reply`, replyCalled=true):
|
|
584
|
+
// ONLY the captured-prose bridge delivers there (the flush skips
|
|
585
|
+
// on reply-called), and its floor is 200 — so short trailing
|
|
586
|
+
// text after a real reply keeps BLOCKING (no duplicate exists in
|
|
587
|
+
// that case today).
|
|
588
|
+
// 2. the governing delivery flag is enabled (read by the hook from
|
|
589
|
+
// its own env): zero-reply needs the turn-flush-safety flag AND
|
|
590
|
+
// the captured-prose flag (the bridge is the capture-divergence
|
|
591
|
+
// backstop when the gateway captured empty); interim-ack needs the
|
|
592
|
+
// captured-prose flag. Flag off → the machine won't fire → BLOCK.
|
|
593
|
+
// 3. retryCount === 0 — a prior failed delivery must fall back to
|
|
594
|
+
// today's BLOCK, preserving the #3228 send-failure recovery net.
|
|
595
|
+
// 4. gateway liveness — the gateway heartbeat file must be FRESH.
|
|
596
|
+
// Allowing into a dead gateway is the one drop worse than a
|
|
597
|
+
// duplicate; when liveness can't be established, BLOCK.
|
|
598
|
+
//
|
|
599
|
+
// Pure and deterministic: the hook wrapper does the IO (env read, stat,
|
|
600
|
+
// state-file write) and maps this verdict to exit-0-allow vs
|
|
601
|
+
// decision:block.
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* @param {{
|
|
605
|
+
* scan: ReturnType<typeof scanTurnForFinalReply>,
|
|
606
|
+
* retryCount: number,
|
|
607
|
+
* turnFlushSafetyEnabled: boolean,
|
|
608
|
+
* capturedProseDeliveryEnabled: boolean,
|
|
609
|
+
* gatewayLive: boolean,
|
|
610
|
+
* }} input
|
|
611
|
+
* @returns {{ action: 'allow-scan' | 'allow-elected' | 'block', reason: string }}
|
|
612
|
+
*/
|
|
613
|
+
export function decideStopHookDisposition(input) {
|
|
614
|
+
const {
|
|
615
|
+
scan,
|
|
616
|
+
retryCount,
|
|
617
|
+
turnFlushSafetyEnabled,
|
|
618
|
+
capturedProseDeliveryEnabled,
|
|
619
|
+
gatewayLive,
|
|
620
|
+
} = input
|
|
621
|
+
if (scan == null || scan.decided !== 'block') {
|
|
622
|
+
return { action: 'allow-scan', reason: `scan-${scan?.decided ?? 'unknown'}` }
|
|
623
|
+
}
|
|
624
|
+
// Gate 4 — never allow into a possibly-dead gateway.
|
|
625
|
+
if (gatewayLive !== true) {
|
|
626
|
+
return { action: 'block', reason: 'gateway-liveness-not-fresh' }
|
|
627
|
+
}
|
|
628
|
+
// Gate 3 — a retry ladder already in flight means a prior delivery
|
|
629
|
+
// attempt failed; today's BLOCK is the recovery net (#3228).
|
|
630
|
+
if (retryCount !== 0) {
|
|
631
|
+
return { action: 'block', reason: 'retry-ladder-in-flight' }
|
|
632
|
+
}
|
|
633
|
+
if (scan.reason === 'no-final-reply') {
|
|
634
|
+
// Gate 2 (zero-reply): the turn-end flush is the delivery machine;
|
|
635
|
+
// the captured-prose bridge is the capture-divergence backstop.
|
|
636
|
+
if (!turnFlushSafetyEnabled) {
|
|
637
|
+
return { action: 'block', reason: 'turn-flush-flag-disabled' }
|
|
638
|
+
}
|
|
639
|
+
if (!capturedProseDeliveryEnabled) {
|
|
640
|
+
return { action: 'block', reason: 'captured-prose-flag-disabled' }
|
|
641
|
+
}
|
|
642
|
+
// Gate 1 (zero-reply): any non-empty non-silent trailing prose.
|
|
643
|
+
if (scan.hasTrailingProse !== true) {
|
|
644
|
+
return { action: 'block', reason: 'no-trailing-prose' }
|
|
645
|
+
}
|
|
646
|
+
return { action: 'allow-elected', reason: 'flush-will-deliver' }
|
|
647
|
+
}
|
|
648
|
+
if (scan.reason === 'trailing-text-after-reply') {
|
|
649
|
+
// Gate 2 (interim-ack): only the captured-prose bridge delivers here.
|
|
650
|
+
if (!capturedProseDeliveryEnabled) {
|
|
651
|
+
return { action: 'block', reason: 'captured-prose-flag-disabled' }
|
|
652
|
+
}
|
|
653
|
+
// Gate 1 (interim-ack): the bridge's substance floor is 200 chars
|
|
654
|
+
// (CAPTURED_PROSE_MIN_CHARS, silent-end.ts). Below it the bridge
|
|
655
|
+
// will NOT deliver — keep blocking (matches today: no duplicate
|
|
656
|
+
// exists for short trailing text after a real reply).
|
|
657
|
+
if (
|
|
658
|
+
typeof scan.pendingText !== 'string' ||
|
|
659
|
+
scan.pendingText.trim().length < FINAL_ANSWER_MIN_CHARS
|
|
660
|
+
) {
|
|
661
|
+
return { action: 'block', reason: 'short-trailing-after-reply' }
|
|
662
|
+
}
|
|
663
|
+
return { action: 'allow-elected', reason: 'bridge-will-deliver' }
|
|
664
|
+
}
|
|
665
|
+
// Unknown block reason — conservatively keep today's behaviour.
|
|
666
|
+
return { action: 'block', reason: `unrecognized-scan-reason-${scan.reason}` }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Mirror of `isTurnFlushSafetyEnabled` (`turn-flush-safety.ts:392-400`).
|
|
671
|
+
* Kept in this .mjs so the hook is self-contained (no TS import); MUST
|
|
672
|
+
* stay in sync — default ON, disabled by `0` / `false` / `off` / `no`.
|
|
673
|
+
*
|
|
674
|
+
* @param {Record<string, string | undefined>} env
|
|
675
|
+
* @returns {boolean}
|
|
676
|
+
*/
|
|
677
|
+
export function isTurnFlushSafetyEnabledEnv(env) {
|
|
678
|
+
const raw = env.SWITCHROOM_TG_TURN_FLUSH_SAFETY
|
|
679
|
+
if (raw == null) return true
|
|
680
|
+
const v = raw.trim().toLowerCase()
|
|
681
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Mirror of `CAPTURED_PROSE_DELIVERY_ENABLED` (`gateway/gateway.ts` —
|
|
686
|
+
* `SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'`). MUST stay in sync.
|
|
687
|
+
*
|
|
688
|
+
* @param {Record<string, string | undefined>} env
|
|
689
|
+
* @returns {boolean}
|
|
690
|
+
*/
|
|
691
|
+
export function isCapturedProseDeliveryEnabledEnv(env) {
|
|
692
|
+
return env.SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// MUST stay in sync with gateway/gateway-heartbeat.ts (the hook is a
|
|
696
|
+
// standalone .mjs and can't import the TS module). The gateway touches
|
|
697
|
+
// the file every GATEWAY_HEARTBEAT_INTERVAL_MS (15s); freshness bound is
|
|
698
|
+
// 4 intervals — conservative against scheduler jitter, small enough that
|
|
699
|
+
// a dead gateway is detected before its stale heartbeat can swallow more
|
|
700
|
+
// than one turn's election.
|
|
701
|
+
export const GATEWAY_HEARTBEAT_FILE = 'gateway-heartbeat'
|
|
702
|
+
export const GATEWAY_HEARTBEAT_FRESH_MS = 60_000
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Gate 4 IO helper: is the gateway heartbeat file fresh? Missing,
|
|
706
|
+
* unstattable, or stale ⇒ false (BLOCK — never allow into a possibly-
|
|
707
|
+
* dead gateway).
|
|
708
|
+
*
|
|
709
|
+
* @param {string} stateDir
|
|
710
|
+
* @param {number} [now]
|
|
711
|
+
* @returns {boolean}
|
|
712
|
+
*/
|
|
713
|
+
export function isGatewayHeartbeatFresh(stateDir, now = Date.now()) {
|
|
714
|
+
try {
|
|
715
|
+
const st = statSync(join(stateDir, GATEWAY_HEARTBEAT_FILE))
|
|
716
|
+
return now - st.mtimeMs <= GATEWAY_HEARTBEAT_FRESH_MS
|
|
717
|
+
} catch {
|
|
718
|
+
return false
|
|
719
|
+
}
|
|
720
|
+
}
|