switchroom 0.19.19 → 0.19.23
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 +2 -1
- package/dist/auth-broker/index.js +121 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +2995 -1583
- package/dist/host-control/main.js +122 -2
- package/dist/vault/approvals/kernel-server.js +124 -4
- package/dist/vault/broker/server.js +124 -4
- package/package.json +7 -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 +13 -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 +90 -107
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +873 -184
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +75 -63
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- 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/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
- package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
- package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -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
|
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot pin sweep WIRING fence (#3664 Defect A).
|
|
3
|
+
*
|
|
4
|
+
* The behaviour lives in `boot-sweep-gate.test.ts`; this file pins the gateway
|
|
5
|
+
* WIRING, because the regression was a wiring regression: `caa4d7568` (#3310)
|
|
6
|
+
* moved the `lockedBot` assignment into `initGatewayBot()` and left the sweep's
|
|
7
|
+
* kick-off at module-eval time. `tsc` stayed green (the `!` definite-assignment
|
|
8
|
+
* assertion on `lockedBot` suppresses the error) and no test noticed, so every
|
|
9
|
+
* boot-cleanup unpin threw `undefined is not an object` for a month.
|
|
10
|
+
*
|
|
11
|
+
* These are STRUCTURAL assertions (the gateway module can't be instantiated
|
|
12
|
+
* in-process — same pattern as silence-liveness-wiring.test.ts) that fail if a
|
|
13
|
+
* future refactor re-introduces a module-eval-time dispatch of the sweep.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
20
|
+
|
|
21
|
+
describe('boot pin sweep wiring (#3664)', () => {
|
|
22
|
+
it('never dispatches runBootPinCleanupAndDmSweep() directly — only through the gate', () => {
|
|
23
|
+
// The original bug shape, verbatim: a fire-and-forget call at module-eval
|
|
24
|
+
// time. Any direct invocation (voided or awaited) bypasses the bot-ready
|
|
25
|
+
// half of the precondition.
|
|
26
|
+
const directCalls =
|
|
27
|
+
gatewaySrc.match(/(?<!function\s)(?<![.\w])runBootPinCleanupAndDmSweep\s*\(/g) ?? []
|
|
28
|
+
expect(directCalls).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('passes the sweep to the gate as a reference, and arms it at both mutex sites', () => {
|
|
32
|
+
expect(gatewaySrc).toContain('createBootSweepGate({ run: runBootPinCleanupAndDmSweep')
|
|
33
|
+
// Both startup-lock outcomes (mutex acquired, and the non-atomic
|
|
34
|
+
// writePidFile fallback) arm — neither may dispatch on its own.
|
|
35
|
+
const armSites = gatewaySrc.match(/bootPinSweepGate\.arm\(\)/g) ?? []
|
|
36
|
+
expect(armSites.length).toBe(2)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('signals botReady() only from initGatewayBot, AFTER lockedBot is assigned', () => {
|
|
40
|
+
const readySites = gatewaySrc.match(/bootPinSweepGate\.botReady\(\)/g) ?? []
|
|
41
|
+
expect(readySites.length).toBe(1)
|
|
42
|
+
|
|
43
|
+
const initStart = gatewaySrc.indexOf('async function initGatewayBot()')
|
|
44
|
+
const assignIdx = gatewaySrc.indexOf('lockedBot = chatLock.wrapBot(')
|
|
45
|
+
const readyIdx = gatewaySrc.indexOf('bootPinSweepGate.botReady()')
|
|
46
|
+
expect(initStart).toBeGreaterThan(-1)
|
|
47
|
+
expect(assignIdx).toBeGreaterThan(initStart)
|
|
48
|
+
expect(readyIdx).toBeGreaterThan(assignIdx)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('runs the sweep steps through runBootPinSweepSteps, not a bare await chain', () => {
|
|
52
|
+
// Salvage S2. The BEHAVIOUR (a throwing step must not strand the steps
|
|
53
|
+
// behind it, and must never strand the DM-eligibility flip) is proven in
|
|
54
|
+
// boot-sweep-gate.test.ts. This fence is what makes that proof apply to the
|
|
55
|
+
// gateway: the reapers must reach the runner as injected steps, and nothing
|
|
56
|
+
// may reintroduce the bare sequential chain that skipped the isolation.
|
|
57
|
+
expect(gatewaySrc).toContain('return runBootPinSweepSteps({')
|
|
58
|
+
const stepFns = ['statusPinBootCleanup', 'activityCardBootReaper', 'queuedCardBootReaper']
|
|
59
|
+
for (const stepFn of stepFns) {
|
|
60
|
+
// Passed BY REFERENCE as a step, never invoked inline in the sweep.
|
|
61
|
+
expect(gatewaySrc).toContain(`: ${stepFn},`)
|
|
62
|
+
const inlineAwaits = gatewaySrc.match(new RegExp(`await\\s+${stepFn}\\s*\\(`, 'g')) ?? []
|
|
63
|
+
expect(inlineAwaits).toEqual([])
|
|
64
|
+
}
|
|
65
|
+
// The DM-eligibility flip is what a throw used to strand, so it must live
|
|
66
|
+
// INSIDE the enableDmSweep step callback — not as a statement sequenced
|
|
67
|
+
// after the reapers, where one rejection skips it for the whole session.
|
|
68
|
+
const lines = gatewaySrc.split('\n')
|
|
69
|
+
const flipIdxs = lines
|
|
70
|
+
.map((l, i) => ({ l, i }))
|
|
71
|
+
.filter(({ l }) => /^\s*dmPinSweepEligible = true\s*$/.test(l))
|
|
72
|
+
.map(({ i }) => i)
|
|
73
|
+
expect(flipIdxs.length).toBe(1)
|
|
74
|
+
expect(lines[flipIdxs[0] - 1]).toContain('enableDmSweep: () => {')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('routes the pin API through the asserting seam, never a raw lockedBot.api pin', () => {
|
|
78
|
+
// Salvage S1/S3: `statusPinApi()` must build on `createStatusPinApi`, which
|
|
79
|
+
// asserts the bot exists and converts a send-gate SHED into a throw. A
|
|
80
|
+
// hand-rolled `robustApiCall(() => lockedBot.api.pinChatMessage(...))`
|
|
81
|
+
// anywhere in the gateway would bypass both invariants.
|
|
82
|
+
expect(gatewaySrc).toContain('return createStatusPinApi(')
|
|
83
|
+
// Every `status-pin.*` verb is now issued from status-pin-api.ts. A raw
|
|
84
|
+
// `robustApiCall(() => lockedBot.api.unpinChatMessage(…), { verb:
|
|
85
|
+
// 'status-pin.unpin' })` reappearing here would be a driver call that
|
|
86
|
+
// skips assertBotReady + assertLanded.
|
|
87
|
+
const statusPinVerbs = gatewaySrc.match(/verb:\s*['"`]status-pin\.(?:un)?pin['"`]/g) ?? []
|
|
88
|
+
expect(statusPinVerbs).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('arms only from inside the startup-mutex block, never at bare module scope', () => {
|
|
92
|
+
// Every arm() site must be indented (i.e. nested inside the
|
|
93
|
+
// `if (isGatewayMain) { … }` startup-lock block), never a top-level
|
|
94
|
+
// statement that would run unconditionally on import.
|
|
95
|
+
for (const line of gatewaySrc.split('\n')) {
|
|
96
|
+
if (line.includes('bootPinSweepGate.arm()')) {
|
|
97
|
+
expect(line.startsWith(' ')).toBe(true)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boot-sweep-gate — the deterministic ordering fence for #3664 Defect A.
|
|
3
|
+
*
|
|
4
|
+
* The regression these assertions exist to catch: the boot orphan sweep was
|
|
5
|
+
* dispatched the moment the gateway won the startup mutex, which happens at
|
|
6
|
+
* module-eval time — thousands of lines before `initGatewayBot()` assigns
|
|
7
|
+
* `lockedBot`. Every boot-cleanup unpin therefore threw
|
|
8
|
+
* `undefined is not an object (evaluating 'lockedBot.api')` before reaching
|
|
9
|
+
* Telegram (14/14 fleet-wide failures since #3310, zero clean successes).
|
|
10
|
+
*
|
|
11
|
+
* The behavioural assertion is "arming ALONE must not dispatch the sweep" —
|
|
12
|
+
* a test that would have failed on the original bug shape.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
createBootSweepGate,
|
|
17
|
+
runBootPinSweepSteps,
|
|
18
|
+
type BootPinSweepSteps,
|
|
19
|
+
} from '../gateway/boot-sweep-gate.js'
|
|
20
|
+
|
|
21
|
+
/** A fake sweep that records whether it ran and what the bot handle looked
|
|
22
|
+
* like at that moment — the thing the original bug got wrong. */
|
|
23
|
+
function fakeSweep() {
|
|
24
|
+
const observed: (string | undefined)[] = []
|
|
25
|
+
let bot: string | undefined
|
|
26
|
+
return {
|
|
27
|
+
run: async () => {
|
|
28
|
+
observed.push(bot)
|
|
29
|
+
},
|
|
30
|
+
setBot: (b: string) => {
|
|
31
|
+
bot = b
|
|
32
|
+
},
|
|
33
|
+
get observed() {
|
|
34
|
+
return observed
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createBootSweepGate (#3664 Defect A)', () => {
|
|
40
|
+
it('does NOT dispatch on arm() alone — the bot handle does not exist yet', async () => {
|
|
41
|
+
const sweep = fakeSweep()
|
|
42
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
43
|
+
|
|
44
|
+
gate.arm()
|
|
45
|
+
await Promise.resolve()
|
|
46
|
+
|
|
47
|
+
expect(gate.hasRun()).toBe(false)
|
|
48
|
+
expect(sweep.observed).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does NOT dispatch on botReady() alone — this boot may not own the pin state', async () => {
|
|
52
|
+
const sweep = fakeSweep()
|
|
53
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
54
|
+
|
|
55
|
+
gate.botReady()
|
|
56
|
+
await Promise.resolve()
|
|
57
|
+
|
|
58
|
+
expect(gate.hasRun()).toBe(false)
|
|
59
|
+
expect(sweep.observed).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('dispatches once BOTH signals arrive, and the bot handle is live when it does', async () => {
|
|
63
|
+
const sweep = fakeSweep()
|
|
64
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
65
|
+
|
|
66
|
+
gate.arm() // startup mutex won (module-eval time)
|
|
67
|
+
await Promise.resolve()
|
|
68
|
+
expect(sweep.observed).toEqual([])
|
|
69
|
+
|
|
70
|
+
sweep.setBot('lockedBot') // initGatewayBot() assigns lockedBot …
|
|
71
|
+
gate.botReady() // … and signals the gate
|
|
72
|
+
await Promise.resolve()
|
|
73
|
+
|
|
74
|
+
// The sweep ran, and it saw a live bot handle — never `undefined`.
|
|
75
|
+
expect(sweep.observed).toEqual(['lockedBot'])
|
|
76
|
+
expect(gate.hasRun()).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is order-independent: botReady() before arm() still dispatches exactly once', async () => {
|
|
80
|
+
const sweep = fakeSweep()
|
|
81
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
82
|
+
|
|
83
|
+
sweep.setBot('lockedBot')
|
|
84
|
+
gate.botReady()
|
|
85
|
+
await Promise.resolve()
|
|
86
|
+
expect(sweep.observed).toEqual([])
|
|
87
|
+
|
|
88
|
+
gate.arm()
|
|
89
|
+
await Promise.resolve()
|
|
90
|
+
expect(sweep.observed).toEqual(['lockedBot'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('dispatches at most once however many times the signals repeat', async () => {
|
|
94
|
+
const sweep = fakeSweep()
|
|
95
|
+
const gate = createBootSweepGate({ run: sweep.run })
|
|
96
|
+
|
|
97
|
+
sweep.setBot('lockedBot')
|
|
98
|
+
gate.arm()
|
|
99
|
+
gate.botReady()
|
|
100
|
+
gate.arm() // e.g. the mutex path AND the mutex-fallback path
|
|
101
|
+
gate.botReady()
|
|
102
|
+
await Promise.resolve()
|
|
103
|
+
|
|
104
|
+
expect(sweep.observed).toEqual(['lockedBot'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('routes a rejecting sweep to onError instead of leaking an unhandled rejection', async () => {
|
|
108
|
+
const rejections: unknown[] = []
|
|
109
|
+
const onUnhandled = (err: unknown) => rejections.push(err)
|
|
110
|
+
process.on('unhandledRejection', onUnhandled)
|
|
111
|
+
try {
|
|
112
|
+
const errs: unknown[] = []
|
|
113
|
+
const gate = createBootSweepGate({
|
|
114
|
+
run: async () => {
|
|
115
|
+
throw new Error('boom')
|
|
116
|
+
},
|
|
117
|
+
onError: (err) => errs.push(err),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
gate.arm()
|
|
121
|
+
gate.botReady()
|
|
122
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
123
|
+
|
|
124
|
+
expect((errs[0] as Error).message).toBe('boom')
|
|
125
|
+
expect(rejections).toEqual([])
|
|
126
|
+
} finally {
|
|
127
|
+
process.off('unhandledRejection', onUnhandled)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Per-step isolation (#3664 salvage S2).
|
|
134
|
+
*
|
|
135
|
+
* The steps used to be a bare sequential `await` chain in gateway.ts. A throw
|
|
136
|
+
* from either card reaper skipped `dmPinSweepEligible = true`, which authorises
|
|
137
|
+
* the DM unpin-all path for the WHOLE SESSION — so one throwing reaper silently
|
|
138
|
+
* disabled DM stale-pin cleanup, including every later lazy first-inbound
|
|
139
|
+
* sweep. That became newly reachable the moment #3664 made the sweep actually
|
|
140
|
+
* run for the first time.
|
|
141
|
+
*
|
|
142
|
+
* The outcome asserted is "was the DM path enabled and were the DM chats
|
|
143
|
+
* swept", not "was a wrapper called".
|
|
144
|
+
*/
|
|
145
|
+
describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
|
|
146
|
+
function deps(over: Partial<BootPinSweepSteps> = {}) {
|
|
147
|
+
const order: string[] = []
|
|
148
|
+
const logs: string[] = []
|
|
149
|
+
const base: BootPinSweepSteps = {
|
|
150
|
+
scanDmChatIds: () => {
|
|
151
|
+
order.push('scan')
|
|
152
|
+
return ['12345']
|
|
153
|
+
},
|
|
154
|
+
statusPinCleanup: async () => {
|
|
155
|
+
order.push('status-pins')
|
|
156
|
+
},
|
|
157
|
+
activityCardReaper: async () => {
|
|
158
|
+
order.push('activity-cards')
|
|
159
|
+
},
|
|
160
|
+
queuedCardReaper: async () => {
|
|
161
|
+
order.push('queued-cards')
|
|
162
|
+
},
|
|
163
|
+
enableDmSweep: () => order.push('enable-dm'),
|
|
164
|
+
sweepDm: async (id) => {
|
|
165
|
+
order.push(`dm:${id}`)
|
|
166
|
+
},
|
|
167
|
+
log: (l) => logs.push(l),
|
|
168
|
+
}
|
|
169
|
+
return { order, logs, deps: { ...base, ...over } }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('runs the store scan first, then the reapers, then enables + runs the DM sweep', async () => {
|
|
173
|
+
const d = deps()
|
|
174
|
+
await runBootPinSweepSteps(d.deps)
|
|
175
|
+
expect(d.order).toEqual([
|
|
176
|
+
'scan',
|
|
177
|
+
'status-pins',
|
|
178
|
+
'activity-cards',
|
|
179
|
+
'queued-cards',
|
|
180
|
+
'enable-dm',
|
|
181
|
+
'dm:12345',
|
|
182
|
+
])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a throwing reaper does not strand the steps behind it', async () => {
|
|
186
|
+
const d = deps({
|
|
187
|
+
activityCardReaper: async () => {
|
|
188
|
+
throw new Error('boom')
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
await runBootPinSweepSteps(d.deps)
|
|
192
|
+
// The DM path is still enabled and the recorded DM chat is still swept.
|
|
193
|
+
expect(d.order).toEqual(['scan', 'status-pins', 'queued-cards', 'enable-dm', 'dm:12345'])
|
|
194
|
+
expect(d.logs.join('')).toContain("step 'activity-card-reaper' failed: boom")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('enables the DM sweep even when EVERY earlier step throws', async () => {
|
|
198
|
+
const boom = async () => {
|
|
199
|
+
throw new Error('boom')
|
|
200
|
+
}
|
|
201
|
+
const d = deps({
|
|
202
|
+
statusPinCleanup: boom,
|
|
203
|
+
activityCardReaper: boom,
|
|
204
|
+
queuedCardReaper: boom,
|
|
205
|
+
})
|
|
206
|
+
await runBootPinSweepSteps(d.deps)
|
|
207
|
+
expect(d.order).toEqual(['scan', 'enable-dm', 'dm:12345'])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('a failing store scan still lets the reapers and the DM enable run', async () => {
|
|
211
|
+
const d = deps({
|
|
212
|
+
scanDmChatIds: () => {
|
|
213
|
+
throw new Error('ENOENT')
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
await runBootPinSweepSteps(d.deps)
|
|
217
|
+
// No DM ids to sweep, but nothing behind the scan is stranded.
|
|
218
|
+
expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-dm'])
|
|
219
|
+
expect(d.logs.join('')).toContain("step 'dm-chat-scan' failed: ENOENT")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('one failing DM chat does not block the next one', async () => {
|
|
223
|
+
const swept: string[] = []
|
|
224
|
+
const d = deps({
|
|
225
|
+
scanDmChatIds: () => ['1', '2'],
|
|
226
|
+
sweepDm: async (id) => {
|
|
227
|
+
if (id === '1') throw new Error('kicked')
|
|
228
|
+
swept.push(id)
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
await runBootPinSweepSteps(d.deps)
|
|
232
|
+
expect(swept).toEqual(['2'])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('logs a non-Error throw honestly instead of "undefined"', async () => {
|
|
236
|
+
const d = deps({
|
|
237
|
+
queuedCardReaper: async () => {
|
|
238
|
+
// A non-Error throw: `(err as Error).message` would log "undefined".
|
|
239
|
+
throw 'not an Error'
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
await runBootPinSweepSteps(d.deps)
|
|
243
|
+
expect(d.logs.join('')).toContain("step 'queued-card-reaper' failed: not an Error")
|
|
244
|
+
expect(d.logs.join('')).not.toContain('failed: undefined')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('survives a log sink that itself throws', async () => {
|
|
248
|
+
// stderr can be gone on a dying process; the one function whose job is
|
|
249
|
+
// "a step must not take the sweep down" must not take the sweep down.
|
|
250
|
+
const order: string[] = []
|
|
251
|
+
await expect(
|
|
252
|
+
runBootPinSweepSteps({
|
|
253
|
+
scanDmChatIds: () => [],
|
|
254
|
+
statusPinCleanup: async () => {
|
|
255
|
+
throw new Error('boom')
|
|
256
|
+
},
|
|
257
|
+
activityCardReaper: async () => {
|
|
258
|
+
order.push('activity-cards')
|
|
259
|
+
},
|
|
260
|
+
queuedCardReaper: async () => {
|
|
261
|
+
order.push('queued-cards')
|
|
262
|
+
},
|
|
263
|
+
enableDmSweep: () => order.push('enable-dm'),
|
|
264
|
+
sweepDm: async () => {},
|
|
265
|
+
log: () => {
|
|
266
|
+
throw new Error('EPIPE')
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
).resolves.toBeUndefined()
|
|
270
|
+
expect(order).toEqual(['activity-cards', 'queued-cards', 'enable-dm'])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('never rejects — the caller fire-and-forgets it', async () => {
|
|
274
|
+
const boom = async () => {
|
|
275
|
+
throw new Error('boom')
|
|
276
|
+
}
|
|
277
|
+
await expect(
|
|
278
|
+
runBootPinSweepSteps({
|
|
279
|
+
scanDmChatIds: () => {
|
|
280
|
+
throw new Error('boom')
|
|
281
|
+
},
|
|
282
|
+
statusPinCleanup: boom,
|
|
283
|
+
activityCardReaper: boom,
|
|
284
|
+
queuedCardReaper: boom,
|
|
285
|
+
enableDmSweep: () => {
|
|
286
|
+
throw new Error('boom')
|
|
287
|
+
},
|
|
288
|
+
sweepDm: boom,
|
|
289
|
+
log: () => {},
|
|
290
|
+
}),
|
|
291
|
+
).resolves.toBeUndefined()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
Binary file
|