switchroom 0.18.15 → 0.18.17
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 +3 -0
- package/dist/auth-broker/index.js +432 -10
- package/dist/cli/notion-write-pretool.mjs +3 -0
- package/dist/cli/switchroom.js +50 -1
- package/dist/host-control/main.js +4 -1
- package/dist/vault/approvals/kernel-server.js +3 -0
- package/dist/vault/broker/server.js +3 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +81 -139
- package/telegram-plugin/dist/gateway/gateway.js +386 -259
- package/telegram-plugin/draft-stream.ts +78 -3
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
- package/telegram-plugin/gateway/effort-command.ts +9 -7
- package/telegram-plugin/gateway/gateway.ts +265 -220
- package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
- package/telegram-plugin/gateway/model-command.ts +96 -18
- package/telegram-plugin/gateway/pending-session-command.ts +10 -8
- package/telegram-plugin/gateway/session-model-file.ts +38 -172
- package/telegram-plugin/litellm-local-notice.ts +189 -0
- package/telegram-plugin/quota-watch.ts +16 -4
- package/telegram-plugin/runtime-metrics.ts +16 -0
- package/telegram-plugin/send-gate-degraded.test.ts +9 -7
- package/telegram-plugin/send-gate.ts +34 -4
- package/telegram-plugin/stream-controller.ts +143 -20
- package/telegram-plugin/stream-reply-handler.ts +12 -2
- package/telegram-plugin/tests/bot-api.harness.ts +7 -2
- package/telegram-plugin/tests/draft-stream.test.ts +110 -1
- package/telegram-plugin/tests/effort-command.test.ts +4 -4
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
- package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
- package/telegram-plugin/tests/model-command.test.ts +84 -1
- package/telegram-plugin/tests/quota-watch.test.ts +21 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
- package/telegram-plugin/tests/session-model-file.test.ts +7 -155
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
- package/telegram-plugin/worker-activity-feed.ts +83 -8
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type BotApiForWorkerFeed,
|
|
8
8
|
} from '../worker-activity-feed.js'
|
|
9
9
|
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from '../status-no-truncate.js'
|
|
10
|
+
import { SEND_GATE_SHED } from '../send-gate.js'
|
|
10
11
|
|
|
11
12
|
describe('isWorkerActivityFeedEnabled (default ON)', () => {
|
|
12
13
|
it('defaults to true when the env var is unset', () => {
|
|
@@ -1336,3 +1337,209 @@ describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
|
|
|
1336
1337
|
expect(last.text).toContain('step-repeat')
|
|
1337
1338
|
})
|
|
1338
1339
|
})
|
|
1340
|
+
|
|
1341
|
+
// ─── send-gate shed contract (#3174) ────────────────────────────────────────
|
|
1342
|
+
//
|
|
1343
|
+
// The feed's send/edit adapters transit the deterministic send gate
|
|
1344
|
+
// (telegram-plugin/send-gate.ts, #3084), wired at the robustApiCall layer. The
|
|
1345
|
+
// gate SHEDS a call — resolves `undefined` WITHOUT hitting the API — when a
|
|
1346
|
+
// flood window is open (or a `useful` send exceeds its queue TTL, or a cosmetic
|
|
1347
|
+
// edit finds no free token). Before this fix the first-paint path dereferenced
|
|
1348
|
+
// `sent.message_id` on that `undefined`, throwing every heartbeat tick (~6s) for
|
|
1349
|
+
// the whole ban — 578 `undefined is not an object (evaluating 'sent.message_id')`
|
|
1350
|
+
// crashes in one 6h flood ban on 2026-07-12. These tests pin the shed contract:
|
|
1351
|
+
// (a) an open flood window parks the handle and makes ZERO api calls; (b) an
|
|
1352
|
+
// undefined send is treated as NOT-delivered (no crash, no phantom message id);
|
|
1353
|
+
// (c)/(d) an undefined edit is not recorded as on-screen (shed honesty, mirroring
|
|
1354
|
+
// PR #3173) so the update re-sends once the gate clears.
|
|
1355
|
+
|
|
1356
|
+
interface GateBot extends BotApiForWorkerFeed {
|
|
1357
|
+
/** Times sendMessage was INVOKED (attempts), delivered or shed. */
|
|
1358
|
+
sendCalls: number
|
|
1359
|
+
/** Times editMessageText was INVOKED (attempts), delivered or shed. */
|
|
1360
|
+
editCalls: number
|
|
1361
|
+
/** DELIVERED sends (gate admitted). */
|
|
1362
|
+
sent: Array<{ text: string }>
|
|
1363
|
+
/** DELIVERED edits (gate admitted). */
|
|
1364
|
+
edits: Array<{ messageId: number; text: string }>
|
|
1365
|
+
/**
|
|
1366
|
+
* One-shot: shed the next send even with the window closed. Faithful to the
|
|
1367
|
+
* gate: a worker-feed SEND is `useful` (never `cosmetic`), so a gate that
|
|
1368
|
+
* can't admit it resolves `undefined` (queue-TTL `expired`) — NEVER the
|
|
1369
|
+
* cosmetic-only SEND_GATE_SHED sentinel. The feed's send-shed detection is
|
|
1370
|
+
* structural (`typeof sent.message_id !== 'number'`), catching either.
|
|
1371
|
+
*/
|
|
1372
|
+
shedNextSend: boolean
|
|
1373
|
+
/**
|
|
1374
|
+
* One-shot: shed the next EDIT even with the window closed. Faithful to the
|
|
1375
|
+
* gate: worker-feed edits are `cosmetic`, so a gate shed resolves the
|
|
1376
|
+
* distinguishable SEND_GATE_SHED sentinel (#3110 F1) — NOT `undefined` (which
|
|
1377
|
+
* the gate reserves for a benign no-op drop whose payload IS on screen).
|
|
1378
|
+
*/
|
|
1379
|
+
shedNextEdit: boolean
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* A bot adapter that models the send gate: it SHEDS (without recording a
|
|
1384
|
+
* delivery) whenever the injected flood probe reports an open window, or a
|
|
1385
|
+
* one-shot `shedNext*` flag is set (the race where the window opens between the
|
|
1386
|
+
* feed's probe read and the send). Faithful to the post-#3110 contract: a shed
|
|
1387
|
+
* EDIT resolves the SEND_GATE_SHED sentinel (cosmetic shed), a shed SEND
|
|
1388
|
+
* resolves `undefined` (`useful` queue-TTL expiry). A DELIVERED edit resolves a
|
|
1389
|
+
* non-shed value (`{}` — grammy returns `true`/`Message`).
|
|
1390
|
+
*/
|
|
1391
|
+
function makeGateBot(floodRemaining: () => number): GateBot {
|
|
1392
|
+
let nextId = 1000
|
|
1393
|
+
const gb: GateBot = {
|
|
1394
|
+
sendCalls: 0,
|
|
1395
|
+
editCalls: 0,
|
|
1396
|
+
sent: [],
|
|
1397
|
+
edits: [],
|
|
1398
|
+
shedNextSend: false,
|
|
1399
|
+
shedNextEdit: false,
|
|
1400
|
+
sendMessage: async (_chatId, text) => {
|
|
1401
|
+
gb.sendCalls++
|
|
1402
|
+
if (floodRemaining() > 0 || gb.shedNextSend) {
|
|
1403
|
+
gb.shedNextSend = false
|
|
1404
|
+
return undefined as unknown as { message_id: number }
|
|
1405
|
+
}
|
|
1406
|
+
gb.sent.push({ text })
|
|
1407
|
+
return { message_id: nextId++ }
|
|
1408
|
+
},
|
|
1409
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
1410
|
+
gb.editCalls++
|
|
1411
|
+
if (floodRemaining() > 0 || gb.shedNextEdit) {
|
|
1412
|
+
gb.shedNextEdit = false
|
|
1413
|
+
return SEND_GATE_SHED as unknown as undefined
|
|
1414
|
+
}
|
|
1415
|
+
gb.edits.push({ messageId, text })
|
|
1416
|
+
return {}
|
|
1417
|
+
},
|
|
1418
|
+
}
|
|
1419
|
+
return gb
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
describe('worker-feed send-gate shed contract', () => {
|
|
1423
|
+
it('makes ZERO api calls while a flood window is open, then paints full state after it closes', async () => {
|
|
1424
|
+
let clock = 0
|
|
1425
|
+
const untilTs = 6 * 60 * 60 * 1000 // a 6h ban, as observed 2026-07-12
|
|
1426
|
+
const probe = () => Math.max(0, untilTs - clock)
|
|
1427
|
+
const bot = makeGateBot(probe)
|
|
1428
|
+
const feed = createWorkerActivityFeed({
|
|
1429
|
+
bot,
|
|
1430
|
+
now: () => clock,
|
|
1431
|
+
firstPaintMinMs: 0,
|
|
1432
|
+
floodWaitRemainingMs: probe,
|
|
1433
|
+
})
|
|
1434
|
+
|
|
1435
|
+
// Drive the heartbeat cadence hammering the feed for the whole ban.
|
|
1436
|
+
for (let i = 0; i < 30; i++) {
|
|
1437
|
+
clock += 6000
|
|
1438
|
+
await feed.update('w1', 'chat', view({ elapsedMs: clock }))
|
|
1439
|
+
}
|
|
1440
|
+
// Parked on the window — never even called the API (the whole point).
|
|
1441
|
+
expect(bot.sendCalls).toBe(0)
|
|
1442
|
+
expect(bot.sent).toHaveLength(0)
|
|
1443
|
+
expect(feed.has('w1')).toBe(false)
|
|
1444
|
+
|
|
1445
|
+
// Window closes: the next tick paints full state exactly once.
|
|
1446
|
+
clock = untilTs + 1000
|
|
1447
|
+
await feed.update('w1', 'chat', view({ elapsedMs: clock }))
|
|
1448
|
+
expect(bot.sendCalls).toBe(1)
|
|
1449
|
+
expect(bot.sent).toHaveLength(1)
|
|
1450
|
+
expect(bot.sent[0].text).toContain('🛠 **Worker**')
|
|
1451
|
+
expect(feed.has('w1')).toBe(true)
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
it('treats an undefined send result as not-delivered — no crash, no phantom message id', async () => {
|
|
1455
|
+
let clock = 10_000
|
|
1456
|
+
const logs: string[] = []
|
|
1457
|
+
const bot = makeGateBot(() => 0) // probe reads clear …
|
|
1458
|
+
bot.shedNextSend = true // … but the gate sheds this send (race)
|
|
1459
|
+
const feed = createWorkerActivityFeed({
|
|
1460
|
+
bot,
|
|
1461
|
+
now: () => clock,
|
|
1462
|
+
firstPaintMinMs: 0,
|
|
1463
|
+
floodWaitRemainingMs: () => 0,
|
|
1464
|
+
log: (m) => logs.push(m),
|
|
1465
|
+
})
|
|
1466
|
+
|
|
1467
|
+
await feed.update('w1', 'chat', view())
|
|
1468
|
+
expect(bot.sendCalls).toBe(1) // it attempted the send
|
|
1469
|
+
expect(bot.sent).toHaveLength(0) // but nothing was delivered
|
|
1470
|
+
expect(feed.messageIdOf('w1')).toBeNull() // and no phantom message id recorded
|
|
1471
|
+
expect(feed.has('w1')).toBe(false)
|
|
1472
|
+
// Regression pin: the old code dereferenced `undefined.message_id` and logged
|
|
1473
|
+
// it as a hard "send failed"; the shed is now a recognized, clean skip.
|
|
1474
|
+
expect(logs.some((l) => l.startsWith('worker-feed: send failed'))).toBe(false)
|
|
1475
|
+
expect(logs.some((l) => l.includes('shed by send gate'))).toBe(true)
|
|
1476
|
+
|
|
1477
|
+
// The next paint (gate no longer shedding) lands cleanly.
|
|
1478
|
+
clock = 20_000
|
|
1479
|
+
await feed.update('w1', 'chat', view())
|
|
1480
|
+
expect(bot.sent).toHaveLength(1)
|
|
1481
|
+
expect(feed.has('w1')).toBe(true)
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1484
|
+
it('does not record a shed cosmetic edit as on-screen — the update re-sends after the gate clears', async () => {
|
|
1485
|
+
let clock = 10_000
|
|
1486
|
+
const bot = makeGateBot(() => 0)
|
|
1487
|
+
const feed = createWorkerActivityFeed({
|
|
1488
|
+
bot,
|
|
1489
|
+
now: () => clock,
|
|
1490
|
+
firstPaintMinMs: 0,
|
|
1491
|
+
minEditIntervalMs: 0,
|
|
1492
|
+
floodWaitRemainingMs: () => 0,
|
|
1493
|
+
})
|
|
1494
|
+
|
|
1495
|
+
await feed.update('w1', 'chat', view({ toolCount: 1 }))
|
|
1496
|
+
expect(bot.sent).toHaveLength(1)
|
|
1497
|
+
|
|
1498
|
+
// Gate sheds the edit (undefined) — it is NOT on screen.
|
|
1499
|
+
clock = 20_000
|
|
1500
|
+
bot.shedNextEdit = true
|
|
1501
|
+
await feed.update('w1', 'chat', view({ toolCount: 2 }))
|
|
1502
|
+
expect(bot.editCalls).toBe(1)
|
|
1503
|
+
expect(bot.edits).toHaveLength(0)
|
|
1504
|
+
|
|
1505
|
+
// Shed honesty: the SAME update re-sends once the gate clears — the shed
|
|
1506
|
+
// payload was never recorded as `lastBody`, so it is not dropped as a dup.
|
|
1507
|
+
clock = 30_000
|
|
1508
|
+
await feed.update('w1', 'chat', view({ toolCount: 2 }))
|
|
1509
|
+
expect(bot.edits).toHaveLength(1)
|
|
1510
|
+
expect(bot.edits[0].text).toContain('2 tools')
|
|
1511
|
+
})
|
|
1512
|
+
|
|
1513
|
+
it('does not falsely finalize on a shed terminal edit — re-drives the finalize once the gate clears', async () => {
|
|
1514
|
+
let clock = 10_000
|
|
1515
|
+
const bot = makeGateBot(() => 0)
|
|
1516
|
+
const feed = createWorkerActivityFeed({
|
|
1517
|
+
bot,
|
|
1518
|
+
now: () => clock,
|
|
1519
|
+
firstPaintMinMs: 0,
|
|
1520
|
+
minEditIntervalMs: 0,
|
|
1521
|
+
floodWaitRemainingMs: () => 0,
|
|
1522
|
+
})
|
|
1523
|
+
|
|
1524
|
+
await feed.update('w1', 'chat', view({ toolCount: 1 }))
|
|
1525
|
+
expect(bot.sent).toHaveLength(1)
|
|
1526
|
+
|
|
1527
|
+
// Gate sheds the terminal edit (undefined).
|
|
1528
|
+
clock = 20_000
|
|
1529
|
+
bot.shedNextEdit = true
|
|
1530
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
1531
|
+
expect(bot.editCalls).toBe(1)
|
|
1532
|
+
expect(bot.edits).toHaveLength(0)
|
|
1533
|
+
// NOT finalized: the handle survives (pendingFinish staged) rather than
|
|
1534
|
+
// being torn down with the card frozen on its last running render.
|
|
1535
|
+
expect(feed.has('w1')).toBe(true)
|
|
1536
|
+
|
|
1537
|
+
// Re-drive with the gate clear: the terminal recap lands and the handle
|
|
1538
|
+
// finalizes.
|
|
1539
|
+
clock = 30_000
|
|
1540
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
1541
|
+
expect(bot.edits).toHaveLength(1)
|
|
1542
|
+
expect(bot.edits[0].text).toContain('_done · 5 tools')
|
|
1543
|
+
expect(feed.has('w1')).toBe(false)
|
|
1544
|
+
})
|
|
1545
|
+
})
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from './card-format.js'
|
|
40
40
|
import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
|
|
41
41
|
import { renderStatusCard, formatStepSuffix } from './tool-activity-summary.js'
|
|
42
|
+
import { isSendGateShed } from './send-gate.js'
|
|
42
43
|
|
|
43
44
|
/** Worker-activity feed is ON by default; an operator opts out with
|
|
44
45
|
* SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
|
|
@@ -195,6 +196,24 @@ export interface WorkerActivityFeedOpts {
|
|
|
195
196
|
firstPaintMinMs?: number
|
|
196
197
|
/** stderr-style log sink. Defaults to noop. */
|
|
197
198
|
log?: (msg: string) => void
|
|
199
|
+
/**
|
|
200
|
+
* Remaining ms of the currently-open per-bot flood window, read from the
|
|
201
|
+
* SAME persisted marker the gateway's `robustApiCall` and held-card sweep
|
|
202
|
+
* consult (`makeFloodWaitProbe(FLOOD_STATE_PATH)`). Two independent reads of
|
|
203
|
+
* one source of truth — not a second notion of "is the channel open".
|
|
204
|
+
*
|
|
205
|
+
* Why the feed needs it (#3084 follow-up): the feed's send/edit adapters run
|
|
206
|
+
* through the send gate, which SHEDS (resolves `undefined`) any call made
|
|
207
|
+
* while a flood window is open. Without this probe the heartbeat re-fires a
|
|
208
|
+
* shed send every `heartbeatTickMs` (~6s) for the WHOLE ban — thousands of
|
|
209
|
+
* gate admissions, and (pre-fix) a `sent.message_id` crash on the `undefined`
|
|
210
|
+
* every tick. With it, a running/first-paint tick that sees an open window
|
|
211
|
+
* parks the handle in cooldown for the window's remaining and makes ZERO api
|
|
212
|
+
* calls until it closes, mirroring the held-card sweep's pre-send probe.
|
|
213
|
+
* Defaults to `() => 0` (no window) so tests and non-gateway callers are
|
|
214
|
+
* unchanged.
|
|
215
|
+
*/
|
|
216
|
+
floodWaitRemainingMs?: () => number
|
|
198
217
|
/**
|
|
199
218
|
* Heartbeat timer factory. Injectable for tests. Defaults to the real
|
|
200
219
|
* `setInterval`, `.unref()`'d so it never keeps the process alive.
|
|
@@ -363,6 +382,7 @@ export interface WorkerActivityFeed {
|
|
|
363
382
|
export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerActivityFeed {
|
|
364
383
|
const log = opts.log ?? (() => {})
|
|
365
384
|
const nowFn = opts.now ?? Date.now
|
|
385
|
+
const floodWaitRemainingMs = opts.floodWaitRemainingMs ?? (() => 0)
|
|
366
386
|
const minEditInterval = opts.minEditIntervalMs ?? 2500
|
|
367
387
|
const firstPaintMin = opts.firstPaintMinMs ?? 8000
|
|
368
388
|
const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
|
|
@@ -419,6 +439,22 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
419
439
|
log(`worker-feed: ${label} 429 — backing off ${retryAfter}s`)
|
|
420
440
|
}
|
|
421
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Park a handle in cooldown for the remaining of an open flood window (if
|
|
444
|
+
* any), so the heartbeat's `nowFn() < h.cooldownUntil` guard suppresses every
|
|
445
|
+
* further send/edit until the ban closes. Returns true when a window was open
|
|
446
|
+
* (caller should abandon the current attempt). Two reads of the SAME on-disk
|
|
447
|
+
* marker `robustApiCall` gates on — never a second notion of "channel open".
|
|
448
|
+
*/
|
|
449
|
+
function parkIfFloodWindowOpen(h: WorkerHandle): boolean {
|
|
450
|
+
const remaining = floodWaitRemainingMs()
|
|
451
|
+
if (remaining <= 0) return false
|
|
452
|
+
// Only extend the cooldown — never shorten one a 429 already set longer.
|
|
453
|
+
const until = nowFn() + remaining + COOLDOWN_JITTER_MS
|
|
454
|
+
if (until > h.cooldownUntil) h.cooldownUntil = until
|
|
455
|
+
return true
|
|
456
|
+
}
|
|
457
|
+
|
|
422
458
|
function accumulateNarrative(h: WorkerHandle, view: WorkerActivityView): void {
|
|
423
459
|
const line = view.latestSummary.trim()
|
|
424
460
|
if (line.length === 0) return
|
|
@@ -453,6 +489,12 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
453
489
|
h.lastView = merged
|
|
454
490
|
if (h.dispatchAtMs == null) h.dispatchAtMs = nowFn() - view.elapsedMs
|
|
455
491
|
if (nowFn() < h.cooldownUntil) return
|
|
492
|
+
// A flood window is open: the send gate would SHED every call made now
|
|
493
|
+
// (resolving `undefined`). Park in cooldown for the window's remaining and
|
|
494
|
+
// make ZERO api calls until it closes — the heartbeat re-drives the paint
|
|
495
|
+
// with full state on the first tick past the window. Same source of truth
|
|
496
|
+
// as robustApiCall's own pre-call probe.
|
|
497
|
+
if (parkIfFloodWindowOpen(h)) return
|
|
456
498
|
const body = renderWorkerActivity(merged, liveSuffix)
|
|
457
499
|
|
|
458
500
|
// First paint: hold off until the worker has run long enough to be
|
|
@@ -461,6 +503,17 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
461
503
|
if (view.elapsedMs < firstPaintMin) return
|
|
462
504
|
try {
|
|
463
505
|
const sent = await opts.bot.sendMessage(h.chatId, body, sendOptsFor(h))
|
|
506
|
+
// Shed contract (#3084): the gate resolves `undefined` for a send it
|
|
507
|
+
// shed (open flood window) or dropped as stale (`useful` TTL). That is
|
|
508
|
+
// NOT a delivered message — dereferencing `sent.message_id` here was
|
|
509
|
+
// the `undefined is not an object` crash that fired every ~6s for a
|
|
510
|
+
// whole 6h ban. Treat it as not-delivered: record no message_id, park
|
|
511
|
+
// on any open window, and let the heartbeat re-drive the paint.
|
|
512
|
+
if (sent == null || typeof sent.message_id !== 'number') {
|
|
513
|
+
parkIfFloodWindowOpen(h)
|
|
514
|
+
log(`worker-feed: first paint shed by send gate agent=${h.agentId} — not delivered`)
|
|
515
|
+
return
|
|
516
|
+
}
|
|
464
517
|
h.messageId = sent.message_id
|
|
465
518
|
h.lastBody = body
|
|
466
519
|
h.lastEditAt = nowFn()
|
|
@@ -480,7 +533,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
480
533
|
if (nowFn() - h.lastEditAt < minEditInterval) return
|
|
481
534
|
|
|
482
535
|
try {
|
|
483
|
-
await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|
|
536
|
+
const res = await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|
|
537
|
+
// Shed honesty (#3084, mirrors #3173): a cosmetic edit the gate shed
|
|
538
|
+
// resolves the distinguishable SEND_GATE_SHED sentinel (#3110 F1 — NOT a
|
|
539
|
+
// bare `undefined`, which the gate reserves for a benign no-op drop whose
|
|
540
|
+
// payload IS already on screen). The shed payload is NOT on screen, so do
|
|
541
|
+
// not record it as `lastBody`, or the next tick's dedup would skip
|
|
542
|
+
// re-sending the very update the gate dropped. Park on any open window and
|
|
543
|
+
// let the heartbeat re-drive once it closes. A benign `undefined`/`true`
|
|
544
|
+
// falls through below and is correctly recorded as delivered.
|
|
545
|
+
if (isSendGateShed(res)) {
|
|
546
|
+
parkIfFloodWindowOpen(h)
|
|
547
|
+
return
|
|
548
|
+
}
|
|
484
549
|
h.lastBody = body
|
|
485
550
|
h.lastEditAt = nowFn()
|
|
486
551
|
log(
|
|
@@ -534,12 +599,11 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
534
599
|
h.pendingFinish = null
|
|
535
600
|
return
|
|
536
601
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
// finished worker's card stuck on its last running render.
|
|
602
|
+
// A flood window is open (or a prior 429 set a cooldown): honour it — a
|
|
603
|
+
// terminal edit isn't worth extending a ban. STAGE the terminal view so the
|
|
604
|
+
// heartbeat re-drives the finalize the instant the window/cooldown expires,
|
|
605
|
+
// so a finished worker's card can't get stuck on its last running render.
|
|
606
|
+
if (parkIfFloodWindowOpen(h) || nowFn() < h.cooldownUntil) {
|
|
543
607
|
h.pendingFinish = view
|
|
544
608
|
return
|
|
545
609
|
}
|
|
@@ -549,7 +613,18 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
549
613
|
return
|
|
550
614
|
}
|
|
551
615
|
try {
|
|
552
|
-
await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|
|
616
|
+
const res = await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|
|
617
|
+
// Shed honesty (#3084): a shed terminal edit resolves the distinguishable
|
|
618
|
+
// SEND_GATE_SHED sentinel (#3110 F1 — NOT a bare `undefined`, which is the
|
|
619
|
+
// gate's benign no-op drop) — it did NOT land. Keep `pendingFinish` staged
|
|
620
|
+
// so the heartbeat re-drives it once the window closes; do NOT clear it or
|
|
621
|
+
// record `lastBody` (that would be a false finalization — card frozen on
|
|
622
|
+
// its running render while we believe it's done). Park on any open window.
|
|
623
|
+
if (isSendGateShed(res)) {
|
|
624
|
+
parkIfFloodWindowOpen(h)
|
|
625
|
+
h.pendingFinish = view
|
|
626
|
+
return
|
|
627
|
+
}
|
|
553
628
|
h.lastBody = body
|
|
554
629
|
h.lastEditAt = nowFn()
|
|
555
630
|
h.pendingFinish = null
|