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
|
@@ -478,6 +478,16 @@ interface MessageEditState {
|
|
|
478
478
|
* edit budget. Bounded by the budget cap; empty when the backstop is disabled.
|
|
479
479
|
*/
|
|
480
480
|
editWindowTs: number[]
|
|
481
|
+
/**
|
|
482
|
+
* Set by the driver while it waits out a NON-critical admission, and fired by
|
|
483
|
+
* `handleEdit` when a `critical` edit is queued for this message meanwhile
|
|
484
|
+
* (#3716). Since cosmetic edits stopped shedding they now occupy the driver,
|
|
485
|
+
* so without this a critical edit arriving one tick after the driver dequeued
|
|
486
|
+
* a cosmetic one would inherit its unbounded wait — re-introducing the
|
|
487
|
+
* multi-hour reply wedge the critical fail-fast path exists to prevent.
|
|
488
|
+
* Null whenever no interruptible wait is in flight.
|
|
489
|
+
*/
|
|
490
|
+
criticalWake: (() => void) | null
|
|
481
491
|
}
|
|
482
492
|
|
|
483
493
|
function hashPayload(payload: unknown): string {
|
|
@@ -669,6 +679,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
669
679
|
running: false,
|
|
670
680
|
suppressedUntilMs: 0,
|
|
671
681
|
editWindowTs: [],
|
|
682
|
+
criticalWake: null,
|
|
672
683
|
}
|
|
673
684
|
perMessage.set(key, state)
|
|
674
685
|
}
|
|
@@ -826,9 +837,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
826
837
|
* without consuming if admission cannot happen before it — the caller drops
|
|
827
838
|
* the call as stale (part3-design §2).
|
|
828
839
|
*/
|
|
829
|
-
async function admitLoop(
|
|
840
|
+
async function admitLoop(
|
|
841
|
+
buckets: TokenBucket[],
|
|
842
|
+
deadline?: number,
|
|
843
|
+
interrupt?: Interrupt,
|
|
844
|
+
): Promise<boolean> {
|
|
830
845
|
let counted = false
|
|
831
846
|
for (;;) {
|
|
847
|
+
// Checked BEFORE the consume below so an interrupted admission never
|
|
848
|
+
// spends a token nobody uses (a leaked token would permanently shrink the
|
|
849
|
+
// bucket's effective rate).
|
|
850
|
+
if (interrupt?.fired) return false
|
|
832
851
|
const now = clock.now()
|
|
833
852
|
let wait = 0
|
|
834
853
|
for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
|
|
@@ -841,13 +860,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
841
860
|
counters.queued++
|
|
842
861
|
counted = true
|
|
843
862
|
}
|
|
844
|
-
|
|
863
|
+
const nap = clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
|
|
864
|
+
// An open flood window makes `wait` as long as the whole ban, so the nap
|
|
865
|
+
// must be raced rather than awaited outright — otherwise the interrupt is
|
|
866
|
+
// not observed until the ban has already elapsed.
|
|
867
|
+
await (interrupt ? Promise.race([nap, interrupt.promise]) : nap)
|
|
845
868
|
}
|
|
846
869
|
}
|
|
847
870
|
|
|
848
871
|
/** Back-compat unbounded admission (used by the per-message edit driver). */
|
|
849
|
-
function admit(buckets: TokenBucket[]): Promise<boolean> {
|
|
850
|
-
return admitLoop(buckets)
|
|
872
|
+
function admit(buckets: TokenBucket[], interrupt?: Interrupt): Promise<boolean> {
|
|
873
|
+
return admitLoop(buckets, undefined, interrupt)
|
|
851
874
|
}
|
|
852
875
|
|
|
853
876
|
// Serializes `critical` sends that must probe the API during an open (short)
|
|
@@ -870,6 +893,36 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
870
893
|
})()
|
|
871
894
|
}
|
|
872
895
|
|
|
896
|
+
/**
|
|
897
|
+
* One-shot abort handle for an in-flight admission wait (#3716). `fired` is
|
|
898
|
+
* the synchronous check (so the loop can bail before consuming a token) and
|
|
899
|
+
* `promise` is the async wake (so a nap covering a whole flood ban is cut
|
|
900
|
+
* short rather than run to completion).
|
|
901
|
+
*/
|
|
902
|
+
interface Interrupt {
|
|
903
|
+
fired: boolean
|
|
904
|
+
promise: Promise<void>
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Create an `Interrupt` plus the trigger that fires it exactly once. */
|
|
908
|
+
function makeInterrupt(): { interrupt: Interrupt; fire: () => void } {
|
|
909
|
+
let resolve!: () => void
|
|
910
|
+
const interrupt: Interrupt = {
|
|
911
|
+
fired: false,
|
|
912
|
+
promise: new Promise<void>((r) => {
|
|
913
|
+
resolve = r
|
|
914
|
+
}),
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
interrupt,
|
|
918
|
+
fire: () => {
|
|
919
|
+
if (interrupt.fired) return
|
|
920
|
+
interrupt.fired = true
|
|
921
|
+
resolve()
|
|
922
|
+
},
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
873
926
|
type AdmitOutcome =
|
|
874
927
|
| { result: 'ok' }
|
|
875
928
|
| { result: 'shed' }
|
|
@@ -1038,7 +1091,24 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1038
1091
|
}
|
|
1039
1092
|
// outcome.result === 'ok' → admitPriority already consumed the buckets.
|
|
1040
1093
|
} else {
|
|
1041
|
-
|
|
1094
|
+
// #3716: this wait is UNBOUNDED, and since cosmetic edits stopped
|
|
1095
|
+
// shedding it is now reachable for them too — so a `critical` edit
|
|
1096
|
+
// arriving while we sit here must be able to jump the queue instead
|
|
1097
|
+
// of inheriting a whole flood ban's worth of waiting. Yield to it and
|
|
1098
|
+
// re-loop; `p` is superseded rather than dropped, so no state is lost
|
|
1099
|
+
// and its callers ride the send that actually goes out.
|
|
1100
|
+
const { interrupt, fire } = makeInterrupt()
|
|
1101
|
+
state.criticalWake = fire
|
|
1102
|
+
let admitted: boolean
|
|
1103
|
+
try {
|
|
1104
|
+
admitted = await admit(bucketsFor(opts), interrupt)
|
|
1105
|
+
} finally {
|
|
1106
|
+
state.criticalWake = null
|
|
1107
|
+
}
|
|
1108
|
+
if (!admitted) {
|
|
1109
|
+
supersede(state, p)
|
|
1110
|
+
continue
|
|
1111
|
+
}
|
|
1042
1112
|
}
|
|
1043
1113
|
// Reserve the send-start time BEFORE awaiting the network so the floor
|
|
1044
1114
|
// is measured from send start (matches the per-message serialization).
|
|
@@ -1084,6 +1154,70 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1084
1154
|
}
|
|
1085
1155
|
}
|
|
1086
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* Hand a dequeued-but-unsent edit back to the queue after the driver yielded
|
|
1159
|
+
* to a higher-priority one (#3716). `p` is by definition OLDER than whatever
|
|
1160
|
+
* is queued now, so last-write-wins says its payload loses — but its callers
|
|
1161
|
+
* must still settle, so they ride the newer edit's result exactly as if they
|
|
1162
|
+
* had coalesced onto it in the first place. With nothing queued (the wake
|
|
1163
|
+
* raced an empty slot) `p` simply goes back.
|
|
1164
|
+
*/
|
|
1165
|
+
function supersede(state: MessageEditState, p: PendingEdit): void {
|
|
1166
|
+
const next = state.pending
|
|
1167
|
+
if (!next) {
|
|
1168
|
+
state.pending = p
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
next.priorityClass = maxPriority(next.priorityClass, p.priorityClass)
|
|
1172
|
+
next.promise.then(p.resolve, p.reject)
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* The condition that used to trigger the cosmetic-edit shed: no token in some
|
|
1177
|
+
* bucket, or a window covering this message. Kept as the trigger — what
|
|
1178
|
+
* changed in #3716 is the CONSEQUENCE (see `settleForCaller`).
|
|
1179
|
+
*/
|
|
1180
|
+
function editUnderPressure(state: MessageEditState, opts: SendGateOpts, now: number): boolean {
|
|
1181
|
+
if (state.suppressedUntilMs > now) return true
|
|
1182
|
+
for (const b of bucketsFor(opts)) {
|
|
1183
|
+
if (b.msUntilAvailable(now) > 0) return true
|
|
1184
|
+
}
|
|
1185
|
+
return false
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* What a queued edit returns to the caller that submitted it (#3716).
|
|
1190
|
+
*
|
|
1191
|
+
* Normally: the shared pending promise, settling with the coalesced send's
|
|
1192
|
+
* outcome. Callers rely on that to pace their own painting, so this is the
|
|
1193
|
+
* path whenever the gate is keeping up.
|
|
1194
|
+
*
|
|
1195
|
+
* Under pressure, a `cosmetic` edit instead settles its caller as soon as it
|
|
1196
|
+
* is QUEUED. Callers serialize their own flushes, so a best-effort paint
|
|
1197
|
+
* parked behind a flood ban would hold a subsequent critical edit hostage
|
|
1198
|
+
* UPSTREAM of the gate — where the fail-fast path can never see it — wedging
|
|
1199
|
+
* the reply path for the length of the ban. Releasing the caller keeps that
|
|
1200
|
+
* path free while the edit itself stays queued and still lands carrying the
|
|
1201
|
+
* newest payload. This is the same trigger the old shed used; the difference
|
|
1202
|
+
* is that the state survives instead of being discarded.
|
|
1203
|
+
*/
|
|
1204
|
+
function settleForCaller<T>(
|
|
1205
|
+
state: MessageEditState,
|
|
1206
|
+
pending: PendingEdit,
|
|
1207
|
+
opts: SendGateOpts,
|
|
1208
|
+
priority: PriorityClass,
|
|
1209
|
+
now: number,
|
|
1210
|
+
): Promise<T> {
|
|
1211
|
+
if (priority !== 'cosmetic' || !editUnderPressure(state, opts, now)) {
|
|
1212
|
+
return pending.promise as Promise<T>
|
|
1213
|
+
}
|
|
1214
|
+
// Nobody may be left awaiting the shared promise, so absorb a rejection here
|
|
1215
|
+
// to keep a failed background paint from surfacing as an unhandled rejection.
|
|
1216
|
+
// (A later `critical`/`useful` coalesce still attaches its own handlers.)
|
|
1217
|
+
void pending.promise.catch(() => {})
|
|
1218
|
+
return Promise.resolve(undefined as unknown as T)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1087
1221
|
function handleEdit<T>(fn: () => Promise<T>, opts: SendGateOpts): Promise<T> {
|
|
1088
1222
|
const messageId = opts.messageId as number
|
|
1089
1223
|
const key = messageKey(opts.chat_id, messageId)
|
|
@@ -1093,22 +1227,39 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1093
1227
|
maybeEvict(now, existing === undefined)
|
|
1094
1228
|
const state = existing ?? messageState(key)
|
|
1095
1229
|
|
|
1096
|
-
// Cosmetic edits
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1230
|
+
// Cosmetic edits COALESCE under pressure; they are never shed (#3716).
|
|
1231
|
+
//
|
|
1232
|
+
// This previously dropped a cosmetic edit outright whenever a bucket had no
|
|
1233
|
+
// free token or a flood window covered the scope, on the reasoning that "a
|
|
1234
|
+
// dropped edit costs nothing: the next edit carries the full state". That
|
|
1235
|
+
// holds for every edit in a burst EXCEPT the last one — and the last one is
|
|
1236
|
+
// the only edit whose state is still on screen when the burst ends. Dropping
|
|
1237
|
+
// it strands the card on a stale body indefinitely (a progress card frozen
|
|
1238
|
+
// mid-run, a finished task still rendered as running). Pressure is exactly
|
|
1239
|
+
// when bursts end, so the drop was biased toward stranding precisely the
|
|
1240
|
+
// edits that mattered.
|
|
1241
|
+
//
|
|
1242
|
+
// Falling through to the last-write-wins coalescing below loses nothing and
|
|
1243
|
+
// costs no extra flood budget: N queued edits collapse into ONE send that
|
|
1244
|
+
// carries the newest payload, and the driver already paces that send against
|
|
1245
|
+
// the token buckets, the per-message edit floor, and the rolling per-window
|
|
1246
|
+
// cosmetic budget — which DEFERS an over-budget edit rather than dropping it.
|
|
1247
|
+
// Flood safety comes from that pacing, never from discarding state.
|
|
1248
|
+
//
|
|
1249
|
+
// Cost of the change: under a long flood window a cosmetic edit now waits in
|
|
1250
|
+
// the driver instead of resolving immediately as SEND_GATE_SHED. The pending
|
|
1251
|
+
// slot is one-per-message and LRU-bounded by `maxMessageStates`, and the edit
|
|
1252
|
+
// that eventually lands carries the newest state rather than a stale one.
|
|
1253
|
+
// `SEND_GATE_SHED` remains the contract for NON-edit cosmetic sends (typing,
|
|
1254
|
+
// reactions), which carry no state worth preserving.
|
|
1255
|
+
//
|
|
1256
|
+
// What that cost must NOT become is a wait the CALLER inherits. Callers
|
|
1257
|
+
// serialize their own flushes (draft-stream awaits the in-flight paint before
|
|
1258
|
+
// starting the next), so a cosmetic edit that blocked its caller for a whole
|
|
1259
|
+
// flood ban would hold the critical finalize behind it — and the finalize
|
|
1260
|
+
// would never reach the gate to fail fast, wedging the reply path for hours.
|
|
1261
|
+
// That is precisely the failure the gate exists to prevent, so a cosmetic
|
|
1262
|
+
// edit settles its caller as soon as it is QUEUED (see `settleForCaller`).
|
|
1112
1263
|
|
|
1113
1264
|
// N1: the coalesce (last-write-wins) check runs BEFORE the no-op skip. When
|
|
1114
1265
|
// a distinct edit is already queued, the newest payload always replaces it —
|
|
@@ -1135,7 +1286,10 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1135
1286
|
state.pending.hash = hash
|
|
1136
1287
|
state.pending.fn = fn as () => Promise<unknown>
|
|
1137
1288
|
}
|
|
1138
|
-
|
|
1289
|
+
// The queued edit is critical now — cut short any non-critical admission
|
|
1290
|
+
// the driver is waiting out for this message (#3716).
|
|
1291
|
+
if (state.pending.priorityClass === 'critical') state.criticalWake?.()
|
|
1292
|
+
return settleForCaller<T>(state, state.pending, opts, opts.priorityClass ?? 'useful', now)
|
|
1139
1293
|
}
|
|
1140
1294
|
|
|
1141
1295
|
// No edit queued → a repeat of the last payload we actually sent is a plain
|
|
@@ -1164,8 +1318,15 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
1164
1318
|
priorityClass: opts.priorityClass ?? 'useful',
|
|
1165
1319
|
}
|
|
1166
1320
|
state.pending = pending
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1321
|
+
if (state.running) {
|
|
1322
|
+
// A driver is mid-flight. If it is waiting out a non-critical admission
|
|
1323
|
+
// and THIS edit is critical, wake it so the critical work does not queue
|
|
1324
|
+
// behind a cosmetic edit's unbounded wait (#3716).
|
|
1325
|
+
if (pending.priorityClass === 'critical') state.criticalWake?.()
|
|
1326
|
+
} else {
|
|
1327
|
+
void drive(state, opts)
|
|
1328
|
+
}
|
|
1329
|
+
return settleForCaller<T>(state, pending, opts, pending.priorityClass, now)
|
|
1169
1330
|
}
|
|
1170
1331
|
|
|
1171
1332
|
async function gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T> {
|
|
@@ -239,10 +239,10 @@ describe('activity card ↔ send gate (#3620)', () => {
|
|
|
239
239
|
}
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
-
it('a
|
|
242
|
+
it('a card edit starved of tokens is HELD, not shed — the newest render still lands (#3716)', async () => {
|
|
243
243
|
// A one-token-per-minute chat bucket: the card open consumes it, so the
|
|
244
|
-
// next COSMETIC card edit
|
|
245
|
-
const { lane, calls, clock } = makeGatedLane({ perChatPerSec: 1 / 60, perChatBurst: 1 })
|
|
244
|
+
// next COSMETIC card edit cannot be admitted.
|
|
245
|
+
const { lane, calls, clock, gate } = makeGatedLane({ perChatPerSec: 1 / 60, perChatBurst: 1 })
|
|
246
246
|
const turn = makeLaneTurn(lane)
|
|
247
247
|
|
|
248
248
|
lane.showNarrativeStep(turn, 'Opening the card')
|
|
@@ -250,17 +250,17 @@ describe('activity card ↔ send gate (#3620)', () => {
|
|
|
250
250
|
await turn.activityInFlight
|
|
251
251
|
expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
|
|
252
252
|
|
|
253
|
-
lane.showNarrativeStep(turn, '
|
|
253
|
+
lane.showNarrativeStep(turn, 'An update with no token available')
|
|
254
254
|
await clock.advance(10)
|
|
255
255
|
await turn.activityInFlight
|
|
256
|
-
//
|
|
257
|
-
//
|
|
256
|
+
// Nothing hits the API while the bucket is empty — unchanged. What changed
|
|
257
|
+
// is that the edit is now QUEUED rather than discarded, so the card can no
|
|
258
|
+
// longer be stranded on a stale body by a burst that ends under pressure.
|
|
258
259
|
expect(calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
259
|
-
expect(
|
|
260
|
-
expect(turn.activityPendingRender).not.toBe(turn.activityLastSentRender)
|
|
260
|
+
expect(gate.stats().global.shed).toBe(0)
|
|
261
261
|
|
|
262
262
|
// Once the bucket refills, the next drain paints the NEWEST body — not the
|
|
263
|
-
//
|
|
263
|
+
// superseded intermediate one.
|
|
264
264
|
lane.showNarrativeStep(turn, 'The newest update')
|
|
265
265
|
await clock.advance(120_000)
|
|
266
266
|
await turn.activityInFlight
|
|
@@ -856,6 +856,48 @@ describe('buildSnapshotsFromState', () => {
|
|
|
856
856
|
expect(snaps[2]!.quota).toBeNull();
|
|
857
857
|
expect(snaps[2]!.quotaError).toBe('HTTP 401');
|
|
858
858
|
});
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Operator report 2026-07-27: the /usage card (which reaches the renderers
|
|
862
|
+
* through THIS builder — see gateway.ts) marked the configured pin active
|
|
863
|
+
* while the broker's soft-avoid roll had the fleet on another account.
|
|
864
|
+
*/
|
|
865
|
+
it('marks the SERVING account active, not the pin, when the two diverge', () => {
|
|
866
|
+
const state = {
|
|
867
|
+
active: 'pinned@x',
|
|
868
|
+
serving: 'rolled-to@x',
|
|
869
|
+
fallback_order: ['pinned@x', 'rolled-to@x'],
|
|
870
|
+
accounts: [
|
|
871
|
+
{ label: 'pinned@x', exhausted: false },
|
|
872
|
+
{ label: 'rolled-to@x', exhausted: false },
|
|
873
|
+
],
|
|
874
|
+
agents: [],
|
|
875
|
+
consumers: [],
|
|
876
|
+
} as unknown as ListStateData;
|
|
877
|
+
const snaps = buildSnapshotsFromState(state, [
|
|
878
|
+
{ ok: true, data: quota({ sevenDayUtilizationPct: 96 }) },
|
|
879
|
+
{ ok: true, data: quota({ sevenDayUtilizationPct: 10 }) },
|
|
880
|
+
]);
|
|
881
|
+
expect(snaps.map((s) => s.isActive)).toEqual([false, true]);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('falls back to the pin when the broker publishes no serving field', () => {
|
|
885
|
+
const state: ListStateData = {
|
|
886
|
+
active: 'pinned@x',
|
|
887
|
+
fallback_order: ['pinned@x', 'other@x'],
|
|
888
|
+
accounts: [
|
|
889
|
+
{ label: 'pinned@x', exhausted: false },
|
|
890
|
+
{ label: 'other@x', exhausted: false },
|
|
891
|
+
],
|
|
892
|
+
agents: [],
|
|
893
|
+
consumers: [],
|
|
894
|
+
};
|
|
895
|
+
const snaps = buildSnapshotsFromState(state, [
|
|
896
|
+
{ ok: true, data: quota({}) },
|
|
897
|
+
{ ok: true, data: quota({}) },
|
|
898
|
+
]);
|
|
899
|
+
expect(snaps.map((s) => s.isActive)).toEqual([true, false]);
|
|
900
|
+
});
|
|
859
901
|
});
|
|
860
902
|
|
|
861
903
|
// ── reviveLastQuota ──────────────────────────────────────────────────
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3725 — the "latest-ended" supersede anchor was the most-recently-STARTED turn.
|
|
3
|
+
*
|
|
4
|
+
* `recentTurnsById` is populated at turn START (`rememberRecentTurn` fires from
|
|
5
|
+
* the `enqueue` lifecycle event in `stream-render.ts`, and the atom is built with
|
|
6
|
+
* `endedAt: null`; `endedAt` is stamped later in `turn-end.ts`). The gateway's
|
|
7
|
+
* lookup had NO `endedAt` filter, so `findLatestEndedTurnForChat` returned the
|
|
8
|
+
* chat's tail entry even when that turn was still RUNNING — its own docstring
|
|
9
|
+
* admitted it ("the most-recently-STARTED turn") while the name and every caller
|
|
10
|
+
* said otherwise. Because the registry is chat-wide and thread-agnostic, a turn
|
|
11
|
+
* running in ANOTHER topic of the same chat was a live route into that state.
|
|
12
|
+
*
|
|
13
|
+
* These tests pin the OUTCOME of the scan (which turn comes back), not the
|
|
14
|
+
* shape of the code.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { latestTurnForChat, type LatestTurnLookupAtom } from '../gateway/latest-turn-lookup.js'
|
|
19
|
+
|
|
20
|
+
interface Turn extends LatestTurnLookupAtom {
|
|
21
|
+
turnId: string
|
|
22
|
+
sessionThreadId?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CHAT = '12345'
|
|
26
|
+
const OTHER_CHAT = '99999'
|
|
27
|
+
|
|
28
|
+
/** Registry insertion order = turn-START order (what `rememberRecentTurn` does). */
|
|
29
|
+
const registry = (...turns: Turn[]): Map<string, Turn> =>
|
|
30
|
+
new Map(turns.map((t) => [t.turnId, t]))
|
|
31
|
+
|
|
32
|
+
describe('latestTurnForChat — endedOnly (#3725)', () => {
|
|
33
|
+
it('does NOT return a still-running turn as the latest ENDED turn — it falls ' +
|
|
34
|
+
'back to the most recent turn that actually ended', () => {
|
|
35
|
+
// Topic A ended at t=1000; topic B started after and is STILL RUNNING, so it
|
|
36
|
+
// sits at the registry tail. This is the exact incident shape.
|
|
37
|
+
const ended = { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000, sessionThreadId: 11 }
|
|
38
|
+
const running = { turnId: 'B#2', sessionChatId: CHAT, endedAt: null, sessionThreadId: 22 }
|
|
39
|
+
const turns = registry(ended, running)
|
|
40
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBe(ended)
|
|
41
|
+
// The routing consumer still wants the tail entry (running or not) — it only
|
|
42
|
+
// picks a topic to deliver into and deletes nothing.
|
|
43
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).toBe(running)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns NULL when the chat has only a running turn — the destructive tier ' +
|
|
47
|
+
'gets no anchor at all rather than an unbounded one (fail closed)', () => {
|
|
48
|
+
const turns = registry({ turnId: 'B#2', sessionChatId: CHAT, endedAt: null })
|
|
49
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBeNull()
|
|
50
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).not.toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('picks the LAST ended turn in insertion order when several have ended', () => {
|
|
54
|
+
const turns = registry(
|
|
55
|
+
{ turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
|
|
56
|
+
{ turnId: 'A#2', sessionChatId: CHAT, endedAt: 2_000 },
|
|
57
|
+
{ turnId: 'B#3', sessionChatId: CHAT, endedAt: null },
|
|
58
|
+
)
|
|
59
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#2')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never crosses chats (the scan is chat-scoped in BOTH modes)', () => {
|
|
63
|
+
const turns = registry(
|
|
64
|
+
{ turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
|
|
65
|
+
{ turnId: 'X#1', sessionChatId: OTHER_CHAT, endedAt: 2_000 },
|
|
66
|
+
{ turnId: 'X#2', sessionChatId: OTHER_CHAT, endedAt: null },
|
|
67
|
+
)
|
|
68
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
|
|
69
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })?.turnId).toBe('A#1')
|
|
70
|
+
expect(latestTurnForChat(turns.values(), 'nobody', { endedOnly: false })).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('an endedAt of 0 counts as ENDED (null is the only "still running" marker)', () => {
|
|
74
|
+
const turns = registry({ turnId: 'A#1', sessionChatId: CHAT, endedAt: 0 })
|
|
75
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -35,6 +35,28 @@ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
|
35
35
|
import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
|
|
36
36
|
import { redact } from '../secret-detect/redact.js'
|
|
37
37
|
import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
|
|
38
|
+
import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
|
|
39
|
+
|
|
40
|
+
/** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
|
|
41
|
+
* candidate set the content-gate bypass corroborates against. These fixtures
|
|
42
|
+
* never exercise the supersede path, so the candidates mirror the resolved turn
|
|
43
|
+
* (the corroborated shape) with no override needed. */
|
|
44
|
+
function ownerRes(turn: CurrentTurn | null, tier: ReplyOwnerTier) {
|
|
45
|
+
const id = turn?.turnId ?? null
|
|
46
|
+
return {
|
|
47
|
+
turn,
|
|
48
|
+
tier,
|
|
49
|
+
candidates: {
|
|
50
|
+
liveTurnId: tier === 'live' ? id : null,
|
|
51
|
+
originTurnId: null,
|
|
52
|
+
quotedTurnId: null,
|
|
53
|
+
latestEndedTurnId: id,
|
|
54
|
+
latestEndedAgeMs: 1_000,
|
|
55
|
+
latestEndedTtlMs: 60_000,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
|
|
39
61
|
const CHAT = '1001'
|
|
40
62
|
|
|
@@ -371,7 +393,7 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
371
393
|
assertSendable: () => {},
|
|
372
394
|
statusKey: key,
|
|
373
395
|
streamKey: key,
|
|
374
|
-
resolveReplyOwnerTurn: () => (
|
|
396
|
+
resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
|
|
375
397
|
getLastSubagentHandbackAt: () => null,
|
|
376
398
|
findTurnByOriginId: () => null,
|
|
377
399
|
findTurnByQuotedMessageId: () => null,
|
|
@@ -485,3 +485,53 @@ describe('renderUsageCard', () => {
|
|
|
485
485
|
expect(out).not.toContain('- top ');
|
|
486
486
|
});
|
|
487
487
|
});
|
|
488
|
+
|
|
489
|
+
// ── serving vs pinned account ────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Operator report 2026-07-27: the /usage card named the CONFIGURED pin
|
|
493
|
+
* (`auth.active`) as "(active)" after the broker's soft-avoid roll had
|
|
494
|
+
* already moved the fleet onto a different account. The broker now publishes
|
|
495
|
+
* `serving` alongside `active`; every "(active)" marker must follow `serving`.
|
|
496
|
+
*/
|
|
497
|
+
describe('quota-bar "(active)" follows the SERVING account, not the pin', () => {
|
|
498
|
+
function stateWith(serving?: string): ListStateData {
|
|
499
|
+
const lq = {
|
|
500
|
+
fiveHourUtilizationPct: 0,
|
|
501
|
+
sevenDayUtilizationPct: 10,
|
|
502
|
+
fiveHourResetAt: new Date(NOW.getTime() + 60_000).toISOString(),
|
|
503
|
+
sevenDayResetAt: new Date(NOW.getTime() + 24 * 60 * 60_000).toISOString(),
|
|
504
|
+
representativeClaim: null,
|
|
505
|
+
overageStatus: null,
|
|
506
|
+
overageDisabledReason: null,
|
|
507
|
+
capturedAt: NOW.getTime(),
|
|
508
|
+
};
|
|
509
|
+
return {
|
|
510
|
+
active: 'pinned@example.com',
|
|
511
|
+
serving,
|
|
512
|
+
fallback_order: ['pinned@example.com', 'rolled-to@example.com'],
|
|
513
|
+
accounts: [
|
|
514
|
+
{ label: 'pinned@example.com', exhausted: false, last_quota: lq },
|
|
515
|
+
{ label: 'rolled-to@example.com', exhausted: false, last_quota: lq },
|
|
516
|
+
],
|
|
517
|
+
agents: [],
|
|
518
|
+
consumers: [],
|
|
519
|
+
} as unknown as ListStateData;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
it('marks the rolled-to account active and the rolled-off pin idle', () => {
|
|
523
|
+
const lines = renderQuotaBarBlockFromListState(stateWith('rolled-to@example.com'), {
|
|
524
|
+
now: NOW,
|
|
525
|
+
}).split('\n');
|
|
526
|
+
// The bug rendered exactly the inverse of these two assertions.
|
|
527
|
+
expect(lines).toContain('- **rolled-to@example.com** (active)');
|
|
528
|
+
expect(lines).toContain('- **pinned@example.com** (idle)');
|
|
529
|
+
expect(lines).not.toContain('- **pinned@example.com** (active)');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('falls back to the pin when the broker publishes no serving field (pre-serving broker)', () => {
|
|
533
|
+
const lines = renderQuotaBarBlockFromListState(stateWith(undefined), { now: NOW }).split('\n');
|
|
534
|
+
expect(lines).toContain('- **pinned@example.com** (active)');
|
|
535
|
+
expect(lines).toContain('- **rolled-to@example.com** (idle)');
|
|
536
|
+
});
|
|
537
|
+
});
|