switchroom 0.19.17 → 0.19.18

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.
Files changed (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8306 -7619
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -38,10 +38,12 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
38
38
  SILENCE_FALLBACK_HARD_MS,
39
39
  SILENCE_FLOOR_MS,
40
40
  SILENCE_DEFER_INFLIGHT_TOOLS,
41
+ isObligationOpenForTurn,
41
42
  TURN_PREVIEW_MAX,
42
43
  STATE_DIR,
43
44
  isLegitimatelyWorking,
44
45
  getCurrentTurn,
46
+ getCurrentTurnForKey,
45
47
  getInFlightUpdate,
46
48
  getTurnsDb,
47
49
  getInboundSpool,
@@ -68,6 +70,23 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
68
70
  thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
69
71
  deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
70
72
  isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
73
+ // #3552 — orphan-state reaper predicate. Deliberately the SAME condition the
74
+ // `onFrameworkFallback` late-fire guard below uses: no `activeTurnStartedAt`
75
+ // entry AND no current turn ⇒ the turn this state belongs to is over. Note
76
+ // `activeTurnStartedAt` alone is NOT sufficient — `releaseTurnBufferGate`
77
+ // clears it mid-turn on every final-answer reply while the turn keeps running
78
+ // (post-answer housekeeping), and silence-poke must keep watching that turn.
79
+ //
80
+ // #3580 — the second clause is read KEYED (`getCurrentTurnForKey(key)`, i.e.
81
+ // `currentTurnMap.get(key)`), never the bare `getCurrentTurn()` singleton.
82
+ // Under `SWITCHROOM_EMISSION_AUTHORITY=1` that singleton is a MOST-RECENT-SET
83
+ // MIRROR, so an unkeyed read answers about whichever topic started a turn last
84
+ // — not about `key`. Combined with the buffer-gate release above, topic A
85
+ // (live, gate cleared mid-turn) read DEAD as soon as topic B started and ended
86
+ // a turn, and the tick false-reaped A: permanent and silent, nothing re-arms
87
+ // until the next `startTurn`, so A loses both its mid-turn beat and its 300 s
88
+ // #1122 unwedge. Flag-OFF the keyed read IS the singleton, byte-for-byte.
89
+ isTurnLive: (key) => !(activeTurnStartedAt.get(key) == null && getCurrentTurnForKey(key) == null),
71
90
  emitMetric: (event) => {
72
91
  // Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
73
92
  emitRuntimeMetric(event)
@@ -96,8 +115,10 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
96
115
  // retired in #2667.
97
116
  onMidTurnFloor: async (ctx) => {
98
117
  // Late-fire guard, mirroring the fallback: a clean turn-end can race the
99
- // tick. If the turn is gone, stay silent.
100
- if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) return
118
+ // tick. If the turn is gone, stay silent. #3580 — keyed read; an unkeyed
119
+ // `getCurrentTurn()` here answers about another topic's turn (see the
120
+ // `isTurnLive` note above) and silences a live turn's approval re-ping.
121
+ if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) return
101
122
  const blockedOnApproval = activeStatusReactions
102
123
  .get(statusKey(ctx.chatId, ctx.threadId))
103
124
  ?.isAwaiting() ?? false
@@ -142,7 +163,18 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
142
163
  // events (124 of 138 `currentTurn_nulled=false` cases). Distinct
143
164
  // log line so observability still tracks the fact that the silence
144
165
  // crossed threshold; the wedge counter is no longer polluted.
145
- if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurn() == null) {
166
+ //
167
+ // #3552: with the `isTurnLive` reaper wired above, this branch is now only
168
+ // the sub-poll-interval race (the turn ends between the tick's reap check
169
+ // and this handler running). The steady-state case it used to absorb —
170
+ // state armed for the full 300s against a turn that ended minutes earlier,
171
+ // 504 events in 14 days vs 110 real fires — is reaped at the tick instead,
172
+ // so this counter finally reads as the genuine race it names.
173
+ //
174
+ // #3580 — keyed read, same reason as `isTurnLive`: unkeyed, a sibling
175
+ // topic's turn flip made this guard skip the 300 s unwedge for a genuinely
176
+ // wedged turn (the #1122 permanent-wedge class) and log it as a clean race.
177
+ if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) {
146
178
  process.stderr.write(
147
179
  `telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
148
180
  `turn ended cleanly during silence window ` +
@@ -259,6 +291,21 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
259
291
  // stays null and we skip the send. The turn-teardown below (the unwedge —
260
292
  // the one job the live draft can't do, per the conversational-pacing RFC)
261
293
  // still runs unconditionally.
294
+ // #3551 L1/L2 — the teardown-notice suppression signal. It must be narrower
295
+ // than "some text went out", and it must mean DELIVERED.
296
+ //
297
+ // L1: `text` has two sources with opposite meanings. The approval re-ping is
298
+ // a LOUD, user-addressed message that explains why nothing is happening and
299
+ // asks the user to act — stacking a teardown notice on it is noise. The
300
+ // deterministic `formatUpdateStatusLine` is a SILENT status surface about an
301
+ // unrelated in-flight `update_apply`; it says nothing about this turn ending.
302
+ // A user who asked a question during an update and got killed anyway is
303
+ // EXACTLY the #3551 case, so that line must not suppress the notice.
304
+ //
305
+ // L2: computed from send SUCCESS, not from `text != null`. The send sits in a
306
+ // try/catch that only logs, so a throwing approval re-ping would otherwise
307
+ // suppress the notice while the user received nothing at all.
308
+ let userAddressedTextDelivered = false
262
309
  if (text != null) {
263
310
  try {
264
311
  // Conditional: when the turn is parked on an approval card, this
@@ -268,6 +315,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
268
315
  // line stays SILENT (a status surface). Gate on `blockedOnApproval`.
269
316
  // The send stays in gateway.ts behind the bot-api retry policy.
270
317
  await sendSilenceText(ctx.chatId, ctx.threadId ?? null, text, blockedOnApproval ? false : true)
318
+ userAddressedTextDelivered = blockedOnApproval
271
319
  } catch (err) {
272
320
  process.stderr.write(
273
321
  `silence-poke fallback sendMessage failed chat=${ctx.chatId} thread=${ctx.threadId}: ${err}\n`,
@@ -299,6 +347,18 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
299
347
  wedgedTurn != null &&
300
348
  wedgedTurn.sessionChatId === fbChatId &&
301
349
  wedgedTurn.sessionThreadId === fbThreadId
350
+ // #3580 L3 — the ONE liveness decision this fire makes about the wedged
351
+ // turn. It gates the teardown below AND is what `decideFallbackTeardownNotice`
352
+ // is told as `tearsDownLiveTurn`, so the notice can never claim a teardown
353
+ // the gate skipped (previously the notice got the looser bare
354
+ // `turnMatchesFallback`, and a user could be told "the framework ended that
355
+ // stalled turn" about a turn that never ended). Evaluated HERE — before the
356
+ // `purgeChatStale` self-heal and before `endCurrentTurnForKey`, both of
357
+ // which drop the very entry `turnLiveForItsTopic` reads — so it answers
358
+ // "was this turn live when the fallback fired", the question both the
359
+ // teardown and the notice actually mean.
360
+ const tearsDownLiveTurn =
361
+ turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
302
362
  const turnStartedAt = activeTurnStartedAt.get(fbKey)
303
363
  if (turnStartedAt != null) {
304
364
  const turnDurationMs = Date.now() - turnStartedAt
@@ -424,7 +484,14 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
424
484
  // Flag-ON: `byKey.get(fbKey) === wedgedTurn`, so the keyed delete still
425
485
  // fires when the LIVE mirror has already flipped to another topic B (a bare
426
486
  // `currentTurn === wedgedTurn` would falsely skip and leak A's byKey entry).
427
- if (turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)) {
487
+ //
488
+ // #3580 L3 — the gate is the `tearsDownLiveTurn` const computed above, the
489
+ // SAME value handed to `decideFallbackTeardownNotice`. Do not re-inline the
490
+ // condition here: it must be evaluated before the `purgeChatStale` sweep
491
+ // above (which can drop this key's entry and so flip
492
+ // `turnLiveForItsTopic`), and the notice must speak for exactly what this
493
+ // gate did.
494
+ if (tearsDownLiveTurn && wedgedTurn != null) {
428
495
  // Status-surface observability: emit the lifecycle CLEAR for the
429
496
  // silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
430
497
  // line like every other clear path (the framework-fallback line below is
@@ -439,6 +506,56 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
439
506
  // the mirror iff it still points here — a live sibling topic is untouched.
440
507
  endCurrentTurnForKey(wedgedTurn, fbKey)
441
508
  }
509
+ // #3551 — the teardown NOTICE. Everything above just ENDED a live turn. On
510
+ // the non-approval branch `text` is null (`formatFrameworkFallbackText`
511
+ // returns a string only when parked on an approval card), so before this
512
+ // the user saw literally nothing: N minutes of silence, then the agent
513
+ // apparently starting the same request over. Killing a user's turn with no
514
+ // user-visible signal is a correctness bug, not a tuning knob — 110 fires /
515
+ // 14 days, ~9h of dead air (#3551). The teardown itself is NOT removed (it
516
+ // is the #1122 unwedge, the fallback's one load-bearing job); it is made
517
+ // observable. Sent AFTER the teardown so the notice cannot itself be
518
+ // clobbered by the state purge, and gated by `decideFallbackTeardownNotice`
519
+ // so it can only ever speak for a human-waiting, undelivered turn that this
520
+ // fire actually killed — at most once, since `endTurn(fbKey)` above dropped
521
+ // the key.
522
+ const teardownNotice = silencePoke.decideFallbackTeardownNotice({
523
+ tearsDownLiveTurn,
524
+ role: wedgedTurn?.role ?? null,
525
+ finalAnswerDelivered: wedgedTurn?.finalAnswerDelivered ?? false,
526
+ userAddressedTextDelivered,
527
+ // #3575 review B1 — this must be a question about THIS turn, not a static
528
+ // env flag. `OBLIGATION_LEDGER_ENABLED` is true for the whole process, so
529
+ // it promised "being re-asked now" in cases where no re-ask can ever
530
+ // happen: the obligation already hit OBLIGATION_REPRESENT_MAX (2) and
531
+ // escalated+closed, or it was closed silently by an outbound-since-open
532
+ // (obligation-wiring.ts:230/:294), or the inbound never opened one at all
533
+ // (synthetic / steering / interrupt). The user was then told to wait for a
534
+ // re-ask that never comes. Ask the ledger about this turn's own origin id
535
+ // instead, so the honest "please re-send it" branch fires when there is no
536
+ // open obligation. The lookup is deliberately made HERE, after the
537
+ // teardown above (which does not touch the ledger), so it reflects the
538
+ // ledger state at the moment we speak.
539
+ representWillFollow: isObligationOpenForTurn(wedgedTurn?.turnId ?? null),
540
+ silenceMs: ctx.silenceMs,
541
+ })
542
+ if (teardownNotice.send) {
543
+ try {
544
+ // LOUD (disable_notification: false). The user asked a question and is
545
+ // being told their attempt was killed — that is exactly the case where
546
+ // silence would train them to distrust the channel.
547
+ await sendSilenceText(fbChatId, ctx.threadId ?? null, teardownNotice.text, false)
548
+ } catch (err) {
549
+ process.stderr.write(
550
+ `silence-poke teardown notice sendMessage failed chat=${fbChatId} thread=${ctx.threadId}: ${err}\n`,
551
+ )
552
+ }
553
+ } else {
554
+ process.stderr.write(
555
+ `telegram gateway: silence-poke teardown notice skipped reason=${teardownNotice.reason} ` +
556
+ `chat=${fbChatId} thread=${ctx.threadId ?? '-'}\n`,
557
+ )
558
+ }
442
559
  // Best-effort: clear any pending silent-end marker so the Stop hook
443
560
  // doesn't double-block when claude eventually exits the wedged turn.
444
561
  try {
@@ -464,7 +581,10 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
464
581
  process.stderr.write(
465
582
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
466
583
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
467
- `currentTurn_nulled=${turnMatchesFallback} ` +
584
+ // #3580 L3 — report the gate that actually ran, not the looser
585
+ // chat/thread match: `turnMatchesFallback` was true in cases where the
586
+ // keyed liveness check skipped the teardown, so the field lied.
587
+ `currentTurn_nulled=${tearsDownLiveTurn} ` +
468
588
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
469
589
  `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
470
590
  `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
@@ -242,6 +242,10 @@ export class ObligationLedger {
242
242
  * since `lastRepresentedAt`. Without this, the 5s sweep can fire again before
243
243
  * the re-presented turn even reaches the agent, burning the represent budget
244
244
  * immediately and producing back-to-back escalations on the same message.
245
+ * #3550: this window is retired early once a turn that started after the
246
+ * re-present has ENDED — at that point the trailing-answer grace above is the
247
+ * one covering the real risk, and holding the rest of the represent window is
248
+ * pure latency. See `representGraceStillProtecting`.
245
249
  */
246
250
  decideAtIdle(opts?: {
247
251
  now: number
@@ -267,6 +271,80 @@ export class ObligationLedger {
267
271
  return { action: 'represent', obligation: o }
268
272
  }
269
273
 
274
+ /**
275
+ * #3550 — is the per-represent grace still doing real work for this
276
+ * obligation? Pure so the rule is testable in isolation.
277
+ *
278
+ * The per-represent grace exists for ONE reason, stated at its definition:
279
+ * "the 5s sweep can fire again before the re-presented turn even reaches the
280
+ * agent". It is a proxy for "the re-present has not landed yet". Once a turn
281
+ * that started AFTER the re-present has ENDED, the proxy is spent — the
282
+ * re-present demonstrably reached the agent, the agent ran a whole turn on
283
+ * it, and that turn is over. Holding the remaining ~110s of a 120s window
284
+ * open at that point delays the next rung of a ladder that is already
285
+ * bounded by `maxRepresents`, for no protective benefit.
286
+ *
287
+ * The obligation is NOT released into the void when this returns false: the
288
+ * trailing-answer grace (`trailingGraceMs` from `lastTurnEndedAt`, 45s by
289
+ * default) is a separate, independently-evaluated window that covers exactly
290
+ * the risk that matters here — a slow answer still landing after its turn
291
+ * ended. So the early-out only applies when that grace is actually armed
292
+ * (`trailingGraceMs > 0`); with it disabled, the represent grace stays the
293
+ * full window rather than leaving the obligation ungated.
294
+ *
295
+ * A re-present with NO ended turn after it — the genuinely in-flight case
296
+ * the grace was written for — is untouched and keeps the full window.
297
+ *
298
+ * FLOOR (`MIN_REPRESENT_INTERVAL_MS`). Retiring the represent window makes the
299
+ * rung interval DERIVED — `trailingGraceMs + the represent-turn's duration`
300
+ * — where it used to be floored by the flat 120s window regardless of how the
301
+ * trailing grace was tuned. Without a floor,
302
+ * `SWITCHROOM_OBLIGATION_ESCALATE_GRACE_MS=1000` (a plausible "make it
303
+ * snappier" tune) collapses the whole ladder to ~1s+d per rung and escalates
304
+ * to the operator within ~15-20s of the original message. That is a config
305
+ * footgun the old code could not have, so it is closed by a MECHANISM, not a
306
+ * doc warning: the early-out is withheld until at least
307
+ * `min(representGraceMs, max(trailingGraceMs, MIN_REPRESENT_INTERVAL_MS))`
308
+ * has elapsed since the re-present. At the defaults (45s trailing / 120s
309
+ * represent) the floor is 45s and never binds — the trailing grace, measured
310
+ * from the strictly-later turn end, always expires after it — so this changes
311
+ * nothing in the shipped configuration.
312
+ */
313
+ static representGraceStillProtecting(
314
+ o: Pick<Obligation, 'lastRepresentedAt' | 'lastTurnEndedAt'>,
315
+ now: number,
316
+ representGraceMs: number,
317
+ trailingGraceMs: number,
318
+ ): boolean {
319
+ if (representGraceMs <= 0) return false
320
+ if (o.lastRepresentedAt == null) return false
321
+ const sinceRepresent = now - o.lastRepresentedAt
322
+ if (sinceRepresent >= representGraceMs) return false
323
+ // Inside the window. Is it still protecting anything?
324
+ const representTurnEnded =
325
+ o.lastTurnEndedAt != null && o.lastTurnEndedAt > o.lastRepresentedAt
326
+ if (representTurnEnded && trailingGraceMs > 0) {
327
+ // Spent — the trailing grace takes over, but never sooner than the floor
328
+ // (and never longer than the represent window the operator configured).
329
+ const floorMs = Math.min(
330
+ representGraceMs,
331
+ Math.max(trailingGraceMs, ObligationLedger.MIN_REPRESENT_INTERVAL_MS),
332
+ )
333
+ return sinceRepresent < floorMs
334
+ }
335
+ return true
336
+ }
337
+
338
+ /**
339
+ * The hard floor on the interval between two rungs of the represent ladder,
340
+ * used by `representGraceStillProtecting`. Independent of how the trailing
341
+ * grace is tuned, an obligation is never acted on again within this long of
342
+ * its own re-present. 30s comfortably exceeds the 5s sweep tick and the
343
+ * round-trip for a re-presented turn to reach the agent and answer, which is
344
+ * the only thing the represent window was ever debouncing.
345
+ */
346
+ static readonly MIN_REPRESENT_INTERVAL_MS = 30_000
347
+
270
348
  /** The oldest open obligation that is currently ELIGIBLE to act on — i.e. NOT
271
349
  * within any grace window:
272
350
  * - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
@@ -275,9 +353,11 @@ export class ObligationLedger {
275
353
  * - background-work grace: when `backgroundWorkActive`, it was opened <
276
354
  * `backgroundGraceMs` ago (genuine in-flight autonomous work — bounded by
277
355
  * the ceiling so a stale/leaked worker can't suppress escalation forever);
278
- * - per-represent grace: it was re-presented < `representGraceMs` ago (prevents
279
- * a 5s sweep tick from immediately firing again on the same obligation before
280
- * the re-presented turn even reaches the agent). */
356
+ * - per-represent grace: it was re-presented < `representGraceMs` ago AND that
357
+ * re-present has not yet produced a completed turn (prevents a 5s sweep tick
358
+ * from immediately firing again before the re-presented turn even reaches the
359
+ * agent — see `representGraceStillProtecting` for why a turn that has already
360
+ * ended retires this window early, #3550). */
281
361
  private oldestEligible(
282
362
  now: number,
283
363
  graceMs: number,
@@ -290,7 +370,7 @@ export class ObligationLedger {
290
370
  if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
291
371
  if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
292
372
  continue // in-flight autonomous work, bounded by the ceiling
293
- if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
373
+ if (ObligationLedger.representGraceStillProtecting(o, now, representGraceMs, graceMs))
294
374
  continue // per-represent grace: sweep fired before re-presented turn landed
295
375
  if (best === undefined || o.openedAt < best.openedAt) best = o
296
376
  }
@@ -458,9 +458,13 @@ export function buildResumeDeferredReportInbound(
458
458
  * the gateway calls this with the classified `ended_via` so the
459
459
  * report-vs-resume policy lives in one testable place.
460
460
  *
461
- * - 'timeout' → 'report' (watchdog kill)
462
- * - 'restart' | 'sigterm' | 'unknown' → 'resume' (clean interrupt)
463
- * - 'stop' → null (finished; nothing to do)
461
+ * - 'timeout' → 'report' (watchdog kill)
462
+ * - 'restart'|'reaped_stale'|'sigterm'|'unknown' → 'resume' (clean interrupt)
463
+ * - 'stop' → null (finished; nothing to do)
464
+ *
465
+ * #3555: `'reaped_stale'` (mid-session stale-row sweep) is treated exactly
466
+ * like `'restart'` here — it is the same clean interrupt, just no longer
467
+ * mislabelled as a restart in the data.
464
468
  */
465
469
  export function selectResumeBuilder(
466
470
  endedVia: TurnEndedVia | null,
@@ -473,7 +477,12 @@ export function selectResumeBuilder(
473
477
  ): 'resume' | 'report' | null {
474
478
  let kind: 'resume' | 'report' | null
475
479
  if (endedVia === 'timeout') kind = 'report'
476
- else if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') kind = 'resume'
480
+ else if (
481
+ endedVia === 'restart' ||
482
+ endedVia === 'reaped_stale' ||
483
+ endedVia === 'sigterm' ||
484
+ endedVia === 'unknown'
485
+ ) kind = 'resume'
477
486
  else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
478
487
  else kind = null
479
488
  if (
@@ -372,10 +372,6 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
372
372
  // `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
373
373
  // EDITS the existing card instead of opening a second one, and so the
374
374
  // turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
375
- // The handback turn also gets the turn-long typing loop it never had —
376
- // whether or not a card was painted (the debounce may not have fired) —
377
- // stopped by the canonical turn-end (`purgeReactionTracking →
378
- // stopTurnTypingLoop`).
379
375
  if (HANDBACK_PRETURN_ENABLED) {
380
376
  const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
381
377
  if (handbackAdoption != null) {
@@ -383,9 +379,32 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
383
379
  next.activityMessageId = handbackAdoption.activityMessageId
384
380
  next.activityEverOpened = true
385
381
  }
386
- startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
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
+ )
387
388
  }
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)
389
408
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
390
409
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
391
410
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is