switchroom 0.19.23 → 0.19.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +18 -7
- package/dist/auth-broker/index.js +117 -33
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +18 -6
- package/dist/cli/switchroom.js +2916 -1481
- package/dist/host-control/main.js +116 -34
- package/dist/vault/approvals/kernel-server.js +115 -33
- package/dist/vault/broker/server.js +281 -76
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +52 -13
- package/profiles/_shared/dev-protocol.md.hbs +3 -4
- package/skills/dev-protocol/SKILL.md +22 -15
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-release/SKILL.md +2 -1
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
- package/telegram-plugin/gateway/gateway.ts +34 -35
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -489,7 +489,7 @@ import {
|
|
|
489
489
|
import {
|
|
490
490
|
resolveReplyOwnerTurnId,
|
|
491
491
|
resolveReplyOwnerTier,
|
|
492
|
-
type ReplyOwnerTier,
|
|
492
|
+
type ReplyOwnerTier, type ReplyOwnerCandidates,
|
|
493
493
|
type AnswerDeliveredLatch,
|
|
494
494
|
} from '../reply-owner-resolve.js'
|
|
495
495
|
import { SubagentHandbackMarker } from './subagent-handback-marker.js'
|
|
@@ -717,6 +717,7 @@ import {
|
|
|
717
717
|
} from './emission-authority.js'
|
|
718
718
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
719
719
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
720
|
+
import { latestTurnForChat } from './latest-turn-lookup.js'
|
|
720
721
|
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
721
722
|
import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
|
|
722
723
|
import {
|
|
@@ -3484,12 +3485,14 @@ export type CurrentTurn = {
|
|
|
3484
3485
|
flushedAnswerText: string | null
|
|
3485
3486
|
// 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
|
|
3486
3487
|
// ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
|
|
3487
|
-
// The `
|
|
3488
|
-
//
|
|
3489
|
-
//
|
|
3488
|
+
// The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
|
|
3489
|
+
// message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
|
|
3490
|
+
// turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
|
|
3491
|
+
// START, so the tail entry may still be RUNNING) AND within the supersede TTL —
|
|
3490
3492
|
// otherwise a late reply belonging to an OLDER turn could resolve its owner to
|
|
3491
|
-
// a NEWER turn
|
|
3492
|
-
//
|
|
3493
|
+
// a NEWER turn at the registry tail and delete that turn's legit answer. The
|
|
3494
|
+
// unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
|
|
3495
|
+
// topic to deliver into and deletes nothing.
|
|
3493
3496
|
endedAt: number | null
|
|
3494
3497
|
// #1675 (over-ping safety net): wall-clock ms of the first reply
|
|
3495
3498
|
// this turn that landed with `disable_notification: false` (a real
|
|
@@ -3999,21 +4002,18 @@ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
|
|
|
3999
4002
|
process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
|
|
4000
4003
|
|
|
4001
4004
|
/**
|
|
4002
|
-
* The most
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
*
|
|
4007
|
-
*
|
|
4008
|
-
*
|
|
4009
|
-
*
|
|
4005
|
+
* The most recent turn for a chat from the bounded recent-turn registry — the
|
|
4006
|
+
* deterministic fallback for a LATE answer reply when the model echoed no
|
|
4007
|
+
* `origin_turn_id` and `currentTurn` has already cleared. Returns null when the
|
|
4008
|
+
* chat has no matching turn (so the caller keeps the legacy result). NB: this is
|
|
4009
|
+
* the chat's own most-recent TURN, not the `chatThreadMap` last-seen-any-message
|
|
4010
|
+
* heuristic that caused the wrong-topic bug. `endedOnly` selects the consumer's
|
|
4011
|
+
* semantics — routing takes the tail entry even when that turn is still RUNNING;
|
|
4012
|
+
* the destructive `latest-ended` owner tier takes only a genuinely ENDED turn,
|
|
4013
|
+
* because the registry is populated at turn START (#3725; see the module).
|
|
4010
4014
|
*/
|
|
4011
|
-
function
|
|
4012
|
-
|
|
4013
|
-
for (const t of recentTurnsById.values()) {
|
|
4014
|
-
if (t.sessionChatId === chatId) latest = t
|
|
4015
|
-
}
|
|
4016
|
-
return latest
|
|
4015
|
+
function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null {
|
|
4016
|
+
return latestTurnForChat(recentTurnsById.values(), chatId, opts)
|
|
4017
4017
|
}
|
|
4018
4018
|
|
|
4019
4019
|
/**
|
|
@@ -4029,7 +4029,7 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
4029
4029
|
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
4030
4030
|
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
4031
4031
|
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
4032
|
-
* 4. `
|
|
4032
|
+
* 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
|
|
4033
4033
|
* Returns the CurrentTurn for the winning id (so callers can read its
|
|
4034
4034
|
* `answerDelivered` latch), or null when every lookup missed.
|
|
4035
4035
|
*/
|
|
@@ -4037,10 +4037,10 @@ function resolveReplyOwnerTurn(
|
|
|
4037
4037
|
liveTurn: CurrentTurn | null,
|
|
4038
4038
|
chatId: string,
|
|
4039
4039
|
args: Record<string, unknown>,
|
|
4040
|
-
): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
|
|
4040
|
+
): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
|
|
4041
4041
|
const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
4042
4042
|
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
4043
|
-
const latestEnded =
|
|
4043
|
+
const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
|
|
4044
4044
|
const byId = new Map<string, CurrentTurn>()
|
|
4045
4045
|
// Populate lowest-precedence first so a higher tier's turn wins the id slot
|
|
4046
4046
|
// when two lookups resolve the same turn (they carry the same turnId anyway).
|
|
@@ -4049,12 +4049,12 @@ function resolveReplyOwnerTurn(
|
|
|
4049
4049
|
}
|
|
4050
4050
|
// F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
|
|
4051
4051
|
// latest-ended turn can't inherit deletion authority over a newer turn's flush
|
|
4052
|
-
// record.
|
|
4053
|
-
//
|
|
4054
|
-
//
|
|
4052
|
+
// record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
|
|
4053
|
+
// and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
|
|
4054
|
+
// candidate at all, and an explicit null age now fails CLOSED downstream.
|
|
4055
4055
|
const latestEndedAgeMs =
|
|
4056
4056
|
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4057
|
-
const candidates = {
|
|
4057
|
+
const candidates: ReplyOwnerCandidates = {
|
|
4058
4058
|
liveTurnId: liveTurn?.turnId ?? null,
|
|
4059
4059
|
originTurnId: origin?.turnId ?? null,
|
|
4060
4060
|
quotedTurnId: quoted?.turnId ?? null,
|
|
@@ -4062,15 +4062,14 @@ function resolveReplyOwnerTurn(
|
|
|
4062
4062
|
latestEndedAgeMs,
|
|
4063
4063
|
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4064
4064
|
}
|
|
4065
|
-
// #3429 — the
|
|
4066
|
-
//
|
|
4067
|
-
//
|
|
4068
|
-
//
|
|
4069
|
-
//
|
|
4070
|
-
// tier can never disagree.
|
|
4065
|
+
// #3429 — the winning tier AND the candidate set it came from travel with the
|
|
4066
|
+
// turn. Tier alone no longer decides the content-gate bypass: the
|
|
4067
|
+
// model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
|
|
4068
|
+
// framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
|
|
4069
|
+
// derive from these SAME candidates, so they can never disagree.
|
|
4071
4070
|
const tier = resolveReplyOwnerTier(candidates)
|
|
4072
4071
|
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4073
|
-
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
|
|
4072
|
+
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
|
|
4074
4073
|
}
|
|
4075
4074
|
|
|
4076
4075
|
/**
|
|
@@ -4115,7 +4114,7 @@ function resolveAnswerThreadWithLog(
|
|
|
4115
4114
|
explicitThreadId == null &&
|
|
4116
4115
|
originTurn == null &&
|
|
4117
4116
|
liveTurn == null
|
|
4118
|
-
?
|
|
4117
|
+
? findLatestTurnForChat(chatId, { endedOnly: false })
|
|
4119
4118
|
: null
|
|
4120
4119
|
const threadId = resolveAnswerThreadId({
|
|
4121
4120
|
explicitThreadId,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recently-seen-turn registry scan, extracted from `gateway.ts` as a pure
|
|
3
|
+
* function so its ENDED-ness contract is unit-testable (#3725; gateway.ts is not
|
|
4
|
+
* importable in tests — the repo's `decideTurnFlush` / `resolveReplyOwnerTurnId`
|
|
5
|
+
* pattern).
|
|
6
|
+
*
|
|
7
|
+
* ## Why `endedOnly` exists (#3725)
|
|
8
|
+
*
|
|
9
|
+
* `recentTurnsById` is populated at turn **start** (`rememberRecentTurn` fires
|
|
10
|
+
* from the `enqueue` lifecycle event in `stream-render.ts`), and the atom is
|
|
11
|
+
* built with `endedAt: null`; `endedAt` is stamped later, in `turn-end.ts`. So
|
|
12
|
+
* the registry's tail entry for a chat is the most-recently-STARTED turn, which
|
|
13
|
+
* may still be RUNNING. The registry is also chat-wide and thread-agnostic, so
|
|
14
|
+
* on a forum a turn running in ANOTHER topic sits at the tail.
|
|
15
|
+
*
|
|
16
|
+
* That distinction is load-bearing because the two consumers want different
|
|
17
|
+
* things:
|
|
18
|
+
*
|
|
19
|
+
* - **Routing** (`resolveAnswerThreadWithLog`) wants the chat's most recent
|
|
20
|
+
* turn whether or not it has ended — it only picks a topic to deliver into,
|
|
21
|
+
* and a still-running turn's topic is a perfectly good (indeed better)
|
|
22
|
+
* answer than falling back to General. `endedOnly: false`.
|
|
23
|
+
* - **Owner resolution** (`resolveReplyOwnerTurn` → the `latest-ended`
|
|
24
|
+
* supersede tier) wants a genuinely ENDED turn: that tier carries
|
|
25
|
+
* DESTRUCTIVE authority (it drives message deletion) and is bounded by the
|
|
26
|
+
* supersede TTL measured from `endedAt`. A turn with `endedAt == null` has
|
|
27
|
+
* no age, so it could not be TTL-bounded at all — it was an unbounded
|
|
28
|
+
* anchor for the corroborated content-gate bypass (#3725). A still-running
|
|
29
|
+
* turn must be resolved by the `live` tier, never by this fallback.
|
|
30
|
+
* `endedOnly: true`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The registry-atom shape this scan needs (structural — `CurrentTurn` in the
|
|
34
|
+
* gateway satisfies it without importing the gateway's type). */
|
|
35
|
+
export interface LatestTurnLookupAtom {
|
|
36
|
+
/** The chat the turn belongs to. */
|
|
37
|
+
sessionChatId: string
|
|
38
|
+
/** Wall-clock ms the turn ENDED, or null while it is still running. */
|
|
39
|
+
endedAt: number | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The last turn for `chatId` in registry insertion order — i.e. the most recent
|
|
44
|
+
* one. With `endedOnly: true` the scan skips turns that have not ended yet, so
|
|
45
|
+
* the result is the most-recently-ended turn (which may NOT be the tail entry).
|
|
46
|
+
* Returns null when the chat has no matching turn.
|
|
47
|
+
*/
|
|
48
|
+
export function latestTurnForChat<T extends LatestTurnLookupAtom>(
|
|
49
|
+
turns: Iterable<T>,
|
|
50
|
+
chatId: string,
|
|
51
|
+
opts: { endedOnly: boolean },
|
|
52
|
+
): T | null {
|
|
53
|
+
let latest: T | null = null
|
|
54
|
+
for (const t of turns) {
|
|
55
|
+
if (t.sessionChatId !== chatId) continue
|
|
56
|
+
if (opts.endedOnly && t.endedAt == null) continue
|
|
57
|
+
latest = t
|
|
58
|
+
}
|
|
59
|
+
return latest
|
|
60
|
+
}
|
|
@@ -62,7 +62,12 @@ import {
|
|
|
62
62
|
DEFAULT_SUPERSEDE_TTL_MS,
|
|
63
63
|
type FlushedTurnSupersedeRegistry,
|
|
64
64
|
} from '../flushed-turn-supersede.js'
|
|
65
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
decideAnswerLatchSuppression,
|
|
67
|
+
decideContentGateBypass,
|
|
68
|
+
type ReplyOwnerTier,
|
|
69
|
+
type ReplyOwnerCandidates,
|
|
70
|
+
} from '../reply-owner-resolve.js'
|
|
66
71
|
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
67
72
|
import {
|
|
68
73
|
mayInjectListenButton,
|
|
@@ -726,7 +731,13 @@ export interface SendReplyGatewayDeps {
|
|
|
726
731
|
assertSendable(f: string): void
|
|
727
732
|
statusKey(chatId: string, threadId?: number | null): string
|
|
728
733
|
streamKey(chatId: string, threadId?: number | null): string
|
|
729
|
-
|
|
734
|
+
/** Resolves the owner turn AND returns the CANDIDATE SET it was derived from.
|
|
735
|
+
* The candidates are load-bearing, not diagnostics: `decideContentGateBypass`
|
|
736
|
+
* corroborates a model-steerable `origin`/`quoted` attribution against the
|
|
737
|
+
* framework-derived `latestEndedTurnId` inside them before allowing a
|
|
738
|
+
* content-gate bypass — an anchor that is an ENDED turn within the supersede
|
|
739
|
+
* TTL, never one still running (#3725). */
|
|
740
|
+
resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates }
|
|
730
741
|
findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
|
|
731
742
|
findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
|
|
732
743
|
resolveAnswerThreadWithLog(
|
|
@@ -946,7 +957,11 @@ export async function sendReply(
|
|
|
946
957
|
// double-send). The quoted / latest-ended recoveries are precisely what the
|
|
947
958
|
// router already did for the same reply, so unifying here makes the two
|
|
948
959
|
// resolvers agree and the late-reply supersede fires by identity.
|
|
949
|
-
const {
|
|
960
|
+
const {
|
|
961
|
+
turn: ownerTurn,
|
|
962
|
+
tier: ownerTier,
|
|
963
|
+
candidates: ownerCandidates,
|
|
964
|
+
} = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
950
965
|
const resolvedTurnId = ownerTurn?.turnId ?? null
|
|
951
966
|
// #3429 — pass the (normalized) reply text so the registry CAN apply the
|
|
952
967
|
// new-content gate: identity match + TTL alone also fits a background
|
|
@@ -998,7 +1013,7 @@ export async function sendReply(
|
|
|
998
1013
|
// owner turn resolved (no record to clobber on the collapse path).
|
|
999
1014
|
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
|
|
1000
1015
|
// MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
|
|
1001
|
-
// lane-specific: `
|
|
1016
|
+
// lane-specific: `findLatestTurnForChat` resolves owners chat-wide, so a
|
|
1002
1017
|
// handback in topic A can supersede topic B's ended turn; a thread-keyed gate
|
|
1003
1018
|
// read (the F2 regression) let a reply dodge that handback by carrying a
|
|
1004
1019
|
// different `message_thread_id`. Chat-wide makes the gate un-steerable — any
|
|
@@ -1014,23 +1029,35 @@ export async function sendReply(
|
|
|
1014
1029
|
ownerEndedAt != null &&
|
|
1015
1030
|
handbackAt > ownerEndedAt &&
|
|
1016
1031
|
now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
|
|
1017
|
-
// MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21)
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1032
|
+
// MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) + the 2026-07-27
|
|
1033
|
+
// corroboration widening — decided by the pure `decideContentGateBypass` so
|
|
1034
|
+
// the gateway runs the exact code the unit tests exercise. The rule, in
|
|
1035
|
+
// brief (full rationale on that function):
|
|
1036
|
+
// - `live` — framework-owned live `currentTurn`; bypasses unconditionally
|
|
1037
|
+
// (`decideSupersede`'s same-turnId check already bars it from
|
|
1038
|
+
// a DIFFERENT ended turn's record).
|
|
1022
1039
|
// - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
|
|
1033
|
-
|
|
1040
|
+
// needs; bypass ONLY when no decoupled completion is in the
|
|
1041
|
+
// window (marker-absence ⇒ own answer).
|
|
1042
|
+
// - `origin` / `quoted` — MODEL-SUPPLIED attributions, so they bypass ONLY
|
|
1043
|
+
// when CORROBORATED: the turn they resolve must be the same
|
|
1044
|
+
// turn the framework-derived, TTL-bounded `latestEndedTurnId`
|
|
1045
|
+
// resolves (#3725 — that anchor is a genuinely ENDED turn
|
|
1046
|
+
// within the TTL; a turn still RUNNING in this chat is not a
|
|
1047
|
+
// candidate and corroborates nothing), and no handback may be
|
|
1048
|
+
// in the window. A reply that
|
|
1049
|
+
// steers itself onto a DIFFERENT ended turn fails
|
|
1050
|
+
// corroboration and keeps the content gate, so the Fable
|
|
1051
|
+
// silent-edit-over stays closed; a reply that merely echoes
|
|
1052
|
+
// its OWN turn no longer loses the collapse it would have got
|
|
1053
|
+
// by omitting the echo entirely (the observed 2026-07-27
|
|
1054
|
+
// `via=origin` duplicate).
|
|
1055
|
+
const replyIsOwnAnswer = decideContentGateBypass({
|
|
1056
|
+
tier: ownerTier,
|
|
1057
|
+
resolvedTurnId,
|
|
1058
|
+
candidates: ownerCandidates,
|
|
1059
|
+
handbackCouldOwnReply,
|
|
1060
|
+
})
|
|
1034
1061
|
const decision = flushedTurnSupersede.take(
|
|
1035
1062
|
chat_id,
|
|
1036
1063
|
gateThreadId,
|
|
@@ -1114,7 +1141,12 @@ export async function sendReply(
|
|
|
1114
1141
|
if (decision.reason === 'new-content') {
|
|
1115
1142
|
process.stderr.write(
|
|
1116
1143
|
`telegram gateway: reply: flush supersede declined — new content (#3429) ` +
|
|
1117
|
-
`chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}
|
|
1144
|
+
`chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)} ` +
|
|
1145
|
+
// WHY the bypass didn't apply — without these two fields the
|
|
1146
|
+
// 2026-07-27 duplicate looked like a pure text-matcher failure and
|
|
1147
|
+
// took a log-archive dig to attribute to the tier restriction.
|
|
1148
|
+
`tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` +
|
|
1149
|
+
`handbackInWindow=${handbackCouldOwnReply}; sending fresh\n`,
|
|
1118
1150
|
)
|
|
1119
1151
|
}
|
|
1120
1152
|
if (suppressByLatch) {
|
|
@@ -201,7 +201,7 @@ export class SubagentHandbackMarker {
|
|
|
201
201
|
* Wall-clock ms of the most recent handback enqueue ANYWHERE in `chatId`
|
|
202
202
|
* (across every topic lane), or null. This is what the content-gate read uses
|
|
203
203
|
* (dup-audit MUST-FIX 2, Fable 2026-07-21): the owner-resolution latest-ended
|
|
204
|
-
* tier is CHAT-WIDE (`
|
|
204
|
+
* tier is CHAT-WIDE (`findLatestTurnForChat` ignores thread), so a
|
|
205
205
|
* background handback in topic A can resolve — and supersede — topic B's
|
|
206
206
|
* ended turn. A thread-SPECIFIC gate read (the F2 regression) let a reply
|
|
207
207
|
* dodge that handback by carrying a different `message_thread_id`, silently
|
|
@@ -436,7 +436,7 @@ function endCurrentTurnAtomic(
|
|
|
436
436
|
// live feed never opened because its sends failed (the resume-400 signature).
|
|
437
437
|
const turnEndedAt = Date.now()
|
|
438
438
|
// 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
|
|
439
|
-
// `
|
|
439
|
+
// `latest-ended` supersede tier can be recency-bounded to the
|
|
440
440
|
// supersede TTL (a stale latest-ended turn must not inherit deletion
|
|
441
441
|
// authority over a newer turn's flush record). Set once; idempotent on the
|
|
442
442
|
// deferRecord flush path (which calls this synchronously before its send).
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import type { QuotaUtilization } from './quota-check.js';
|
|
40
40
|
import { refillNormalizedUtils, isProbeThin } from '../src/auth/quota.js';
|
|
41
41
|
import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
|
|
42
|
+
import { effectiveServingLabel } from '../src/auth/broker/client.js';
|
|
42
43
|
import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
|
|
43
44
|
import { escapeMarkdown } from './card-format.js';
|
|
44
45
|
import { maskEmail } from './demo-mask.js';
|
|
@@ -358,9 +359,11 @@ export function renderQuotaBarBlockFromListState(
|
|
|
358
359
|
const exhaustedByLabel = new Map<string, boolean>(
|
|
359
360
|
state.accounts.map((a: AccountState) => [a.label, a.exhausted]),
|
|
360
361
|
);
|
|
362
|
+
// SERVING, not pinned — see effectiveServingLabel.
|
|
363
|
+
const serving = effectiveServingLabel(state);
|
|
361
364
|
const snapshots: AccountSnapshot[] = state.accounts.map((acc: AccountState) => ({
|
|
362
365
|
label: acc.label,
|
|
363
|
-
isActive: acc.label ===
|
|
366
|
+
isActive: acc.label === serving,
|
|
364
367
|
quota: reviveLastQuota(acc.last_quota ?? null),
|
|
365
368
|
quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
|
|
366
369
|
expiresAtMs: acc.expiresAt,
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
*
|
|
26
26
|
* The DECISIVE divergence: the gateway's *thread-routing* path DID recover the
|
|
27
27
|
* owner turn for the same late reply — via `findTurnByQuotedMessageId` (the
|
|
28
|
-
* framework-owned default quote target) and `
|
|
29
|
-
* chat's most
|
|
28
|
+
* framework-owned default quote target) and `findLatestTurnForChat` (the
|
|
29
|
+
* chat's most recent turn). The supersede resolver chain omitted BOTH
|
|
30
30
|
* recoveries, so the two resolvers disagreed on who owned the reply. This module
|
|
31
31
|
* unifies them onto ONE precedence so they can never diverge again.
|
|
32
32
|
*
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* `decideCapturedProseDelivery` — is to extract the decision core into a pure,
|
|
38
38
|
* unit-testable function and have the gateway run the EXACT code the regression
|
|
39
39
|
* tests exercise. The gateway performs the four turn lookups (currentTurn,
|
|
40
|
-
* findTurnByOriginId, findTurnByQuotedMessageId,
|
|
40
|
+
* findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat) and
|
|
41
41
|
* feeds their resolved turnIds here; the precedence lives in one place.
|
|
42
42
|
*/
|
|
43
43
|
|
|
@@ -56,16 +56,25 @@ export interface ReplyOwnerCandidates {
|
|
|
56
56
|
/** `findTurnByQuotedMessageId(chat_id, reply_to)` — the framework-owned
|
|
57
57
|
* quoted message id, resolved with NO model thread assertion. */
|
|
58
58
|
quotedTurnId: string | null
|
|
59
|
-
/** `
|
|
60
|
-
* turn. The deterministic late-reply fallback (the DM
|
|
59
|
+
/** `findLatestTurnForChat(chat_id, {endedOnly:true})` — the chat's
|
|
60
|
+
* most-recently-ENDED turn. The deterministic late-reply fallback (the DM
|
|
61
|
+
* path's recovery). #3725: the gateway lookup skips turns that have not
|
|
62
|
+
* ended, so this is never a still-running turn. */
|
|
61
63
|
latestEndedTurnId: string | null
|
|
62
64
|
/** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
|
|
63
65
|
* tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
|
|
64
66
|
* honoured ONLY when the turn ended within `latestEndedTtlMs` (the supersede
|
|
65
67
|
* TTL). Without the bound, a late reply belonging to an OLDER turn could
|
|
66
68
|
* resolve its owner to a NEWER turn now sitting at the registry tail and
|
|
67
|
-
* delete THAT turn's legit answer.
|
|
68
|
-
*
|
|
69
|
+
* delete THAT turn's legit answer.
|
|
70
|
+
*
|
|
71
|
+
* Two distinct absences (#3725):
|
|
72
|
+
* - `undefined` (property omitted) ⇒ unbounded, the pre-F2 back-compat
|
|
73
|
+
* escape for callers that don't supply an age at all;
|
|
74
|
+
* - explicit `null` ⇒ the caller COMPUTED no age, i.e. its candidate turn
|
|
75
|
+
* has no `endedAt` and has NOT ended. That cannot be TTL-bounded, so it
|
|
76
|
+
* fails CLOSED (not accepted) rather than granting unbounded authority
|
|
77
|
+
* to a turn that is still running. */
|
|
69
78
|
latestEndedAgeMs?: number | null
|
|
70
79
|
/** The supersede TTL bound applied to `latestEndedAgeMs`. Undefined ⇒
|
|
71
80
|
* unbounded. */
|
|
@@ -74,13 +83,17 @@ export interface ReplyOwnerCandidates {
|
|
|
74
83
|
|
|
75
84
|
/**
|
|
76
85
|
* Whether the latest-ended candidate is fresh enough to carry supersede
|
|
77
|
-
* (deletion) authority.
|
|
86
|
+
* (deletion) authority. An OMITTED age or TTL means unbounded (the pre-F2
|
|
87
|
+
* back-compat escape); an EXPLICIT null age fails closed (#3725 — the caller
|
|
88
|
+
* computed no age because its candidate turn has not ended, and an un-ended turn
|
|
89
|
+
* must be resolved by the `live` tier, never by this destructive fallback).
|
|
78
90
|
*/
|
|
79
91
|
function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
80
92
|
if (candidates.latestEndedTurnId == null) return false
|
|
81
93
|
const age = candidates.latestEndedAgeMs
|
|
82
94
|
const ttl = candidates.latestEndedTtlMs
|
|
83
|
-
if (age
|
|
95
|
+
if (age === null) return false
|
|
96
|
+
if (age === undefined || ttl == null) return true
|
|
84
97
|
return age <= ttl
|
|
85
98
|
}
|
|
86
99
|
|
|
@@ -143,6 +156,94 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
143
156
|
}
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether the supersede path may BYPASS the #3429 content gate — i.e. treat the
|
|
161
|
+
* landing reply as this flushed turn's OWN answer and collapse the provisional
|
|
162
|
+
* flush REGARDLESS of the model having reworded it.
|
|
163
|
+
*
|
|
164
|
+
* ## Why this is not simply "the tier is positive"
|
|
165
|
+
*
|
|
166
|
+
* The pre-existing rule was `tier === 'live' || (tier === 'latest-ended' &&
|
|
167
|
+
* !handbackCouldOwnReply)`. `origin` and `quoted` were excluded WHOLESALE
|
|
168
|
+
* because both derive from MODEL-SUPPLIED args (`args.origin_turn_id` /
|
|
169
|
+
* `args.reply_to`): a reply can STEER its own attribution onto a DIFFERENT
|
|
170
|
+
* ended turn and, with the gate bypassed, silently edit over that turn's
|
|
171
|
+
* delivered answer (the #3429 double-loss, executed by Fable 2026-07-21).
|
|
172
|
+
*
|
|
173
|
+
* That wholesale exclusion over-fires. Observed 2026-07-27 on a DM agent (two
|
|
174
|
+
* answers delivered twice): the answer-ready quiescence flush posted the turn's
|
|
175
|
+
* composed prose as message A, the model then fired `reply` with a REWORDED
|
|
176
|
+
* version of the SAME answer, and — because it had echoed `origin_turn_id` back
|
|
177
|
+
* pointing at its OWN turn — the tier resolved `origin` rather than
|
|
178
|
+
* `latest-ended`, so no bypass applied and the content gate declined on the
|
|
179
|
+
* rewording (`reply: flush supersede declined — new content (#3429)`). Message B
|
|
180
|
+
* shipped as a visible duplicate. Had the model simply OMITTED the echo, the
|
|
181
|
+
* identical reply would have resolved `latest-ended` and collapsed to ONE
|
|
182
|
+
* message. The exclusion punished the model for supplying MORE information.
|
|
183
|
+
*
|
|
184
|
+
* ## The rule: corroborate the steerable tier, don't blanket-ban it
|
|
185
|
+
*
|
|
186
|
+
* A model-supplied attribution is dangerous only when it points somewhere the
|
|
187
|
+
* FRAMEWORK would not have gone on its own. So `origin`/`quoted` bypass the
|
|
188
|
+
* content gate IFF the turn they resolve is the SAME turn the framework-derived,
|
|
189
|
+
* TTL-bounded `latest-ended` candidate resolves — a candidate computed from
|
|
190
|
+
* `findLatestTurnForChat(chat_id, {endedOnly:true})` with no model input at all.
|
|
191
|
+
* "TTL-bounded" is enforced, not assumed (#3725): `latestEndedAccepted` demands
|
|
192
|
+
* an age within `latestEndedTtlMs`, and an anchor turn that has NOT ended (age
|
|
193
|
+
* explicitly null) is rejected outright rather than treated as unbounded — so a
|
|
194
|
+
* turn still running in another topic of the same chat can corroborate nothing.
|
|
195
|
+
*
|
|
196
|
+
* This grants ZERO new capability, which is the safety argument: any reply that
|
|
197
|
+
* reaches the bypass via a corroborated `origin`/`quoted` attribution could
|
|
198
|
+
* already have reached it by omitting `origin_turn_id`/`reply_to` entirely and
|
|
199
|
+
* landing on `latest-ended` with the same turn and the same outcome. Steering to
|
|
200
|
+
* a DIFFERENT ended turn breaks corroboration (`origin` id ≠ latest-ended id),
|
|
201
|
+
* so the gate holds and the #3429/Fable silent-edit-over defence is untouched.
|
|
202
|
+
*
|
|
203
|
+
* `latest-ended` corroborates itself trivially (its resolved id IS the
|
|
204
|
+
* latest-ended candidate), so the rule below SUBSUMES the previous behaviour on
|
|
205
|
+
* that tier rather than changing it.
|
|
206
|
+
*
|
|
207
|
+
* `live` keeps its unconditional bypass: `currentTurn` is framework-owned, and
|
|
208
|
+
* `decideSupersede`'s same-turnId requirement already bars it from reaching a
|
|
209
|
+
* DIFFERENT ended turn's record. `none` never bypasses (nothing to attribute).
|
|
210
|
+
*/
|
|
211
|
+
export function decideContentGateBypass(input: {
|
|
212
|
+
/** The winning owner tier (`resolveReplyOwnerTier`). */
|
|
213
|
+
tier: ReplyOwnerTier
|
|
214
|
+
/** The owner turnId the supersede will act on (`resolveReplyOwnerTurnId`). */
|
|
215
|
+
resolvedTurnId: string | null
|
|
216
|
+
/** The SAME candidate set both of the above were derived from — supplies the
|
|
217
|
+
* framework-derived `latestEndedTurnId` plus its freshness bound, so the
|
|
218
|
+
* corroboration reuses the EXACT rule `resolveReplyOwnerTier` applies
|
|
219
|
+
* (`latestEndedAccepted`) instead of duplicating it: within the TTL, and —
|
|
220
|
+
* since #3725 — not a turn that is still running. */
|
|
221
|
+
candidates: ReplyOwnerCandidates
|
|
222
|
+
/** True when a decoupled-completion inbound (`subagent_handback`) was enqueued
|
|
223
|
+
* in this chat AFTER the owner turn ended and within the supersede TTL — the
|
|
224
|
+
* ambiguous window where the late reply might BE that handback rather than
|
|
225
|
+
* the turn's own answer. Keeps the content gate on every non-`live` tier. */
|
|
226
|
+
handbackCouldOwnReply: boolean
|
|
227
|
+
}): boolean {
|
|
228
|
+
if (input.tier === 'live') return true
|
|
229
|
+
if (input.tier === 'none') return false
|
|
230
|
+
if (input.handbackCouldOwnReply) return false
|
|
231
|
+
// Total over degenerate input (#3726). TypeScript makes `candidates` mandatory
|
|
232
|
+
// and the one production caller (`resolveReplyOwnerTurn`) always builds it, so
|
|
233
|
+
// this cannot fire today — but this module is exported precisely so the
|
|
234
|
+
// decision can be exercised OUTSIDE the gateway's construction discipline.
|
|
235
|
+
// Every other defensive branch here fails CLOSED (keep the #3429 content
|
|
236
|
+
// gate); without this guard the missing-input case instead fails by THROWING
|
|
237
|
+
// out of the supersede path — a fail-open-by-crash. Matches the module's own
|
|
238
|
+
// precedent: `latestEndedAccepted` is total over null age/ttl/turnId.
|
|
239
|
+
if (input.candidates == null) return false
|
|
240
|
+
if (!latestEndedAccepted(input.candidates)) return false
|
|
241
|
+
return (
|
|
242
|
+
input.resolvedTurnId != null &&
|
|
243
|
+
input.resolvedTurnId === input.candidates.latestEndedTurnId
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
146
247
|
/**
|
|
147
248
|
* The answer-delivered latch value — SOURCE-TAGGED (#3426).
|
|
148
249
|
*
|
|
@@ -113,7 +113,7 @@ describe('send-gate PR2: cosmetic shedding', () => {
|
|
|
113
113
|
expect(gate.stats().global.sent).toBe(1)
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
it('
|
|
116
|
+
it('COALESCES a cosmetic EDIT through an open message-edit window instead of shedding it (#3716)', async () => {
|
|
117
117
|
const clock = new FakeClock()
|
|
118
118
|
const { calls, fn } = recorder(clock)
|
|
119
119
|
const gate = createSendGate({
|
|
@@ -122,17 +122,35 @@ describe('send-gate PR2: cosmetic shedding', () => {
|
|
|
122
122
|
// H1: msg-edit scope is keyed `${chat_id}:${messageId}`.
|
|
123
123
|
initialWindows: [{ scopeKey: 'msg-edit:5:42', untilTs: HOUR }],
|
|
124
124
|
})
|
|
125
|
+
const opts = { chat_id: '5', messageId: 42, priorityClass: 'cosmetic' as const }
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})
|
|
127
|
+
// A burst of cosmetic edits arrives while the window is wide open. The old
|
|
128
|
+
// behaviour dropped every one of them (SEND_GATE_SHED), which stranded the
|
|
129
|
+
// card on whatever body happened to be on screen when the window opened.
|
|
130
|
+
const p1 = gate.gate(fn('v1'), { ...opts, editPayload: 'v1' })
|
|
131
|
+
await flush()
|
|
132
|
+
const p2 = gate.gate(fn('v2'), { ...opts, editPayload: 'v2' })
|
|
133
|
+
await flush()
|
|
134
|
+
const p3 = gate.gate(fn('v3'), { ...opts, editPayload: 'v3' })
|
|
135
|
+
await flush()
|
|
132
136
|
|
|
133
|
-
|
|
137
|
+
// Nothing has hit the API yet — the window still suppresses the send, which
|
|
138
|
+
// is the flood protection doing its job.
|
|
134
139
|
expect(calls).toHaveLength(0)
|
|
135
|
-
|
|
140
|
+
// ...but nothing was DISCARDED either.
|
|
141
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
142
|
+
|
|
143
|
+
// Walk past the window. Exactly ONE send lands, carrying the NEWEST payload:
|
|
144
|
+
// the burst was aggregated, not dropped, and not replayed edit-by-edit.
|
|
145
|
+
await clock.advance(HOUR + 1000)
|
|
146
|
+
await flush()
|
|
147
|
+
await Promise.allSettled([p1, p2, p3])
|
|
148
|
+
|
|
149
|
+
expect(calls.map((c) => c.label)).toEqual(['v3'])
|
|
150
|
+
expect(gate.stats().global.sent).toBe(1)
|
|
151
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
152
|
+
// v1 and v2 were superseded in the pending slot (last-write-wins).
|
|
153
|
+
expect(gate.stats().global.coalesced).toBeGreaterThan(0)
|
|
136
154
|
})
|
|
137
155
|
})
|
|
138
156
|
|
|
@@ -343,7 +361,7 @@ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
|
|
|
343
361
|
expect(calls.every((c) => c.at === 0)).toBe(true)
|
|
344
362
|
})
|
|
345
363
|
|
|
346
|
-
it('a 429 on chat A message 100 does NOT
|
|
364
|
+
it('a 429 on chat A message 100 does NOT delay chat B message 100 cosmetic edits', async () => {
|
|
347
365
|
const clock = new FakeClock()
|
|
348
366
|
const { calls, fn } = recorder(clock)
|
|
349
367
|
const gate = createSendGate({
|
|
@@ -353,14 +371,17 @@ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
|
|
|
353
371
|
initialWindows: [{ scopeKey: 'msg-edit:A:100', untilTs: HOUR }],
|
|
354
372
|
})
|
|
355
373
|
|
|
356
|
-
// Chat A cosmetic edit →
|
|
357
|
-
|
|
374
|
+
// Chat A cosmetic edit → deferred, its scope window is open (#3716: deferred,
|
|
375
|
+
// NOT shed — the edit is held and lands with its state once the window ends).
|
|
376
|
+
const pA = gate.gate(fn('A-edit'), {
|
|
358
377
|
chat_id: 'A',
|
|
359
378
|
messageId: 100,
|
|
360
379
|
editPayload: 'A-edit',
|
|
361
380
|
priorityClass: 'cosmetic',
|
|
362
381
|
})
|
|
363
|
-
|
|
382
|
+
await flush()
|
|
383
|
+
// Chat B cosmetic edit to the SAME message_id → must be unaffected (different
|
|
384
|
+
// scope). This is the H1 isolation the test exists to guard.
|
|
364
385
|
const rB = await gate.gate(fn('B-edit'), {
|
|
365
386
|
chat_id: 'B',
|
|
366
387
|
messageId: 100,
|
|
@@ -368,10 +389,18 @@ describe('send-gate PR2: H1 cross-chat message_id isolation', () => {
|
|
|
368
389
|
priorityClass: 'cosmetic',
|
|
369
390
|
})
|
|
370
391
|
|
|
371
|
-
expect(
|
|
372
|
-
expect(rB).toBe('B-edit') // B sent
|
|
392
|
+
expect(rB).toBe('B-edit') // B sent immediately, unblocked by A's window
|
|
373
393
|
expect(calls.map((c) => c.label)).toEqual(['B-edit'])
|
|
374
|
-
|
|
394
|
+
// A is still pending, not discarded.
|
|
395
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
396
|
+
|
|
397
|
+
// Once A's window closes, A's edit lands too — nothing was lost.
|
|
398
|
+
await clock.advance(HOUR + 1000)
|
|
399
|
+
await flush()
|
|
400
|
+
await Promise.allSettled([pA])
|
|
401
|
+
|
|
402
|
+
expect(calls.map((c) => c.label).sort()).toEqual(['A-edit', 'B-edit'])
|
|
403
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
375
404
|
})
|
|
376
405
|
})
|
|
377
406
|
|