switchroom 0.19.22 → 0.19.24
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 +5 -2
- package/dist/auth-broker/index.js +95 -2
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +749 -357
- package/dist/host-control/main.js +96 -3
- package/dist/vault/approvals/kernel-server.js +98 -5
- package/dist/vault/broker/server.js +98 -5
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +12 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +97 -107
- package/skills/switchroom-release/SKILL.md +2 -1
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +267 -52
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +43 -42
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- 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/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -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/reply-owner-resolve.test.ts +531 -0
- 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/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -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
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runtime alarm for the BUN half of the state-dir hermeticity guard.
|
|
7
|
+
*
|
|
8
|
+
* vitest loads `tests/vitest-setup/agent-state-dir-guard.mjs` via
|
|
9
|
+
* `test.setupFiles`; `bun test` loads the same file via `[test] preload` in
|
|
10
|
+
* bunfig.toml (repo root) and telegram-plugin/bunfig.toml (CI's bun-test-run
|
|
11
|
+
* has `working-directory: telegram-plugin`, and bun reads the bunfig in its CWD
|
|
12
|
+
* only). Without the bun half, ~75 bun-run test files still default every agent
|
|
13
|
+
* state writer to `/state/agent` — inside an agent container that is a LIVE
|
|
14
|
+
* agent's bind-mounted state dir, which is exactly how 356 synthetic turn rows
|
|
15
|
+
* ended up in production `turns.jsonl` files and were scored as real drift by
|
|
16
|
+
* the fleet-health sensor.
|
|
17
|
+
*
|
|
18
|
+
* `npm run lint:agent-state-dir-hermeticity` pins the WIRING statically; this
|
|
19
|
+
* pins the EFFECT, so a bunfig that is present but no longer loading the guard
|
|
20
|
+
* (wrong relative path, bun config-discovery change) fails a test rather than
|
|
21
|
+
* silently un-protecting the runner.
|
|
22
|
+
*/
|
|
23
|
+
describe('bun test runs with the agent state dir redirected', () => {
|
|
24
|
+
for (const k of ['SWITCHROOM_AGENT_STATE_DIR', 'SWITCHROOM_RUNTIME_STATE_DIR']) {
|
|
25
|
+
it(`${k} points at a tmp dir, not a live agent state dir`, () => {
|
|
26
|
+
const v = process.env[k]
|
|
27
|
+
expect(v, `${k} unset — bunfig.toml \`[test] preload\` did not run`).toBeTruthy()
|
|
28
|
+
expect(v).not.toBe('/state/agent')
|
|
29
|
+
expect(v!.startsWith(tmpdir())).toBe(true)
|
|
30
|
+
expect(existsSync(v!)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../gateway/backstop-delivery.js'
|
|
11
11
|
import {
|
|
12
12
|
backstopSendOutcomeGated,
|
|
13
|
+
buildTurnRecord,
|
|
13
14
|
finalizeBackstopSendGated,
|
|
14
15
|
computeTurnStatus,
|
|
15
16
|
} from '../gateway/turn-record-status.js'
|
|
@@ -348,25 +349,29 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
|
|
|
348
349
|
expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
|
|
349
350
|
})
|
|
350
351
|
|
|
351
|
-
it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed,
|
|
352
|
+
it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, still DELIVERED', async () => {
|
|
352
353
|
const ledger = new BackstopDeliveryLedger()
|
|
353
354
|
const sendChunk = vi.fn(async () => [950])
|
|
354
355
|
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
355
356
|
const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
|
|
356
357
|
expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
|
|
357
|
-
|
|
358
|
-
|
|
358
|
+
// An inconclusive probe establishes nothing, so the landed-id evidence
|
|
359
|
+
// stands: the answer IS in the chat and the turn is not a failure.
|
|
360
|
+
expect(res.delivered).toBe(true)
|
|
361
|
+
expect(res.confirmed).toBe(false) // landed-unconfirmed, honestly reported
|
|
362
|
+
expect(res.exhausted).toBe(false)
|
|
359
363
|
expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
|
|
360
364
|
expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
|
|
361
365
|
})
|
|
362
366
|
|
|
363
|
-
it('a read-back adapter THAT THROWS is
|
|
367
|
+
it('a read-back adapter THAT THROWS is ambiguous — never re-sent, never a failure', async () => {
|
|
364
368
|
const ledger = new BackstopDeliveryLedger()
|
|
365
369
|
const sendChunk = vi.fn(async () => [951])
|
|
366
370
|
const readBack = vi.fn(async () => { throw new Error('boom') })
|
|
367
371
|
const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
|
|
368
372
|
expect(sendChunk).toHaveBeenCalledTimes(1)
|
|
369
|
-
expect(res.delivered).toBe(
|
|
373
|
+
expect(res.delivered).toBe(true)
|
|
374
|
+
expect(res.confirmed).toBe(false)
|
|
370
375
|
})
|
|
371
376
|
|
|
372
377
|
it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
|
|
@@ -403,15 +408,207 @@ describe('#3278 runBackstopDelivery — read-back drives the delivery outcome',
|
|
|
403
408
|
expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
|
|
404
409
|
})
|
|
405
410
|
|
|
406
|
-
it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered
|
|
411
|
+
it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ both landed ⇒ delivered, chunk 0 not re-sent', async () => {
|
|
407
412
|
const ledger = new BackstopDeliveryLedger()
|
|
408
413
|
const calls: number[] = []
|
|
409
414
|
const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
|
|
410
415
|
const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
|
|
411
416
|
const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
|
|
412
|
-
expect(res.delivered).toBe(
|
|
417
|
+
expect(res.delivered).toBe(true) // every chunk landed a fresh id
|
|
418
|
+
expect(res.confirmed).toBe(false) // ...but chunk 1 was never corroborated
|
|
413
419
|
expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
|
|
414
420
|
expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
|
|
415
421
|
expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
|
|
416
422
|
})
|
|
423
|
+
|
|
424
|
+
// ── The measurement bug this PR fixes ────────────────────────────────────
|
|
425
|
+
//
|
|
426
|
+
// The probe is issued at cosmetic priority in the same millisecond as the
|
|
427
|
+
// send it probes, so the per-chat token bucket (1/sec, just consumed by that
|
|
428
|
+
// very send) sheds it: in production it resolved `ambiguous` 146 times in two
|
|
429
|
+
// weeks and `absent` ZERO times. Under the old confirmation-gated verdict each
|
|
430
|
+
// of those became `delivered:false` → `send_failed` on a turn whose answer the
|
|
431
|
+
// user had received, plus an error reaction and a still-OPEN obligation.
|
|
432
|
+
it('AMBIGUOUS probe ⇒ turn record `complete`, NOT send_failed (the fixed bug)', async () => {
|
|
433
|
+
const ledger = new BackstopDeliveryLedger()
|
|
434
|
+
const cardId = 500
|
|
435
|
+
const sendChunk = vi.fn(async () => [971]) // a fresh, non-card chat id
|
|
436
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
437
|
+
const res = await runBackstopDelivery(ledger, '#shed', ['answer'], cardId, { sendChunk, readBack }, 3)
|
|
438
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
439
|
+
finalAnswerDelivered: true,
|
|
440
|
+
}
|
|
441
|
+
finalizeBackstopSendGated(turn, {
|
|
442
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
|
|
443
|
+
})
|
|
444
|
+
expect(computeTurnStatus(turn)).toBe('complete')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('an ambiguous probe on a CARD-ONLY delivery is still send_failed (guard 7 holds)', async () => {
|
|
448
|
+
const ledger = new BackstopDeliveryLedger()
|
|
449
|
+
const cardId = 501
|
|
450
|
+
// The only id that landed IS the progress card — swept ~60-90s later, so
|
|
451
|
+
// the user receives nothing. Ambiguity must not rescue this.
|
|
452
|
+
const sendChunk = vi.fn(async () => [cardId])
|
|
453
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
454
|
+
const res = await runBackstopDelivery(ledger, '#cardonly', ['answer'], cardId, { sendChunk, readBack }, 3)
|
|
455
|
+
expect(res.delivered).toBe(false)
|
|
456
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
457
|
+
finalAnswerDelivered: true,
|
|
458
|
+
}
|
|
459
|
+
finalizeBackstopSendGated(turn, {
|
|
460
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
|
|
461
|
+
})
|
|
462
|
+
expect(computeTurnStatus(turn)).toBe('send_failed')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('an ambiguous probe on a chunk that never landed is still NOT delivered', async () => {
|
|
466
|
+
const ledger = new BackstopDeliveryLedger()
|
|
467
|
+
// chunk 0 lands; chunk 1 throws on every attempt → never landed.
|
|
468
|
+
const sendChunk = vi.fn(async (i: number) => {
|
|
469
|
+
if (i === 1) throw new Error('flood')
|
|
470
|
+
return [800 + i]
|
|
471
|
+
})
|
|
472
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
473
|
+
const res = await runBackstopDelivery(ledger, '#gap', ['c0', 'c1'], null, { sendChunk, readBack }, 2)
|
|
474
|
+
expect(res.delivered).toBe(false)
|
|
475
|
+
expect(res.exhausted).toBe(true)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// Mixed sequence: the two probe states that MOVE the verdict, composed in one
|
|
479
|
+
// run. Chunk 0's first send is silently discarded (`absent` ⇒ demote ⇒
|
|
480
|
+
// re-send) and the probe on the RE-SEND is shed (`ambiguous`). Under the old
|
|
481
|
+
// confirmation-gated verdict the re-sent chunk was never `landed-confirmed`,
|
|
482
|
+
// so the whole turn came out `delivered:false` — a `send_failed` for an answer
|
|
483
|
+
// that had just been successfully re-sent. Demote-and-re-send and
|
|
484
|
+
// ambiguity-is-not-failure must compose.
|
|
485
|
+
it('absent ⇒ demote ⇒ re-send, then AMBIGUOUS on the re-send ⇒ delivered, sent exactly twice', async () => {
|
|
486
|
+
const ledger = new BackstopDeliveryLedger()
|
|
487
|
+
let sends = 0
|
|
488
|
+
const sendChunk = vi.fn(async () => { sends++; return [990 + sends] })
|
|
489
|
+
let probes = 0
|
|
490
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => {
|
|
491
|
+
probes++
|
|
492
|
+
return probes === 1 ? 'absent' : 'ambiguous'
|
|
493
|
+
})
|
|
494
|
+
const res = await runBackstopDelivery(ledger, '#mixed', ['answer'], null, { sendChunk, readBack }, 3)
|
|
495
|
+
expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
|
|
496
|
+
expect(readBack).toHaveBeenCalledTimes(2) // probed once per landing
|
|
497
|
+
expect(res.delivered).toBe(true) // the re-send landed a fresh id; ambiguity proves nothing
|
|
498
|
+
expect(res.confirmed).toBe(false) // ...and is honestly reported as uncorroborated
|
|
499
|
+
expect(res.sentIds).toEqual([992]) // the demoted first id is gone; the re-send's id stands
|
|
500
|
+
expect(res.landedUnconfirmedIds).toEqual([992])
|
|
501
|
+
// The outcome the user actually experiences: a `complete` turn record.
|
|
502
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
503
|
+
finalAnswerDelivered: true,
|
|
504
|
+
}
|
|
505
|
+
finalizeBackstopSendGated(turn, {
|
|
506
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
|
|
507
|
+
})
|
|
508
|
+
expect(computeTurnStatus(turn)).toBe('complete')
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// ── The observability of the new optimism (#3702 L2/L5/L6a) ─────────────────
|
|
513
|
+
//
|
|
514
|
+
// Counting an inconclusive probe as delivered is a BET. These pin the two
|
|
515
|
+
// artifacts that let the fleet find out if it is ever wrong: the per-delivery
|
|
516
|
+
// stderr line, and the `landed_unconfirmed` field on the turns.jsonl row.
|
|
517
|
+
describe('#3702 landed-unconfirmed is MEASURED, not silently assumed', () => {
|
|
518
|
+
it('a delivered-but-unconfirmed turn writes the diagnostic stderr line', async () => {
|
|
519
|
+
const ledger = new BackstopDeliveryLedger()
|
|
520
|
+
const lines: string[] = []
|
|
521
|
+
const res = await runBackstopDelivery(
|
|
522
|
+
ledger, '#obs', ['answer'], null,
|
|
523
|
+
{
|
|
524
|
+
sendChunk: async () => [1001],
|
|
525
|
+
readBack: async (): Promise<ReadBackResult> => 'ambiguous',
|
|
526
|
+
stderr: (s) => lines.push(s),
|
|
527
|
+
},
|
|
528
|
+
3,
|
|
529
|
+
)
|
|
530
|
+
expect(res.delivered).toBe(true)
|
|
531
|
+
expect(res.confirmed).toBe(false)
|
|
532
|
+
const diag = lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))
|
|
533
|
+
expect(diag).toHaveLength(1)
|
|
534
|
+
expect(diag[0]).toContain('#obs')
|
|
535
|
+
expect(diag[0]).toContain('1 of 1 landed id(s)') // the measured quantity, not just a warning
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('a fully CONFIRMED delivery writes NO landed-unconfirmed line (no false alarm)', async () => {
|
|
539
|
+
const ledger = new BackstopDeliveryLedger()
|
|
540
|
+
const lines: string[] = []
|
|
541
|
+
const res = await runBackstopDelivery(
|
|
542
|
+
ledger, '#conf', ['answer'], null,
|
|
543
|
+
{
|
|
544
|
+
sendChunk: async () => [1002],
|
|
545
|
+
readBack: async (): Promise<ReadBackResult> => 'exists',
|
|
546
|
+
stderr: (s) => lines.push(s),
|
|
547
|
+
},
|
|
548
|
+
3,
|
|
549
|
+
)
|
|
550
|
+
expect(res.confirmed).toBe(true)
|
|
551
|
+
expect(res.landedUnconfirmedIds).toEqual([])
|
|
552
|
+
expect(lines.filter(l => l.includes('backstop delivery landed-unconfirmed'))).toHaveLength(0)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('landedUnconfirmedIds is exactly the uncorroborated subset of sentIds', async () => {
|
|
556
|
+
const ledger = new BackstopDeliveryLedger()
|
|
557
|
+
// chunk 0 confirmed, chunk 1 landed-unconfirmed (shed probe).
|
|
558
|
+
const res = await runBackstopDelivery(
|
|
559
|
+
ledger, '#subset', ['c0', 'c1'], null,
|
|
560
|
+
{
|
|
561
|
+
sendChunk: async (i: number) => [1100 + i],
|
|
562
|
+
readBack: async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'),
|
|
563
|
+
},
|
|
564
|
+
3,
|
|
565
|
+
)
|
|
566
|
+
expect(res.sentIds).toEqual([1100, 1101])
|
|
567
|
+
expect(res.landedUnconfirmedIds).toEqual([1101]) // c0 is corroborated; c1 is the bet
|
|
568
|
+
expect(res.delivered).toBe(true)
|
|
569
|
+
expect(res.confirmed).toBe(false)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('the count reaches turns.jsonl: a complete turn carries `landed_unconfirmed`', async () => {
|
|
573
|
+
const ledger = new BackstopDeliveryLedger()
|
|
574
|
+
const res = await runBackstopDelivery(
|
|
575
|
+
ledger, '#row', ['c0', 'c1'], null,
|
|
576
|
+
{
|
|
577
|
+
sendChunk: async (i: number) => [1200 + i],
|
|
578
|
+
readBack: async (): Promise<ReadBackResult> => 'ambiguous',
|
|
579
|
+
},
|
|
580
|
+
3,
|
|
581
|
+
)
|
|
582
|
+
const turn = {
|
|
583
|
+
agent: 'test-agent',
|
|
584
|
+
startedAt: 1_000_000,
|
|
585
|
+
toolCallCount: 0,
|
|
586
|
+
turnId: '#row',
|
|
587
|
+
finalAnswerDelivered: true,
|
|
588
|
+
deliveryOutcome: 'delivered' as const,
|
|
589
|
+
landedUnconfirmed: res.landedUnconfirmedIds.length,
|
|
590
|
+
}
|
|
591
|
+
const row = buildTurnRecord(turn, 1_002_000)
|
|
592
|
+
// The status is honest AND the bet is visible on the same row — that pairing
|
|
593
|
+
// is the point: a `complete` we could not corroborate is countable.
|
|
594
|
+
expect(row.status).toBe('complete')
|
|
595
|
+
expect(row.landed_unconfirmed).toBe(2)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
it('an ordinary confirmed turn omits the field entirely (row shape unchanged)', () => {
|
|
599
|
+
const row = buildTurnRecord(
|
|
600
|
+
{
|
|
601
|
+
agent: 'test-agent',
|
|
602
|
+
startedAt: 1_000_000,
|
|
603
|
+
toolCallCount: 0,
|
|
604
|
+
turnId: '#plain',
|
|
605
|
+
finalAnswerDelivered: true,
|
|
606
|
+
deliveryOutcome: 'delivered',
|
|
607
|
+
landedUnconfirmed: 0,
|
|
608
|
+
},
|
|
609
|
+
1_002_000,
|
|
610
|
+
)
|
|
611
|
+
expect(row.status).toBe('complete')
|
|
612
|
+
expect('landed_unconfirmed' in row).toBe(false)
|
|
613
|
+
})
|
|
417
614
|
})
|
|
@@ -141,4 +141,16 @@ describe('#3278 createBackstopReadBack — full gate+edit+classify wiring', () =
|
|
|
141
141
|
const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
|
|
142
142
|
expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
test('gate NO-OP drop (resolves undefined) ⇒ ambiguous, NOT a false exists', async () => {
|
|
146
|
+
// The gate's edit path drops a repeat of the last payload it actually sent
|
|
147
|
+
// (`counters.dropped`) and drops an expired queue entry, resolving
|
|
148
|
+
// `undefined` in both cases. Nothing reached Telegram, so nothing was
|
|
149
|
+
// proven — classifying it `exists` would fabricate a confirmation from a
|
|
150
|
+
// call that never happened.
|
|
151
|
+
const gate = vi.fn(async () => undefined)
|
|
152
|
+
const editMessageText = vi.fn(async () => true)
|
|
153
|
+
const readBack = createBackstopReadBack(wiring({ gate, editMessageText }))
|
|
154
|
+
expect(await readBack(0, [55], 'answer')).toBe('ambiguous')
|
|
155
|
+
})
|
|
144
156
|
})
|