switchroom 0.19.4 → 0.19.6
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/auth-broker/index.js +7 -3
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +585 -302
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +128 -259
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
} from '../gateway/outbound-send-path.js'
|
|
47
47
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
48
48
|
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
49
|
+
import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
|
|
50
|
+
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
|
|
49
51
|
import { redact } from '../secret-detect/redact.js'
|
|
50
52
|
import type { CurrentTurn, Access } from '../gateway/gateway.js'
|
|
51
53
|
|
|
@@ -209,7 +211,8 @@ function makeHarness(opts?: {
|
|
|
209
211
|
assertSendable: () => {},
|
|
210
212
|
statusKey: key,
|
|
211
213
|
streamKey: key,
|
|
212
|
-
resolveReplyOwnerTurn: () => null,
|
|
214
|
+
resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
|
|
215
|
+
getLastSubagentHandbackAt: () => null,
|
|
213
216
|
findTurnByOriginId: () => null,
|
|
214
217
|
findTurnByQuotedMessageId: () => null,
|
|
215
218
|
resolveAnswerThreadWithLog: (_c, explicit) => explicit,
|
|
@@ -633,8 +636,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
633
636
|
Date.now(),
|
|
634
637
|
)
|
|
635
638
|
// The late reply resolves the ENDED flush-delivered turn as its owner
|
|
636
|
-
// (latest-ended tier — no live turn, no origin echo, no quote in a DM).
|
|
637
|
-
|
|
639
|
+
// (latest-ended tier — no live turn, no origin echo, no quote in a DM). This
|
|
640
|
+
// is the AMBIGUOUS fallback tier: the content gate applies here, so a
|
|
641
|
+
// genuinely-new handback sends fresh while the turn's own contained answer
|
|
642
|
+
// still supersedes.
|
|
643
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
638
644
|
}
|
|
639
645
|
|
|
640
646
|
it('CORE REGRESSION (red pre-fix): a handback with genuinely NEW content sends a ' +
|
|
@@ -642,6 +648,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
642
648
|
const h = makeHarness()
|
|
643
649
|
const owner = makeFlushDeliveredEndedTurn()
|
|
644
650
|
seedFlushRecord(h, owner)
|
|
651
|
+
// A background sub-agent handback WAS enqueued for this chat after the
|
|
652
|
+
// flushed turn ended and within the supersede TTL — so this late reply might
|
|
653
|
+
// BE that handback. The marker keeps the #3429 content gate. (owner.endedAt
|
|
654
|
+
// = now-30s; handback at now-15s is after it and within the 60s TTL.)
|
|
655
|
+
h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
|
|
645
656
|
|
|
646
657
|
// The handback lands LATE: req.turn = null (no live gateway turn — a
|
|
647
658
|
// sub-agent completion is not a new inbound).
|
|
@@ -700,8 +711,8 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
700
711
|
const h = makeHarness()
|
|
701
712
|
const owner = makeFlushDeliveredEndedTurn()
|
|
702
713
|
// NO registry record (the post-fire pre-record window); owner resolution
|
|
703
|
-
// still recovers the ended flush-armed turn.
|
|
704
|
-
h.deps.resolveReplyOwnerTurn = () => owner
|
|
714
|
+
// still recovers the ended flush-armed turn via the latest-ended tier.
|
|
715
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
705
716
|
|
|
706
717
|
const res = await sendReply(h.deps, req(HANDBACK))
|
|
707
718
|
|
|
@@ -715,11 +726,215 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
715
726
|
'window is still suppressed (the #2996 Part 2 backstop holds)', async () => {
|
|
716
727
|
const h = makeHarness()
|
|
717
728
|
const owner = makeFlushDeliveredEndedTurn()
|
|
718
|
-
h.deps.resolveReplyOwnerTurn = () => owner
|
|
729
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
719
730
|
|
|
720
731
|
const res = await sendReply(h.deps, req(FLUSHED_TEXT))
|
|
721
732
|
|
|
722
733
|
expect(h.calls).toHaveLength(0) // nothing sent, nothing edited
|
|
723
734
|
expect(res.content[0]!.text).toContain('deduped')
|
|
724
735
|
})
|
|
736
|
+
|
|
737
|
+
// ─── The REAL marko DM incident (fix/backstop-duplicate-reply) ───
|
|
738
|
+
//
|
|
739
|
+
// The dominant real duplicate: the model narrates its answer as prose (flushed
|
|
740
|
+
// as message A), then fires the `reply` tool with a RE-WORDED version of the
|
|
741
|
+
// same answer. On a DM this late reply has NO live turn, NO `origin_turn_id`
|
|
742
|
+
// echo, and NO explicit `reply_to`, so it resolves its owner via the ambiguous
|
|
743
|
+
// `latest-ended` tier — the #3429 content gate then declines (reworded text ⊄
|
|
744
|
+
// flushed blob) and a fresh SECOND bubble ships = the visible duplicate
|
|
745
|
+
// (agent:marko 2026-07-20: turns #1177/#1182/#1201 double-sent; 11/11 declines
|
|
746
|
+
// were own-replies with NO handback in flight).
|
|
747
|
+
//
|
|
748
|
+
// The two cases are indistinguishable by tier (both latest-ended) and by
|
|
749
|
+
// content (rewording). The deterministic discriminator is the gateway-
|
|
750
|
+
// synthesized `subagent_handback` marker: case A has NONE enqueued after the
|
|
751
|
+
// flushed turn ended; a genuine background handback (case B) does. These two
|
|
752
|
+
// tests drive the REAL send path with the REAL supersede registry over the
|
|
753
|
+
// SAME reworded text and the SAME latest-ended tier, differing ONLY in the
|
|
754
|
+
// handback marker — proving it is the marker that flips the outcome.
|
|
755
|
+
const REWORDED_SAME_TURN_ANSWER =
|
|
756
|
+
'Good news on the fleet: every one of the twelve agents is running fine right now. ' +
|
|
757
|
+
'The gateway, the vault broker and the approval kernel are all green, and nothing has ' +
|
|
758
|
+
'restarted in the past day — so there is nothing you need to do.'
|
|
759
|
+
|
|
760
|
+
function seedRecord(h: ReturnType<typeof makeHarness>, owner: CurrentTurn): void {
|
|
761
|
+
h.deps.flushedTurnSupersede.record(
|
|
762
|
+
CHAT,
|
|
763
|
+
undefined,
|
|
764
|
+
{ turnId: owner.turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
|
|
765
|
+
Date.now(),
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
it('CASE A — REAL marko DM incident: reworded own reply, latest-ended tier, ' +
|
|
770
|
+
'NO handback in flight → supersedes the flushed draft, collapses to ONE message', async () => {
|
|
771
|
+
const h = makeHarness()
|
|
772
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
773
|
+
seedRecord(h, owner)
|
|
774
|
+
// Late DM reply: latest-ended tier (the path the tier discriminator MISSES),
|
|
775
|
+
// and crucially NO subagent_handback was enqueued for this chat after the
|
|
776
|
+
// turn ended — so the reply is the flushed turn's OWN answer.
|
|
777
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
778
|
+
h.deps.getLastSubagentHandbackAt = () => null
|
|
779
|
+
|
|
780
|
+
const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
|
|
781
|
+
|
|
782
|
+
// Exactly ONE client-visible message: the flushed message edited in place
|
|
783
|
+
// into the reworded reply — NO fresh second bubble (duplicate closed).
|
|
784
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
785
|
+
expect(edits).toHaveLength(1)
|
|
786
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
787
|
+
expect(edits[0]!.text).toContain('every one of the twelve agents')
|
|
788
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
789
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
790
|
+
// Record consumed.
|
|
791
|
+
expect(
|
|
792
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
793
|
+
liveTurnId: OWNER_TURN_ID,
|
|
794
|
+
now: Date.now(),
|
|
795
|
+
}).reason,
|
|
796
|
+
).toBe('no-record')
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('CASE B — genuine background handback: SAME reworded text, SAME latest-ended tier, ' +
|
|
800
|
+
'but a handback WAS enqueued in-window → keeps the gate, sends FRESH (TWO messages)', async () => {
|
|
801
|
+
const h = makeHarness()
|
|
802
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
803
|
+
seedRecord(h, owner)
|
|
804
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
805
|
+
// A background sub-agent handback was enqueued for this chat AFTER the turn
|
|
806
|
+
// ended (now-30s) and within the 60s TTL (now-15s) — this reply might BE it.
|
|
807
|
+
h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
|
|
808
|
+
|
|
809
|
+
const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
|
|
810
|
+
|
|
811
|
+
// A fresh notifying bubble ships; the flushed message is NEITHER edited nor
|
|
812
|
+
// deleted — flush (A) + fresh (B) = two surfaced messages (#3429 preserved).
|
|
813
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
814
|
+
expect(fresh).toHaveLength(1)
|
|
815
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
816
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
817
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
818
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
819
|
+
// Record NOT consumed.
|
|
820
|
+
expect(
|
|
821
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
822
|
+
liveTurnId: OWNER_TURN_ID,
|
|
823
|
+
replyText: CANONICAL_REPLY,
|
|
824
|
+
now: Date.now(),
|
|
825
|
+
}).supersede,
|
|
826
|
+
).toBe(true)
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it('MUST-FIX 1 (silent-data-loss): a handback reply model-STEERED to the quoted ' +
|
|
830
|
+
'tier (reply_to = the flushed turn\'s source msg) must NOT edit/delete the flushed ' +
|
|
831
|
+
'answer while a handback is in flight — sends FRESH (two messages)', async () => {
|
|
832
|
+
const h = makeHarness()
|
|
833
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
834
|
+
seedRecord(h, owner)
|
|
835
|
+
// The `quoted` tier is derived from the MODEL-SUPPLIED `args.reply_to`, so a
|
|
836
|
+
// background handback turn can point reply_to at the user's original message
|
|
837
|
+
// (which the prior turn's flush is recorded against) to resolve THAT turn via
|
|
838
|
+
// `quoted`. A handback IS in flight, so a positive tier must NOT override the
|
|
839
|
+
// #3429 content gate — else the reworded handback text silently edits over
|
|
840
|
+
// the flushed turn's DELIVERED answer (Telegram edits don't re-notify).
|
|
841
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
|
|
842
|
+
h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
|
|
843
|
+
|
|
844
|
+
const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
|
|
845
|
+
|
|
846
|
+
// Fresh notifying send; the flushed answer is NEITHER edited nor deleted.
|
|
847
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
848
|
+
expect(fresh).toHaveLength(1)
|
|
849
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
850
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
851
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
852
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
853
|
+
// Flush record NOT consumed — the flushed answer stands.
|
|
854
|
+
expect(
|
|
855
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
856
|
+
liveTurnId: OWNER_TURN_ID,
|
|
857
|
+
replyText: CANONICAL_REPLY,
|
|
858
|
+
now: Date.now(),
|
|
859
|
+
}).supersede,
|
|
860
|
+
).toBe(true)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('the framework-owned `live` tier MAY still bypass a handback window (a live ' +
|
|
864
|
+
'currentTurn is not model-derived and cannot collide with an ended turn\'s record)', async () => {
|
|
865
|
+
const h = makeHarness()
|
|
866
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
867
|
+
seedRecord(h, owner)
|
|
868
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'live' })
|
|
869
|
+
h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
|
|
870
|
+
|
|
871
|
+
const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
|
|
872
|
+
|
|
873
|
+
// Live tier bypasses → supersede via edit-in-place (one message).
|
|
874
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
875
|
+
expect(edits).toHaveLength(1)
|
|
876
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
877
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
878
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('MUST-FIX 2 (boot-replay): a handback re-pushed through the buffer chokepoint ' +
|
|
882
|
+
'stamps the marker (with the envelope ts) → gate preserved → FRESH send, no silent edit', async () => {
|
|
883
|
+
// Real marker + real buffer wired exactly as the gateway wires them. The
|
|
884
|
+
// boot-replay loop re-pushes un-acked spooled inbounds — including handback
|
|
885
|
+
// envelopes — through this SAME push(); the chokepoint stamp is what makes a
|
|
886
|
+
// replayed handback populate the (post-restart empty) marker.
|
|
887
|
+
const marker = new SubagentHandbackMarker()
|
|
888
|
+
const buffer = createPendingInboundBuffer({
|
|
889
|
+
log: () => {},
|
|
890
|
+
onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
// Simulate the boot-replay re-push of an un-acked spooled handback envelope.
|
|
894
|
+
// The envelope carries its own ms `ts` (after the flushed turn ended, within
|
|
895
|
+
// TTL) — owner.endedAt below is now-30s, this handback is now-15s.
|
|
896
|
+
const handbackTs = Date.now() - 15_000
|
|
897
|
+
buffer.push('marko', {
|
|
898
|
+
type: 'inbound',
|
|
899
|
+
chatId: CHAT,
|
|
900
|
+
messageId: handbackTs,
|
|
901
|
+
user: 'subagent-watcher',
|
|
902
|
+
userId: 0,
|
|
903
|
+
ts: handbackTs,
|
|
904
|
+
text: 'handback result',
|
|
905
|
+
meta: { source: 'subagent_handback' },
|
|
906
|
+
})
|
|
907
|
+
// The chokepoint stamped the marker from the replay push (pre-fix: empty).
|
|
908
|
+
expect(marker.lastAt(CHAT)).toBe(handbackTs)
|
|
909
|
+
|
|
910
|
+
const h = makeHarness()
|
|
911
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
912
|
+
seedRecord(h, owner)
|
|
913
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
914
|
+
// The gateway reads the marker the boot-replay stamped.
|
|
915
|
+
h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAt(chatId)
|
|
916
|
+
|
|
917
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
918
|
+
|
|
919
|
+
// Gate preserved → the replayed handback delivers FRESH; the post-boot
|
|
920
|
+
// flushed answer is NEITHER edited nor deleted (no silent #3429 on restart).
|
|
921
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
922
|
+
expect(fresh).toHaveLength(1)
|
|
923
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
924
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
925
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('the buffer chokepoint stamps ONLY handback envelopes (a normal inbound does not)', () => {
|
|
929
|
+
const marker = new SubagentHandbackMarker()
|
|
930
|
+
const buffer = createPendingInboundBuffer({
|
|
931
|
+
log: () => {},
|
|
932
|
+
onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
|
|
933
|
+
})
|
|
934
|
+
buffer.push('marko', {
|
|
935
|
+
type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
|
|
936
|
+
ts: Date.now(), text: 'hi', meta: {},
|
|
937
|
+
})
|
|
938
|
+
expect(marker.lastAt(CHAT)).toBe(null)
|
|
939
|
+
})
|
|
725
940
|
})
|
|
@@ -296,4 +296,67 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
296
296
|
expect(r.status).toBe(0)
|
|
297
297
|
expect(r.stdout.trim()).toBe('')
|
|
298
298
|
})
|
|
299
|
+
|
|
300
|
+
// ── Single-writer election (duplicate-message fix) ─────────────────
|
|
301
|
+
describe('single-writer election end-to-end', () => {
|
|
302
|
+
function writeFreshHeartbeat() {
|
|
303
|
+
writeFileSync(join(stateDir, 'gateway-heartbeat'), String(Date.now()), 'utf8')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
it('zero-reply ≥200 + FRESH gateway heartbeat → ALLOW (no block re-prompt), state file still written for the flush', () => {
|
|
307
|
+
// The duplicate repro: today this BLOCKS *and* the gateway flush fires →
|
|
308
|
+
// two messages. With a fresh heartbeat the election ALLOWS the stop so
|
|
309
|
+
// the gateway flush is the single writer.
|
|
310
|
+
writeFreshHeartbeat()
|
|
311
|
+
const transcript = writeTranscript(tmp, [
|
|
312
|
+
ENQUEUE,
|
|
313
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
|
|
314
|
+
])
|
|
315
|
+
const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
|
|
316
|
+
expect(r.status).toBe(0)
|
|
317
|
+
// ALLOW → no block JSON on stdout.
|
|
318
|
+
expect(r.stdout.trim()).toBe('')
|
|
319
|
+
expect(r.stderr).toMatch(/single-writer election ALLOWED/)
|
|
320
|
+
// State file IS written so the gateway's captured-prose bridge has its
|
|
321
|
+
// input (turnKey/turnId/pendingText); retryCount stays 0 (not a re-prompt).
|
|
322
|
+
const statePath = join(stateDir, 'silent-end-pending.json')
|
|
323
|
+
expect(existsSync(statePath)).toBe(true)
|
|
324
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
325
|
+
expect(state.retryCount).toBe(0)
|
|
326
|
+
expect(state.turnKey).toBe('111:_')
|
|
327
|
+
expect(state.pendingText).toBe('A'.repeat(300))
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('zero-reply ≥200 + STALE/missing heartbeat → BLOCK (never allow into a possibly-dead gateway)', () => {
|
|
331
|
+
// No heartbeat file → the liveness gate forces today's BLOCK behaviour.
|
|
332
|
+
const transcript = writeTranscript(tmp, [
|
|
333
|
+
ENQUEUE,
|
|
334
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
|
|
335
|
+
])
|
|
336
|
+
const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
|
|
337
|
+
expect(r.status).toBe(0)
|
|
338
|
+
expect(JSON.parse(r.stdout).decision).toBe('block')
|
|
339
|
+
const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
|
|
340
|
+
expect(state.retryCount).toBe(1)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('retryCount>0 (prior failed delivery) + fresh heartbeat → BLOCK (preserve #3228 send-failure net)', () => {
|
|
344
|
+
writeFreshHeartbeat()
|
|
345
|
+
// Seed a prior state file with retryCount=1 (a delivery already failed).
|
|
346
|
+
writeFileSync(
|
|
347
|
+
join(stateDir, 'silent-end-pending.json'),
|
|
348
|
+
JSON.stringify({ chatId: '111', threadId: null, turnKey: '111:_', retryCount: 1, timestamp: Date.now() }),
|
|
349
|
+
'utf8',
|
|
350
|
+
)
|
|
351
|
+
const transcript = writeTranscript(tmp, [
|
|
352
|
+
ENQUEUE,
|
|
353
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
|
|
354
|
+
])
|
|
355
|
+
const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
|
|
356
|
+
expect(r.status).toBe(0)
|
|
357
|
+
// retryCount was 1 → not the exhaustion boundary (MAX=2) → still blocks,
|
|
358
|
+
// and the election does NOT allow (retry-ladder-in-flight).
|
|
359
|
+
expect(JSON.parse(r.stdout).decision).toBe('block')
|
|
360
|
+
})
|
|
361
|
+
})
|
|
299
362
|
})
|
|
@@ -184,13 +184,20 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
|
|
|
184
184
|
expect(r.pendingText).toBe(trailing)
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
-
it('Option A: a SHORT trailing
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
187
|
+
it('Option A: a SHORT single trailing fragment (zero-reply) IS persisted for the lowered-floor corner', () => {
|
|
188
|
+
// Post duplicate-message fix: a single short plain-text block in the
|
|
189
|
+
// ZERO-reply case is the real short-answer shape, so the scan persists it
|
|
190
|
+
// as pendingText for the capture-divergence corner (gateway captured empty →
|
|
191
|
+
// the bridge delivers with a lowered floor). Under the gateway's DEFAULT
|
|
192
|
+
// 200-char floor it is still NOT delivered, so the substance guard holds at
|
|
193
|
+
// the delivery layer.
|
|
194
|
+
const short = 'ok done, let me know if you need anything else'
|
|
195
|
+
const text = jsonl(ENQUEUE, assistantText(short))
|
|
191
196
|
const r = scanTurnForFinalReply(text)
|
|
192
197
|
expect(r.decided).toBe('block')
|
|
193
|
-
expect(r.
|
|
198
|
+
expect(r.reason).toBe('no-final-reply')
|
|
199
|
+
expect(r.pendingText).toBe(short)
|
|
200
|
+
expect(r.hasTrailingProse).toBe(true)
|
|
194
201
|
})
|
|
195
202
|
|
|
196
203
|
it('notification-bearing reply → allow', () => {
|
|
@@ -388,14 +395,19 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
388
395
|
// "Let me check…" / "Still querying…" narration crossed 200 and was delivered
|
|
389
396
|
// as if it were the answer. Post-fix only a single block that clears the floor
|
|
390
397
|
// ON ITS OWN becomes pendingText.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
398
|
+
// Genuinely-narration blocks: each matches the opener/trailer heuristic
|
|
399
|
+
// (`isNarrationBlock`) the flush's `selectFlushDeliveryText` uses, so a pure
|
|
400
|
+
// run of them is delivered as NOTHING by the flush — and the scan must agree
|
|
401
|
+
// (no pendingText) so the capture-divergence bridge never masquerades a
|
|
402
|
+
// narration run as an answer (#3228 Finding 2).
|
|
403
|
+
const NARRATION_A = 'Let me check the first data source now, pulling the records and scanning the rows…' // opener + …
|
|
404
|
+
const NARRATION_B = "Now let me query the second source; it is slower than expected, hang tight…" // opener + …
|
|
405
|
+
const NARRATION_C = "I'll cross-reference the last set of figures against the ledger before I summarise…" // opener + …
|
|
406
|
+
|
|
407
|
+
it('zero-delivery turn of only NARRATION blocks → block WITHOUT pendingText (#3228 Finding 2 / flush parity)', () => {
|
|
408
|
+
// Combined length of the three blocks is ≥ 200, so the OLD joined-floor code
|
|
409
|
+
// would join them and set pendingText (masquerade). The NEW code mirrors the
|
|
410
|
+
// flush's narration strip: every block is narration → nothing to deliver.
|
|
399
411
|
expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
|
|
400
412
|
.toBeGreaterThanOrEqual(200)
|
|
401
413
|
const text = jsonl(
|
|
@@ -413,6 +425,35 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
413
425
|
expect(r.pendingText).toBeUndefined()
|
|
414
426
|
})
|
|
415
427
|
|
|
428
|
+
it('zero-delivery turn of MULTIPLE sub-200 REAL-CONTENT blocks → JOINED pendingText (review item 3 drop fix)', () => {
|
|
429
|
+
// A real answer split as two ~150-char paragraphs, each individually under
|
|
430
|
+
// the 200-char substance floor. Pre-fix the scan set NO pendingText — and in
|
|
431
|
+
// the capture-divergence-empty corner (gateway captured nothing → flush
|
|
432
|
+
// skips 'empty-text') the bridge then had nothing to deliver and the hook
|
|
433
|
+
// had already allowed the stop → DROPPED ANSWER. Post-fix the scan mirrors
|
|
434
|
+
// the flush's join so the bridge delivers the joined prose.
|
|
435
|
+
const PARA_1 = 'The root cause is a stale cache entry that survives the reload because the invalidation key is derived from the wrong field.' // ~150, real content
|
|
436
|
+
const PARA_2 = 'The fix is to key invalidation off the canonical id so the entry is dropped on every write; I have verified it against the repro.' // ~150, real content
|
|
437
|
+
expect(PARA_1.length).toBeLessThan(200)
|
|
438
|
+
expect(PARA_2.length).toBeLessThan(200)
|
|
439
|
+
const text = jsonl(ENQUEUE, assistantText(PARA_1), assistantText(PARA_2))
|
|
440
|
+
const r = scanTurnForFinalReply(text)
|
|
441
|
+
expect(r.decided).toBe('block')
|
|
442
|
+
expect(r.reason).toBe('no-final-reply')
|
|
443
|
+
// Joined prose, mirroring the flush's selectFlushDeliveryText.
|
|
444
|
+
expect(r.pendingText).toBe(`${PARA_1}\n\n${PARA_2}`)
|
|
445
|
+
expect(r.hasTrailingProse).toBe(true)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('leading narration + a real sub-200 answer block → only the answer is delivered (narration stripped)', () => {
|
|
449
|
+
const NARR = 'Let me pull the numbers first…' // narration opener + …
|
|
450
|
+
const ANSWER = 'Revenue was up 12% quarter-over-quarter, driven mostly by the new enterprise tier.' // ~85, real
|
|
451
|
+
const text = jsonl(ENQUEUE, assistantText(NARR), assistantText(ANSWER))
|
|
452
|
+
const r = scanTurnForFinalReply(text)
|
|
453
|
+
expect(r.decided).toBe('block')
|
|
454
|
+
expect(r.pendingText).toBe(ANSWER)
|
|
455
|
+
})
|
|
456
|
+
|
|
416
457
|
it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
|
|
417
458
|
// Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
|
|
418
459
|
// false → allow. (The old joined-floor logic never affected the block
|
|
@@ -447,9 +488,12 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
447
488
|
const text = jsonl(ENQUEUE, assistantText('X'.repeat(n)))
|
|
448
489
|
return scanTurnForFinalReply(text)
|
|
449
490
|
}
|
|
450
|
-
// 199 → under floor,
|
|
451
|
-
//
|
|
452
|
-
|
|
491
|
+
// 199 → under the 200-char SUBSTANCE floor, but a SINGLE trailing block is
|
|
492
|
+
// the real short-answer shape: post duplicate-message fix the scan persists
|
|
493
|
+
// it as pendingText so the capture-divergence corner can deliver it with a
|
|
494
|
+
// lowered floor. The gateway's default-floor decide still won't deliver it,
|
|
495
|
+
// so the #3228 masquerade guard is unchanged at the delivery layer.
|
|
496
|
+
expect(at(199).pendingText).toBe('X'.repeat(199))
|
|
453
497
|
// 200 → exactly the floor, delivered.
|
|
454
498
|
expect(at(200).pendingText).toBe('X'.repeat(200))
|
|
455
499
|
// 201 → over floor, delivered.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the single-writer election (duplicate-message fix).
|
|
3
|
+
*
|
|
4
|
+
* RCA: when a turn ends with its final answer as PLAIN TRANSCRIPT TEXT (no
|
|
5
|
+
* `reply` tool call), two uncoordinated recovery paths both fired — (A) the
|
|
6
|
+
* gateway turn-end flush (`decideTurnFlush`) delivered the captured prose, and
|
|
7
|
+
* (B) this Stop hook blocked and re-prompted, regenerating a REWORDED reply
|
|
8
|
+
* that defeated the exact-match dedup. The user got two near-identical
|
|
9
|
+
* messages.
|
|
10
|
+
*
|
|
11
|
+
* Fix: the Stop hook is the single elector (`decideStopHookDisposition`). On a
|
|
12
|
+
* would-BLOCK scan it ALLOWS the stop — handing delivery to the gateway's
|
|
13
|
+
* flush / captured-prose bridge — IFF four never-drop gates all hold; otherwise
|
|
14
|
+
* it BLOCKS exactly as today. These tests pin the whole verdict matrix and
|
|
15
|
+
* prove every allow path has a delivery machine and every guard forces BLOCK.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import {
|
|
20
|
+
decideStopHookDisposition,
|
|
21
|
+
isTurnFlushSafetyEnabledEnv,
|
|
22
|
+
isCapturedProseDeliveryEnabledEnv,
|
|
23
|
+
isGatewayHeartbeatFresh,
|
|
24
|
+
GATEWAY_HEARTBEAT_FRESH_MS,
|
|
25
|
+
} from '../hooks/silent-end-scan.mjs'
|
|
26
|
+
import {
|
|
27
|
+
mkdtempSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
utimesSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
} from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
|
|
35
|
+
// Scan fixtures — the shapes `scanTurnForFinalReply` returns on a would-block.
|
|
36
|
+
const ZERO_REPLY_LONG = {
|
|
37
|
+
decided: 'block' as const,
|
|
38
|
+
reason: 'no-final-reply',
|
|
39
|
+
turnKey: 'c:_',
|
|
40
|
+
hasTrailingProse: true,
|
|
41
|
+
pendingText: 'A'.repeat(300),
|
|
42
|
+
}
|
|
43
|
+
const ZERO_REPLY_SHORT = {
|
|
44
|
+
decided: 'block' as const,
|
|
45
|
+
reason: 'no-final-reply',
|
|
46
|
+
turnKey: 'c:_',
|
|
47
|
+
hasTrailingProse: true,
|
|
48
|
+
pendingText: 'ok done',
|
|
49
|
+
}
|
|
50
|
+
const ZERO_REPLY_NO_PROSE = {
|
|
51
|
+
decided: 'block' as const,
|
|
52
|
+
reason: 'no-final-reply',
|
|
53
|
+
turnKey: 'c:_',
|
|
54
|
+
// hasTrailingProse absent — tool-calls-only turn, nothing for a machine to send.
|
|
55
|
+
}
|
|
56
|
+
const INTERIM_ACK_LONG = {
|
|
57
|
+
decided: 'block' as const,
|
|
58
|
+
reason: 'trailing-text-after-reply',
|
|
59
|
+
turnKey: 'c:_',
|
|
60
|
+
hasTrailingProse: true,
|
|
61
|
+
pendingText: 'B'.repeat(300),
|
|
62
|
+
}
|
|
63
|
+
const INTERIM_ACK_SHORT = {
|
|
64
|
+
decided: 'block' as const,
|
|
65
|
+
reason: 'trailing-text-after-reply',
|
|
66
|
+
turnKey: 'c:_',
|
|
67
|
+
hasTrailingProse: true,
|
|
68
|
+
pendingText: 'thanks!',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ALL_ON = {
|
|
72
|
+
retryCount: 0,
|
|
73
|
+
turnFlushSafetyEnabled: true,
|
|
74
|
+
capturedProseDeliveryEnabled: true,
|
|
75
|
+
gatewayLive: true,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('decideStopHookDisposition — duplicate repro (#1: zero-reply ≥200)', () => {
|
|
79
|
+
it('zero-reply long answer, flags on, retry 0, live → ALLOW-elected (today it blocks → duplicate)', () => {
|
|
80
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON })
|
|
81
|
+
expect(d.action).toBe('allow-elected')
|
|
82
|
+
expect(d.reason).toBe('flush-will-deliver')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('decideStopHookDisposition — short-answer repro (#2: zero-reply <200)', () => {
|
|
87
|
+
it('zero-reply SHORT answer, flags on, retry 0, live → ALLOW-elected (flush has no length floor)', () => {
|
|
88
|
+
// Today this BLOCKS *and* the flush fires → duplicate. Exactly one machine
|
|
89
|
+
// must win: the flush. The election allows the stop so the hook does not
|
|
90
|
+
// also re-prompt.
|
|
91
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON })
|
|
92
|
+
expect(d.action).toBe('allow-elected')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('decideStopHookDisposition — never-drop matrix (#3)', () => {
|
|
97
|
+
it('every ALLOW verdict is backed by a delivery machine', () => {
|
|
98
|
+
// Zero-reply → the turn-flush delivers (no length floor); interim-ack ≥200 →
|
|
99
|
+
// the captured-prose bridge delivers. Both are allow.
|
|
100
|
+
expect(decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON }).action).toBe('allow-elected')
|
|
101
|
+
expect(decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON }).action).toBe('allow-elected')
|
|
102
|
+
expect(decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON }).action).toBe('allow-elected')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('turn-flush flag OFF (zero-reply) → BLOCK (the flush will not fire)', () => {
|
|
106
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, turnFlushSafetyEnabled: false })
|
|
107
|
+
expect(d.action).toBe('block')
|
|
108
|
+
expect(d.reason).toBe('turn-flush-flag-disabled')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('captured-prose flag OFF (interim-ack) → BLOCK (the bridge will not fire)', () => {
|
|
112
|
+
const d = decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
|
|
113
|
+
expect(d.action).toBe('block')
|
|
114
|
+
expect(d.reason).toBe('captured-prose-flag-disabled')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('captured-prose flag OFF (zero-reply) → BLOCK (bridge is the capture-divergence backstop)', () => {
|
|
118
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
|
|
119
|
+
expect(d.action).toBe('block')
|
|
120
|
+
expect(d.reason).toBe('captured-prose-flag-disabled')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('retryCount > 0 → BLOCK (a prior failed delivery keeps the #3228 recovery net)', () => {
|
|
124
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, retryCount: 1 })
|
|
125
|
+
expect(d.action).toBe('block')
|
|
126
|
+
expect(d.reason).toBe('retry-ladder-in-flight')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('gateway NOT live → BLOCK (never allow into a possibly-dead gateway)', () => {
|
|
130
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false })
|
|
131
|
+
expect(d.action).toBe('block')
|
|
132
|
+
expect(d.reason).toBe('gateway-liveness-not-fresh')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('interim-ack SHORT (<200) → BLOCK (bridge floor is 200; no duplicate exists today)', () => {
|
|
136
|
+
const d = decideStopHookDisposition({ scan: INTERIM_ACK_SHORT, ...ALL_ON })
|
|
137
|
+
expect(d.action).toBe('block')
|
|
138
|
+
expect(d.reason).toBe('short-trailing-after-reply')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('zero-reply with NO trailing prose (tool-calls only) → BLOCK (nothing to deliver)', () => {
|
|
142
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_NO_PROSE, ...ALL_ON })
|
|
143
|
+
expect(d.action).toBe('block')
|
|
144
|
+
expect(d.reason).toBe('no-trailing-prose')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a non-block scan (allow/unknown) is passed straight through as allow-scan', () => {
|
|
148
|
+
expect(decideStopHookDisposition({ scan: { decided: 'allow', reason: 'final-reply' }, ...ALL_ON }).action).toBe('allow-scan')
|
|
149
|
+
expect(decideStopHookDisposition({ scan: { decided: 'unknown', reason: 'no-turn-start' }, ...ALL_ON }).action).toBe('allow-scan')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('gateway-liveness gate is checked BEFORE the flag/prose gates (dead gateway always blocks)', () => {
|
|
153
|
+
// Even with an otherwise-perfect zero-reply allow, a dead gateway forces block.
|
|
154
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false, retryCount: 0 })
|
|
155
|
+
expect(d.action).toBe('block')
|
|
156
|
+
expect(d.reason).toBe('gateway-liveness-not-fresh')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('env-flag mirrors stay in sync with the TS source', () => {
|
|
161
|
+
it('turn-flush-safety: default ON; 0/false/off/no disable', () => {
|
|
162
|
+
expect(isTurnFlushSafetyEnabledEnv({})).toBe(true)
|
|
163
|
+
for (const v of ['0', 'false', 'off', 'no', 'FALSE', 'Off']) {
|
|
164
|
+
expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: v })).toBe(false)
|
|
165
|
+
}
|
|
166
|
+
expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: '1' })).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
it('captured-prose: default ON; only exact "0" disables', () => {
|
|
169
|
+
expect(isCapturedProseDeliveryEnabledEnv({})).toBe(true)
|
|
170
|
+
expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '0' })).toBe(false)
|
|
171
|
+
expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '1' })).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('isGatewayHeartbeatFresh — liveness gate IO', () => {
|
|
176
|
+
it('fresh heartbeat → true; stale → false; missing → false', () => {
|
|
177
|
+
const dir = mkdtempSync(join(tmpdir(), 'gw-hb-'))
|
|
178
|
+
try {
|
|
179
|
+
// Missing file → not fresh.
|
|
180
|
+
expect(isGatewayHeartbeatFresh(dir)).toBe(false)
|
|
181
|
+
const path = join(dir, 'gateway-heartbeat')
|
|
182
|
+
writeFileSync(path, 'x')
|
|
183
|
+
// Just-written → fresh.
|
|
184
|
+
expect(isGatewayHeartbeatFresh(dir)).toBe(true)
|
|
185
|
+
// Age it past the freshness bound → stale.
|
|
186
|
+
const old = new Date(Date.now() - GATEWAY_HEARTBEAT_FRESH_MS - 10_000)
|
|
187
|
+
utimesSync(path, old, old)
|
|
188
|
+
expect(isGatewayHeartbeatFresh(dir)).toBe(false)
|
|
189
|
+
} finally {
|
|
190
|
+
rmSync(dir, { recursive: true, force: true })
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
})
|