switchroom 0.20.2 → 0.20.4
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/handoff-briefing.sh +41 -1
- package/dist/cli/switchroom.js +10259 -1232
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -3
- package/profiles/default/CLAUDE.md.hbs +13 -11
- package/telegram-plugin/dist/gateway/gateway.js +429 -98
- package/telegram-plugin/edit-flood-fuse.ts +332 -14
- package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
- package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
- package/telegram-plugin/gateway/gateway.ts +128 -104
- package/telegram-plugin/gateway/narrative-lane.ts +4 -0
- package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
- package/telegram-plugin/gateway/stream-render.ts +67 -12
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
- package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
- package/telegram-plugin/registry/subagents-schema.ts +61 -0
- package/telegram-plugin/registry/turns-schema.ts +26 -0
- package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
- package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
- package/telegram-plugin/tests/progress-cap.test.ts +182 -0
- package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
- package/telegram-plugin/tests/progress-update.test.ts +108 -12
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
|
@@ -204,6 +204,45 @@
|
|
|
204
204
|
* on the persisted path exactly the "a nudge and a 4.4h ban are the same
|
|
205
205
|
* signal" defect that #3856 had just fixed on the in-memory path.
|
|
206
206
|
*
|
|
207
|
+
* ── 2026-08 (#4300): one shared cosmetic bucket starved the watched card ──
|
|
208
|
+
* Point 2's `cosmeticPerChatMaxPerWindow` (6/60s) is ONE bucket shared across
|
|
209
|
+
* every cosmetic surface in a chat — the primary activity card, the worker /
|
|
210
|
+
* sub-agent card, and answer-stream draft edits. With two live cards plus a
|
|
211
|
+
* draft contending, the surface a user actually watches — the primary activity
|
|
212
|
+
* card — worst-cased well past 60s between permitted frames, purely from
|
|
213
|
+
* intra-cosmetic contention (not event starvation: the 6s heartbeat is trying
|
|
214
|
+
* to repaint). AIMD tightening halves the bucket and makes it worse. Two
|
|
215
|
+
* changes, both confined to this file, restore fairness WITHOUT raising the
|
|
216
|
+
* wire rate (so 429 risk is unchanged) and WITHOUT touching the reply reserve:
|
|
217
|
+
*
|
|
218
|
+
* 9. **Per-`message_id` fair-share of the cosmetic pool.** The shared bucket
|
|
219
|
+
* is split into a FLOOR lane — each distinct cosmetic `message_id` gets a
|
|
220
|
+
* guaranteed `cosmeticFloorPerWindow` edits/window (default 1/30s), up to
|
|
221
|
+
* `cosmeticFloorSlots` ids at once — and a REMAINDER lane (the rest of the
|
|
222
|
+
* pool), still shared and AIMD-reduced. The two caps SUM to
|
|
223
|
+
* `cosmeticPerChatMaxPerWindow`, so nothing new reaches the wire; a lone
|
|
224
|
+
* card still bursts to `cosmeticPerMessageMaxPerWindow` (its floor + the
|
|
225
|
+
* whole remainder). No surface can starve another, and no primary-vs-not
|
|
226
|
+
* discriminator is needed — the floor is symmetric per message id. Stale
|
|
227
|
+
* ids self-evict through the existing window LRU. See
|
|
228
|
+
* {@link createEditFloodFuse}'s `awaitCosmeticChatFair`.
|
|
229
|
+
* 10. **An AIMD tighten floor on the watched card.** The per-message cosmetic
|
|
230
|
+
* ceiling and the floor lane are both held at `cosmeticFloorPerWindow`
|
|
231
|
+
* (1/30s) no matter how many 429s tighten the pool — the same "floor the
|
|
232
|
+
* thing users watch" pattern as `perChatCriticalMinPerWindow`, so a flood
|
|
233
|
+
* backoff slows a card without freezing it. The aggregate cosmetic floor
|
|
234
|
+
* under maximum tightening is `cosmeticFloorPerWindow * cosmeticFloorSlots`
|
|
235
|
+
* (4/60s by default), which sits at the BOTTOM of the 4-6/min band the
|
|
236
|
+
* 2026-07-27 chat provably survived for hours — a deliberate, bounded
|
|
237
|
+
* relaxation of "cosmetic sheds to zero", reversible via the kill-switch
|
|
238
|
+
* `SWITCHROOM_FEED_FAIR_SHARE=0` (flag OFF is byte-identical to the old
|
|
239
|
+
* single shared bucket). The reply-starvation guarantee is UNTOUCHED: a
|
|
240
|
+
* floored cosmetic edit still charges `perChatTotalMaxPerWindow` and still
|
|
241
|
+
* yields to a real reply at that tier — the floor governs cosmetic-vs-
|
|
242
|
+
* cosmetic only. When the fuse defers a cosmetic edit past
|
|
243
|
+
* `throttleNoticeMs` (45s) it raises a `'throttled'` `onTrip` signal so the
|
|
244
|
+
* lag is observable rather than a silent freeze.
|
|
245
|
+
*
|
|
207
246
|
* Every ceiling is operator-overridable via env — see
|
|
208
247
|
* {@link editFloodFuseConfigFromEnv}. Previously none of them were, and until
|
|
209
248
|
* #3885 the tightening CURVE (`maxTightenLevel`, `tightenFactor`) still was
|
|
@@ -332,6 +371,55 @@ export interface EditFloodFuseConfig {
|
|
|
332
371
|
* own per-message ceiling still add up to a flood.
|
|
333
372
|
*/
|
|
334
373
|
cosmeticPerChatMaxPerWindow?: number
|
|
374
|
+
/**
|
|
375
|
+
* PER-`message_id` FAIRNESS inside the shared cosmetic per-chat pool. Default
|
|
376
|
+
* true. When true, `cosmeticPerChatMaxPerWindow` is not a single contended
|
|
377
|
+
* bucket every cosmetic surface races for — instead each distinct cosmetic
|
|
378
|
+
* `message_id` is guaranteed a small floor of the pool (see
|
|
379
|
+
* `cosmeticFloorPerWindow` / `cosmeticFloorSlots`) that the OTHER cosmetic
|
|
380
|
+
* surfaces cannot consume, and only the REMAINDER stays shared/contended as
|
|
381
|
+
* before. This is the anti-starvation fix (#4300): with two live cosmetic
|
|
382
|
+
* cards (the primary activity card + a worker/sub-agent card) plus answer
|
|
383
|
+
* draft edits all charging one 6/60s bucket, the primary card worst-cased
|
|
384
|
+
* well past 60s between permitted frames purely from intra-cosmetic
|
|
385
|
+
* contention. Flag OFF is byte-identical to the pre-fix single shared bucket.
|
|
386
|
+
*
|
|
387
|
+
* SAFETY: the floor is carved FROM `cosmeticPerChatMaxPerWindow`, never added
|
|
388
|
+
* on top, so the wire rate is unchanged (429 risk does not rise) and the 40%
|
|
389
|
+
* per-chat reply reserve (`perChatReplyReserve`, on `perChatTotalMaxPerWindow`)
|
|
390
|
+
* is untouched — a floored cosmetic edit STILL yields to a real reply at the
|
|
391
|
+
* shared total tier. This governs cosmetic-vs-cosmetic only.
|
|
392
|
+
*/
|
|
393
|
+
cosmeticFairShareEnabled?: boolean
|
|
394
|
+
/**
|
|
395
|
+
* The per-`message_id` guaranteed floor, in edits per `perChatWindowMs`,
|
|
396
|
+
* carved from `cosmeticPerChatMaxPerWindow`. Default 2 = 1 edit / 30s. This is
|
|
397
|
+
* ALSO the AIMD tighten floor: a floored cosmetic message never drops below
|
|
398
|
+
* this rate no matter how many 429s tighten the pool (mirrors
|
|
399
|
+
* `perChatCriticalMinPerWindow` — "floor the thing users watch"), so a flood
|
|
400
|
+
* backoff can slow a watched card but not freeze it. Set 0 to disable the
|
|
401
|
+
* floor while leaving fair-share keying on.
|
|
402
|
+
*/
|
|
403
|
+
cosmeticFloorPerWindow?: number
|
|
404
|
+
/**
|
|
405
|
+
* How many DISTINCT cosmetic `message_id`s may each hold a guaranteed floor
|
|
406
|
+
* concurrently. Default 2 — the two sustained mid-turn cards (primary
|
|
407
|
+
* activity + worker feed). `cosmeticFloorPerWindow * cosmeticFloorSlots` is
|
|
408
|
+
* the slice reserved from the pool; the rest is the shared remainder, so this
|
|
409
|
+
* is clamped so the reserve can never exceed the pool. Additional concurrent
|
|
410
|
+
* cosmetic surfaces (e.g. an answer draft) contend the remainder exactly as
|
|
411
|
+
* today. Stale ids self-evict via the existing window LRU, so the tracked set
|
|
412
|
+
* cannot grow unbounded.
|
|
413
|
+
*/
|
|
414
|
+
cosmeticFloorSlots?: number
|
|
415
|
+
/**
|
|
416
|
+
* How long a COSMETIC edit may sit deferred before the fuse emits a
|
|
417
|
+
* `'throttled'` `onTrip` signal, so the lag is observable (and a card can
|
|
418
|
+
* render a "updates throttled (flood backoff)" line) rather than freezing
|
|
419
|
+
* silently. Default 45000ms. The render itself is a gateway concern; the fuse
|
|
420
|
+
* only raises the signal.
|
|
421
|
+
*/
|
|
422
|
+
throttleNoticeMs?: number
|
|
335
423
|
/**
|
|
336
424
|
* Shared per-chat allowance counting EVERY admitted call — edits and sends,
|
|
337
425
|
* every class. Default 20 per 60s, matching Telegram's own per-chat/group
|
|
@@ -431,7 +519,7 @@ export interface EditFloodFuseConfig {
|
|
|
431
519
|
onTrip?: (info: {
|
|
432
520
|
method: string
|
|
433
521
|
key: string
|
|
434
|
-
action: 'deferred' | 'dropped' | 'superseded'
|
|
522
|
+
action: 'deferred' | 'dropped' | 'superseded' | 'throttled'
|
|
435
523
|
cls: OutboundClass
|
|
436
524
|
}) => void
|
|
437
525
|
}
|
|
@@ -444,6 +532,14 @@ export interface EditFloodFuseStats {
|
|
|
444
532
|
dropped: number
|
|
445
533
|
/** Edits dropped because a NEWER edit to the same message arrived. */
|
|
446
534
|
superseded: number
|
|
535
|
+
/**
|
|
536
|
+
* COSMETIC edits that crossed `throttleNoticeMs` while deferred — the direct
|
|
537
|
+
* measure of a card the flood backoff is visibly slowing. Non-zero here is
|
|
538
|
+
* the signal a "throttled" indicator should render for.
|
|
539
|
+
*/
|
|
540
|
+
throttled: number
|
|
541
|
+
/** Whether per-`message_id` cosmetic fair-share is in force (the #4300 fix). */
|
|
542
|
+
cosmeticFairShareEnabled: boolean
|
|
447
543
|
/** 429s observed (each one tightens the ceilings). */
|
|
448
544
|
floodObserved: number
|
|
449
545
|
/** Whether a tightened ceiling is in force right now. */
|
|
@@ -504,6 +600,23 @@ export const EDIT_FLOOD_FUSE_DEFAULTS = {
|
|
|
504
600
|
* sits at the top of the survived band and less than half the banned rate.
|
|
505
601
|
*/
|
|
506
602
|
cosmeticPerChatMaxPerWindow: 6,
|
|
603
|
+
/**
|
|
604
|
+
* 2/60s = 1 edit / 30s per distinct cosmetic message_id, guaranteed and
|
|
605
|
+
* AIMD-immune. Sized so a LONE card still bursts to the full
|
|
606
|
+
* `cosmeticPerMessageMaxPerWindow` (4): its 2 floor slots + the 2-slot
|
|
607
|
+
* remainder (6 - 2*2) = 4. Two live cards each keep 1/30s and share the
|
|
608
|
+
* remaining 2; a third cosmetic surface contends the remainder as before.
|
|
609
|
+
* The aggregate cosmetic floor under maximum tightening is therefore
|
|
610
|
+
* `cosmeticFloorPerWindow * cosmeticFloorSlots` = 4/60s, which sits at the
|
|
611
|
+
* bottom of the 4-6/min band the incident chat provably survived for hours
|
|
612
|
+
* (see this file's 2026-07-27 note) — enough to keep a watched card from
|
|
613
|
+
* freezing, low enough not to re-earn a ban. Disable via the kill-switch to
|
|
614
|
+
* restore shed-cosmetic-to-zero under pressure.
|
|
615
|
+
*/
|
|
616
|
+
cosmeticFloorPerWindow: 2,
|
|
617
|
+
cosmeticFloorSlots: 2,
|
|
618
|
+
/** A cosmetic edit deferred longer than this raises a `'throttled'` signal. */
|
|
619
|
+
throttleNoticeMs: 45_000,
|
|
507
620
|
/**
|
|
508
621
|
* 20/60s. Telegram's documented per-group ceiling, and it meters sends and
|
|
509
622
|
* edits together — so this is the only window that reflects the real budget.
|
|
@@ -579,6 +692,13 @@ export function editFloodFuseConfigFromEnv(
|
|
|
579
692
|
}
|
|
580
693
|
assign('cosmeticPerMessageMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN))
|
|
581
694
|
assign('cosmeticPerChatMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN))
|
|
695
|
+
// #4300 — per-message_id cosmetic fair-share. Kill-switch OFF restores the
|
|
696
|
+
// pre-fix single shared cosmetic bucket byte-for-byte; the floor/slots/notice
|
|
697
|
+
// knobs let an operator retune the reserved slice without a redeploy.
|
|
698
|
+
if (env.SWITCHROOM_FEED_FAIR_SHARE === '0') cfg.cosmeticFairShareEnabled = false
|
|
699
|
+
assign('cosmeticFloorPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_FLOOR_PER_MSG_PER_MIN))
|
|
700
|
+
assign('cosmeticFloorSlots', envInt(env.SWITCHROOM_FEED_EDIT_FLOOR_SLOTS))
|
|
701
|
+
assign('throttleNoticeMs', envInt(env.SWITCHROOM_FEED_THROTTLE_NOTICE_MS))
|
|
582
702
|
assign('perChatTotalMaxPerWindow', envInt(env.SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN))
|
|
583
703
|
assign('perChatReplyReserve', envInt(env.SWITCHROOM_CHAT_REPLY_RESERVE))
|
|
584
704
|
assign('perChatCriticalMinPerWindow', envInt(env.SWITCHROOM_CHAT_CRITICAL_MIN_PER_MIN))
|
|
@@ -665,6 +785,23 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
665
785
|
perMessageMax, config.cosmeticPerMessageMaxPerWindow ?? D.cosmeticPerMessageMaxPerWindow)
|
|
666
786
|
const cosmeticPerChatMax = Math.min(
|
|
667
787
|
perChatEditMax, config.cosmeticPerChatMaxPerWindow ?? D.cosmeticPerChatMaxPerWindow)
|
|
788
|
+
// #4300 — per-message_id fair-share of the cosmetic per-chat pool.
|
|
789
|
+
const cosmeticFairShareEnabled = config.cosmeticFairShareEnabled ?? true
|
|
790
|
+
// The per-message guaranteed floor, capped by the per-message cosmetic
|
|
791
|
+
// ceiling (a floor above the message's own max would be incoherent).
|
|
792
|
+
const cosmeticFloorPerWindow = Math.min(
|
|
793
|
+
cosmeticPerMessageMax,
|
|
794
|
+
Math.max(0, config.cosmeticFloorPerWindow ?? D.cosmeticFloorPerWindow))
|
|
795
|
+
// The reserved slice = floor * slots, clamped so it can never exceed the pool
|
|
796
|
+
// (a reserve larger than the pool would leave a negative remainder). This is
|
|
797
|
+
// the aggregate cap that bounds how many distinct ids hold a floor at once.
|
|
798
|
+
const cosmeticFloorAggMax = Math.min(
|
|
799
|
+
cosmeticPerChatMax,
|
|
800
|
+
cosmeticFloorPerWindow * Math.max(0, config.cosmeticFloorSlots ?? D.cosmeticFloorSlots))
|
|
801
|
+
// What is left of the pool after the reserve — the still-shared, still-AIMD
|
|
802
|
+
// remainder. Cosmetic edits beyond a message's floor contend here as before.
|
|
803
|
+
const cosmeticRemainderMax = Math.max(0, cosmeticPerChatMax - cosmeticFloorAggMax)
|
|
804
|
+
const throttleNoticeMs = Math.max(0, config.throttleNoticeMs ?? D.throttleNoticeMs)
|
|
668
805
|
const perChatTotalMax = config.perChatTotalMaxPerWindow ?? D.perChatTotalMaxPerWindow
|
|
669
806
|
const perChatWindowMs = config.perChatWindowMs ?? D.perChatWindowMs
|
|
670
807
|
// Clamped so a misconfigured reserve can never eat more than the whole
|
|
@@ -704,6 +841,8 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
704
841
|
meteredByDefault: 0,
|
|
705
842
|
/** Calls with no `chat_id`, charged to the token window only. */
|
|
706
843
|
chatless: 0,
|
|
844
|
+
/** Cosmetic edits that crossed `throttleNoticeMs` while deferred (#4300). */
|
|
845
|
+
throttled: 0,
|
|
707
846
|
}
|
|
708
847
|
/**
|
|
709
848
|
* Compounding 429 backoff. `tightenLevel` is the number of multiplicative
|
|
@@ -807,6 +946,25 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
807
946
|
return Math.max(eff, Math.min(base, perChatCriticalMin))
|
|
808
947
|
}
|
|
809
948
|
|
|
949
|
+
/**
|
|
950
|
+
* The PER-MESSAGE cosmetic ceiling, with the #4300 AIMD tighten floor. Same
|
|
951
|
+
* shape as the `critical` floor in `classCeiling`: no amount of 429 tightening
|
|
952
|
+
* may drop a cosmetic card below `cosmeticFloorPerWindow` (default 1 edit/30s)
|
|
953
|
+
* on its own message tier, so the flood backoff slows a watched card without
|
|
954
|
+
* freezing it. Capped by `base` so it only ever raises a tightened ceiling
|
|
955
|
+
* back towards the configured max, never above it. Flag OFF ⇒ plain `ceiling`,
|
|
956
|
+
* i.e. byte-identical to the pre-fix behaviour.
|
|
957
|
+
*
|
|
958
|
+
* This floors ONLY cosmetic-vs-AIMD; the reply-starvation guarantee is on a
|
|
959
|
+
* different tier (`cosmeticTotalMax` / `perChatTotalMax`) and is untouched, so
|
|
960
|
+
* a floored cosmetic edit still yields to a real reply.
|
|
961
|
+
*/
|
|
962
|
+
function cosmeticMessageCeiling(now: number): number {
|
|
963
|
+
const eff = ceiling(cosmeticPerMessageMax, now)
|
|
964
|
+
if (!cosmeticFairShareEnabled) return eff
|
|
965
|
+
return Math.max(eff, Math.min(cosmeticPerMessageMax, cosmeticFloorPerWindow))
|
|
966
|
+
}
|
|
967
|
+
|
|
810
968
|
/**
|
|
811
969
|
* The shared per-chat budget COSMETIC traffic may reach, re-derived against
|
|
812
970
|
* the EFFECTIVE ceiling (#3885).
|
|
@@ -971,6 +1129,144 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
971
1129
|
if (i >= 0) w.ts.splice(i, 1)
|
|
972
1130
|
}
|
|
973
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* A per-wait tracker that raises the `'throttled'` signal once (#4300) when a
|
|
1134
|
+
* COSMETIC call has been continuously deferred longer than `throttleNoticeMs`.
|
|
1135
|
+
* The first `tick(now)` records the defer start; each subsequent `tick` fires
|
|
1136
|
+
* the signal exactly once on crossing the threshold. `sleepCap(now)` returns
|
|
1137
|
+
* the ms until that threshold so the wait loop can clamp its sleep and wake to
|
|
1138
|
+
* fire the notice — WITHOUT this, a single wait that runs straight to the defer
|
|
1139
|
+
* deadline (`sleep(deadline - now)`) would skip past the threshold and never
|
|
1140
|
+
* signal. Once fired, `sleepCap` returns Infinity (no more clamping). Both are
|
|
1141
|
+
* no-ops for non-cosmetic calls and a zero/disabled threshold. Rendering the
|
|
1142
|
+
* visible indicator is a gateway concern reading `onTrip`/`stats().throttled`;
|
|
1143
|
+
* the fuse only signals.
|
|
1144
|
+
*/
|
|
1145
|
+
function makeThrottleNotice(
|
|
1146
|
+
method: string, key: string, cls: OutboundClass,
|
|
1147
|
+
): { tick: (now: number) => void; sleepCap: (now: number) => number } {
|
|
1148
|
+
// The 'throttled' marker is part of the #4300 fair-share feature, so the
|
|
1149
|
+
// kill-switch also disables it — leaving flag-OFF byte-identical to the
|
|
1150
|
+
// pre-fix behaviour (no new signal, no extra threshold wakeup).
|
|
1151
|
+
if (!cosmeticFairShareEnabled || cls !== 'cosmetic' || throttleNoticeMs <= 0) {
|
|
1152
|
+
return { tick: () => {}, sleepCap: () => Number.POSITIVE_INFINITY }
|
|
1153
|
+
}
|
|
1154
|
+
let firstAt = Number.NaN
|
|
1155
|
+
let fired = false
|
|
1156
|
+
return {
|
|
1157
|
+
tick: (now: number): void => {
|
|
1158
|
+
if (Number.isNaN(firstAt)) firstAt = now
|
|
1159
|
+
if (fired || now - firstAt < throttleNoticeMs) return
|
|
1160
|
+
fired = true
|
|
1161
|
+
counters.throttled++
|
|
1162
|
+
onTrip?.({ method, key, action: 'throttled', cls })
|
|
1163
|
+
},
|
|
1164
|
+
sleepCap: (now: number): number => {
|
|
1165
|
+
if (fired || Number.isNaN(firstAt)) return Number.POSITIVE_INFINITY
|
|
1166
|
+
return Math.max(1, firstAt + throttleNoticeMs - now)
|
|
1167
|
+
},
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Fair admission to the per-chat COSMETIC pool, keyed per `message_id` (#4300).
|
|
1173
|
+
*
|
|
1174
|
+
* The single shared `ce:${chat}` bucket every cosmetic surface raced for is
|
|
1175
|
+
* replaced (when `cosmeticFairShareEnabled`) by two lanes drawn from the SAME
|
|
1176
|
+
* `cosmeticPerChatMax` budget — so the wire rate, the 429 risk, and the reply
|
|
1177
|
+
* reserve are all unchanged:
|
|
1178
|
+
*
|
|
1179
|
+
* - a FLOOR lane: each distinct cosmetic `message_id` is guaranteed up to
|
|
1180
|
+
* `cosmeticFloorPerWindow` edits/window (`cmf:${chat}:${msg}`), bounded in
|
|
1181
|
+
* aggregate to `cosmeticFloorAggMax` = floor*slots (`cfa:${chat}`) so only
|
|
1182
|
+
* that many distinct ids hold a floor at once. This lane is AIMD-IMMUNE —
|
|
1183
|
+
* it is the anti-starvation + anti-freeze guarantee, and 429 tightening
|
|
1184
|
+
* cannot take a watched card below it.
|
|
1185
|
+
* - a REMAINDER lane (`cer:${chat}`, cap `cosmeticRemainderMax`, AIMD-reduced)
|
|
1186
|
+
* — the still-shared, still-contended part. A lone card uses its floor AND
|
|
1187
|
+
* the whole remainder, so it still bursts to `cosmeticPerMessageMax`.
|
|
1188
|
+
*
|
|
1189
|
+
* The two caps sum to `cosmeticPerChatMax`, so a floor edit and a remainder
|
|
1190
|
+
* edit can never both be admitted beyond the pool. Stale message windows
|
|
1191
|
+
* self-evict via the existing `evict` LRU, so the distinct-id set is bounded.
|
|
1192
|
+
*
|
|
1193
|
+
* Returns the reserved [key, ts] pairs (to hand to the caller's give-back on a
|
|
1194
|
+
* later-tier denial), or null when the edit is dropped. Same drop/late-release
|
|
1195
|
+
* semantics as the `'drop'` mode of {@link awaitRoom}: an over-budget frame is
|
|
1196
|
+
* dropped unless it is the LONE frame for its message, in which case it is
|
|
1197
|
+
* released late against the bounded overshoot budget.
|
|
1198
|
+
*/
|
|
1199
|
+
async function awaitCosmeticChatFair(
|
|
1200
|
+
chat: string, msg: string, method: string, cls: OutboundClass,
|
|
1201
|
+
dropGuard: () => boolean, deadline: number, lateReleaseKey: string,
|
|
1202
|
+
): Promise<Array<[string, number]> | null> {
|
|
1203
|
+
const fMsgKey = `cmf:${chat}:${msg}`
|
|
1204
|
+
const fAggKey = `cfa:${chat}`
|
|
1205
|
+
const remKey = `cer:${chat}`
|
|
1206
|
+
const fw = win(fMsgKey)
|
|
1207
|
+
const aw = win(fAggKey)
|
|
1208
|
+
const rw = win(remKey)
|
|
1209
|
+
let counted = false
|
|
1210
|
+
const throttle = makeThrottleNotice(method, remKey, cls)
|
|
1211
|
+
for (;;) {
|
|
1212
|
+
const now = clock.now()
|
|
1213
|
+
// Floor lane is free only when BOTH the per-message and the aggregate
|
|
1214
|
+
// floor windows have room; those caps are constants (AIMD-immune).
|
|
1215
|
+
const wFloor = Math.max(
|
|
1216
|
+
waitFor(fw, now, perChatWindowMs, cosmeticFloorPerWindow),
|
|
1217
|
+
waitFor(aw, now, perChatWindowMs, cosmeticFloorAggMax),
|
|
1218
|
+
)
|
|
1219
|
+
const remCap = cosmeticRemainderMax <= 0 ? 0 : ceiling(cosmeticRemainderMax, now)
|
|
1220
|
+
const wRem = waitFor(rw, now, perChatWindowMs, remCap)
|
|
1221
|
+
if (wFloor === 0) {
|
|
1222
|
+
fw.ts.push(now); aw.ts.push(now)
|
|
1223
|
+
return [[fMsgKey, now], [fAggKey, now]]
|
|
1224
|
+
}
|
|
1225
|
+
if (remCap > 0 && wRem === 0) {
|
|
1226
|
+
rw.ts.push(now)
|
|
1227
|
+
return [[remKey, now]]
|
|
1228
|
+
}
|
|
1229
|
+
if (now >= deadline) {
|
|
1230
|
+
// Over budget at the deadline: drop, unless this is the LONE frame for
|
|
1231
|
+
// its message (nothing newer will repaint it), which is released late
|
|
1232
|
+
// against the shared overshoot budget — identical to `awaitRoom`'s
|
|
1233
|
+
// per-chat drop mode, so a card's terminal frame never freezes it.
|
|
1234
|
+
if (dropGuard()) {
|
|
1235
|
+
counters.dropped++
|
|
1236
|
+
onTrip?.({ method, key: remKey, action: 'dropped', cls })
|
|
1237
|
+
return null
|
|
1238
|
+
}
|
|
1239
|
+
const lw = win(lateReleaseKey)
|
|
1240
|
+
prune(lw, now, perChatWindowMs)
|
|
1241
|
+
if (lw.ts.length >= ceiling(lateReleaseMax, now)) {
|
|
1242
|
+
counters.dropped++
|
|
1243
|
+
onTrip?.({ method, key: remKey, action: 'dropped', cls })
|
|
1244
|
+
return null
|
|
1245
|
+
}
|
|
1246
|
+
lw.ts.push(now)
|
|
1247
|
+
// Release into the remainder lane when it exists, else the floor lane —
|
|
1248
|
+
// either way the slot is recorded so the window reflects the wire.
|
|
1249
|
+
if (cosmeticRemainderMax > 0) {
|
|
1250
|
+
rw.ts.push(now)
|
|
1251
|
+
return [[remKey, now]]
|
|
1252
|
+
}
|
|
1253
|
+
fw.ts.push(now); aw.ts.push(now)
|
|
1254
|
+
return [[fMsgKey, now], [fAggKey, now]]
|
|
1255
|
+
}
|
|
1256
|
+
if (!counted) {
|
|
1257
|
+
counters.deferred++
|
|
1258
|
+
counted = true
|
|
1259
|
+
onTrip?.({ method, key: remKey, action: 'deferred', cls })
|
|
1260
|
+
}
|
|
1261
|
+
throttle.tick(now)
|
|
1262
|
+
// Both lanes are per-chat keys (a "newer" frame is usually a DIFFERENT
|
|
1263
|
+
// message), so this tier never supersedes — it waits for the sooner of the
|
|
1264
|
+
// two lanes to open, bounded by the shared deadline and clamped so the
|
|
1265
|
+
// loop wakes to raise the 'throttled' notice at its threshold.
|
|
1266
|
+
await clock.sleep(Math.min(Math.min(wFloor, wRem), deadline - now, throttle.sleepCap(now)))
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
974
1270
|
/**
|
|
975
1271
|
* Wait for room on `key` and RESERVE the slot on success. Reserving inside
|
|
976
1272
|
* the same synchronous step as the check is what makes the ceiling hold
|
|
@@ -1027,6 +1323,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1027
1323
|
): Promise<number | null> {
|
|
1028
1324
|
const w = win(key)
|
|
1029
1325
|
let counted = false
|
|
1326
|
+
const throttle = makeThrottleNotice(method, key, cls)
|
|
1030
1327
|
for (;;) {
|
|
1031
1328
|
const now = clock.now()
|
|
1032
1329
|
const wait = waitFor(w, now, windowMs, maxFor(now))
|
|
@@ -1067,6 +1364,10 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1067
1364
|
counted = true
|
|
1068
1365
|
onTrip?.({ method, key, action: 'deferred', cls })
|
|
1069
1366
|
}
|
|
1367
|
+
throttle.tick(now)
|
|
1368
|
+
// Clamp the sleep so a single long wait wakes to raise the 'throttled'
|
|
1369
|
+
// notice at its threshold rather than sleeping straight to the deadline.
|
|
1370
|
+
const nap = Math.min(wait, deadline - now, throttle.sleepCap(now))
|
|
1070
1371
|
// Last-write-wins: a newer edit to the same message kills this one. Only
|
|
1071
1372
|
// valid on the per-MESSAGE tier — on a per-chat key the "newer" edit is
|
|
1072
1373
|
// usually to a DIFFERENT message, and killing an unrelated card's edit is
|
|
@@ -1077,7 +1378,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1077
1378
|
const superseded = new Promise<void>((resolve) => {
|
|
1078
1379
|
w.waiter = { kill: () => { killed = true; resolve() } }
|
|
1079
1380
|
})
|
|
1080
|
-
await Promise.race([clock.sleep(
|
|
1381
|
+
await Promise.race([clock.sleep(nap), superseded])
|
|
1081
1382
|
if (killed) {
|
|
1082
1383
|
counters.superseded++
|
|
1083
1384
|
onTrip?.({ method, key, action: 'superseded', cls })
|
|
@@ -1085,7 +1386,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1085
1386
|
}
|
|
1086
1387
|
w.waiter = null
|
|
1087
1388
|
} else {
|
|
1088
|
-
await clock.sleep(
|
|
1389
|
+
await clock.sleep(nap)
|
|
1089
1390
|
}
|
|
1090
1391
|
}
|
|
1091
1392
|
}
|
|
@@ -1178,7 +1479,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1178
1479
|
const msgSlot = await awaitRoom(
|
|
1179
1480
|
msgKey, perMessageWindowMs,
|
|
1180
1481
|
cls === 'cosmetic'
|
|
1181
|
-
|
|
1482
|
+
// #4300: cosmetic per-message tier carries the AIMD tighten floor
|
|
1483
|
+
// (1 edit/30s) so a 429 cannot freeze a watched card at this tier.
|
|
1484
|
+
? (t) => cosmeticMessageCeiling(t)
|
|
1182
1485
|
: (t) => classCeiling(perMessageMax, cls, t),
|
|
1183
1486
|
method, 'supersede', cls, dropGuard, deadline, lateKey,
|
|
1184
1487
|
)
|
|
@@ -1186,16 +1489,29 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1186
1489
|
const reserved: Array<[string, number]> = [[msgKey, msgSlot]]
|
|
1187
1490
|
const giveBack = (): void => { for (const [k, at] of reserved) unreserve(k, at) }
|
|
1188
1491
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1492
|
+
// Per-chat COSMETIC pool. #4300: when fair-share is on, admit through the
|
|
1493
|
+
// per-`message_id` floor/remainder lanes so one cosmetic surface cannot
|
|
1494
|
+
// starve another; the two lanes sum to `cosmeticPerChatMax`, so the wire
|
|
1495
|
+
// rate is unchanged. Flag OFF (or a non-cosmetic edit) keeps the single
|
|
1496
|
+
// shared `ce:${chat}` bucket byte-for-byte.
|
|
1497
|
+
if (cls === 'cosmetic' && cosmeticFairShareEnabled) {
|
|
1498
|
+
const fairSlots = await awaitCosmeticChatFair(
|
|
1499
|
+
chat, msg, method, cls, dropGuard, deadline, lateKey!,
|
|
1500
|
+
)
|
|
1501
|
+
if (fairSlots === null) { giveBack(); return DROPPED_RESULT as unknown as R }
|
|
1502
|
+
reserved.push(...fairSlots)
|
|
1503
|
+
} else {
|
|
1504
|
+
const chatKey = `ce:${chat}`
|
|
1505
|
+
const chatSlot = await awaitRoom(
|
|
1506
|
+
chatKey, perChatWindowMs,
|
|
1507
|
+
cls === 'cosmetic'
|
|
1508
|
+
? (t) => ceiling(cosmeticPerChatMax, t)
|
|
1509
|
+
: (t) => classCeiling(perChatEditMax, cls, t),
|
|
1510
|
+
method, 'drop', cls, dropGuard, deadline, lateKey,
|
|
1511
|
+
)
|
|
1512
|
+
if (chatSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
|
|
1513
|
+
reserved.push([chatKey, chatSlot])
|
|
1514
|
+
}
|
|
1199
1515
|
|
|
1200
1516
|
// Shared per-chat budget — the one that mirrors Telegram's real
|
|
1201
1517
|
// metering. A non-cosmetic edit is RELEASED late rather than dropped:
|
|
@@ -1271,6 +1587,8 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
1271
1587
|
deferred: counters.deferred,
|
|
1272
1588
|
dropped: counters.dropped,
|
|
1273
1589
|
superseded: counters.superseded,
|
|
1590
|
+
throttled: counters.throttled,
|
|
1591
|
+
cosmeticFairShareEnabled,
|
|
1274
1592
|
floodObserved: counters.floodObserved,
|
|
1275
1593
|
tightened: isTightened(now),
|
|
1276
1594
|
tightenLevel: levelAt(now),
|
|
@@ -184,6 +184,21 @@ export interface FeedOpenInput {
|
|
|
184
184
|
* this see no behaviour change.
|
|
185
185
|
*/
|
|
186
186
|
postAnswerSubagentActivity?: boolean
|
|
187
|
+
/**
|
|
188
|
+
* Post-substantive feed reopen (long multi-phase MAIN-agent turns). True when
|
|
189
|
+
* the turn delivered a substantive final answer EARLY and then kept doing real
|
|
190
|
+
* tool work — `>= SUBSTANTIVE_REOPEN_MIN_LABELS` post-answer tool labels have
|
|
191
|
+
* arrived (`feed-reopen-gate.ts` → `decideFeedReopen().liftLeverOne`). The
|
|
192
|
+
* sibling of `postAnswerSubagentActivity`, but for the foreground agent's own
|
|
193
|
+
* post-answer tool labels rather than a sub-agent watcher.
|
|
194
|
+
*
|
|
195
|
+
* When true AND `producer === 'tool'`, Lever 1's blanket post-answer block is
|
|
196
|
+
* lifted so a fresh activity card may open below the delivered reply to show
|
|
197
|
+
* the still-working agent. Idle producers (`liveness`, `narrative`) stay
|
|
198
|
+
* blocked. Defaults to `false` (Lever 1 fully active) — callers that don't
|
|
199
|
+
* pass it see no behaviour change.
|
|
200
|
+
*/
|
|
201
|
+
postAnswerMainActivity?: boolean
|
|
187
202
|
}
|
|
188
203
|
|
|
189
204
|
/**
|
|
@@ -194,13 +209,15 @@ export interface FeedOpenInput {
|
|
|
194
209
|
* - crossTurnAnswerDelivered → false: lever 4. A cross-turn synthetic surface
|
|
195
210
|
* whose exchange already delivered a substantive answer in an EARLIER turn;
|
|
196
211
|
* no card may open below it (any producer). Checked FIRST.
|
|
197
|
-
* - finalAnswerEverDelivered && !postAnswerSubagentActivity
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
212
|
+
* - finalAnswerEverDelivered && !(postAnswerSubagentActivity ||
|
|
213
|
+
* postAnswerMainActivity) → false: lever 1. A substantive final already
|
|
214
|
+
* landed THIS turn; no card may open below it. Exception: when EITHER
|
|
215
|
+
* post-answer activity signal is true AND `producer === 'tool'`, Lever 1 is
|
|
216
|
+
* lifted so a card can surface below the reply — the background-agent
|
|
217
|
+
* liveness heartbeat (`postAnswerSubagentActivity`) OR the foreground
|
|
218
|
+
* agent's own still-working post-answer tool labels (`postAnswerMainActivity`,
|
|
219
|
+
* the post-substantive feed reopen). Idle producers ('liveness', 'narrative')
|
|
220
|
+
* stay blocked — no card opens from wall-clock alone after the final answer.
|
|
204
221
|
* - producer 'narrative': always allowed when pre-answer (lever 5 is INERT —
|
|
205
222
|
* Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead).
|
|
206
223
|
* - producer 'tool' or 'liveness' → true (unless lever 1/4).
|
|
@@ -216,7 +233,10 @@ export function mayOpenActivityCard(input: FeedOpenInput): boolean {
|
|
|
216
233
|
// (Fix 2 / #2587 supersede). Only 'tool' is exempted so idle liveness and
|
|
217
234
|
// narrative producers remain blocked after the final answer.
|
|
218
235
|
if (input.finalAnswerEverDelivered) {
|
|
219
|
-
if (
|
|
236
|
+
if (
|
|
237
|
+
(input.postAnswerSubagentActivity || input.postAnswerMainActivity)
|
|
238
|
+
&& input.producer === 'tool'
|
|
239
|
+
) return true
|
|
220
240
|
return false
|
|
221
241
|
}
|
|
222
242
|
// Lever 5 — INERT (see module comment above). Pre-answer narrative may now
|
|
@@ -66,6 +66,37 @@
|
|
|
66
66
|
* (correct — the user got only an ack, no answer).
|
|
67
67
|
* 3. the feed gate itself — this module.
|
|
68
68
|
*
|
|
69
|
+
* ## Post-SUBSTANTIVE reopen (long multi-phase turns)
|
|
70
|
+
*
|
|
71
|
+
* The ACK-only refinement above deliberately keeps the feed dark after a
|
|
72
|
+
* GENUINE final answer — routine post-answer housekeeping (a memory write /
|
|
73
|
+
* TodoWrite) should not resurrect the card. But that same gate blanks the
|
|
74
|
+
* feed for a legitimately DIFFERENT shape: a turn that delivers a substantive
|
|
75
|
+
* reply EARLY ("Here's the plan — starting now") and then keeps doing real
|
|
76
|
+
* tool work for 10+ minutes. The user sees the reply, then goes completely
|
|
77
|
+
* dark for the rest of the turn even though the agent is visibly working.
|
|
78
|
+
*
|
|
79
|
+
* The post-substantive reopen closes that gap WITHOUT reintroducing the
|
|
80
|
+
* duplicate-answer hazard the ACK-only refinement fixed:
|
|
81
|
+
* - It fires only after `SUBSTANTIVE_REOPEN_MIN_LABELS` (>= 2) post-answer
|
|
82
|
+
* tool labels have arrived, so a genuinely-final single-reply turn (0-1
|
|
83
|
+
* housekeeping tools) never flaps the card.
|
|
84
|
+
* - Crucially it does NOT clear `finalAnswerDelivered` (the ack path does):
|
|
85
|
+
* clearing it would trip the turn-end silent-end re-prompt (`turn-end-
|
|
86
|
+
* gate.ts`: `finalAnswerDelivered === false → reprompt`) → the exact
|
|
87
|
+
* duplicate-answer failure the ACK-only guard exists to prevent. The
|
|
88
|
+
* substantive answer STAYS delivered; only the visual feed reopens.
|
|
89
|
+
* - Because the sticky lever-1 latch stays true (it is never cleared by a
|
|
90
|
+
* reopen), the caller must LIFT lever 1 for this drain so the fresh card opens
|
|
91
|
+
* BELOW the delivered reply — exactly the exemption the post-answer
|
|
92
|
+
* sub-agent liveness path already uses (`feed-open-gate.ts`,
|
|
93
|
+
* `postAnswerSubagentActivity`). The outcome flags this via `liftLeverOne`.
|
|
94
|
+
*
|
|
95
|
+
* Gated by its own flag `SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE` (default
|
|
96
|
+
* ON), passed by the caller as `reopenAfterSubstantiveEnabled`. When that flag
|
|
97
|
+
* is off (or omitted), the substantive branch returns `false` exactly as
|
|
98
|
+
* before — byte-identical legacy behaviour.
|
|
99
|
+
*
|
|
69
100
|
* ## Kill switch
|
|
70
101
|
*
|
|
71
102
|
* `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0` reverts to the legacy behaviour: a
|
|
@@ -74,6 +105,12 @@
|
|
|
74
105
|
* passes `enabled` here.
|
|
75
106
|
*/
|
|
76
107
|
|
|
108
|
+
/** Number of post-substantive-answer tool labels that must arrive before the
|
|
109
|
+
* activity feed re-opens for a still-working turn. Two (not one) so a
|
|
110
|
+
* genuinely-final single-reply turn with a stray bit of post-answer
|
|
111
|
+
* housekeeping (0-1 tools) never flaps the card. */
|
|
112
|
+
export const SUBSTANTIVE_REOPEN_MIN_LABELS = 2
|
|
113
|
+
|
|
77
114
|
export interface FeedReopenInput {
|
|
78
115
|
/** Whether the turn has already been classified as having delivered its
|
|
79
116
|
* final answer (`turn.finalAnswerDelivered`). On an ack-first turn this
|
|
@@ -90,6 +127,19 @@ export interface FeedReopenInput {
|
|
|
90
127
|
/** Kill-switch state. When false the reopen behaviour is OFF and a tool
|
|
91
128
|
* label after `finalAnswerDelivered` is dropped (legacy). */
|
|
92
129
|
enabled: boolean
|
|
130
|
+
/** Post-SUBSTANTIVE reopen flag (`SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE`,
|
|
131
|
+
* default ON). When true, a turn that delivered a substantive final answer
|
|
132
|
+
* and then kept doing tool work may RE-OPEN the feed once
|
|
133
|
+
* `postSubstantiveToolLabelCount >= SUBSTANTIVE_REOPEN_MIN_LABELS`. Omitted
|
|
134
|
+
* / false → the substantive branch returns false exactly as before (legacy).
|
|
135
|
+
* Distinct from `enabled` (the ack-first kill switch) so the two behaviours
|
|
136
|
+
* can be toggled independently. */
|
|
137
|
+
reopenAfterSubstantiveEnabled?: boolean
|
|
138
|
+
/** Count of tool labels that have arrived SINCE the substantive final answer
|
|
139
|
+
* was delivered this turn (`turn.postSubstantiveToolLabelCount`). Only
|
|
140
|
+
* consulted on the substantive branch; the reopen fires at
|
|
141
|
+
* `>= SUBSTANTIVE_REOPEN_MIN_LABELS`. Omitted → treated as 0 (no reopen). */
|
|
142
|
+
postSubstantiveToolLabelCount?: number
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
/**
|
|
@@ -99,10 +149,12 @@ export interface FeedReopenInput {
|
|
|
99
149
|
*
|
|
100
150
|
* - !finalAnswerDelivered → false: the feed was never gated off; the normal
|
|
101
151
|
* append/drain path applies (no reopen needed).
|
|
102
|
-
* - finalAnswerDelivered && finalAnswerSubstantive
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
152
|
+
* - finalAnswerDelivered && finalAnswerSubstantive: the prior final was a
|
|
153
|
+
* genuine answer (not an ack). Reopen ONLY under the post-substantive path —
|
|
154
|
+
* `reopenAfterSubstantiveEnabled` AND `>= SUBSTANTIVE_REOPEN_MIN_LABELS`
|
|
155
|
+
* post-answer tool labels (a still-working multi-phase turn). Otherwise keep
|
|
156
|
+
* the legacy gate so post-answer housekeeping does not flap the card and the
|
|
157
|
+
* silent-end re-prompt / #2137 drain see the delivered final correctly.
|
|
106
158
|
* - finalAnswerDelivered && !enabled (kill switch off) → false: legacy
|
|
107
159
|
* behaviour, the label is dropped by the caller.
|
|
108
160
|
* - finalAnswerDelivered && !finalAnswerSubstantive && enabled → true: the
|
|
@@ -110,7 +162,14 @@ export interface FeedReopenInput {
|
|
|
110
162
|
*/
|
|
111
163
|
export function shouldReopenFeedAfterAck(input: FeedReopenInput): boolean {
|
|
112
164
|
if (!input.finalAnswerDelivered) return false
|
|
113
|
-
if (input.finalAnswerSubstantive)
|
|
165
|
+
if (input.finalAnswerSubstantive) {
|
|
166
|
+
// Post-substantive reopen: a genuine answer landed, then the model kept
|
|
167
|
+
// doing tool work. Reopen only under the dedicated flag AND once enough
|
|
168
|
+
// post-answer labels have arrived that this is plainly a still-working
|
|
169
|
+
// turn, not a single-reply turn with a stray housekeeping tool.
|
|
170
|
+
if (input.reopenAfterSubstantiveEnabled !== true) return false
|
|
171
|
+
return (input.postSubstantiveToolLabelCount ?? 0) >= SUBSTANTIVE_REOPEN_MIN_LABELS
|
|
172
|
+
}
|
|
114
173
|
return input.enabled === true
|
|
115
174
|
}
|
|
116
175
|
|
|
@@ -128,8 +187,20 @@ export interface FeedReopenOutcome {
|
|
|
128
187
|
/** True → the handler returns early (legacy: label dropped, feed dark). */
|
|
129
188
|
dropLabel: boolean
|
|
130
189
|
/** When dropLabel is false, the new feed-state fields to write on `turn`
|
|
131
|
-
* before the normal append/drain proceeds.
|
|
190
|
+
* before the normal append/drain proceeds. Present on the ACK-reopen path
|
|
191
|
+
* (which reclassifies the interim ack). ABSENT on the post-substantive
|
|
192
|
+
* reopen path: there `finalAnswerDelivered` MUST stay true (clearing it
|
|
193
|
+
* trips the turn-end re-prompt → duplicate answer) and `activityMessageId`
|
|
194
|
+
* is already null (the substantive answer's `clearActivitySummary` nulled
|
|
195
|
+
* it), so the drain opens a fresh card then edits it — no reset needed. */
|
|
132
196
|
reset?: FeedReopenState
|
|
197
|
+
/** Post-substantive reopen only. True → the caller must lift lever 1 for
|
|
198
|
+
* this drain (`drainActivitySummary(..., { postAnswerMainActivity: true })`)
|
|
199
|
+
* so the fresh card may OPEN below the already-delivered substantive reply.
|
|
200
|
+
* The sticky lever-1 latch stays set, so without this lift
|
|
201
|
+
* `mayOpenActivityCard` would refuse the OPEN. Absent/false on the ack path
|
|
202
|
+
* (an ack never sets the sticky latch, so lever 1 is inert there). */
|
|
203
|
+
liftLeverOne?: boolean
|
|
133
204
|
}
|
|
134
205
|
|
|
135
206
|
/**
|
|
@@ -151,6 +222,17 @@ export function decideFeedReopen(input: FeedReopenInput): FeedReopenOutcome {
|
|
|
151
222
|
if (!shouldReopenFeedAfterAck(input)) {
|
|
152
223
|
return { dropLabel: true }
|
|
153
224
|
}
|
|
225
|
+
// Post-substantive reopen: KEEP finalAnswerDelivered true (clearing it would
|
|
226
|
+
// trip the turn-end silent-end re-prompt → duplicate answer) and do NOT
|
|
227
|
+
// reset activityMessageId (the substantive answer's clearActivitySummary
|
|
228
|
+
// already nulled it; the drain OPENs once then EDITs). Only signal the
|
|
229
|
+
// caller to lift lever 1 so the fresh card may open below the reply.
|
|
230
|
+
if (input.finalAnswerSubstantive) {
|
|
231
|
+
return { dropLabel: false, liftLeverOne: true }
|
|
232
|
+
}
|
|
233
|
+
// ACK reopen (legacy): reclassify the interim ack — finalAnswerDelivered
|
|
234
|
+
// back to false, a FRESH feed message (activityMessageId null), last-sent
|
|
235
|
+
// render cleared so the drain re-sends.
|
|
154
236
|
return {
|
|
155
237
|
dropLabel: false,
|
|
156
238
|
reset: {
|