switchroom 0.19.31 → 0.19.32
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 +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +3 -0
- package/telegram-plugin/dist/gateway/gateway.js +229 -130
- package/telegram-plugin/dist/server.js +3 -0
- package/telegram-plugin/gateway/stream-render.ts +578 -321
- package/telegram-plugin/session-tail.ts +13 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
- package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
- package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
|
@@ -94,6 +94,487 @@ import * as pendingProgress from '../pending-work-progress.js'
|
|
|
94
94
|
import * as signalTracker from '../turn-signal-tracker.js'
|
|
95
95
|
import * as silencePoke from '../silence-poke.js'
|
|
96
96
|
|
|
97
|
+
|
|
98
|
+
// ─── #3927: parked turn starts (FIX A) ────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// An `enqueue` transcript record is a QUEUE event, NOT a turn-start event. The
|
|
101
|
+
// claude CLI writes it the moment a message lands on the queue, whether or not
|
|
102
|
+
// a turn is already running. Ground truth from real transcripts (60 agent
|
|
103
|
+
// sessions, 372 enqueues): every enqueue is terminated by exactly ONE of
|
|
104
|
+
//
|
|
105
|
+
// • `dequeue` — the queue was drained into a NEW user turn. Always the
|
|
106
|
+
// immediately-preceding enqueue's terminal (199/200 dequeues are directly
|
|
107
|
+
// preceded by an enqueue); median gap 6 ms when the session was idle, but
|
|
108
|
+
// 2–14 s when the message sat behind a running turn. THIS is turn start.
|
|
109
|
+
// • `remove` — the queued item was folded into the ALREADY-RUNNING turn as a
|
|
110
|
+
// `queued_command` attachment. No new turn exists, and none ever will for
|
|
111
|
+
// that message. 161/372, and its `content` is byte-identical to its
|
|
112
|
+
// enqueue's (13/13 exact matches in the carrie session, 160/161 fleet-wide).
|
|
113
|
+
//
|
|
114
|
+
// Treating `enqueue` as turn start therefore minted a brand-new `CurrentTurn`
|
|
115
|
+
// on top of a live one, which (a) froze the running turn's card and opened a
|
|
116
|
+
// fresh one with reset stats, and (b) quoted the just-queued message on a card
|
|
117
|
+
// that then streamed the STILL-RUNNING previous work into it.
|
|
118
|
+
//
|
|
119
|
+
// So: park the envelope while a turn is live and mint on the CLI's own
|
|
120
|
+
// turn-start signal instead. Ordering is LIFO by evidence, not FIFO —
|
|
121
|
+
// `dequeue` pairs with the MOST RECENT enqueue (max observed gap to the newest
|
|
122
|
+
// parked envelope: 14.2 s; the gap to the oldest ran to hours). Popping the
|
|
123
|
+
// oldest would mint a stale, long-since-folded message.
|
|
124
|
+
//
|
|
125
|
+
// BOUNDS (a parked envelope must never live forever — a `dequeue` that never
|
|
126
|
+
// arrives, e.g. because the CLI died mid-turn, must not wedge the lane):
|
|
127
|
+
// • PARKED_TURN_START_MAX caps the store; the OLDEST is evicted on overflow
|
|
128
|
+
// (the newest message is the one the user is waiting on). An evicted
|
|
129
|
+
// envelope still reaches the model — the CLI owns the real queue — it just
|
|
130
|
+
// loses its own progress card.
|
|
131
|
+
// • PARKED_TURN_START_TTL_MS prunes on every touch, so a stale envelope can
|
|
132
|
+
// never be minted as a spurious turn later.
|
|
133
|
+
const PARKED_TURN_START_MAX = 16
|
|
134
|
+
const PARKED_TURN_START_TTL_MS = 30 * 60_000
|
|
135
|
+
|
|
136
|
+
/** The `enqueue` envelope fields `beginTurn` needs — the SessionEvent minus its
|
|
137
|
+
* discriminant. A parked entry additionally carries `parkedAt` for the TTL. */
|
|
138
|
+
export interface TurnStartEnvelope {
|
|
139
|
+
chatId: string | null
|
|
140
|
+
messageId: string | null
|
|
141
|
+
threadId: string | null
|
|
142
|
+
rawContent: string
|
|
143
|
+
}
|
|
144
|
+
interface ParkedTurnStart extends TurnStartEnvelope {
|
|
145
|
+
parkedAt: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Arrival-ordered (oldest first). Module-scope by design: it mirrors the ONE
|
|
149
|
+
* claude CLI session's ONE queue, exactly like the `currentTurn` mirror. */
|
|
150
|
+
const parkedTurnStarts: ParkedTurnStart[] = []
|
|
151
|
+
|
|
152
|
+
function pruneParkedTurnStarts(now: number): void {
|
|
153
|
+
for (let i = parkedTurnStarts.length - 1; i >= 0; i--) {
|
|
154
|
+
if (now - parkedTurnStarts[i].parkedAt > PARKED_TURN_START_TTL_MS) {
|
|
155
|
+
const [dropped] = parkedTurnStarts.splice(i, 1)
|
|
156
|
+
process.stderr.write(
|
|
157
|
+
`telegram gateway: parked-turn-start expired chat=${dropped.chatId ?? '-'} ` +
|
|
158
|
+
`msg=${dropped.messageId ?? '-'} age_ms=${now - dropped.parkedAt}\n`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parkTurnStart(env: TurnStartEnvelope, now: number): void {
|
|
165
|
+
parkedTurnStarts.push({ ...env, parkedAt: now })
|
|
166
|
+
while (parkedTurnStarts.length > PARKED_TURN_START_MAX) {
|
|
167
|
+
const [dropped] = parkedTurnStarts.splice(0, 1)
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`telegram gateway: parked-turn-start evicted (cap ${PARKED_TURN_START_MAX}) ` +
|
|
170
|
+
`chat=${dropped.chatId ?? '-'} msg=${dropped.messageId ?? '-'}\n`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Pop the MOST RECENTLY parked envelope — the one a `dequeue` pairs with. */
|
|
176
|
+
function takeParkedTurnStart(): ParkedTurnStart | null {
|
|
177
|
+
return parkedTurnStarts.pop() ?? null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Drop the parked envelope a `remove` names (byte-identical `content`), newest
|
|
181
|
+
* match first. A `remove` means "folded into the running turn" — that message
|
|
182
|
+
* will never get a turn of its own, so leaving it parked would let a LATER
|
|
183
|
+
* `dequeue` mint a spurious turn for an already-answered message. */
|
|
184
|
+
function discardParkedTurnStart(rawContent: string): boolean {
|
|
185
|
+
for (let i = parkedTurnStarts.length - 1; i >= 0; i--) {
|
|
186
|
+
if (parkedTurnStarts[i].rawContent === rawContent) {
|
|
187
|
+
parkedTurnStarts.splice(i, 1)
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Test seam: the parked store is module-scope (one CLI session, one queue), so
|
|
195
|
+
* suites that drive `handleSessionEvent` need a deterministic reset. */
|
|
196
|
+
export function __resetParkedTurnStartsForTest(): void {
|
|
197
|
+
parkedTurnStarts.length = 0
|
|
198
|
+
}
|
|
199
|
+
/** Test seam: parked-envelope count (bound / eviction assertions). */
|
|
200
|
+
export function __parkedTurnStartCountForTest(): number {
|
|
201
|
+
return parkedTurnStarts.length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Mint the turn atom and open its surfaces. This is the ENTIRE pre-#3927
|
|
206
|
+
* `case 'enqueue'` body, relocated verbatim except for (a) the hoisted
|
|
207
|
+
* `ackDelivery` call (which acks RECEIPT and therefore stayed on the enqueue
|
|
208
|
+
* event) and (b) the FIX B supersession finalizer. Called from `enqueue` only
|
|
209
|
+
* when the session is idle, and otherwise from `dequeue`.
|
|
210
|
+
*/
|
|
211
|
+
function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
|
|
212
|
+
const {
|
|
213
|
+
HANDBACK_PRETURN_ENABLED,
|
|
214
|
+
STATE_DIR,
|
|
215
|
+
clearActivitySummary,
|
|
216
|
+
extractUserPromptPreview,
|
|
217
|
+
getCurrentTurn,
|
|
218
|
+
getPendingPtyPartial,
|
|
219
|
+
handbackPreturnSignal,
|
|
220
|
+
handlePtyPartial,
|
|
221
|
+
isDmChatId,
|
|
222
|
+
makeNarrativeGate,
|
|
223
|
+
pendingCrossTurnGate,
|
|
224
|
+
preambleSuppressor,
|
|
225
|
+
promoteQueuedStatus,
|
|
226
|
+
rememberRecentTurn,
|
|
227
|
+
scheduleEarlyLivenessOpen,
|
|
228
|
+
setCurrentTurn,
|
|
229
|
+
setPendingPtyPartial,
|
|
230
|
+
startTurnTypingLoop,
|
|
231
|
+
statusKey,
|
|
232
|
+
turnsDb,
|
|
233
|
+
typingWrapper,
|
|
234
|
+
} = deps
|
|
235
|
+
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
236
|
+
// prior turn before resetting focus.
|
|
237
|
+
typingWrapper.drainAll()
|
|
238
|
+
if (ev.chatId) {
|
|
239
|
+
// #1445 cross-turn pending-async ambient — backstop for the
|
|
240
|
+
// `handleInbound` path's `clearPending('inbound')`. The
|
|
241
|
+
// inbound path covers real user messages, but synthesised
|
|
242
|
+
// wakes (subagent-handback channel turn, cron fires, vault
|
|
243
|
+
// grant resumes, restart markers) push directly to
|
|
244
|
+
// `pendingInboundBuffer` and bypass `handleInbound`. The
|
|
245
|
+
// `enqueue` session-event fires for EVERY fresh turn atom
|
|
246
|
+
// regardless of source — clearing here drops any prior turn's
|
|
247
|
+
// ambient before the new turn's `noteOutbound` lands. The
|
|
248
|
+
// call is idempotent so it's safe to fire in addition to the
|
|
249
|
+
// inbound-path clear (for the real-inbound case, this is a
|
|
250
|
+
// no-op because state was already deleted by then).
|
|
251
|
+
const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
252
|
+
pendingProgress.clearPending(
|
|
253
|
+
statusKey(ev.chatId, enqThreadId),
|
|
254
|
+
'handback',
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
if (ev.chatId) {
|
|
258
|
+
// Issue #195: if a previous turn left an answer-lane stream open
|
|
259
|
+
// (rapid steer/queue), force it to a new generation so its in-flight
|
|
260
|
+
// edits don't mutate the new turn's message. Materialize is best-effort
|
|
261
|
+
// — we don't await here because turn_end on the prior turn should
|
|
262
|
+
// have already done it; this is a defensive supersession guard.
|
|
263
|
+
const prior = getCurrentTurn()
|
|
264
|
+
if (prior?.answerStream != null) {
|
|
265
|
+
prior.answerStream.forceNewMessage()
|
|
266
|
+
prior.answerStream.stop()
|
|
267
|
+
prior.answerStream = null
|
|
268
|
+
}
|
|
269
|
+
// Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
|
|
270
|
+
// fuse before it is superseded. The fire callback re-reads currentTurn
|
|
271
|
+
// and no-ops on a stale turn, but proactively clearing the timer avoids
|
|
272
|
+
// a bounded pile-up of dangling timers across rapid steer/queue turns.
|
|
273
|
+
if (prior?.orphanedReplyTimeoutId != null) {
|
|
274
|
+
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
275
|
+
prior.orphanedReplyTimeoutId = null
|
|
276
|
+
}
|
|
277
|
+
// Same bounded-leak class (early-paint 250ms setTimeout): the prior
|
|
278
|
+
// turn may have armed its narrative gate's early-paint timer before
|
|
279
|
+
// being superseded. Left untorn, ~250ms later it fires showNarrativeStep
|
|
280
|
+
// on the dead turn and can paint a stale narration card below the new
|
|
281
|
+
// turn's surface. Teardown is guard-safe and idempotent (no-op when never
|
|
282
|
+
// armed / already fired / already disarmed by the prior turn's turn_end).
|
|
283
|
+
prior?.narrativeGate?.teardown()
|
|
284
|
+
// FIX B (#3927) — NEVER ORPHAN A SUPERSEDED CARD. Reaching here means a
|
|
285
|
+
// fresh turn is being minted while `prior` is still the live turn atom.
|
|
286
|
+
// After FIX A that is rare (a real inbound now parks instead of
|
|
287
|
+
// preempting), but it is still reachable: a turn whose `turn_end` was
|
|
288
|
+
// never observed (bridge death, transcript gap, an unclean restart)
|
|
289
|
+
// leaves a live-looking atom in the slot, and the next genuine
|
|
290
|
+
// dequeue-driven turn start must not inherit its surfaces. Pre-fix, the
|
|
291
|
+
// teardown above dropped the answer stream, the orphaned-reply fuse and
|
|
292
|
+
// the narrative gate but NEVER touched the activity card — so the card
|
|
293
|
+
// froze on its last landed edit, kept the `fg:<statusKey>` status pin
|
|
294
|
+
// forever, and left NO `turn-lifecycle clear` line to explain it (carrie
|
|
295
|
+
// 2026-07-28, turn `-1004223464247:_#1078`). `clearActivitySummary`
|
|
296
|
+
// finalizes/deletes the card AND releases the pin; it is idempotent and
|
|
297
|
+
// no-ops when the turn never opened a card.
|
|
298
|
+
// `endedAt == null` narrows this to a turn that never ended: a turn that
|
|
299
|
+
// ended normally already ran its own `clearActivitySummary` + `clear`
|
|
300
|
+
// log, and `endCurrentTurnAtomic` nulls the mirror, so this is a
|
|
301
|
+
// belt-and-braces guard against double-finalizing / double-logging.
|
|
302
|
+
if (prior != null && prior.endedAt == null) {
|
|
303
|
+
clearActivitySummary(prior)
|
|
304
|
+
// The missing breadcrumb: a clobber must never again be invisible.
|
|
305
|
+
// Same field format as every other `turn-lifecycle clear`.
|
|
306
|
+
process.stderr.write(
|
|
307
|
+
`telegram gateway: ${formatTurnLifecycle('clear', 'superseded', prior, Date.now())}\n`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
// #1067: swap the entire turn atom in one assignment. Every
|
|
311
|
+
// handler captures `const turn = currentTurn` at entry, so a
|
|
312
|
+
// captured-then-awaited read can't reattribute to the new turn.
|
|
313
|
+
const startedAt = Date.now()
|
|
314
|
+
// Component 3 — stable per-turn identity. For a real inbound this
|
|
315
|
+
// matches the `origin_turn_id` stamped into the inbound meta at
|
|
316
|
+
// build time (same chat/thread/messageId). Synthetic turns (cron /
|
|
317
|
+
// handback — no messageId) get a unique startedAt-based fallback id
|
|
318
|
+
// that no reply will ever echo, so they correctly fall through to
|
|
319
|
+
// the live-turn routing in resolveAnswerThreadId.
|
|
320
|
+
const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
321
|
+
const turnId =
|
|
322
|
+
deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId)
|
|
323
|
+
?? `${chatKey(ev.chatId, enqThreadIdNum ?? null)}#synthetic-${startedAt}`
|
|
324
|
+
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Consume any
|
|
325
|
+
// pending cross-turn gate `obligationSweep` armed for THIS exact turn
|
|
326
|
+
// when it pushed an `obligation_represent` inbound. The gate is keyed on
|
|
327
|
+
// the obligation's `originTurnId`, and the represent inbound reuses the
|
|
328
|
+
// original chat/thread/messageId, so this turn's `turnId` (derived just
|
|
329
|
+
// above) equals that key iff this turn IS the represent surface armed for.
|
|
330
|
+
// An unrelated foreground turn on the same chat/thread derives a
|
|
331
|
+
// different `turnId` → finds no entry → no gate → its card opens normally
|
|
332
|
+
// (correct). Consume-once: delete on read so the matched gate can't leak
|
|
333
|
+
// forward, and a never-matched stale gate can never suppress another turn.
|
|
334
|
+
const xTurnGateKey = turnId
|
|
335
|
+
const consumedCrossTurnGate = pendingCrossTurnGate.get(xTurnGateKey)
|
|
336
|
+
if (consumedCrossTurnGate != null) pendingCrossTurnGate.delete(xTurnGateKey)
|
|
337
|
+
const next: CurrentTurn = {
|
|
338
|
+
sessionChatId: ev.chatId,
|
|
339
|
+
sessionThreadId: enqThreadIdNum,
|
|
340
|
+
// Accept the inbound id as a reply anchor only when it is a plausible
|
|
341
|
+
// Telegram message id. Synthetic boot-resume inbounds fabricate a
|
|
342
|
+
// 13-digit Date.now() message_id (for ack-tracking); if that reached
|
|
343
|
+
// the activity-feed reply anchor it 400'd every feed send and darkened
|
|
344
|
+
// the live feed for the whole resume turn (2026-06-05). The ack-queue
|
|
345
|
+
// still keys on ev.messageId independently — only the anchor is gated.
|
|
346
|
+
sourceMessageId: parseSourceMessageId(ev.messageId),
|
|
347
|
+
startedAt,
|
|
348
|
+
gatewayReceiveAt: startedAt,
|
|
349
|
+
// #2527 — stamp the loop role once, from the enqueue envelope.
|
|
350
|
+
role: deriveTurnRole(ev.rawContent),
|
|
351
|
+
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Only a
|
|
352
|
+
// synthetic represent/owed-reply turn carries this; a foreground turn
|
|
353
|
+
// leaves it undefined and the cross-turn card-OPEN gate is inert.
|
|
354
|
+
...(consumedCrossTurnGate != null ? { crossTurnGate: consumedCrossTurnGate } : {}),
|
|
355
|
+
replyCalled: false,
|
|
356
|
+
finalAnswerDelivered: false,
|
|
357
|
+
finalAnswerSubstantive: false,
|
|
358
|
+
// Sticky latch — reset ONLY here (turn start), never by reopen.
|
|
359
|
+
finalAnswerEverDelivered: false,
|
|
360
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
361
|
+
// latch, reset at turn start alongside the other answer flags.
|
|
362
|
+
answerDelivered: false,
|
|
363
|
+
// #3429 — flushed-answer text for the content-vs-flush latch
|
|
364
|
+
// discrimination; stamped at flush arm, reset at turn start.
|
|
365
|
+
flushedAnswerText: null,
|
|
366
|
+
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
367
|
+
endedAt: null,
|
|
368
|
+
firstPingAt: null,
|
|
369
|
+
// Notification ownership (R8 / PR-2): no slot claimed yet, so the
|
|
370
|
+
// "claimer was substantive" flag starts false. Set atomically with
|
|
371
|
+
// firstPingAt at the over-ping decision site.
|
|
372
|
+
firstPingWasSubstantive: false,
|
|
373
|
+
silentAnchorMessageId: null,
|
|
374
|
+
silentAnchorText: '',
|
|
375
|
+
capturedText: [],
|
|
376
|
+
capturedBlockMeta: [],
|
|
377
|
+
orphanedReplyTimeoutId: null,
|
|
378
|
+
answerReadyFlushTimeoutId: null,
|
|
379
|
+
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
380
|
+
// so a turn that never streams still trips the fuse after windowMs.
|
|
381
|
+
liveness: new LivenessTracker(startedAt),
|
|
382
|
+
turnId,
|
|
383
|
+
registryKey: null,
|
|
384
|
+
noReplyDrainTimer: null,
|
|
385
|
+
lastAssistantMsgId: null,
|
|
386
|
+
lastAssistantDone: false,
|
|
387
|
+
toolCallCount: 0,
|
|
388
|
+
labeledToolCount: 0,
|
|
389
|
+
totalTokens: 0,
|
|
390
|
+
seenUsageMessageIds: new Set<string>(),
|
|
391
|
+
activityMessageId: null,
|
|
392
|
+
activityInFlight: null,
|
|
393
|
+
activityPendingRender: null,
|
|
394
|
+
activityLastSentRender: null,
|
|
395
|
+
activityEverOpened: false,
|
|
396
|
+
activityDrainFailures: 0,
|
|
397
|
+
mirrorLines: [],
|
|
398
|
+
// Assigned immediately after this literal via makeNarrativeGate(next) —
|
|
399
|
+
// the controller's SHOW/RETRACT effects close over the turn object, which
|
|
400
|
+
// can't reference itself inside its own initializer.
|
|
401
|
+
narrativeGate: undefined as unknown as NarrativeFlushController,
|
|
402
|
+
lastReplyText: '',
|
|
403
|
+
foregroundSubAgents: new Map(),
|
|
404
|
+
answerStream: null,
|
|
405
|
+
isDm: isDmChatId(ev.chatId),
|
|
406
|
+
// PR-4a — construct ONE emission-authority façade per turn, passing
|
|
407
|
+
// the chat/thread key in EXPLICITLY (the PR-4e seam; today equal to
|
|
408
|
+
// the singleton-sourced key). Per-turn: born with this turn literal,
|
|
409
|
+
// discarded with it — never persists across turns.
|
|
410
|
+
emissionAuthority: new EmissionAuthority(
|
|
411
|
+
statusKey(ev.chatId, enqThreadIdNum),
|
|
412
|
+
),
|
|
413
|
+
}
|
|
414
|
+
// Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
|
|
415
|
+
// effects close over the turn). Born with this turn, torn down at turn end.
|
|
416
|
+
next.narrativeGate = makeNarrativeGate(next)
|
|
417
|
+
// Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
|
|
418
|
+
// If a subagent-handback pre-turn signal was emitted for THIS exact turn
|
|
419
|
+
// (matched on `turnId`, not the bare topic key, so a racing user inbound
|
|
420
|
+
// can't mis-adopt), consume it. A card-bearing adoption seeds
|
|
421
|
+
// `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
|
|
422
|
+
// EDITS the existing card instead of opening a second one, and so the
|
|
423
|
+
// turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
|
|
424
|
+
if (HANDBACK_PRETURN_ENABLED) {
|
|
425
|
+
const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
|
|
426
|
+
if (handbackAdoption != null) {
|
|
427
|
+
if (handbackAdoption.activityMessageId != null) {
|
|
428
|
+
next.activityMessageId = handbackAdoption.activityMessageId
|
|
429
|
+
next.activityEverOpened = true
|
|
430
|
+
}
|
|
431
|
+
// Observability (#3544): adoption is the rare, previously-silent
|
|
432
|
+
// branch — one line per adopted handback turn, not per turn.
|
|
433
|
+
process.stderr.write(
|
|
434
|
+
`telegram gateway: handback pre-turn adopted turnId=${turnId} ` +
|
|
435
|
+
`key=${handbackAdoption.statusKey} card=${handbackAdoption.activityMessageId ?? 'none'}\n`,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// #3544 — arm the turn-long `typing…` loop for EVERY minted turn,
|
|
440
|
+
// unconditionally. It used to hang off the handback ADOPTION above,
|
|
441
|
+
// which misses whenever the pre-turn entry was deduped (parallel
|
|
442
|
+
// workers on one topic), had no derivable turn id, or was already
|
|
443
|
+
// reaped — and the whole compose window went dark. Only the real-inbound
|
|
444
|
+
// path (`turn-start-surfaces.ts`) armed a loop, so a synthetic turn
|
|
445
|
+
// (handback / cron / wake) could have none at all. Unconditional is safe
|
|
446
|
+
// and costs nothing extra on the wire:
|
|
447
|
+
// - `turnTypingLoop.start` is restart-safe (stops any prior loop on
|
|
448
|
+
// the key first, so a real inbound's loop is replaced, not doubled);
|
|
449
|
+
// - every send goes through the SHARED per-chat-key emitter floor
|
|
450
|
+
// (`typing-emitter.ts`, TYPING_FLOOR_MS) so N arms on one chat still
|
|
451
|
+
// cost at most one chat action per floor window — the 2026-07-11
|
|
452
|
+
// flood-ban guard is what makes arming more loops free;
|
|
453
|
+
// - `turn-end.ts` (`purgeReactionTracking → stopTurnTypingLoop`) is
|
|
454
|
+
// already the single stop-owner for ALL turns, and a start is
|
|
455
|
+
// self-healing anyway, so this cannot leak an interval.
|
|
456
|
+
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
457
|
+
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
458
|
+
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
459
|
+
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
460
|
+
// the SAME statusKey the ctor's façade was constructed with just above.
|
|
461
|
+
setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
|
|
462
|
+
// (turn start already stamped the idle clock at the top of
|
|
463
|
+
// handleSessionEvent, along with every other session event — see the
|
|
464
|
+
// idle-clear block there.)
|
|
465
|
+
// Early-open the "Working…" liveness card at turn start so narration /
|
|
466
|
+
// thinking emitted BEFORE the first tool surfaces within ~a second
|
|
467
|
+
// instead of after the old 12 s threshold (the dead-air gap). Fires the
|
|
468
|
+
// SAME `openLivenessFeedIfDue` the 6 s heartbeat uses — a no-op if a
|
|
469
|
+
// tool/narrative already opened the card, and gated by `mayOpenActivityCard`
|
|
470
|
+
// (lever 1/4) so it never opens below a delivered answer. Scoped to real
|
|
471
|
+
// turns by construction: only the `enqueue` lifecycle event reaches here,
|
|
472
|
+
// and anonymous one-shot hook clients (recall.py) never emit it.
|
|
473
|
+
scheduleEarlyLivenessOpen(next)
|
|
474
|
+
// Status-surface observability: one line at every turn SET so a later
|
|
475
|
+
// dark card is traceable to which turn/topic key it belonged to.
|
|
476
|
+
process.stderr.write(
|
|
477
|
+
`telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
|
|
478
|
+
)
|
|
479
|
+
// Component 3 — retain in the bounded recently-ended registry so a
|
|
480
|
+
// LATE reply (landing after currentTurn flips to a successor) can
|
|
481
|
+
// still resolve THIS turn's origin thread by its turnId.
|
|
482
|
+
rememberRecentTurn(next)
|
|
483
|
+
// Component 5 (Hook B) — this turn's topic had a queued placeholder
|
|
484
|
+
// from Hook A; promote it to "On it — replying now." (deleted later
|
|
485
|
+
// when the answer lands). No-op when there's no placeholder / DM.
|
|
486
|
+
promoteQueuedStatus(ev.chatId, enqThreadIdNum)
|
|
487
|
+
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
488
|
+
// machine. `enqueue` fires for EVERY turn atom regardless of
|
|
489
|
+
// source — inbound, cron, subagent-handback, vault-resume,
|
|
490
|
+
// restart-marker — so it is the single chokepoint that captures
|
|
491
|
+
// the non-inbound turns the machine's own `inbound` event never
|
|
492
|
+
// sees (those bypass handleInbound). Without it the machine reads
|
|
493
|
+
// idle during a cron/handback turn and the gate would mis-deliver
|
|
494
|
+
// a concurrent inbound mid-turn (the #1556 composer wedge).
|
|
495
|
+
// Idempotent when already in_turn (turnStart only sets perKey).
|
|
496
|
+
shadowEmit({
|
|
497
|
+
kind: 'turnStart',
|
|
498
|
+
key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined) as _ChatKey,
|
|
499
|
+
at: startedAt,
|
|
500
|
+
})
|
|
501
|
+
// #549 fix — fresh turn, reset preamble-suppression state.
|
|
502
|
+
preambleSuppressor.reset()
|
|
503
|
+
// Reset the silent-end retry budget for this chat. The stored
|
|
504
|
+
// turnKey is `chat:thread` shape (no per-instance suffix), so
|
|
505
|
+
// without an explicit per-turn clear, `writeSilentEndState`
|
|
506
|
+
// (silent-end.ts:114) inherits `retryCount` across turns
|
|
507
|
+
// whenever a prior turn for the same chat hit retryCount=1.
|
|
508
|
+
// The Stop hook then sees `retryCount >= MAX_RETRIES=1` on the
|
|
509
|
+
// very first silent-end of every subsequent turn and bails
|
|
510
|
+
// without re-prompting. finn hit this on 2026-05-25 with a
|
|
511
|
+
// stuck retryCount=1 file. A new turn invalidates any prior
|
|
512
|
+
// turn's retry budget by definition; clear it eagerly here.
|
|
513
|
+
// ev.threadId is `string | null` (Telegram's wire shape);
|
|
514
|
+
// statusKey wants `number | null` — same conversion as the
|
|
515
|
+
// registry-key branch a few lines down.
|
|
516
|
+
clearSilentEndState(statusKey(
|
|
517
|
+
ev.chatId,
|
|
518
|
+
ev.threadId != null ? Number(ev.threadId) : null,
|
|
519
|
+
))
|
|
520
|
+
// Stage 3b: stamp turn-start in the registry. turn_key is
|
|
521
|
+
// chat:thread:startTs — unique per turn, distinct from the
|
|
522
|
+
// progress-card-driver's per-chat sequence number (these are two
|
|
523
|
+
// independent identifier schemes and don't need to align).
|
|
524
|
+
if (turnsDb != null) {
|
|
525
|
+
// ev.threadId is `string | null` (Telegram emits as string); convert
|
|
526
|
+
// to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
|
|
527
|
+
// to '_' — same as the explicit `null` branch below.
|
|
528
|
+
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
|
|
529
|
+
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
|
|
530
|
+
next.registryKey = turnKey
|
|
531
|
+
// Phase 1 of #332: capture first ~200 chars of the user's message.
|
|
532
|
+
const userPromptPreview = extractUserPromptPreview(ev.rawContent)
|
|
533
|
+
// Closes #472 finding #11. Pre-fix: this write was scheduled
|
|
534
|
+
// via setImmediate to "avoid stalling the turn handler" — but
|
|
535
|
+
// SQLite local writes are sub-millisecond, and the deferral
|
|
536
|
+
// opened a SIGTERM race window: a kill landing in the gap
|
|
537
|
+
// between scheduling and firing left a turn with no start
|
|
538
|
+
// row, invisible to the resume protocol (the user sent a
|
|
539
|
+
// message, the gateway lost it, no SWITCHROOM_PENDING_TURN
|
|
540
|
+
// env on next boot). Sibling writeTurnActiveMarker has always
|
|
541
|
+
// been synchronous here; this matches it.
|
|
542
|
+
try {
|
|
543
|
+
recordTurnStart(turnsDb, {
|
|
544
|
+
turnKey,
|
|
545
|
+
chatId: String(ev.chatId),
|
|
546
|
+
threadId: ev.threadId != null ? String(ev.threadId) : null,
|
|
547
|
+
lastUserMsgId: ev.messageId != null ? String(ev.messageId) : null,
|
|
548
|
+
userPromptPreview,
|
|
549
|
+
})
|
|
550
|
+
} catch (err) {
|
|
551
|
+
process.stderr.write(`telegram gateway: recordTurnStart failed turnKey=${turnKey}: ${(err as Error).message}\n`)
|
|
552
|
+
}
|
|
553
|
+
// #412: turn-active marker for the bridge-watchdog. File exists
|
|
554
|
+
// for the duration of the in-flight turn; mtime advances on
|
|
555
|
+
// every tool_use; deleted on turn_complete. The watchdog
|
|
556
|
+
// distinguishes wedged-mid-turn from healthy-idle by checking
|
|
557
|
+
// for this file's presence + mtime staleness.
|
|
558
|
+
writeTurnActiveMarker(STATE_DIR, {
|
|
559
|
+
turnKey,
|
|
560
|
+
chatId: String(ev.chatId),
|
|
561
|
+
threadId: ev.threadId != null ? String(ev.threadId) : null,
|
|
562
|
+
startedAt,
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
// (accessor-narrowing spelling: the pre-move body guarded the
|
|
566
|
+
// `pendingPtyPartial` variable then re-read it into `pending`; the
|
|
567
|
+
// injected accessor is a call expression TS can't narrow across, so
|
|
568
|
+
// capture once then guard the local — equivalent, no await between.)
|
|
569
|
+
const pending = getPendingPtyPartial()
|
|
570
|
+
if (pending != null) {
|
|
571
|
+
setPendingPtyPartial(null)
|
|
572
|
+
handlePtyPartial(pending)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
97
578
|
export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): void {
|
|
98
579
|
const {
|
|
99
580
|
ANSWER_LANE,
|
|
@@ -209,241 +690,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
209
690
|
}
|
|
210
691
|
switch (ev.kind) {
|
|
211
692
|
case 'enqueue': {
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// grant resumes, restart markers) push directly to
|
|
221
|
-
// `pendingInboundBuffer` and bypass `handleInbound`. The
|
|
222
|
-
// `enqueue` session-event fires for EVERY fresh turn atom
|
|
223
|
-
// regardless of source — clearing here drops any prior turn's
|
|
224
|
-
// ambient before the new turn's `noteOutbound` lands. The
|
|
225
|
-
// call is idempotent so it's safe to fire in addition to the
|
|
226
|
-
// inbound-path clear (for the real-inbound case, this is a
|
|
227
|
-
// no-op because state was already deleted by then).
|
|
228
|
-
const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
229
|
-
pendingProgress.clearPending(
|
|
230
|
-
statusKey(ev.chatId, enqThreadId),
|
|
231
|
-
'handback',
|
|
232
|
-
)
|
|
233
|
-
}
|
|
693
|
+
// #3927 FIX A — an `enqueue` is a QUEUE event, not a turn START event
|
|
694
|
+
// (see the parked-turn-start block at the top of this module for the
|
|
695
|
+
// transcript evidence). Mint ONLY when the session is genuinely idle;
|
|
696
|
+
// otherwise park the envelope and let the CLI's own `dequeue` start the
|
|
697
|
+
// turn, or its `remove` discard it (the message was folded into the
|
|
698
|
+
// running turn and will never own a turn).
|
|
699
|
+
const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
700
|
+
const now = Date.now()
|
|
234
701
|
if (ev.chatId) {
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
// fuse before it is superseded. The fire callback re-reads currentTurn
|
|
248
|
-
// and no-ops on a stale turn, but proactively clearing the timer avoids
|
|
249
|
-
// a bounded pile-up of dangling timers across rapid steer/queue turns.
|
|
250
|
-
if (prior?.orphanedReplyTimeoutId != null) {
|
|
251
|
-
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
252
|
-
prior.orphanedReplyTimeoutId = null
|
|
253
|
-
}
|
|
254
|
-
// Same bounded-leak class (early-paint 250ms setTimeout): the prior
|
|
255
|
-
// turn may have armed its narrative gate's early-paint timer before
|
|
256
|
-
// being superseded. Left untorn, ~250ms later it fires showNarrativeStep
|
|
257
|
-
// on the dead turn and can paint a stale narration card below the new
|
|
258
|
-
// turn's surface. Teardown is guard-safe and idempotent (no-op when never
|
|
259
|
-
// armed / already fired / already disarmed by the prior turn's turn_end).
|
|
260
|
-
prior?.narrativeGate?.teardown()
|
|
261
|
-
// #1067: swap the entire turn atom in one assignment. Every
|
|
262
|
-
// handler captures `const turn = currentTurn` at entry, so a
|
|
263
|
-
// captured-then-awaited read can't reattribute to the new turn.
|
|
264
|
-
const startedAt = Date.now()
|
|
265
|
-
// Component 3 — stable per-turn identity. For a real inbound this
|
|
266
|
-
// matches the `origin_turn_id` stamped into the inbound meta at
|
|
267
|
-
// build time (same chat/thread/messageId). Synthetic turns (cron /
|
|
268
|
-
// handback — no messageId) get a unique startedAt-based fallback id
|
|
269
|
-
// that no reply will ever echo, so they correctly fall through to
|
|
270
|
-
// the live-turn routing in resolveAnswerThreadId.
|
|
271
|
-
const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
272
|
-
const turnId =
|
|
273
|
-
deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId)
|
|
274
|
-
?? `${chatKey(ev.chatId, enqThreadIdNum ?? null)}#synthetic-${startedAt}`
|
|
275
|
-
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Consume any
|
|
276
|
-
// pending cross-turn gate `obligationSweep` armed for THIS exact turn
|
|
277
|
-
// when it pushed an `obligation_represent` inbound. The gate is keyed on
|
|
278
|
-
// the obligation's `originTurnId`, and the represent inbound reuses the
|
|
279
|
-
// original chat/thread/messageId, so this turn's `turnId` (derived just
|
|
280
|
-
// above) equals that key iff this turn IS the represent surface armed for.
|
|
281
|
-
// An unrelated foreground turn on the same chat/thread derives a
|
|
282
|
-
// different `turnId` → finds no entry → no gate → its card opens normally
|
|
283
|
-
// (correct). Consume-once: delete on read so the matched gate can't leak
|
|
284
|
-
// forward, and a never-matched stale gate can never suppress another turn.
|
|
285
|
-
const xTurnGateKey = turnId
|
|
286
|
-
const consumedCrossTurnGate = pendingCrossTurnGate.get(xTurnGateKey)
|
|
287
|
-
if (consumedCrossTurnGate != null) pendingCrossTurnGate.delete(xTurnGateKey)
|
|
288
|
-
const next: CurrentTurn = {
|
|
289
|
-
sessionChatId: ev.chatId,
|
|
290
|
-
sessionThreadId: enqThreadIdNum,
|
|
291
|
-
// Accept the inbound id as a reply anchor only when it is a plausible
|
|
292
|
-
// Telegram message id. Synthetic boot-resume inbounds fabricate a
|
|
293
|
-
// 13-digit Date.now() message_id (for ack-tracking); if that reached
|
|
294
|
-
// the activity-feed reply anchor it 400'd every feed send and darkened
|
|
295
|
-
// the live feed for the whole resume turn (2026-06-05). The ack-queue
|
|
296
|
-
// still keys on ev.messageId independently — only the anchor is gated.
|
|
297
|
-
sourceMessageId: parseSourceMessageId(ev.messageId),
|
|
298
|
-
startedAt,
|
|
299
|
-
gatewayReceiveAt: startedAt,
|
|
300
|
-
// #2527 — stamp the loop role once, from the enqueue envelope.
|
|
301
|
-
role: deriveTurnRole(ev.rawContent),
|
|
302
|
-
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Only a
|
|
303
|
-
// synthetic represent/owed-reply turn carries this; a foreground turn
|
|
304
|
-
// leaves it undefined and the cross-turn card-OPEN gate is inert.
|
|
305
|
-
...(consumedCrossTurnGate != null ? { crossTurnGate: consumedCrossTurnGate } : {}),
|
|
306
|
-
replyCalled: false,
|
|
307
|
-
finalAnswerDelivered: false,
|
|
308
|
-
finalAnswerSubstantive: false,
|
|
309
|
-
// Sticky latch — reset ONLY here (turn start), never by reopen.
|
|
310
|
-
finalAnswerEverDelivered: false,
|
|
311
|
-
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
312
|
-
// latch, reset at turn start alongside the other answer flags.
|
|
313
|
-
answerDelivered: false,
|
|
314
|
-
// #3429 — flushed-answer text for the content-vs-flush latch
|
|
315
|
-
// discrimination; stamped at flush arm, reset at turn start.
|
|
316
|
-
flushedAnswerText: null,
|
|
317
|
-
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
318
|
-
endedAt: null,
|
|
319
|
-
firstPingAt: null,
|
|
320
|
-
// Notification ownership (R8 / PR-2): no slot claimed yet, so the
|
|
321
|
-
// "claimer was substantive" flag starts false. Set atomically with
|
|
322
|
-
// firstPingAt at the over-ping decision site.
|
|
323
|
-
firstPingWasSubstantive: false,
|
|
324
|
-
silentAnchorMessageId: null,
|
|
325
|
-
silentAnchorText: '',
|
|
326
|
-
capturedText: [],
|
|
327
|
-
capturedBlockMeta: [],
|
|
328
|
-
orphanedReplyTimeoutId: null,
|
|
329
|
-
answerReadyFlushTimeoutId: null,
|
|
330
|
-
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
331
|
-
// so a turn that never streams still trips the fuse after windowMs.
|
|
332
|
-
liveness: new LivenessTracker(startedAt),
|
|
333
|
-
turnId,
|
|
334
|
-
registryKey: null,
|
|
335
|
-
noReplyDrainTimer: null,
|
|
336
|
-
lastAssistantMsgId: null,
|
|
337
|
-
lastAssistantDone: false,
|
|
338
|
-
toolCallCount: 0,
|
|
339
|
-
labeledToolCount: 0,
|
|
340
|
-
totalTokens: 0,
|
|
341
|
-
seenUsageMessageIds: new Set<string>(),
|
|
342
|
-
activityMessageId: null,
|
|
343
|
-
activityInFlight: null,
|
|
344
|
-
activityPendingRender: null,
|
|
345
|
-
activityLastSentRender: null,
|
|
346
|
-
activityEverOpened: false,
|
|
347
|
-
activityDrainFailures: 0,
|
|
348
|
-
mirrorLines: [],
|
|
349
|
-
// Assigned immediately after this literal via makeNarrativeGate(next) —
|
|
350
|
-
// the controller's SHOW/RETRACT effects close over the turn object, which
|
|
351
|
-
// can't reference itself inside its own initializer.
|
|
352
|
-
narrativeGate: undefined as unknown as NarrativeFlushController,
|
|
353
|
-
lastReplyText: '',
|
|
354
|
-
foregroundSubAgents: new Map(),
|
|
355
|
-
answerStream: null,
|
|
356
|
-
isDm: isDmChatId(ev.chatId),
|
|
357
|
-
// PR-4a — construct ONE emission-authority façade per turn, passing
|
|
358
|
-
// the chat/thread key in EXPLICITLY (the PR-4e seam; today equal to
|
|
359
|
-
// the singleton-sourced key). Per-turn: born with this turn literal,
|
|
360
|
-
// discarded with it — never persists across turns.
|
|
361
|
-
emissionAuthority: new EmissionAuthority(
|
|
362
|
-
statusKey(ev.chatId, enqThreadIdNum),
|
|
363
|
-
),
|
|
364
|
-
}
|
|
365
|
-
// Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
|
|
366
|
-
// effects close over the turn). Born with this turn, torn down at turn end.
|
|
367
|
-
next.narrativeGate = makeNarrativeGate(next)
|
|
368
|
-
// Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
|
|
369
|
-
// If a subagent-handback pre-turn signal was emitted for THIS exact turn
|
|
370
|
-
// (matched on `turnId`, not the bare topic key, so a racing user inbound
|
|
371
|
-
// can't mis-adopt), consume it. A card-bearing adoption seeds
|
|
372
|
-
// `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
|
|
373
|
-
// EDITS the existing card instead of opening a second one, and so the
|
|
374
|
-
// turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
|
|
375
|
-
if (HANDBACK_PRETURN_ENABLED) {
|
|
376
|
-
const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
|
|
377
|
-
if (handbackAdoption != null) {
|
|
378
|
-
if (handbackAdoption.activityMessageId != null) {
|
|
379
|
-
next.activityMessageId = handbackAdoption.activityMessageId
|
|
380
|
-
next.activityEverOpened = true
|
|
381
|
-
}
|
|
382
|
-
// Observability (#3544): adoption is the rare, previously-silent
|
|
383
|
-
// branch — one line per adopted handback turn, not per turn.
|
|
384
|
-
process.stderr.write(
|
|
385
|
-
`telegram gateway: handback pre-turn adopted turnId=${turnId} ` +
|
|
386
|
-
`key=${handbackAdoption.statusKey} card=${handbackAdoption.activityMessageId ?? 'none'}\n`,
|
|
387
|
-
)
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
// #3544 — arm the turn-long `typing…` loop for EVERY minted turn,
|
|
391
|
-
// unconditionally. It used to hang off the handback ADOPTION above,
|
|
392
|
-
// which misses whenever the pre-turn entry was deduped (parallel
|
|
393
|
-
// workers on one topic), had no derivable turn id, or was already
|
|
394
|
-
// reaped — and the whole compose window went dark. Only the real-inbound
|
|
395
|
-
// path (`turn-start-surfaces.ts`) armed a loop, so a synthetic turn
|
|
396
|
-
// (handback / cron / wake) could have none at all. Unconditional is safe
|
|
397
|
-
// and costs nothing extra on the wire:
|
|
398
|
-
// - `turnTypingLoop.start` is restart-safe (stops any prior loop on
|
|
399
|
-
// the key first, so a real inbound's loop is replaced, not doubled);
|
|
400
|
-
// - every send goes through the SHARED per-chat-key emitter floor
|
|
401
|
-
// (`typing-emitter.ts`, TYPING_FLOOR_MS) so N arms on one chat still
|
|
402
|
-
// cost at most one chat action per floor window — the 2026-07-11
|
|
403
|
-
// flood-ban guard is what makes arming more loops free;
|
|
404
|
-
// - `turn-end.ts` (`purgeReactionTracking → stopTurnTypingLoop`) is
|
|
405
|
-
// already the single stop-owner for ALL turns, and a start is
|
|
406
|
-
// self-healing anyway, so this cannot leak an interval.
|
|
407
|
-
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
408
|
-
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
409
|
-
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
410
|
-
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
411
|
-
// the SAME statusKey the ctor's façade was constructed with just above.
|
|
412
|
-
setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
|
|
413
|
-
// (turn start already stamped the idle clock at the top of
|
|
414
|
-
// handleSessionEvent, along with every other session event — see the
|
|
415
|
-
// idle-clear block there.)
|
|
416
|
-
// Early-open the "Working…" liveness card at turn start so narration /
|
|
417
|
-
// thinking emitted BEFORE the first tool surfaces within ~a second
|
|
418
|
-
// instead of after the old 12 s threshold (the dead-air gap). Fires the
|
|
419
|
-
// SAME `openLivenessFeedIfDue` the 6 s heartbeat uses — a no-op if a
|
|
420
|
-
// tool/narrative already opened the card, and gated by `mayOpenActivityCard`
|
|
421
|
-
// (lever 1/4) so it never opens below a delivered answer. Scoped to real
|
|
422
|
-
// turns by construction: only the `enqueue` lifecycle event reaches here,
|
|
423
|
-
// and anonymous one-shot hook clients (recall.py) never emit it.
|
|
424
|
-
scheduleEarlyLivenessOpen(next)
|
|
425
|
-
// Status-surface observability: one line at every turn SET so a later
|
|
426
|
-
// dark card is traceable to which turn/topic key it belonged to.
|
|
427
|
-
process.stderr.write(
|
|
428
|
-
`telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
|
|
429
|
-
)
|
|
430
|
-
// Component 3 — retain in the bounded recently-ended registry so a
|
|
431
|
-
// LATE reply (landing after currentTurn flips to a successor) can
|
|
432
|
-
// still resolve THIS turn's origin thread by its turnId.
|
|
433
|
-
rememberRecentTurn(next)
|
|
434
|
-
// Component 5 (Hook B) — this turn's topic had a queued placeholder
|
|
435
|
-
// from Hook A; promote it to "On it — replying now." (deleted later
|
|
436
|
-
// when the answer lands). No-op when there's no placeholder / DM.
|
|
437
|
-
promoteQueuedStatus(ev.chatId, enqThreadIdNum)
|
|
438
|
-
// Ack inbound delivery (the marko drop-wedge): claude actually started
|
|
439
|
-
// this turn, so its delivered inbound landed — stop tracking it for
|
|
440
|
-
// re-delivery. `enqueue` carries the same chat/thread the inbound was
|
|
441
|
-
// keyed on, so the key matches.
|
|
702
|
+
// Ack inbound delivery (the marko drop-wedge): the message reached
|
|
703
|
+
// claude's queue, so its delivered inbound landed — stop tracking it
|
|
704
|
+
// for re-delivery. `enqueue` carries the same chat/thread the inbound
|
|
705
|
+
// was keyed on, so the key matches.
|
|
706
|
+
//
|
|
707
|
+
// #3927: this ack stays on the ENQUEUE event and did NOT move into
|
|
708
|
+
// `beginTurn` with the rest of the old body. It asserts RECEIPT, not
|
|
709
|
+
// turn start — and receipt is exactly what an enqueue proves. A parked
|
|
710
|
+
// envelope whose terminal turns out to be `remove` (folded into the
|
|
711
|
+
// running turn) never reaches `beginTurn` at all, so acking there would
|
|
712
|
+
// leave that message tracked forever and the delivery machine would
|
|
713
|
+
// re-deliver it: a duplicate inbound.
|
|
442
714
|
if (DELIVERY_CONFIRM_ENABLED) {
|
|
443
|
-
// Match on the source message id: `enqueue` fires for EVERY
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
// turn clear a real user message still waiting under the
|
|
715
|
+
// Match on the source message id: `enqueue` fires for EVERY queued
|
|
716
|
+
// message regardless of source (cron / subagent-handback /
|
|
717
|
+
// vault-resume / restart-marker too), so a key-only ack would let a
|
|
718
|
+
// synthetic turn clear a real user message still waiting under the
|
|
719
|
+
// same key.
|
|
447
720
|
ackDelivery(
|
|
448
721
|
deliveryQueue,
|
|
449
722
|
chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
|
|
@@ -457,97 +730,81 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
457
730
|
ev.rawContent,
|
|
458
731
|
)
|
|
459
732
|
}
|
|
460
|
-
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
461
|
-
// machine. `enqueue` fires for EVERY turn atom regardless of
|
|
462
|
-
// source — inbound, cron, subagent-handback, vault-resume,
|
|
463
|
-
// restart-marker — so it is the single chokepoint that captures
|
|
464
|
-
// the non-inbound turns the machine's own `inbound` event never
|
|
465
|
-
// sees (those bypass handleInbound). Without it the machine reads
|
|
466
|
-
// idle during a cron/handback turn and the gate would mis-deliver
|
|
467
|
-
// a concurrent inbound mid-turn (the #1556 composer wedge).
|
|
468
|
-
// Idempotent when already in_turn (turnStart only sets perKey).
|
|
469
|
-
shadowEmit({
|
|
470
|
-
kind: 'turnStart',
|
|
471
|
-
key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined) as _ChatKey,
|
|
472
|
-
at: startedAt,
|
|
473
|
-
})
|
|
474
|
-
// #549 fix — fresh turn, reset preamble-suppression state.
|
|
475
|
-
preambleSuppressor.reset()
|
|
476
|
-
// Reset the silent-end retry budget for this chat. The stored
|
|
477
|
-
// turnKey is `chat:thread` shape (no per-instance suffix), so
|
|
478
|
-
// without an explicit per-turn clear, `writeSilentEndState`
|
|
479
|
-
// (silent-end.ts:114) inherits `retryCount` across turns
|
|
480
|
-
// whenever a prior turn for the same chat hit retryCount=1.
|
|
481
|
-
// The Stop hook then sees `retryCount >= MAX_RETRIES=1` on the
|
|
482
|
-
// very first silent-end of every subsequent turn and bails
|
|
483
|
-
// without re-prompting. finn hit this on 2026-05-25 with a
|
|
484
|
-
// stuck retryCount=1 file. A new turn invalidates any prior
|
|
485
|
-
// turn's retry budget by definition; clear it eagerly here.
|
|
486
|
-
// ev.threadId is `string | null` (Telegram's wire shape);
|
|
487
|
-
// statusKey wants `number | null` — same conversion as the
|
|
488
|
-
// registry-key branch a few lines down.
|
|
489
|
-
clearSilentEndState(statusKey(
|
|
490
|
-
ev.chatId,
|
|
491
|
-
ev.threadId != null ? Number(ev.threadId) : null,
|
|
492
|
-
))
|
|
493
|
-
// Stage 3b: stamp turn-start in the registry. turn_key is
|
|
494
|
-
// chat:thread:startTs — unique per turn, distinct from the
|
|
495
|
-
// progress-card-driver's per-chat sequence number (these are two
|
|
496
|
-
// independent identifier schemes and don't need to align).
|
|
497
|
-
if (turnsDb != null) {
|
|
498
|
-
// ev.threadId is `string | null` (Telegram emits as string); convert
|
|
499
|
-
// to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
|
|
500
|
-
// to '_' — same as the explicit `null` branch below.
|
|
501
|
-
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
|
|
502
|
-
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
|
|
503
|
-
next.registryKey = turnKey
|
|
504
|
-
// Phase 1 of #332: capture first ~200 chars of the user's message.
|
|
505
|
-
const userPromptPreview = extractUserPromptPreview(ev.rawContent)
|
|
506
|
-
// Closes #472 finding #11. Pre-fix: this write was scheduled
|
|
507
|
-
// via setImmediate to "avoid stalling the turn handler" — but
|
|
508
|
-
// SQLite local writes are sub-millisecond, and the deferral
|
|
509
|
-
// opened a SIGTERM race window: a kill landing in the gap
|
|
510
|
-
// between scheduling and firing left a turn with no start
|
|
511
|
-
// row, invisible to the resume protocol (the user sent a
|
|
512
|
-
// message, the gateway lost it, no SWITCHROOM_PENDING_TURN
|
|
513
|
-
// env on next boot). Sibling writeTurnActiveMarker has always
|
|
514
|
-
// been synchronous here; this matches it.
|
|
515
|
-
try {
|
|
516
|
-
recordTurnStart(turnsDb, {
|
|
517
|
-
turnKey,
|
|
518
|
-
chatId: String(ev.chatId),
|
|
519
|
-
threadId: ev.threadId != null ? String(ev.threadId) : null,
|
|
520
|
-
lastUserMsgId: ev.messageId != null ? String(ev.messageId) : null,
|
|
521
|
-
userPromptPreview,
|
|
522
|
-
})
|
|
523
|
-
} catch (err) {
|
|
524
|
-
process.stderr.write(`telegram gateway: recordTurnStart failed turnKey=${turnKey}: ${(err as Error).message}\n`)
|
|
525
|
-
}
|
|
526
|
-
// #412: turn-active marker for the bridge-watchdog. File exists
|
|
527
|
-
// for the duration of the in-flight turn; mtime advances on
|
|
528
|
-
// every tool_use; deleted on turn_complete. The watchdog
|
|
529
|
-
// distinguishes wedged-mid-turn from healthy-idle by checking
|
|
530
|
-
// for this file's presence + mtime staleness.
|
|
531
|
-
writeTurnActiveMarker(STATE_DIR, {
|
|
532
|
-
turnKey,
|
|
533
|
-
chatId: String(ev.chatId),
|
|
534
|
-
threadId: ev.threadId != null ? String(ev.threadId) : null,
|
|
535
|
-
startedAt,
|
|
536
|
-
})
|
|
537
|
-
}
|
|
538
|
-
// (accessor-narrowing spelling: the pre-move body guarded the
|
|
539
|
-
// `pendingPtyPartial` variable then re-read it into `pending`; the
|
|
540
|
-
// injected accessor is a call expression TS can't narrow across, so
|
|
541
|
-
// capture once then guard the local — equivalent, no await between.)
|
|
542
|
-
const pending = getPendingPtyPartial()
|
|
543
|
-
if (pending != null) {
|
|
544
|
-
setPendingPtyPartial(null)
|
|
545
|
-
handlePtyPartial(pending)
|
|
546
|
-
}
|
|
547
733
|
}
|
|
734
|
+
pruneParkedTurnStarts(now)
|
|
735
|
+
// `enqueue` with no chat can never mint a turn (the whole mint block is
|
|
736
|
+
// `if (ev.chatId)`-gated); parking it would only pollute the store, so
|
|
737
|
+
// run the pre-turn drain path verbatim and stop.
|
|
738
|
+
if (!ev.chatId) {
|
|
739
|
+
beginTurn(deps, ev)
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
// The live-turn probe is the module's ONLY turn accessor: the
|
|
743
|
+
// most-recently-set turn mirror. Under the sequential-CLI invariant that
|
|
744
|
+
// IS the live turn, and `endedAt` (stamped in turn-end.ts) is the second
|
|
745
|
+
// guard for a mirror that outlived its turn. A non-empty parked store is
|
|
746
|
+
// equally disqualifying: the CLI's queue is not drained, so this message
|
|
747
|
+
// is queued BEHIND those and minting now would reorder them.
|
|
748
|
+
const live = getCurrentTurn()
|
|
749
|
+
const sessionBusy = live != null && live.endedAt == null
|
|
750
|
+
if (!sessionBusy && parkedTurnStarts.length === 0) {
|
|
751
|
+
beginTurn(deps, ev)
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
// PARKED. Deliberately NO surface work here — no `promoteQueuedStatus`
|
|
755
|
+
// ("On it — replying now" is a lie until the turn actually starts), no
|
|
756
|
+
// `typingWrapper.drainAll()` (that drains the LIVE turn's wraps), no card.
|
|
757
|
+
// The queued placeholder Hook A already posted is the honest surface, and
|
|
758
|
+
// its lifecycle stays owned by the existing reap machinery.
|
|
759
|
+
//
|
|
760
|
+
// UNIFORM ACROSS SOURCES — synthetic enqueues (cron fire, subagent
|
|
761
|
+
// handback, obligation-represent, vault-grant resume, wake inbound) park
|
|
762
|
+
// exactly like a real inbound and do NOT preempt. That is not a policy
|
|
763
|
+
// choice, it is what the CLI does: carrie's `obligation_represent`
|
|
764
|
+
// enqueue at 2026-07-28T18:19:07.032Z was terminated by a `remove` at
|
|
765
|
+
// 18:19:59.794Z (folded into the running turn as a `queued_command`
|
|
766
|
+
// attachment), never by a `dequeue`. Minting for it invented a turn the
|
|
767
|
+
// CLI never started. FIX B covers the residual case where a mint DOES
|
|
768
|
+
// land on top of a live atom.
|
|
769
|
+
parkTurnStart(
|
|
770
|
+
{
|
|
771
|
+
chatId: ev.chatId,
|
|
772
|
+
messageId: ev.messageId,
|
|
773
|
+
threadId: ev.threadId,
|
|
774
|
+
rawContent: ev.rawContent,
|
|
775
|
+
},
|
|
776
|
+
now,
|
|
777
|
+
)
|
|
778
|
+
process.stderr.write(
|
|
779
|
+
`telegram gateway: turn-start parked (session busy) chat=${ev.chatId} ` +
|
|
780
|
+
`thread=${enqThreadIdNum ?? '-'} msg=${ev.messageId ?? '-'} ` +
|
|
781
|
+
`parked=${parkedTurnStarts.length}\n`,
|
|
782
|
+
)
|
|
783
|
+
return
|
|
784
|
+
}
|
|
785
|
+
case 'dequeue': {
|
|
786
|
+
// #3927 FIX A — the CLI's authoritative TURN-START signal: the queue was
|
|
787
|
+
// drained into a new user turn. Carries no ids (session-tail.ts), so the
|
|
788
|
+
// pairing is positional — and the evidence says it pairs with the MOST
|
|
789
|
+
// RECENT enqueue, not the oldest. An empty store is the normal idle path
|
|
790
|
+
// (the enqueue ms earlier already minted) and a dequeue with no parked
|
|
791
|
+
// start at all is a no-op, exactly as before.
|
|
792
|
+
pruneParkedTurnStarts(Date.now())
|
|
793
|
+
const parked = takeParkedTurnStart()
|
|
794
|
+
if (parked == null) return
|
|
795
|
+
beginTurn(deps, parked)
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
case 'queue_remove': {
|
|
799
|
+
// #3927 FIX A — the queued message was folded into the ALREADY-RUNNING
|
|
800
|
+
// turn (a `queued_command` attachment); it will never own a turn. Drop
|
|
801
|
+
// its parked envelope so a later `dequeue` cannot mint a spurious turn
|
|
802
|
+
// for it. Content-matched: `remove` replays its enqueue's `content`
|
|
803
|
+
// byte-for-byte.
|
|
804
|
+
pruneParkedTurnStarts(Date.now())
|
|
805
|
+
discardParkedTurnStart(ev.rawContent)
|
|
548
806
|
return
|
|
549
807
|
}
|
|
550
|
-
case 'dequeue': return
|
|
551
808
|
case 'model': {
|
|
552
809
|
// Live model capture for the main turn. The session-tail projection
|
|
553
810
|
// already filtered sentinels (`<synthetic>` compaction lines), so any
|