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.
@@ -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
- // Drain any orphaned typing-wrap entries left over from a crashed
213
- // prior turn before resetting focus.
214
- typingWrapper.drainAll()
215
- if (ev.chatId) {
216
- // #1445 cross-turn pending-async ambient backstop for the
217
- // `handleInbound` path's `clearPending('inbound')`. The
218
- // inbound path covers real user messages, but synthesised
219
- // wakes (subagent-handback channel turn, cron fires, vault
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
- // Issue #195: if a previous turn left an answer-lane stream open
236
- // (rapid steer/queue), force it to a new generation so its in-flight
237
- // edits don't mutate the new turn's message. Materialize is best-effort
238
- // we don't await here because turn_end on the prior turn should
239
- // have already done it; this is a defensive supersession guard.
240
- const prior = getCurrentTurn()
241
- if (prior?.answerStream != null) {
242
- prior.answerStream.forceNewMessage()
243
- prior.answerStream.stop()
244
- prior.answerStream = null
245
- }
246
- // Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
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 turn
444
- // start (cron / subagent-handback / vault-resume / restart-marker
445
- // too see comment below), so a key-only ack would let a synthetic
446
- // turn clear a real user message still waiting under the same key.
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