switchroom 0.17.6 → 0.17.10
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 +38 -4
- package/dist/auth-broker/index.js +302 -203
- package/dist/cli/notion-write-pretool.mjs +35 -2
- package/dist/cli/switchroom.js +1178 -576
- package/dist/host-control/main.js +148 -14
- package/dist/vault/approvals/kernel-server.js +140 -55
- package/dist/vault/broker/server.js +142 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2565 -610
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +293 -0
- package/telegram-plugin/gateway/gateway.ts +1376 -82
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +60 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +153 -4
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
isConsolidationLegibilityEnabled,
|
|
4
|
+
detectConsolidationEvent,
|
|
5
|
+
renderConsolidationLine,
|
|
6
|
+
consolidationSignature,
|
|
7
|
+
ConsolidationRateLimiter,
|
|
8
|
+
HINDSIGHT_WEBHOOK_SOURCE,
|
|
9
|
+
CONSOLIDATION_COMPLETED_EVENT,
|
|
10
|
+
} from '../consolidation-legibility.js'
|
|
11
|
+
|
|
12
|
+
describe('isConsolidationLegibilityEnabled — OPT-IN (default OFF)', () => {
|
|
13
|
+
it('is OFF when unset (opposite of the default-ON tool-observation path)', () => {
|
|
14
|
+
expect(isConsolidationLegibilityEnabled(undefined)).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
it('is OFF for the empty string and for "0"/"false"/"off"', () => {
|
|
17
|
+
expect(isConsolidationLegibilityEnabled('')).toBe(false)
|
|
18
|
+
expect(isConsolidationLegibilityEnabled('0')).toBe(false)
|
|
19
|
+
expect(isConsolidationLegibilityEnabled('false')).toBe(false)
|
|
20
|
+
expect(isConsolidationLegibilityEnabled('off')).toBe(false)
|
|
21
|
+
expect(isConsolidationLegibilityEnabled('no')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
it('is ON only for an explicit truthy opt-in', () => {
|
|
24
|
+
expect(isConsolidationLegibilityEnabled('1')).toBe(true)
|
|
25
|
+
expect(isConsolidationLegibilityEnabled('true')).toBe(true)
|
|
26
|
+
expect(isConsolidationLegibilityEnabled('on')).toBe(true)
|
|
27
|
+
expect(isConsolidationLegibilityEnabled('yes')).toBe(true)
|
|
28
|
+
expect(isConsolidationLegibilityEnabled(' TRUE ')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('wire constants', () => {
|
|
33
|
+
it('names the hindsight consolidation.completed source/event', () => {
|
|
34
|
+
expect(HINDSIGHT_WEBHOOK_SOURCE).toBe('hindsight')
|
|
35
|
+
expect(CONSOLIDATION_COMPLETED_EVENT).toBe('consolidation.completed')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('detectConsolidationEvent — MATERIAL-only (sparse)', () => {
|
|
40
|
+
it('returns null on a routine no-op consolidation (nothing stored/corrected)', () => {
|
|
41
|
+
expect(detectConsolidationEvent({ stored: 0, corrected: 0 })).toBeNull()
|
|
42
|
+
expect(detectConsolidationEvent({})).toBeNull()
|
|
43
|
+
expect(detectConsolidationEvent(undefined)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('classifies a pure store as "updated"', () => {
|
|
47
|
+
const ev = detectConsolidationEvent({ stored: 2, subject: 'your deploy preferences' })
|
|
48
|
+
expect(ev).toEqual({ kind: 'updated', topic: 'your deploy preferences' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('classifies any correction as "revised" (higher signal), even alongside stores', () => {
|
|
52
|
+
expect(detectConsolidationEvent({ corrected: 1, subject: 'old runbook' })).toEqual({
|
|
53
|
+
kind: 'revised',
|
|
54
|
+
topic: 'old runbook',
|
|
55
|
+
})
|
|
56
|
+
expect(detectConsolidationEvent({ stored: 3, corrected: 1, subject: 'X' })).toEqual({
|
|
57
|
+
kind: 'revised',
|
|
58
|
+
topic: 'X',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('tolerates field aliases (created/added/new_observations; invalidated/superseded)', () => {
|
|
63
|
+
expect(detectConsolidationEvent({ created: 1, topic: 'a' })?.kind).toBe('updated')
|
|
64
|
+
expect(detectConsolidationEvent({ new_observations: 5, topic: 'a' })?.kind).toBe('updated')
|
|
65
|
+
expect(detectConsolidationEvent({ invalidated: 2, topic: 'a' })?.kind).toBe('revised')
|
|
66
|
+
expect(detectConsolidationEvent({ superseded: 1, topic: 'a' })?.kind).toBe('revised')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('counts an observations array by length as a store', () => {
|
|
70
|
+
const ev = detectConsolidationEvent({
|
|
71
|
+
observations: [{ statement: 'User prefers TS', subject: 'coding style' }],
|
|
72
|
+
})
|
|
73
|
+
expect(ev).toEqual({ kind: 'updated', topic: 'coding style' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('accepts numeric-string counts', () => {
|
|
77
|
+
expect(detectConsolidationEvent({ stored: '2', subject: 'x' })?.kind).toBe('updated')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('derives topic from subjects[]/entities[] arrays when no direct subject', () => {
|
|
81
|
+
expect(detectConsolidationEvent({ stored: 1, subjects: ['travel plans'] })?.topic).toBe(
|
|
82
|
+
'travel plans',
|
|
83
|
+
)
|
|
84
|
+
expect(
|
|
85
|
+
detectConsolidationEvent({ stored: 1, entities: [{ name: 'Acme Corp' }] })?.topic,
|
|
86
|
+
).toBe('Acme Corp')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('falls back to the first observation statement when no subject', () => {
|
|
90
|
+
const ev = detectConsolidationEvent({
|
|
91
|
+
observations: [{ statement: 'The user lives in Sydney' }],
|
|
92
|
+
})
|
|
93
|
+
expect(ev?.topic).toBe('The user lives in Sydney')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('leaves topic empty when the payload carries no legible subject', () => {
|
|
97
|
+
expect(detectConsolidationEvent({ stored: 1, bank_id: 'agent-x' })?.topic).toBe('')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('renderConsolidationLine — terse, HTML, notification-free surface', () => {
|
|
102
|
+
it('renders the updated line with a subject', () => {
|
|
103
|
+
expect(renderConsolidationLine({ kind: 'updated', topic: 'your deploy preferences' })).toBe(
|
|
104
|
+
'🧠 <i>updated what I know</i> about "your deploy preferences"',
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
it('renders the revised line with a subject', () => {
|
|
108
|
+
expect(renderConsolidationLine({ kind: 'revised', topic: 'the old runbook' })).toBe(
|
|
109
|
+
'🧠 <i>revised what I know</i> about "the old runbook"',
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
it('falls back to a bare line when there is no legible subject', () => {
|
|
113
|
+
expect(renderConsolidationLine({ kind: 'updated', topic: '' })).toBe(
|
|
114
|
+
'🧠 <i>updated what I know about you.</i>',
|
|
115
|
+
)
|
|
116
|
+
expect(renderConsolidationLine({ kind: 'revised', topic: ' ' })).toBe(
|
|
117
|
+
'🧠 <i>revised what I know about you.</i>',
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
it('HTML-escapes the subject (no entity injection)', () => {
|
|
121
|
+
expect(renderConsolidationLine({ kind: 'updated', topic: 'a <b> & c' })).toBe(
|
|
122
|
+
'🧠 <i>updated what I know</i> about "a <b> & c"',
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
it('truncates a very long subject', () => {
|
|
126
|
+
const long = 'x'.repeat(500)
|
|
127
|
+
const out = renderConsolidationLine({ kind: 'updated', topic: long })
|
|
128
|
+
expect(out.length).toBeLessThan(200)
|
|
129
|
+
expect(out).toContain('…')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('ConsolidationRateLimiter — sparse / non-spammy', () => {
|
|
134
|
+
it('allows the first line, then blocks a second within the min-interval', () => {
|
|
135
|
+
let t = 1_000_000
|
|
136
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
|
|
137
|
+
expect(rl.allow('klanker', 'updated:a')).toBe(true)
|
|
138
|
+
t += 60_000 // 1 min later
|
|
139
|
+
expect(rl.allow('klanker', 'updated:b')).toBe(false) // different topic, still capped
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('allows again once the min-interval has elapsed', () => {
|
|
143
|
+
let t = 1_000_000
|
|
144
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
|
|
145
|
+
expect(rl.allow('klanker', 'updated:a')).toBe(true)
|
|
146
|
+
t += 600_001
|
|
147
|
+
expect(rl.allow('klanker', 'updated:b')).toBe(true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('suppresses an IDENTICAL signature across the dedup window even after the interval', () => {
|
|
151
|
+
let t = 1_000_000
|
|
152
|
+
const rl = new ConsolidationRateLimiter({
|
|
153
|
+
minIntervalMs: 600_000,
|
|
154
|
+
dedupWindowMs: 3_600_000,
|
|
155
|
+
now: () => t,
|
|
156
|
+
})
|
|
157
|
+
expect(rl.allow('klanker', 'updated:deploy')).toBe(true)
|
|
158
|
+
t += 700_000 // past min-interval…
|
|
159
|
+
expect(rl.allow('klanker', 'updated:deploy')).toBe(false) // …but same topic, still deduped
|
|
160
|
+
t += 3_600_000 // past dedup window
|
|
161
|
+
expect(rl.allow('klanker', 'updated:deploy')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('rate-limits per agent independently', () => {
|
|
165
|
+
let t = 1_000_000
|
|
166
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
|
|
167
|
+
expect(rl.allow('klanker', 'updated:a')).toBe(true)
|
|
168
|
+
expect(rl.allow('reggie', 'updated:a')).toBe(true) // different agent, own budget
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('accepts an explicit now override per call', () => {
|
|
172
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000 })
|
|
173
|
+
expect(rl.allow('klanker', 'updated:a', 0)).toBe(true)
|
|
174
|
+
expect(rl.allow('klanker', 'updated:a', 100)).toBe(false)
|
|
175
|
+
expect(rl.allow('klanker', 'updated:a', 700_000)).toBe(false) // dedup window
|
|
176
|
+
expect(rl.allow('klanker', 'updated:b', 700_000)).toBe(true) // new topic past interval
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Regression: the gateway caller (surfaceConsolidationLegibility) MUST pass
|
|
180
|
+
// Date.now() as the clock, never a payload-derived rec.ts. This test proves
|
|
181
|
+
// WHY: a spoofed/stale timestamp from the webhook payload poisons the
|
|
182
|
+
// per-agent min-interval gate. A far-FUTURE payload ts leaves the stored
|
|
183
|
+
// last-surface time in the future, so a genuine later wall-clock surface
|
|
184
|
+
// reads as "in the past" and the gate stays jammed shut; a far-PAST ts makes
|
|
185
|
+
// every subsequent surface look older than the window and the gate never
|
|
186
|
+
// engages (spam). Feeding wall-clock avoids both.
|
|
187
|
+
it('a payload-derived far-future timestamp would jam the interval gate shut', () => {
|
|
188
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
|
|
189
|
+
const wallNow = 1_000_000
|
|
190
|
+
const spoofedFuture = wallNow + 10 * 365 * 24 * 3_600_000 // ~10y ahead
|
|
191
|
+
// First surface stamped with the attacker's future ts.
|
|
192
|
+
expect(rl.allow('klanker', 'updated:a', spoofedFuture)).toBe(true)
|
|
193
|
+
// A genuine later surface (real wall-clock) is now "before" the stored
|
|
194
|
+
// last time → interval never elapses → legitimate surface suppressed.
|
|
195
|
+
expect(rl.allow('klanker', 'updated:b', wallNow + 700_000)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('a payload-derived timestamp lets a spoofer defeat the interval gate (spam)', () => {
|
|
199
|
+
// Two events arrive ~1ms apart in REALITY, but each carries a payload ts
|
|
200
|
+
// inflated to appear far apart. Keyed on payload ts, the gate sees them as
|
|
201
|
+
// past the interval and lets BOTH through — spam.
|
|
202
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
|
|
203
|
+
expect(rl.allow('klanker', 'updated:a', 0)).toBe(true)
|
|
204
|
+
expect(rl.allow('klanker', 'updated:b', 700_000)).toBe(true) // spoofed far-apart ts → not blocked
|
|
205
|
+
// Wall-clock (what the fix passes) blocks the same two events 1ms apart.
|
|
206
|
+
const rl2 = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
|
|
207
|
+
const wallNow = 1_000_000_000
|
|
208
|
+
expect(rl2.allow('klanker', 'updated:a', wallNow)).toBe(true)
|
|
209
|
+
expect(rl2.allow('klanker', 'updated:b', wallNow + 1)).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('consolidationSignature — stable dedup key', () => {
|
|
214
|
+
it('normalises whitespace + case so trivially-different topics collapse', () => {
|
|
215
|
+
expect(consolidationSignature({ kind: 'updated', topic: ' Deploy Prefs ' })).toBe(
|
|
216
|
+
'updated:deploy prefs',
|
|
217
|
+
)
|
|
218
|
+
})
|
|
219
|
+
it('separates kind so a store and a correction of the same topic differ', () => {
|
|
220
|
+
expect(consolidationSignature({ kind: 'updated', topic: 'x' })).not.toBe(
|
|
221
|
+
consolidationSignature({ kind: 'revised', topic: 'x' }),
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -315,7 +315,7 @@ describe('façade delegates to the existing emission primitives (call-site liter
|
|
|
315
315
|
})
|
|
316
316
|
})
|
|
317
317
|
|
|
318
|
-
describe('the
|
|
318
|
+
describe('the drain sites route through the façade with producers preserved verbatim', () => {
|
|
319
319
|
it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
|
|
320
320
|
const body = fnSrc('showNarrativeStep')
|
|
321
321
|
expect(body).toMatch(/openOrEditCard\('narrative'/)
|
|
@@ -325,26 +325,30 @@ describe('the 7 drain sites route through the façade with producers preserved v
|
|
|
325
325
|
expect(body).toMatch(/ea\.mayDrain\(turn\)/)
|
|
326
326
|
})
|
|
327
327
|
|
|
328
|
-
it('
|
|
328
|
+
it('all liveness sites route via openOrEditCard("liveness") + producer-"liveness" drains (one in openLivenessFeedIfDue, two in feedHeartbeatTick)', () => {
|
|
329
329
|
// The early-card-open refactor extracted the 0-tool liveness OPEN out of
|
|
330
330
|
// feedHeartbeatTick into the shared `openLivenessFeedIfDue` helper (so the
|
|
331
331
|
// 6 s heartbeat and the enqueue-time early-open timer reach ONE open path
|
|
332
|
-
// and never double-open).
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
332
|
+
// and never double-open). deterministic-turn-liveness.md Phase 1 then added
|
|
333
|
+
// the 0-label CLIMB of an already-open card back into feedHeartbeatTick (an
|
|
334
|
+
// EDIT, never an OPEN — so it does not double-open). So the liveness sites now
|
|
335
|
+
// live in:
|
|
336
|
+
// - openLivenessFeedIfDue: the 0-tool early-OPEN;
|
|
337
|
+
// - feedHeartbeatTick: the 0-label CLIMB of an already-open card
|
|
338
|
+
// (Phase 1) + the labelled-feed stale-step maintain.
|
|
339
|
+
// All must still route via the façade with the producer arg verbatim.
|
|
336
340
|
const earlyOpen = fnSrc('openLivenessFeedIfDue')
|
|
337
341
|
const heartbeat = fnSrc('feedHeartbeatTick')
|
|
338
342
|
const opens = [
|
|
339
343
|
...earlyOpen.matchAll(/openOrEditCard\('liveness'/g),
|
|
340
344
|
...heartbeat.matchAll(/openOrEditCard\('liveness'/g),
|
|
341
345
|
]
|
|
342
|
-
expect(opens).toHaveLength(
|
|
346
|
+
expect(opens).toHaveLength(3)
|
|
343
347
|
const drains = [
|
|
344
348
|
...earlyOpen.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
|
|
345
349
|
...heartbeat.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
|
|
346
350
|
]
|
|
347
|
-
expect(drains).toHaveLength(
|
|
351
|
+
expect(drains).toHaveLength(3)
|
|
348
352
|
// The literal the feed-heartbeat oracle greps must still be present in both.
|
|
349
353
|
expect(earlyOpen).toMatch(/turn\.activityInFlight = drainActivitySummary/)
|
|
350
354
|
expect(heartbeat).toMatch(/turn\.activityInFlight = drainActivitySummary/)
|
|
@@ -366,8 +370,14 @@ describe('the 7 drain sites route through the façade with producers preserved v
|
|
|
366
370
|
|
|
367
371
|
it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
|
|
368
372
|
const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
|
|
369
|
-
// narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness
|
|
373
|
+
// narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness
|
|
374
|
+
// (Fix 2) = 7. The Phase-1 0-label climb (deterministic-turn-liveness.md)
|
|
375
|
+
// consults the SAME guard, but its `if (deps.mayDrain())` lives in the
|
|
376
|
+
// extracted tick body (feed-heartbeat-climb.ts) with `() => ea.mayDrain(turn)`
|
|
377
|
+
// injected at the gateway call site — pinned by
|
|
378
|
+
// feed-heartbeat-liveness-open.test.ts + silent-turn-climb-transport.test.ts.
|
|
370
379
|
expect(mayDrainGuards).toHaveLength(7)
|
|
380
|
+
expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
|
|
371
381
|
})
|
|
372
382
|
})
|
|
373
383
|
|
|
@@ -487,11 +497,16 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
|
|
|
487
497
|
expect(ctxFn).toMatch(/turnInFlight:\s*turnInFlightForGate\(\)/)
|
|
488
498
|
})
|
|
489
499
|
|
|
490
|
-
it('the
|
|
500
|
+
it('the card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
|
|
491
501
|
// Option A: the 7 `if (ea.mayDrain(turn))` guards + drainActivitySummary
|
|
492
502
|
// thunks stay byte-identical, wrapped by the centralized helper.
|
|
493
503
|
// 6 original + 1 new post-answer background-agent liveness drain (Fix 2).
|
|
494
504
|
const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
|
|
495
505
|
expect(wraps).toHaveLength(7)
|
|
506
|
+
// The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
|
|
507
|
+
// the SAME helper via an injected thunk — its call lives in the extracted
|
|
508
|
+
// tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
|
|
509
|
+
// `cardDrainGate: (run) => cardDrainGate(turn, ea, run)`.
|
|
510
|
+
expect(gatewaySrc).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
|
|
496
511
|
})
|
|
497
512
|
})
|
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
* `openLivenessFeedIfDue` BEFORE the drain — a turn that hasn't passed the
|
|
26
26
|
* threshold, or one whose card is already open, returns early (no
|
|
27
27
|
* double-open).
|
|
28
|
-
* 4. The 0-tool branch of `feedHeartbeatTick`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* 4. The 0-tool branch of `feedHeartbeatTick` OPENS via that one shared helper
|
|
29
|
+
* (so the heartbeat and the enqueue-time timer can never double-open) AND,
|
|
30
|
+
* once a card is open, CLIMBS it every tick via `silentTurnClimbRender`
|
|
31
|
+
* (deterministic-turn-liveness.md Phase 1) — the freeze this test used to
|
|
32
|
+
* enshrine is inverted. The outcome-based proof is the transport test
|
|
33
|
+
* `silent-turn-climb-transport.test.ts`.
|
|
31
34
|
*
|
|
32
35
|
* These are STRUCTURAL (source-read) assertions; the gateway IIFE can't be
|
|
33
36
|
* instantiated in-process. Pattern matches silence-liveness-wiring.test.ts.
|
|
@@ -71,17 +74,38 @@ describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
|
|
|
71
74
|
expect(initBlock).toMatch(/1[_]?200/)
|
|
72
75
|
})
|
|
73
76
|
|
|
74
|
-
it('the 0-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
77
|
+
it('the 0-label tick routes through the REAL extracted tick body with the gateway deps wired verbatim', () => {
|
|
78
|
+
// deterministic-turn-liveness.md Phase 1 + Phase 4(d): the prior version of
|
|
79
|
+
// this test enshrined the freeze — it asserted only that the branch delegates
|
|
80
|
+
// to `openLivenessFeedIfDue` (which no-ops once a card is open), which is
|
|
81
|
+
// exactly why a busy-but-silent 0-label card used to freeze. The property is
|
|
82
|
+
// now INVERTED: the 0-label tick must route through the extracted
|
|
83
|
+
// `runSilentTurnHeartbeatTick` (feed-heartbeat-climb.ts) — the shipped tick
|
|
84
|
+
// body that CLIMBS an already-open card — with the gateway's REAL deps
|
|
85
|
+
// (the shared OPEN helper, cardDrainGate, ea.mayDrain,
|
|
86
|
+
// ea.openOrEditCard('liveness'), the 'liveness' drain) wired at the call
|
|
87
|
+
// site. Removing or mis-wiring this call site fails HERE; the tick body's
|
|
88
|
+
// behaviour is proven outcome-based in
|
|
89
|
+
// tests/silent-turn-climb-transport.test.ts (>=4 climbing edits across a
|
|
90
|
+
// 40s silent tool, edit-only, gate sequence in order).
|
|
78
91
|
const body = feedHeartbeatTickSrc()
|
|
79
|
-
const start = body.indexOf('
|
|
92
|
+
const start = body.indexOf('runSilentTurnHeartbeatTick(')
|
|
80
93
|
expect(start).toBeGreaterThan(-1)
|
|
81
94
|
const branch = body.slice(start)
|
|
82
95
|
const end = branch.indexOf('// Labelled-feed heartbeat')
|
|
83
96
|
const scoped = end === -1 ? branch : branch.slice(0, end)
|
|
84
|
-
|
|
97
|
+
// The real deps, wired verbatim at the call site:
|
|
98
|
+
expect(scoped).toMatch(/openLivenessFeedIfDue:\s*\(\)\s*=>\s*openLivenessFeedIfDue\(turn\)/)
|
|
99
|
+
expect(scoped).toMatch(/setPendingRender:\s*\(rendered\)\s*=>\s*\{\s*turn\.activityPendingRender = rendered\s*\}/)
|
|
100
|
+
expect(scoped).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
|
|
101
|
+
expect(scoped).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
|
|
102
|
+
expect(scoped).toMatch(/openOrEditCard:\s*\(apply\)\s*=>\s*ea\.openOrEditCard\('liveness', apply\)/)
|
|
103
|
+
expect(scoped).toMatch(/drain:\s*\(\)\s*=>\s*\{\s*turn\.activityInFlight = drainActivitySummary\(turn, 'liveness'\)\s*\}/)
|
|
104
|
+
// The view carries the wall-clock elapsed + the labelled branch's stale floor.
|
|
105
|
+
expect(scoped).toMatch(/ageMs:\s*Date\.now\(\)\s*-\s*turn\.startedAt/)
|
|
106
|
+
expect(scoped).toMatch(/minStaleMs:\s*FEED_HEARTBEAT_MIN_STALE_MS/)
|
|
107
|
+
// A handled tick (0-label branch) stops before the labelled heartbeat.
|
|
108
|
+
expect(scoped).toMatch(/if \(handled\) return/)
|
|
85
109
|
})
|
|
86
110
|
|
|
87
111
|
it('the WHEN-gate (shouldEarlyOpenLiveness) precedes the drain call inside openLivenessFeedIfDue', () => {
|
|
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
43
43
|
// window to ~5KB so we don't pick up the unrelated
|
|
44
44
|
// `removeTurnActiveMarker` call in the onTurnComplete handler
|
|
45
45
|
// way down at the bottom of the file.
|
|
46
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
46
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
|
|
47
47
|
const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
|
|
48
48
|
expect(clearMatches.length).toBe(1)
|
|
49
49
|
})
|
|
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
53
53
|
// must not prevent the gateway from coming up. Pin the try-wrapping
|
|
54
54
|
// to make sure a future refactor doesn't drop it.
|
|
55
55
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
56
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
56
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
|
|
57
57
|
expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
62
62
|
// Clearing the marker first means the watchdog sees a clean slate
|
|
63
63
|
// immediately, even if pin sweep takes 30s+ to finish.
|
|
64
64
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
65
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
65
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
|
|
66
66
|
const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
|
|
67
67
|
const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
|
|
68
68
|
expect(clearIdx).toBeGreaterThan(0)
|
|
@@ -99,6 +99,48 @@ describe('spoolId — stable dedup key', () => {
|
|
|
99
99
|
)
|
|
100
100
|
expect(a).not.toBe(b)
|
|
101
101
|
})
|
|
102
|
+
// #2793 part B: a cron BOOT-REPLAY carries meta.replay_fire_ms (the
|
|
103
|
+
// minute-aligned fire being replayed). spoolId() keys on it + the
|
|
104
|
+
// schedule index so a re-replay of the SAME missed fire across a gateway
|
|
105
|
+
// restart — which mints a fresh synthetic messageId=ts each boot —
|
|
106
|
+
// collapses to one live entry instead of stacking a fresh one each boot.
|
|
107
|
+
it('cron boot-replay → s:cron-replay:chat:idx:fireMs, stable across ts/messageId', () => {
|
|
108
|
+
const a = spoolId(
|
|
109
|
+
msg({
|
|
110
|
+
messageId: 1700_000_000_000,
|
|
111
|
+
ts: 1700_000_000_000,
|
|
112
|
+
meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '1699999980000' },
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
const b = spoolId(
|
|
116
|
+
msg({
|
|
117
|
+
messageId: 1700_000_999_999,
|
|
118
|
+
ts: 1700_000_999_999,
|
|
119
|
+
meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '1699999980000' },
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
expect(a).toBe('s:cron-replay:c1:3:1699999980000')
|
|
123
|
+
expect(b).toBe(a) // stable across the fresh per-boot messageId/ts
|
|
124
|
+
})
|
|
125
|
+
it('cron boot-replays for distinct missed fires stay distinct', () => {
|
|
126
|
+
const a = spoolId(
|
|
127
|
+
msg({ messageId: 0, meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '100' } }),
|
|
128
|
+
)
|
|
129
|
+
const b = spoolId(
|
|
130
|
+
msg({ messageId: 0, meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '200' } }),
|
|
131
|
+
)
|
|
132
|
+
const c = spoolId(
|
|
133
|
+
msg({ messageId: 0, meta: { source: 'cron', schedule_index: '4', replay_fire_ms: '100' } }),
|
|
134
|
+
)
|
|
135
|
+
expect(a).not.toBe(b) // different fire minute
|
|
136
|
+
expect(a).not.toBe(c) // different schedule index
|
|
137
|
+
})
|
|
138
|
+
it('a LIVE cron tick (no replay_fire_ms) keeps its per-fire identity', () => {
|
|
139
|
+
// Live ticks are not routed through the spool, but assert the id path
|
|
140
|
+
// is unchanged so live fires never collapse into a replay bucket.
|
|
141
|
+
const live = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 500 }))
|
|
142
|
+
expect(live).toBe('s:c1:cron:500')
|
|
143
|
+
})
|
|
102
144
|
it('subagent_handback without jsonl id falls back to legacy id (back-compat)', () => {
|
|
103
145
|
const a = spoolId(
|
|
104
146
|
msg({ messageId: 555, meta: { source: 'subagent_handback' }, ts: 100 }),
|
|
@@ -578,3 +620,66 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
|
|
|
578
620
|
expect(events).toEqual([true, false]) // degraded → recovered
|
|
579
621
|
})
|
|
580
622
|
})
|
|
623
|
+
|
|
624
|
+
// #2793 part B — cron boot-replay accept-vs-consume durability. A missed
|
|
625
|
+
// scheduled fire re-injected at boot is routed through this spool by the
|
|
626
|
+
// gateway's onInjectInbound handler (put on accept, ack on confirmed
|
|
627
|
+
// delivery). These tests pin the two windows the ledger closes.
|
|
628
|
+
describe('#2793 — cron boot-replay accept vs consume', () => {
|
|
629
|
+
function replayMsg(fireMs: number, boot: number): InboundMessage {
|
|
630
|
+
// A fresh synthetic messageId/ts is minted every replay attempt; the
|
|
631
|
+
// stable identity is (schedule_index, replay_fire_ms).
|
|
632
|
+
return msg({
|
|
633
|
+
messageId: boot,
|
|
634
|
+
ts: boot,
|
|
635
|
+
user: 'cron',
|
|
636
|
+
userId: 0,
|
|
637
|
+
meta: { source: 'cron', schedule_index: '3', replay_fire_ms: String(fireMs) },
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
it('accepted-but-NOT-consumed replay survives a restart and re-fires', () => {
|
|
642
|
+
const fs = fakeFs()
|
|
643
|
+
// Boot 1: the gateway accepts the replay (put) but the session never
|
|
644
|
+
// came up, so it is never delivered → never acked.
|
|
645
|
+
createInboundSpool({ path: PATH, fs, log: () => {} }).put('a', replayMsg(1000, 111))
|
|
646
|
+
// Boot 2: a fresh gateway rehydrates from the durable log. The un-acked
|
|
647
|
+
// replay is STILL live, so the boot-replay path re-delivers it — the
|
|
648
|
+
// silent-loss window (accepted, never consumed) is closed.
|
|
649
|
+
const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
650
|
+
const live = s2.liveEntries()
|
|
651
|
+
expect(live).toHaveLength(1)
|
|
652
|
+
expect(live[0]!.msg.meta?.replay_fire_ms).toBe('1000')
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it('a CONSUMED replay (acked on delivery) does NOT re-fire on restart', () => {
|
|
656
|
+
const fs = fakeFs()
|
|
657
|
+
const s1 = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
658
|
+
const m = replayMsg(1000, 111)
|
|
659
|
+
s1.put('a', m)
|
|
660
|
+
s1.ack(m) // gateway delivered it to a live bridge → tombstone
|
|
661
|
+
// A fresh gateway sees the tombstone and does not re-deliver — the
|
|
662
|
+
// double-fire window is closed for a consumed replay.
|
|
663
|
+
const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
664
|
+
expect(s2.liveEntries()).toHaveLength(0)
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('a re-replay of the SAME missed fire dedups (at-least-once with dedup)', () => {
|
|
668
|
+
const fs = fakeFs()
|
|
669
|
+
const s = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
670
|
+
// First accept.
|
|
671
|
+
expect(s.put('a', replayMsg(1000, 111))).toBe(true)
|
|
672
|
+
// The scheduler re-replays the same missed fire on a later boot (fresh
|
|
673
|
+
// messageId/ts) while the first is still un-acked → collapses to one.
|
|
674
|
+
expect(s.put('a', replayMsg(1000, 222))).toBe(false)
|
|
675
|
+
expect(s.liveCount()).toBe(1)
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
it('distinct missed fires are each retained (no cross-fire collapse)', () => {
|
|
679
|
+
const fs = fakeFs()
|
|
680
|
+
const s = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
681
|
+
expect(s.put('a', replayMsg(1000, 111))).toBe(true)
|
|
682
|
+
expect(s.put('a', replayMsg(2000, 222))).toBe(true)
|
|
683
|
+
expect(s.liveCount()).toBe(2)
|
|
684
|
+
})
|
|
685
|
+
})
|