switchroom 0.18.28 → 0.18.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff-briefing.sh +8 -1
- package/dist/auth-broker/index.js +0 -57
- package/dist/cli/switchroom.js +501 -497
- package/dist/host-control/main.js +1 -58
- package/dist/vault/approvals/kernel-server.js +0 -57
- package/dist/vault/broker/server.js +0 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +37 -19
- package/telegram-plugin/dist/gateway/gateway.js +578 -580
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +458 -385
- package/telegram-plugin/gateway/model-command.ts +227 -602
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/shared/local-time.ts +56 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -0
- package/telegram-plugin/tests/model-command.test.ts +317 -1535
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
- package/vendor/hindsight-memory/tests/test_content.py +28 -7
|
@@ -79,6 +79,51 @@ export function backstopSendOutcome(args: {
|
|
|
79
79
|
return 'delivered'
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a backstop send's outcome using the #3276 RECEIPT gate: success
|
|
84
|
+
* requires at least one FRESH, non-card chat message id. This supersedes the
|
|
85
|
+
* naive chunk-count comparison for the turn-flush backstop, where a delivery
|
|
86
|
+
* that landed only onto the (soon-swept) progress card must count as a failure,
|
|
87
|
+
* not `complete`.
|
|
88
|
+
*
|
|
89
|
+
* threw → failed
|
|
90
|
+
* no fresh non-card id delivered → failed (card-only "success")
|
|
91
|
+
* fewer fresh ids than chunks split → failed (partial delivery)
|
|
92
|
+
* >=1 fresh id AND all chunks landed → delivered
|
|
93
|
+
*
|
|
94
|
+
* `cardMessageId` is the taken-over progress-card id (or null); any id equal to
|
|
95
|
+
* it is excluded from the delivered set before counting.
|
|
96
|
+
*/
|
|
97
|
+
export function backstopSendOutcomeGated(args: {
|
|
98
|
+
threw: boolean
|
|
99
|
+
sentIds: readonly number[]
|
|
100
|
+
chunkCount: number
|
|
101
|
+
cardMessageId: number | null
|
|
102
|
+
}): DeliveryOutcome {
|
|
103
|
+
if (args.threw) return 'failed'
|
|
104
|
+
if (args.chunkCount === 0) return 'failed'
|
|
105
|
+
const freshCount = args.sentIds.filter(
|
|
106
|
+
id => args.cardMessageId == null || id !== args.cardMessageId,
|
|
107
|
+
).length
|
|
108
|
+
// Guard 7: a card-only delivery (zero fresh ids) is a hard failure.
|
|
109
|
+
if (freshCount === 0) return 'failed'
|
|
110
|
+
if (freshCount < args.chunkCount) return 'failed'
|
|
111
|
+
return 'delivered'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stamp a turn's `deliveryOutcome` from a resolved backstop send using the
|
|
116
|
+
* #3276 receipt gate (fresh non-card ids only). Mutates and returns the outcome.
|
|
117
|
+
*/
|
|
118
|
+
export function finalizeBackstopSendGated(
|
|
119
|
+
turn: { deliveryOutcome?: DeliveryOutcome },
|
|
120
|
+
send: { threw: boolean; sentIds: readonly number[]; chunkCount: number; cardMessageId: number | null },
|
|
121
|
+
): DeliveryOutcome {
|
|
122
|
+
const outcome = backstopSendOutcomeGated(send)
|
|
123
|
+
turn.deliveryOutcome = outcome
|
|
124
|
+
return outcome
|
|
125
|
+
}
|
|
126
|
+
|
|
82
127
|
/**
|
|
83
128
|
* Stamp a turn's `deliveryOutcome` from a resolved backstop send. This is the
|
|
84
129
|
* exact accounting the turn-flush IIFE's `finally` performs — factored here so
|
|
@@ -142,6 +142,40 @@ const MAX_LIMIT = 50
|
|
|
142
142
|
let db: SqliteDatabase | null = null
|
|
143
143
|
let dbPath: string | null = null
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Loud, unconditional failure logging for the history writer.
|
|
147
|
+
*
|
|
148
|
+
* Recording bugs are silent by construction: every gateway call site wraps
|
|
149
|
+
* `recordOutbound` / `recordInbound` in a `try { … } catch {}` (or a catch that
|
|
150
|
+
* logs a caller-shaped message), and several of those catches are EMPTY. When a
|
|
151
|
+
* write throws or drops a row, the caller's empty catch hides it — the exact
|
|
152
|
+
* failure mode behind the 2026-07-16 incident (turn-flush deliveries 18944 /
|
|
153
|
+
* 18958 delivered to Telegram but absent from history.db, blinding
|
|
154
|
+
* `getRecentOutboundCount` / `hasOutboundDeliveredSince`). We therefore log HERE,
|
|
155
|
+
* inside the writer, BEFORE any throw — so even a caller's `catch {}` cannot
|
|
156
|
+
* suppress the diagnostic. Deterministic surfacing, not prompt discipline.
|
|
157
|
+
*/
|
|
158
|
+
function warnHistory(msg: string): void {
|
|
159
|
+
try {
|
|
160
|
+
process.stderr.write(`telegram history: ${msg}\n`)
|
|
161
|
+
} catch {
|
|
162
|
+
/* stderr write must never itself break the record path */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A Telegram message_id is a positive 32-bit-ish integer. A null / undefined /
|
|
168
|
+
* NaN / non-integer id can only arrive from a malformed send result (e.g. an
|
|
169
|
+
* API wrapper that resolved without a real message object). Inserting it either
|
|
170
|
+
* violates the NOT NULL PRIMARY KEY (throws → swallowed by an empty caller
|
|
171
|
+
* catch) or silently corrupts the key space. We filter such ids out and log
|
|
172
|
+
* loudly instead — a delivered-but-unrecorded row is a durability defect the
|
|
173
|
+
* operator must see, not a silent drop.
|
|
174
|
+
*/
|
|
175
|
+
function isValidMessageId(id: unknown): id is number {
|
|
176
|
+
return typeof id === 'number' && Number.isInteger(id) && id > 0
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* Open (or create) the history DB and run migrations + retention sweep.
|
|
147
181
|
* Idempotent — safe to call once at server startup.
|
|
@@ -271,6 +305,72 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
271
305
|
const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
|
|
272
306
|
db.prepare('DELETE FROM messages WHERE ts < ?').run(cutoff)
|
|
273
307
|
}
|
|
308
|
+
|
|
309
|
+
// Boot-time writer self-check (2026-07-16 incident hardening). "history
|
|
310
|
+
// capture enabled" was logged at every boot — including the 02:17 and 04:58
|
|
311
|
+
// restarts around the incident — yet a whole class of deliveries never
|
|
312
|
+
// reached the DB. A successful `new Database(...)` + schema DDL does NOT prove
|
|
313
|
+
// the row-insert path is functional (a read-only mount, a full disk, an
|
|
314
|
+
// orphaned inode after an in-place dir replace, or a corrupt page all pass DDL
|
|
315
|
+
// but fail INSERT). So we prove it with a real INSERT + SELECT + DELETE
|
|
316
|
+
// round-trip on a sentinel row, and log LOUDLY if it fails. This turns a
|
|
317
|
+
// silent, hours-later-discovered writer outage into a deterministic boot
|
|
318
|
+
// signal the operator can see immediately.
|
|
319
|
+
const check = verifyHistoryWritable()
|
|
320
|
+
if (!check.ok) {
|
|
321
|
+
warnHistory(
|
|
322
|
+
`WRITER SELF-CHECK FAILED at boot (path=${path}): ${check.error ?? 'unknown'} — ` +
|
|
323
|
+
`history recording is NOT durable; get_recent_messages recovery and the ` +
|
|
324
|
+
`reply-backstop already-replied suppression will be blind. Investigate the ` +
|
|
325
|
+
`DB path/mount/permissions before trusting delivery accounting.`,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Prove the history writer's INSERT path actually works, not just that the DB
|
|
332
|
+
* opened and the schema DDL ran. Performs a real INSERT + SELECT + DELETE of a
|
|
333
|
+
* sentinel row keyed on a reserved chat_id that no live chat can collide with,
|
|
334
|
+
* and cleans it up unconditionally. Returns `{ ok:false, error }` (never throws)
|
|
335
|
+
* so the boot path and an operator self-check tool can both call it safely.
|
|
336
|
+
*
|
|
337
|
+
* This is the deterministic mechanism the 2026-07-16 incident lacked: a
|
|
338
|
+
* writer that opens fine but cannot persist rows (read-only mount, full disk,
|
|
339
|
+
* orphaned inode, corruption) is caught HERE at boot instead of being inferred
|
|
340
|
+
* hours later from missing rows.
|
|
341
|
+
*
|
|
342
|
+
* No-op safe: returns `{ ok:false }` with an explanatory error if
|
|
343
|
+
* `initHistory` was never called.
|
|
344
|
+
*/
|
|
345
|
+
export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
346
|
+
if (db == null) return { ok: false, error: 'initHistory() not called' }
|
|
347
|
+
const SENTINEL_CHAT = '__history_selfcheck__'
|
|
348
|
+
const sentinelId = Date.now()
|
|
349
|
+
try {
|
|
350
|
+
// Clear any stale sentinel from a prior crashed self-check first.
|
|
351
|
+
db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
|
|
352
|
+
db.prepare(
|
|
353
|
+
`INSERT OR REPLACE INTO messages
|
|
354
|
+
(chat_id, thread_id, message_id, role, ts, text)
|
|
355
|
+
VALUES (?, NULL, ?, 'assistant', ?, ?)`,
|
|
356
|
+
).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), 'selfcheck')
|
|
357
|
+
const row = db
|
|
358
|
+
.prepare('SELECT text FROM messages WHERE chat_id = ? AND message_id = ?')
|
|
359
|
+
.get(SENTINEL_CHAT, sentinelId) as { text?: string } | undefined
|
|
360
|
+
if (row?.text !== 'selfcheck') {
|
|
361
|
+
return { ok: false, error: 'sentinel row not read back after insert' }
|
|
362
|
+
}
|
|
363
|
+
return { ok: true }
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
366
|
+
} finally {
|
|
367
|
+
// Never leave the sentinel behind, even if the SELECT/assert path threw.
|
|
368
|
+
try {
|
|
369
|
+
db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
|
|
370
|
+
} catch {
|
|
371
|
+
/* best-effort cleanup */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
274
374
|
}
|
|
275
375
|
|
|
276
376
|
/**
|
|
@@ -404,6 +504,13 @@ interface RecordInboundArgs {
|
|
|
404
504
|
*/
|
|
405
505
|
export function recordInbound(args: RecordInboundArgs): void {
|
|
406
506
|
if (args.message_id == null) return
|
|
507
|
+
if (!isValidMessageId(args.message_id)) {
|
|
508
|
+
warnHistory(
|
|
509
|
+
`recordInbound: dropping row with invalid message_id=${String(args.message_id)} ` +
|
|
510
|
+
`(chat=${args.chat_id}) — a delivered inbound will be absent from history`,
|
|
511
|
+
)
|
|
512
|
+
return
|
|
513
|
+
}
|
|
407
514
|
const stmt = requireDb().prepare(`
|
|
408
515
|
INSERT OR REPLACE INTO messages
|
|
409
516
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
|
|
@@ -453,7 +560,35 @@ interface RecordOutboundArgs {
|
|
|
453
560
|
export function recordOutbound(args: RecordOutboundArgs): void {
|
|
454
561
|
if (args.message_ids.length === 0) return
|
|
455
562
|
const ts = args.ts ?? Math.floor(Date.now() / 1000)
|
|
456
|
-
|
|
563
|
+
// Filter out invalid ids (null/undefined/NaN/non-positive) BEFORE the insert.
|
|
564
|
+
// A malformed send result (an API wrapper that resolved without a real message
|
|
565
|
+
// object) would otherwise inject a NULL/NaN message_id: the NOT NULL PRIMARY
|
|
566
|
+
// KEY throws, and the caller's `catch {}` swallows it — the delivered reply is
|
|
567
|
+
// then absent from history AND the failure is invisible. This was the shape of
|
|
568
|
+
// the 2026-07-16 turn-flush loss (18944/18958 delivered, never recorded). We
|
|
569
|
+
// log loudly and record only the valid rows instead of losing them silently.
|
|
570
|
+
const validRows: Array<{ id: number; text: string; attachKind: string | null }> = []
|
|
571
|
+
for (let i = 0; i < args.message_ids.length; i++) {
|
|
572
|
+
const id = args.message_ids[i]
|
|
573
|
+
if (!isValidMessageId(id)) {
|
|
574
|
+
warnHistory(
|
|
575
|
+
`recordOutbound: dropping chunk ${i} with invalid message_id=${String(id)} ` +
|
|
576
|
+
`(chat=${args.chat_id}) — a delivered outbound will be absent from history, ` +
|
|
577
|
+
`blinding the reply-backstop already-replied suppression`,
|
|
578
|
+
)
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
validRows.push({
|
|
582
|
+
id,
|
|
583
|
+
// Outbound redaction: the agent→user direction has no other secret scrub,
|
|
584
|
+
// so this is the chokepoint that keeps an agent-echoed secret out of the
|
|
585
|
+
// message store. Masks the secret bytes in place; surrounding text kept.
|
|
586
|
+
text: redact(args.texts[i] ?? ''),
|
|
587
|
+
attachKind: args.attachment_kinds?.[i] ?? null,
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (validRows.length === 0) return
|
|
591
|
+
const groupId = validRows[0]!.id
|
|
457
592
|
const stmt = requireDb().prepare(`
|
|
458
593
|
INSERT OR REPLACE INTO messages
|
|
459
594
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
|
|
@@ -463,30 +598,25 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
463
598
|
// writes if the process dies mid-loop. The transaction signature is
|
|
464
599
|
// typed as variadic-unknown for genericity; cast the typed callback
|
|
465
600
|
// through the wider shape.
|
|
466
|
-
const tx = requireDb().transaction(((rows: Array<
|
|
467
|
-
for (const
|
|
468
|
-
stmt.run(
|
|
469
|
-
args.chat_id,
|
|
470
|
-
args.thread_id ?? null,
|
|
471
|
-
msgId,
|
|
472
|
-
ts,
|
|
473
|
-
text,
|
|
474
|
-
attachKind,
|
|
475
|
-
groupId,
|
|
476
|
-
)
|
|
601
|
+
const tx = requireDb().transaction(((rows: Array<{ id: number; text: string; attachKind: string | null }>) => {
|
|
602
|
+
for (const r of rows) {
|
|
603
|
+
stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId)
|
|
477
604
|
}
|
|
478
605
|
}) as (...args: unknown[]) => unknown)
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
606
|
+
// Surface a write failure LOUDLY before rethrowing. Callers wrap this in a
|
|
607
|
+
// `catch {}` / caller-shaped catch; without this log an insert failure (disk
|
|
608
|
+
// full, read-only mount, lock exhaustion) would be completely invisible —
|
|
609
|
+
// exactly the diagnostic gap the 2026-07-16 incident exposed. Rethrow so the
|
|
610
|
+
// caller's existing control flow is unchanged.
|
|
611
|
+
try {
|
|
612
|
+
tx(validRows)
|
|
613
|
+
} catch (err) {
|
|
614
|
+
warnHistory(
|
|
615
|
+
`recordOutbound: INSERT failed (chat=${args.chat_id} ids=[${validRows.map((r) => r.id).join(',')}]): ` +
|
|
616
|
+
`${err instanceof Error ? err.message : String(err)} — this outbound will be absent from history`,
|
|
617
|
+
)
|
|
618
|
+
throw err
|
|
619
|
+
}
|
|
490
620
|
}
|
|
491
621
|
|
|
492
622
|
interface RecordEditArgs {
|
|
@@ -67,3 +67,59 @@ export function tzAbbrev(ms: number, tz: string): string {
|
|
|
67
67
|
.find((p) => p.type === 'timeZoneName')?.value ?? tz
|
|
68
68
|
)
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the agent's configured timezone from the process environment, the
|
|
73
|
+
* SAME cascade `bin/timezone-hook.sh` and the config resolver use:
|
|
74
|
+
* `SWITCHROOM_TIMEZONE` → `TZ` → `UTC`. Centralised so the inbound-tag,
|
|
75
|
+
* forwarded_date, and recent-buffer callsites can't drift.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveEnvTimezone(env: NodeJS.ProcessEnv = process.env): string {
|
|
78
|
+
return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? 'UTC'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Full model-facing local wall-clock stamp, e.g.
|
|
83
|
+
* `Thursday 2026-07-16 04:09 PM AEST`.
|
|
84
|
+
*
|
|
85
|
+
* The deterministic replacement for the UTC ISO strings the model used to see
|
|
86
|
+
* on inbound channel tags (`ts="…Z"`), `forwarded_date`, and the
|
|
87
|
+
* `get_recent_messages` buffer. am/pm form in the agent's CONFIGURED timezone,
|
|
88
|
+
* with NO "UTC" / trailing-Z — so the LLM can never read one of these as UTC
|
|
89
|
+
* "now" and reason an offset wrong.
|
|
90
|
+
*
|
|
91
|
+
* Format matches the CORE of `bin/timezone-hook.sh`'s stamp —
|
|
92
|
+
* `%A %Y-%m-%d %I:%M %p %Z` (weekday, ISO date, am/pm, zone abbrev) — so the
|
|
93
|
+
* inbound-tag time and the UserPromptSubmit local-time hint read the same way.
|
|
94
|
+
* The hook additionally appends a ` (UTC±HH:MM)` numeric-offset LABEL that this
|
|
95
|
+
* helper deliberately omits: the abbrev (AEST/EDT) already disambiguates, and
|
|
96
|
+
* keeping the output free of any "UTC" substring makes the deterministic
|
|
97
|
+
* no-UTC-current-time guard trivially strict for every callsite. So it is NOT
|
|
98
|
+
* a byte-for-byte match — same core shape, minus the offset tail.
|
|
99
|
+
*
|
|
100
|
+
* Pure / total: an invalid IANA `tz` (misconfigured agent) degrades to the
|
|
101
|
+
* same am/pm shape rendered in UTC rather than throwing out of the inbound
|
|
102
|
+
* path — a bad zone must never crash a turn.
|
|
103
|
+
*/
|
|
104
|
+
export function fmtLocalStamp(ms: number, tz: string): string {
|
|
105
|
+
try {
|
|
106
|
+
const at = new Date(ms)
|
|
107
|
+
const weekday = new Intl.DateTimeFormat('en-US', {
|
|
108
|
+
timeZone: tz,
|
|
109
|
+
weekday: 'long',
|
|
110
|
+
}).format(at)
|
|
111
|
+
// localDay (en-CA) yields YYYY-MM-DD and throws first on a bad zone.
|
|
112
|
+
const date = localDay(ms, tz)
|
|
113
|
+
const time = new Intl.DateTimeFormat('en-US', {
|
|
114
|
+
timeZone: tz,
|
|
115
|
+
hour: '2-digit',
|
|
116
|
+
minute: '2-digit',
|
|
117
|
+
hour12: true,
|
|
118
|
+
}).format(at) // "04:09 PM"
|
|
119
|
+
return `${weekday} ${date} ${time} ${tzAbbrev(ms, tz)}`
|
|
120
|
+
} catch {
|
|
121
|
+
// Invalid IANA zone — degrade to am/pm in UTC rather than crash the turn.
|
|
122
|
+
if (tz !== 'UTC') return fmtLocalStamp(ms, 'UTC')
|
|
123
|
+
return new Date(ms).toISOString()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BackstopDeliveryLedger,
|
|
5
|
+
backstopReceiptIds,
|
|
6
|
+
backstopDelivered,
|
|
7
|
+
runBackstopDelivery,
|
|
8
|
+
} from '../gateway/backstop-delivery.js'
|
|
9
|
+
import {
|
|
10
|
+
backstopSendOutcomeGated,
|
|
11
|
+
finalizeBackstopSendGated,
|
|
12
|
+
computeTurnStatus,
|
|
13
|
+
} from '../gateway/turn-record-status.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* #3276 — the turn-flush backstop delivered by editing the ephemeral progress
|
|
17
|
+
* card and counted that card-edit id as an answer delivery, so the turn record
|
|
18
|
+
* said `complete` while the card was GC'd and nothing durable reached the chat.
|
|
19
|
+
*
|
|
20
|
+
* These assert the deterministic OUTCOMES the fix guarantees:
|
|
21
|
+
* - a delivered answer is a FRESH non-card chat id (guard 7),
|
|
22
|
+
* - `complete` IFF such an id exists; card-only "success" ⇒ `send_failed`,
|
|
23
|
+
* - a per-turn backstop double-fire latch (guard 5),
|
|
24
|
+
* - a bounded retry resumes mid-chunk and never re-sends chunk 0 (guard 6),
|
|
25
|
+
* - terminal failure reports `delivered:false` so the caller leaves the
|
|
26
|
+
* delivery obligation OPEN (finding 1).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
describe('guard 7 — receipt gate: a delivered answer is a FRESH non-card chat id', () => {
|
|
30
|
+
it('primary: a fresh chat id is recorded whose id ≠ any card id', () => {
|
|
31
|
+
const cardId = 500
|
|
32
|
+
const sentIds = [777] // a fresh sendMessage id, not the card
|
|
33
|
+
const fresh = backstopReceiptIds(sentIds, cardId)
|
|
34
|
+
expect(fresh).toEqual([777])
|
|
35
|
+
expect(fresh).not.toContain(cardId)
|
|
36
|
+
expect(backstopDelivered(sentIds, cardId)).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('card-present: the delivered id excludes backstopCardMessageId', () => {
|
|
40
|
+
const cardId = 18944
|
|
41
|
+
// The pre-fix backstop pushed the card id into sentIds via editMessageText.
|
|
42
|
+
const raw = [18944]
|
|
43
|
+
expect(backstopReceiptIds(raw, cardId)).toEqual([])
|
|
44
|
+
expect(backstopDelivered(raw, cardId)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('mixed: only the fresh ids survive the gate', () => {
|
|
48
|
+
const cardId = 18944
|
|
49
|
+
expect(backstopReceiptIds([18944, 18950, 18951], cardId)).toEqual([18950, 18951])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('status honesty — complete IFF a real non-card id exists', () => {
|
|
54
|
+
it('card-only delivery ⇒ send_failed, never complete', () => {
|
|
55
|
+
const cardId = 18944
|
|
56
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
57
|
+
finalAnswerDelivered: true,
|
|
58
|
+
}
|
|
59
|
+
finalizeBackstopSendGated(turn, {
|
|
60
|
+
threw: false,
|
|
61
|
+
sentIds: [18944], // only the card was edited
|
|
62
|
+
chunkCount: 1,
|
|
63
|
+
cardMessageId: cardId,
|
|
64
|
+
})
|
|
65
|
+
expect(turn.deliveryOutcome).toBe('failed')
|
|
66
|
+
expect(computeTurnStatus(turn)).toBe('send_failed')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('fresh chat id delivered ⇒ complete', () => {
|
|
70
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
71
|
+
finalAnswerDelivered: true,
|
|
72
|
+
}
|
|
73
|
+
finalizeBackstopSendGated(turn, {
|
|
74
|
+
threw: false,
|
|
75
|
+
sentIds: [18950],
|
|
76
|
+
chunkCount: 1,
|
|
77
|
+
cardMessageId: 18944,
|
|
78
|
+
})
|
|
79
|
+
expect(turn.deliveryOutcome).toBe('delivered')
|
|
80
|
+
expect(computeTurnStatus(turn)).toBe('complete')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('partial multi-chunk (fewer fresh ids than chunks) ⇒ send_failed', () => {
|
|
84
|
+
expect(
|
|
85
|
+
backstopSendOutcomeGated({ threw: false, sentIds: [18950], chunkCount: 2, cardMessageId: null }),
|
|
86
|
+
).toBe('failed')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('throw ⇒ failed regardless of ids', () => {
|
|
90
|
+
expect(
|
|
91
|
+
backstopSendOutcomeGated({ threw: true, sentIds: [18950], chunkCount: 1, cardMessageId: null }),
|
|
92
|
+
).toBe('failed')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('empty split (0 chunks) ⇒ failed, not delivered', () => {
|
|
96
|
+
expect(
|
|
97
|
+
backstopSendOutcomeGated({ threw: false, sentIds: [], chunkCount: 0, cardMessageId: null }),
|
|
98
|
+
).toBe('failed')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('guard 5 — once-per-turn backstop double-fire latch', () => {
|
|
103
|
+
it('claim returns true exactly once per turnId', () => {
|
|
104
|
+
const ledger = new BackstopDeliveryLedger()
|
|
105
|
+
expect(ledger.claim('#18925')).toBe(true)
|
|
106
|
+
expect(ledger.claim('#18925')).toBe(false)
|
|
107
|
+
// A different turn is independent.
|
|
108
|
+
expect(ledger.claim('#18929')).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('release re-opens the latch after a terminal failure', () => {
|
|
112
|
+
const ledger = new BackstopDeliveryLedger()
|
|
113
|
+
ledger.claim('#t')
|
|
114
|
+
ledger.release('#t')
|
|
115
|
+
expect(ledger.claim('#t')).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('guard 6 — per-chunk idempotency ledger: retry never re-sends chunk 0', () => {
|
|
120
|
+
it('resumes at the first unsent chunk after a partial send', () => {
|
|
121
|
+
const ledger = new BackstopDeliveryLedger()
|
|
122
|
+
const turnId = '#partial'
|
|
123
|
+
const chunkCount = 3
|
|
124
|
+
ledger.markPending(turnId, 0)
|
|
125
|
+
ledger.recordChunk(turnId, 0, [1001])
|
|
126
|
+
ledger.markPending(turnId, 1)
|
|
127
|
+
ledger.recordChunk(turnId, 1, [1002])
|
|
128
|
+
ledger.markPending(turnId, 2) // in-flight, never acked
|
|
129
|
+
|
|
130
|
+
expect(ledger.hasChunk(turnId, 0)).toBe(true)
|
|
131
|
+
expect(ledger.hasChunk(turnId, 1)).toBe(true)
|
|
132
|
+
expect(ledger.hasChunk(turnId, 2)).toBe(false)
|
|
133
|
+
expect(ledger.unsentIndices(turnId, chunkCount)).toEqual([2])
|
|
134
|
+
|
|
135
|
+
ledger.recordChunk(turnId, 2, [1003])
|
|
136
|
+
expect(ledger.sentIds(turnId)).toEqual([1001, 1002, 1003])
|
|
137
|
+
expect(ledger.sentIds(turnId)[0]).toBe(1001)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('sentIds are returned in chunk-index order regardless of record order', () => {
|
|
141
|
+
const ledger = new BackstopDeliveryLedger()
|
|
142
|
+
ledger.recordChunk('#o', 2, [3])
|
|
143
|
+
ledger.recordChunk('#o', 0, [1])
|
|
144
|
+
ledger.recordChunk('#o', 1, [2])
|
|
145
|
+
expect(ledger.sentIds('#o')).toEqual([1, 2, 3])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('entries() zip a resplit chunk (2 ids for 1 input chunk) in order', () => {
|
|
149
|
+
const ledger = new BackstopDeliveryLedger()
|
|
150
|
+
ledger.recordChunk('#z', 0, [10])
|
|
151
|
+
ledger.recordChunk('#z', 1, [11, 12]) // chunk 1 length-resplit into 2 sends
|
|
152
|
+
expect(ledger.entries('#z')).toEqual([
|
|
153
|
+
{ index: 0, messageIds: [10] },
|
|
154
|
+
{ index: 1, messageIds: [11, 12] },
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Integration oracles over `runBackstopDelivery` — the exact orchestration that
|
|
161
|
+
* replaced the card-coupled send. It drives the real retry/ledger/receipt code
|
|
162
|
+
* with an injected `sendChunk`, asserting on what reaches `recordOutbound` and
|
|
163
|
+
* on the `delivered`/`exhausted` decision the gateway feeds to the obligation
|
|
164
|
+
* ledger + turn record.
|
|
165
|
+
*/
|
|
166
|
+
describe('runBackstopDelivery — integration oracle over the delivery wiring', () => {
|
|
167
|
+
it('records a FRESH non-card id in history; delivered=true (#3276 primary)', async () => {
|
|
168
|
+
const ledger = new BackstopDeliveryLedger()
|
|
169
|
+
const cardId = 18944
|
|
170
|
+
const recorded: Array<{ ids: number[]; texts: string[] }> = []
|
|
171
|
+
const sendChunk = vi.fn(async (i: number) => [18950 + i]) // fresh chat ids
|
|
172
|
+
const res = await runBackstopDelivery(
|
|
173
|
+
ledger,
|
|
174
|
+
'#18925',
|
|
175
|
+
['the answer'],
|
|
176
|
+
cardId,
|
|
177
|
+
{ sendChunk, recordOutbound: (ids, texts) => recorded.push({ ids, texts }) },
|
|
178
|
+
)
|
|
179
|
+
expect(res.delivered).toBe(true)
|
|
180
|
+
expect(res.exhausted).toBe(false)
|
|
181
|
+
expect(recorded).toHaveLength(1)
|
|
182
|
+
// A real fresh chat id landed in history whose id ≠ the progress-card id.
|
|
183
|
+
expect(recorded[0].ids).toEqual([18950])
|
|
184
|
+
expect(recorded[0].ids).not.toContain(cardId)
|
|
185
|
+
expect(backstopReceiptIds(recorded[0].ids, cardId)).toEqual([18950])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('card-only "delivery" can never happen — the receipt gate excludes the card id', async () => {
|
|
189
|
+
const ledger = new BackstopDeliveryLedger()
|
|
190
|
+
const cardId = 18944
|
|
191
|
+
// Even if a buggy send echoed the card id, the receipt gate drops it.
|
|
192
|
+
const sendChunk = vi.fn(async () => [cardId])
|
|
193
|
+
const res = await runBackstopDelivery(ledger, '#c', ['x'], cardId, { sendChunk })
|
|
194
|
+
expect(res.delivered).toBe(false)
|
|
195
|
+
expect(res.exhausted).toBe(true)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('retry resumes mid-chunk — chunk 0 is NOT re-sent on attempt 2 (guard 6, finding 1)', async () => {
|
|
199
|
+
const ledger = new BackstopDeliveryLedger()
|
|
200
|
+
const calls: number[] = []
|
|
201
|
+
let failedOnce = false
|
|
202
|
+
const sendChunk = vi.fn(async (i: number) => {
|
|
203
|
+
calls.push(i)
|
|
204
|
+
if (i === 2 && !failedOnce) {
|
|
205
|
+
failedOnce = true
|
|
206
|
+
throw new Error('FLOOD_WAIT_ACTIVE')
|
|
207
|
+
}
|
|
208
|
+
return [700 + i]
|
|
209
|
+
})
|
|
210
|
+
const res = await runBackstopDelivery(ledger, '#resume', ['c0', 'c1', 'c2'], null, { sendChunk }, 3)
|
|
211
|
+
|
|
212
|
+
expect(res.delivered).toBe(true)
|
|
213
|
+
expect(res.sentIds).toEqual([700, 701, 702])
|
|
214
|
+
// chunk 0 and 1 sent exactly once; chunk 2 attempted twice (fail, then ok).
|
|
215
|
+
expect(calls.filter(i => i === 0)).toHaveLength(1) // <-- chunk 0 never re-sent
|
|
216
|
+
expect(calls.filter(i => i === 1)).toHaveLength(1)
|
|
217
|
+
expect(calls.filter(i => i === 2)).toHaveLength(2)
|
|
218
|
+
expect(res.attempts).toBe(2)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('terminal failure ⇒ delivered=false / exhausted=true after maxAttempts (obligation left open)', async () => {
|
|
222
|
+
const ledger = new BackstopDeliveryLedger()
|
|
223
|
+
const sendChunk = vi.fn(async () => { throw new Error('FLOOD_WAIT_ACTIVE') })
|
|
224
|
+
const res = await runBackstopDelivery(ledger, '#dead', ['only chunk'], null, { sendChunk }, 3)
|
|
225
|
+
expect(res.delivered).toBe(false)
|
|
226
|
+
expect(res.exhausted).toBe(true)
|
|
227
|
+
expect(res.attempts).toBe(3) // exhausted the bounded retry
|
|
228
|
+
expect(res.sentIds).toEqual([]) // nothing landed
|
|
229
|
+
// This is the exact input the gateway uses: delivered=false ⇒ it records
|
|
230
|
+
// send_failed AND leaves the obligation OPEN (noteTurnEnded, not close).
|
|
231
|
+
expect(backstopSendOutcomeGated({
|
|
232
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
|
|
233
|
+
})).toBe('failed')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('recordOutbound texts are ALIGNED to sent ids even when a chunk resplits (finding 5)', async () => {
|
|
237
|
+
const ledger = new BackstopDeliveryLedger()
|
|
238
|
+
const recorded: Array<{ ids: number[]; texts: string[] }> = []
|
|
239
|
+
// chunk 1 lands TWO ids (a length-resplit); a naive chunks.slice zip would
|
|
240
|
+
// misalign. entries()-based zip repeats the source text per landed id.
|
|
241
|
+
const sendChunk = vi.fn(async (i: number) => (i === 1 ? [11, 12] : [10]))
|
|
242
|
+
await runBackstopDelivery(
|
|
243
|
+
ledger, '#zip', ['A', 'B'], null,
|
|
244
|
+
{ sendChunk, recordOutbound: (ids, texts) => recorded.push({ ids, texts }) },
|
|
245
|
+
)
|
|
246
|
+
expect(recorded[0].ids).toEqual([10, 11, 12])
|
|
247
|
+
expect(recorded[0].texts).toEqual(['A', 'B', 'B'])
|
|
248
|
+
expect(recorded[0].ids).toHaveLength(recorded[0].texts.length)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
@@ -30,10 +30,15 @@ import {
|
|
|
30
30
|
FORWARDED_FROM_NAME_MAX,
|
|
31
31
|
type ForwardOriginInfo,
|
|
32
32
|
} from '../gateway/forward-origin.js'
|
|
33
|
+
import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
|
|
33
34
|
|
|
34
35
|
// Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
|
|
35
36
|
const DATE = 1750000000 // unix seconds
|
|
36
|
-
|
|
37
|
+
// switchroom #tz-fix: forwarded_date is now the agent's LOCAL am/pm wall clock
|
|
38
|
+
// (NOT UTC ISO), so it can't compete with the local-time hint. Compute the
|
|
39
|
+
// expected value through the SAME helper production uses, so the assertion is
|
|
40
|
+
// deterministic under whatever TZ the runner env carries.
|
|
41
|
+
const DATE_LOCAL = fmtLocalStamp(DATE * 1000, resolveEnvTimezone())
|
|
37
42
|
|
|
38
43
|
function userOrigin(overrides: Partial<{
|
|
39
44
|
first_name: string
|
|
@@ -204,7 +209,7 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
|
|
|
204
209
|
forwarded_from: 'Ada Lovelace (@adalove)',
|
|
205
210
|
forwarded_from_type: 'user',
|
|
206
211
|
forwarded_from_id: '42',
|
|
207
|
-
forwarded_date:
|
|
212
|
+
forwarded_date: DATE_LOCAL,
|
|
208
213
|
})
|
|
209
214
|
})
|
|
210
215
|
|
|
@@ -245,6 +250,28 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
|
|
|
245
250
|
it('no origins → empty record (no attrs on a normal message)', () => {
|
|
246
251
|
expect(buildForwardOriginMeta([])).toEqual({})
|
|
247
252
|
})
|
|
253
|
+
|
|
254
|
+
// switchroom #tz-fix (deterministic outcome): under a real configured zone
|
|
255
|
+
// the forwarded_date the MODEL sees is LOCAL am/pm with NO "UTC" / trailing-Z.
|
|
256
|
+
it('renders forwarded_date as LOCAL am/pm — never a UTC ISO string', () => {
|
|
257
|
+
const prevTz = process.env.SWITCHROOM_TIMEZONE
|
|
258
|
+
const prevTZ = process.env.TZ
|
|
259
|
+
process.env.SWITCHROOM_TIMEZONE = 'Australia/Melbourne'
|
|
260
|
+
delete process.env.TZ
|
|
261
|
+
try {
|
|
262
|
+
const meta = buildForwardOriginMeta([{ name: 'Ada', type: 'user', id: 42, date: DATE }])
|
|
263
|
+
const d = meta.forwarded_date!
|
|
264
|
+
// e.g. "Sunday 2025-06-15 08:26 PM AEST" — weekday, ISO date, am/pm, abbrev.
|
|
265
|
+
expect(d).toMatch(/ (?:AM|PM) [A-Za-z]{2,5}$/)
|
|
266
|
+
expect(d).not.toContain('UTC')
|
|
267
|
+
expect(d.endsWith('Z')).toBe(false)
|
|
268
|
+
} finally {
|
|
269
|
+
if (prevTz === undefined) delete process.env.SWITCHROOM_TIMEZONE
|
|
270
|
+
else process.env.SWITCHROOM_TIMEZONE = prevTz
|
|
271
|
+
if (prevTZ === undefined) delete process.env.TZ
|
|
272
|
+
else process.env.TZ = prevTZ
|
|
273
|
+
}
|
|
274
|
+
})
|
|
248
275
|
})
|
|
249
276
|
|
|
250
277
|
describe('coalesced bursts — dedupe + numbered siblings', () => {
|
|
@@ -264,7 +291,7 @@ describe('coalesced bursts — dedupe + numbered siblings', () => {
|
|
|
264
291
|
expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
|
|
265
292
|
expect(meta.forwarded_from_2).toBeUndefined()
|
|
266
293
|
// First occurrence wins — the emitted date is the first part's.
|
|
267
|
-
expect(meta.forwarded_date).toBe(
|
|
294
|
+
expect(meta.forwarded_date).toBe(DATE_LOCAL)
|
|
268
295
|
})
|
|
269
296
|
|
|
270
297
|
it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {
|