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.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8306 -7619
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
279
|
-
*
|
|
280
|
-
* the re-presented turn even reaches the
|
|
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 (
|
|
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'
|
|
462
|
-
* - 'restart'
|
|
463
|
-
* - 'stop'
|
|
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 (
|
|
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
|
-
|
|
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
|